now") == "https://example.com/a")
+ }
+
+ @Test func usesTheFirstWebURL() {
+ #expect(extract("ftp://files.example.com then https://b.example.com and https://c.example.com") == "https://b.example.com")
+ }
+
+ @Test func textWithoutAWebLinkHasNoLink() {
+ #expect(extract("javascript:alert(1)") == nil)
+ #expect(extract("file:///etc/passwd") == nil)
+ #expect(extract("mailto:someone@example.com") == nil)
+ #expect(extract("ftp://files.example.com") == nil)
+ #expect(extract("httpx://example.com") == nil)
+ }
+
+ @Test func rejectsEmptyAndHostlessInput() {
+ #expect(extract("") == nil)
+ #expect(extract(" \n") == nil)
+ #expect(extract("no links here, just httpbin and https://") == nil)
+ }
+
+ @Test func acceptsUppercaseSchemes() {
+ #expect(extract("HTTPS://EXAMPLE.COM/") == "HTTPS://EXAMPLE.COM/")
+ }
+
+ @Test func acceptsLinksUpToTheLengthLimit() {
+ let base = "https://example.com/"
+ let longest = base + String(repeating: "a", count: LinkIntake.maxURLBytes - base.utf8.count)
+ #expect(extract(longest) == longest)
+ #expect(extract("Go to " + longest + ".") == longest) // Measured after trimming.
+ #expect(extract(longest + "a") == nil)
+ // Measured as encoded: each `{` becomes `%7B`.
+ #expect(extract(base + String(repeating: "{", count: 6000)) == nil)
+ }
+
+ @Test func skipsAnOverlongTokenWholeIncludingLinksNestedInIt() {
+ let overlong = "https://example.com/" + String(repeating: "a", count: LinkIntake.maxURLBytes)
+ #expect(extract(overlong + "https://nested.example.com/") == nil)
+ #expect(extract(overlong + " https://next.example.com/") == "https://next.example.com/")
+ }
+
+ @Test(arguments: [
+ String(repeating: "http", count: 256 * 1024),
+ String(repeating: "http ", count: 100_000),
+ String(repeating: "http" + String(repeating: ")", count: 16_000) + " ", count: 16),
+ String(repeating: "http](", count: 200_000),
+ ])
+ func hostileTextIsScannedInLinearTime(_ hostile: String) {
+ // Quadratic versions took 10 s to minutes on these; linear takes well under a second.
+ let start = ContinuousClock.now
+ #expect(extract(hostile) == nil)
+ #expect(ContinuousClock.now - start < .seconds(5))
+ }
+
+ @Test func onlyLinksStartingNearTheStartOfHugeTextCount() {
+ let filler = String(repeating: "x", count: LinkIntake.maxScannedCharacters)
+ #expect(extract(filler + " https://example.com/") == nil)
+ #expect(extract("https://example.com/ " + filler) == "https://example.com/")
+ }
+
+ @Test func aLinkThatStartsBeforeTheScanLimitIsReadWhole() {
+ let link = "https://example.com/very/long/path?token=abcdef"
+ let filler = String(repeating: "x", count: LinkIntake.maxScannedCharacters - 30)
+ #expect(extract(filler + " " + link) == link)
+ }
+
+ @Test func findsTheLinkBehindSelectedHyperlinkText() {
+ func extract(html: String) -> String? { LinkIntake.extractURL(fromHTML: html)?.absoluteString }
+ #expect(extract(html: #"Amflow's latest e-bikes"#)
+ == "https://www.theverge.com/transportation/909060/amflows-latest-e-bikes")
+ #expect(extract(html: #"Verify"#)
+ == "https://www.google.com/url?q=https://example.com/&sa=D")
+ #expect(extract(html: "x") == "https://example.com/a")
+ #expect(extract(html: #"mail web"#) == "https://example.org/")
+ #expect(extract(html: "see https://example.com/page.
") == "https://example.com/page")
+ #expect(extract(html: "no links
") == nil)
+ }
+
+ @Test func validatesURLObjects() throws {
+ let web = try #require(URL(string: "https://example.com/x"))
+ #expect(LinkIntake.validate(web) == web)
+ #expect(LinkIntake.validate(try #require(URL(string: "HTTP://example.com/"))) != nil)
+ #expect(LinkIntake.validate(URL(filePath: "/etc/passwd")) == nil)
+ #expect(LinkIntake.validate(try #require(URL(string: "ftp://files.example.com/"))) == nil)
+ #expect(LinkIntake.validate(try #require(URL(string: "mailto:a@example.com"))) == nil)
+ #expect(LinkIntake.validate(try #require(URL(string: "https:///no-host"))) == nil)
+ #expect(LinkIntake.validate(try #require(URL(string: "http://:80/"))) == nil)
+ let long = "https://example.com/" + String(repeating: "a", count: LinkIntake.maxURLBytes)
+ #expect(LinkIntake.validate(try #require(URL(string: long))) == nil)
+ }
+}
diff --git a/Tests/KnotchyCoreTests/NetworkRouteTests.swift b/Tests/KnotchyCoreTests/NetworkRouteTests.swift
new file mode 100644
index 0000000..09be1fb
--- /dev/null
+++ b/Tests/KnotchyCoreTests/NetworkRouteTests.swift
@@ -0,0 +1,145 @@
+import Foundation
+import Testing
+@testable import KnotchyCore
+
+@Suite struct NetworkRouteTests {
+ static let key = Data(repeating: 7, count: 32).base64EncodedString()
+ static let otherKey = Data(repeating: 9, count: 32).base64EncodedString()
+
+ static let mullvadStyle = """
+ [Interface]
+ # Device: Quick Fox
+ PrivateKey = \(key)
+ Address = 10.64.12.34/32,fc00:bbbb:bbbb:bb01::1:c21/128
+ DNS = 10.64.0.1
+
+ [Peer]
+ PublicKey = \(otherKey)
+ AllowedIPs = 0.0.0.0/0,::0/0
+ Endpoint = se-got-wg-001.relays.mullvad.net:51820
+ """
+
+ @Test func parsesAProviderConfig() throws {
+ let config = try WireGuardConfig.parse(Self.mullvadStyle)
+ #expect(config.privateKey == Self.key)
+ #expect(config.addresses == ["10.64.12.34/32"])
+ #expect(config.dns == ["10.64.0.1"])
+ #expect(config.peerPublicKey == Self.otherKey)
+ #expect(config.endpointHost == "se-got-wg-001.relays.mullvad.net")
+ #expect(config.endpointPort == 51820)
+ #expect(config.mtu == nil)
+ #expect(config.persistentKeepalive == nil)
+ }
+
+ @Test func keysAreCaseInsensitiveAndRepeatsJoin() throws {
+ let text = """
+ [interface]
+ privatekey=\(Self.key)
+ Address = 10.2.0.2
+ Address = 10.2.0.3/32
+ DNS = 10.2.0.1, 1.1.1.1, example.search.domain
+ MTU = 1380
+ [PEER]
+ publickey = \(Self.otherKey)
+ PresharedKey = \(Self.key)
+ endpoint = 203.0.113.9:443
+ PersistentKeepalive = 15
+ """
+ let config = try WireGuardConfig.parse(text)
+ #expect(config.addresses == ["10.2.0.2/32", "10.2.0.3/32"])
+ #expect(config.dns == ["10.2.0.1", "1.1.1.1"])
+ #expect(config.mtu == 1380)
+ #expect(config.presharedKey == Self.key)
+ #expect(config.persistentKeepalive == 15)
+ }
+
+ @Test func rejectsWhatTheVMCannotUse() {
+ func parse(_ text: String) -> WireGuardConfig.ParseError? {
+ do { _ = try WireGuardConfig.parse(text); return nil } catch { return error }
+ }
+ let peer = "[Peer]\nPublicKey = \(Self.otherKey)\nEndpoint = 1.2.3.4:51820\n"
+ #expect(parse("[Interface]\nAddress = 10.0.0.2/32\n" + peer) == .missing("PrivateKey"))
+ #expect(parse("[Interface]\nPrivateKey = nope\nAddress = 10.0.0.2/32\n" + peer) == .invalid("PrivateKey"))
+ #expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = fd00::2/128\n" + peer) == .missing("an IPv4 Address"))
+ #expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = 10.0.0.2/32\n") == .missing("[Peer]"))
+ #expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = 10.0.0.2/32\n" + peer + peer) == .multiplePeers)
+ let v6Endpoint = "[Peer]\nPublicKey = \(Self.otherKey)\nEndpoint = [2001:db8::1]:51820\n"
+ #expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = 10.0.0.2/32\n" + v6Endpoint) != nil)
+ let shellEndpoint = "[Peer]\nPublicKey = \(Self.otherKey)\nEndpoint = $(reboot):51820\n"
+ #expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = 10.0.0.2/32\n" + shellEndpoint) == .invalid("Endpoint"))
+ #expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = 10.0.0.2/32\nPostUp = rm -rf /\n" + peer) == nil)
+ }
+
+ @Test func setconfForcesAFullTunnelAndAKeepalive() throws {
+ let config = try WireGuardConfig.parse(Self.mullvadStyle)
+ let setconf = config.setconf(endpointAddress: "185.213.154.66")
+ #expect(setconf == """
+ [Interface]
+ PrivateKey = \(Self.key)
+
+ [Peer]
+ PublicKey = \(Self.otherKey)
+ Endpoint = 185.213.154.66:51820
+ AllowedIPs = 0.0.0.0/0
+ PersistentKeepalive = 25
+
+ """)
+ }
+
+ @Test func proxyURLsEncodeCredentialsAndUseTheAddress() {
+ let proxy = ProxySettings(kind: .socks5, host: "proxy.example", port: 1080, username: "me@home", password: "p:w/d@!")
+ #expect(proxy.url(address: "203.0.113.7") == "socks5://me%40home:p%3Aw%2Fd%40%21@203.0.113.7:1080")
+ #expect(ProxySettings(kind: .http, host: "h", port: 8080).url(address: "10.0.0.5") == "http://10.0.0.5:8080")
+ #expect(ProxySettings(kind: .http, host: "h", port: 8080, username: "u").url(address: "10.0.0.5") == "http://u@10.0.0.5:8080")
+ }
+
+ @Test func launchKeepsSecretsOutOfTheEnvironment() throws {
+ var asked: [String] = []
+ let resolve = { (host: String) -> String in asked.append(host); return "198.51.100.4" }
+ #expect(try NetworkRoute.direct.launch(resolve: resolve) == RouteLaunch(environment: ["KNOTCHY_ROUTE=direct"], secrets: []))
+
+ let proxy = NetworkRoute.proxy(ProxySettings(kind: .http, host: "proxy.corp", port: 3128, username: "u", password: "p"))
+ #expect(try proxy.launch(resolve: resolve) == RouteLaunch(
+ environment: ["KNOTCHY_ROUTE=proxy", "KNOTCHY_PROXY_ADDR=198.51.100.4:3128"],
+ secrets: ["KNOTCHY_PROXY_URL=http://u:p@198.51.100.4:3128"]
+ ))
+
+ let wireGuard = try NetworkRoute.wireGuard(WireGuardConfig.parse(Self.mullvadStyle)).launch(resolve: resolve)
+ #expect(asked == ["proxy.corp", "se-got-wg-001.relays.mullvad.net"])
+ #expect(wireGuard.environment == [
+ "KNOTCHY_ROUTE=wireguard", "KNOTCHY_WG_ENDPOINT=198.51.100.4:51820", "KNOTCHY_WG_ADDRESS=10.64.12.34/32",
+ "KNOTCHY_WG_DNS=10.64.0.1", "KNOTCHY_WG_MTU=1420",
+ ])
+ #expect(wireGuard.environment.allSatisfy { !$0.contains(Self.key) })
+ #expect(wireGuard.secrets.count == 1 && wireGuard.secrets[0].hasPrefix("KNOTCHY_WG_SETCONF="))
+ #expect((wireGuard.environment + wireGuard.secrets).allSatisfy { !$0.contains("\n") })
+
+ #expect(throws: VMBackendError.failed("no")) { try NetworkRoute.unusable(reason: "no").launch(resolve: resolve) }
+ }
+
+ @Test func routeLabels() throws {
+ #expect(NetworkRoute.direct.label == nil)
+ #expect(NetworkRoute.proxy(ProxySettings(kind: .socks5, host: "h", port: 1)).label == "SOCKS5 proxy")
+ var config = try WireGuardConfig.parse(Self.mullvadStyle)
+ #expect(NetworkRoute.wireGuard(config).label == "VPN")
+ config.name = "sg-sin-wg-001"
+ #expect(NetworkRoute.wireGuard(config).label == "sg-sin-wg-001")
+ }
+
+ @Test func proxiesOnTheMacOrItsNetworkAreRefused() throws {
+ for local in ["10.1.2.3", "127.0.0.1", "169.254.169.254", "172.20.0.1", "192.168.64.1", "100.100.1.1", "0.0.0.0"] {
+ #expect(ProxySettings.isLocal(local, hostNetworks: []), "\(local)")
+ }
+ #expect(ProxySettings.isLocal("203.0.113.9", hostNetworks: ["203.0.113.0/24"]))
+ #expect(!ProxySettings.isLocal("203.0.113.9", hostNetworks: ["198.51.100.0/24"]))
+ #expect(!ProxySettings.isLocal("8.8.8.8", hostNetworks: []))
+ #expect(!ProxySettings.isLocal("172.32.0.1", hostNetworks: []))
+
+ let proxy = NetworkRoute.proxy(ProxySettings(kind: .http, host: "proxy.lan", port: 3128))
+ #expect(throws: VMBackendError.self) { try proxy.launch(resolve: { _ in "192.168.1.10" }) }
+ #expect(try proxy.launch(resolve: { _ in "192.168.1.10" }, allowLocalProxy: true).environment.count == 2)
+ // A local WireGuard server is fine: only wireguard-go can reach it.
+ let wireGuard = NetworkRoute.wireGuard(try WireGuardConfig.parse(Self.mullvadStyle))
+ #expect(try wireGuard.launch(resolve: { _ in "192.168.1.1" }).environment.contains("KNOTCHY_WG_ENDPOINT=192.168.1.1:51820"))
+ }
+}
diff --git a/Tests/KnotchyCoreTests/NotchGeometryTests.swift b/Tests/KnotchyCoreTests/NotchGeometryTests.swift
new file mode 100644
index 0000000..63f279d
--- /dev/null
+++ b/Tests/KnotchyCoreTests/NotchGeometryTests.swift
@@ -0,0 +1,165 @@
+import CoreGraphics
+import Testing
+@testable import KnotchyCore
+
+struct NotchGeometryTests {
+ private let macBookPro = ScreenInfo(
+ frame: CGRect(x: 0, y: 0, width: 1512, height: 982),
+ visibleFrame: CGRect(x: 0, y: 80, width: 1512, height: 870),
+ safeAreaTop: 32,
+ auxLeft: CGRect(x: 0, y: 950, width: 662, height: 32),
+ auxRight: CGRect(x: 850, y: 950, width: 662, height: 32),
+ menuBarThickness: 24
+ )
+
+ private let external = ScreenInfo(
+ frame: CGRect(x: 1512, y: -200, width: 1920, height: 1080),
+ visibleFrame: CGRect(x: 1512, y: -200, width: 1920, height: 1055),
+ safeAreaTop: 0, auxLeft: .zero, auxRight: .zero, menuBarThickness: 24
+ )
+
+ @Test func peekUnderAPhysicalNotch() {
+ let layout = NotchGeometry.layout(for: macBookPro, mode: .peek)
+ #expect(layout.notch == CGRect(x: 662, y: 950, width: 188, height: 32))
+ #expect(layout.pill == CGRect(x: 590, y: 950, width: 332, height: 32))
+ #expect(layout.panel == CGRect(x: 430, y: 510, width: 652, height: 472))
+ #expect(layout.toolbar == CGRect(x: 436, y: 916, width: 640, height: 34))
+ #expect(layout.content == CGRect(x: 436, y: 516, width: 640, height: 400))
+ #expect(layout.contentScale == 0.625)
+ }
+
+ @Test func expandedUnderAPhysicalNotch() {
+ let layout = NotchGeometry.layout(for: macBookPro, mode: .expanded)
+ #expect(layout.panel == CGRect(x: 238, y: 270, width: 1036, height: 712))
+ #expect(layout.content == CGRect(x: 244, y: 276, width: 1024, height: 640))
+ #expect(layout.contentScale == 1)
+ }
+
+ @Test func theNotchHeightIsTheSafeArea() {
+ var screen = macBookPro
+ screen.safeAreaTop = 37
+ screen.auxLeft = CGRect(x: 0, y: 945, width: 662, height: 37)
+ screen.auxRight = CGRect(x: 850, y: 945, width: 662, height: 37)
+ let layout = NotchGeometry.layout(for: screen, mode: .peek)
+ #expect(layout.notch == CGRect(x: 662, y: 945, width: 188, height: 37))
+ #expect(layout.panel.height == CGFloat(37 + 34 + 400 + 6))
+ }
+
+ @Test func aNotchNeedsASafeAreaAndBothMenuBarAreas() {
+ let virtualNotch = CGRect(x: 656, y: 950, width: 200, height: 32)
+ var noRightArea = macBookPro
+ noRightArea.auxRight = .zero
+ #expect(!noRightArea.hasNotch)
+ #expect(NotchGeometry.layout(for: noRightArea, mode: .peek).notch == virtualNotch)
+
+ var noSafeArea = macBookPro
+ noSafeArea.safeAreaTop = 0
+ #expect(!noSafeArea.hasNotch)
+ #expect(NotchGeometry.layout(for: noSafeArea, mode: .peek).notch == virtualNotch)
+ }
+
+ @Test func asymmetricEarsAndNotchWidthClamping() {
+ var screen = macBookPro
+ screen.auxLeft = CGRect(x: 0, y: 950, width: 600, height: 32)
+ screen.auxRight = CGRect(x: 850, y: 950, width: 662, height: 32)
+ #expect(NotchGeometry.layout(for: screen, mode: .peek).notch == CGRect(x: 600, y: 950, width: 250, height: 32))
+
+ screen.auxLeft = CGRect(x: 0, y: 950, width: 500, height: 32)
+ screen.auxRight = CGRect(x: 1000, y: 950, width: 512, height: 32)
+ #expect(NotchGeometry.layout(for: screen, mode: .peek).notch.width == 300)
+
+ screen.auxLeft = CGRect(x: 0, y: 950, width: 700, height: 32)
+ screen.auxRight = CGRect(x: 760, y: 950, width: 752, height: 32)
+ #expect(NotchGeometry.layout(for: screen, mode: .peek).notch == CGRect(x: 670, y: 950, width: 120, height: 32))
+ }
+
+ @Test func virtualNotchOnAScreenWithoutOne() {
+ let layout = NotchGeometry.layout(for: external, mode: .peek)
+ #expect(layout.notch == CGRect(x: 2372, y: 855, width: 200, height: 25))
+ #expect(layout.panel == CGRect(x: 2146, y: 415, width: 652, height: 465))
+ #expect(layout.content == CGRect(x: 2152, y: 421, width: 640, height: 400))
+ }
+
+ @Test func aScreenLeftOfTheMainOne() {
+ var screen = external
+ screen.frame.origin = CGPoint(x: -1920, y: 0)
+ screen.visibleFrame.origin = CGPoint(x: -1920, y: 0)
+ let layout = NotchGeometry.layout(for: screen, mode: .peek)
+ #expect(layout.notch == CGRect(x: -1060, y: 1055, width: 200, height: 25))
+ #expect(layout.panel == CGRect(x: -1286, y: 615, width: 652, height: 465))
+ }
+
+ @Test func autoHiddenMenuBarKeepsAVisibleNotch() {
+ var screen = external
+ screen.visibleFrame = screen.frame
+ screen.menuBarThickness = 0
+ #expect(NotchGeometry.layout(for: screen, mode: .peek).notch.height == 24)
+
+ screen.menuBarThickness = 30
+ #expect(NotchGeometry.layout(for: screen, mode: .peek).notch.height == 30)
+ }
+
+ @Test func expandedScalesDownOnAShortScreenKeeping16by10() {
+ let short = ScreenInfo(
+ frame: CGRect(x: 0, y: 0, width: 1280, height: 720),
+ visibleFrame: CGRect(x: 0, y: 60, width: 1280, height: 636),
+ safeAreaTop: 0, auxLeft: .zero, auxRight: .zero, menuBarThickness: 24
+ )
+ let layout = NotchGeometry.layout(for: short, mode: .expanded)
+ // Usable height: 720 - 60 (Dock) - 24 (notch) - 34 - 6 - 8 = 588, rounded down to 585.
+ #expect(layout.content.size == CGSize(width: 936, height: 585))
+ #expect(layout.content.width / layout.content.height == 1.6)
+ #expect(layout.panel.minY >= short.visibleFrame.minY)
+ #expect(layout.contentScale == CGFloat(936) / 1024)
+ #expect(NotchGeometry.layout(for: short, mode: .peek).content.size == CGSize(width: 640, height: 400))
+ }
+
+ @Test func expandedScalesDownOnANarrowScreen() {
+ let portrait = ScreenInfo(
+ frame: CGRect(x: 0, y: 0, width: 900, height: 1600),
+ visibleFrame: CGRect(x: 0, y: 0, width: 900, height: 1575),
+ safeAreaTop: 0, auxLeft: .zero, auxRight: .zero, menuBarThickness: 25
+ )
+ let layout = NotchGeometry.layout(for: portrait, mode: .expanded)
+ // Usable width: 900 - 16 - 12 = 872, which is exactly 16:10 at 545 high.
+ #expect(layout.panel == CGRect(x: 8, y: 990, width: 884, height: 610))
+ #expect(layout.content == CGRect(x: 14, y: 996, width: 872, height: 545))
+ }
+
+ @Test func aVirtualNotchIsOnWholePoints() {
+ var odd = external
+ odd.frame.size.width = 1365
+ odd.visibleFrame.size.width = 1365
+ let layout = NotchGeometry.layout(for: odd, mode: .peek)
+ #expect(layout.notch.minX == layout.notch.minX.rounded())
+ #expect(layout.panel.minX == layout.panel.minX.rounded())
+ }
+
+ @Test func mapsSurfacePointsToRenderPixels() {
+ let render = PixelSize(width: 1024, height: 640)
+ let peek = CGSize(width: 640, height: 400)
+ func point(_ p: CGPoint, _ surface: CGSize) -> [UInt16]? {
+ NotchGeometry.videoPoint(p, surfaceSize: surface, renderSize: render).map { [$0.x, $0.y] }
+ }
+ #expect(point(CGPoint(x: 320, y: 200), peek) == [512, 320])
+ #expect(point(.zero, peek) == [0, 0])
+ #expect(point(CGPoint(x: 0.5, y: 0.5), peek) == [0, 0])
+ #expect(point(CGPoint(x: 640, y: 400), peek) == [1023, 639])
+ #expect(point(CGPoint(x: -5, y: 900), peek) == [0, 639])
+ // Letterboxing: a wider surface gets 30 pt bars at the sides, a taller one 50 pt above and below.
+ #expect(point(CGPoint(x: 30, y: 0), CGSize(width: 700, height: 400)) == [0, 0])
+ #expect(point(CGPoint(x: 0, y: 450), CGSize(width: 640, height: 500)) == [0, 639])
+ #expect(point(CGPoint(x: 0, y: 50), CGSize(width: 640, height: 500)) == [0, 0])
+ }
+
+ @Test func nothingToMapOntoGivesNoPoint() {
+ let render = PixelSize(width: 1024, height: 640)
+ let peek = CGSize(width: 640, height: 400)
+ #expect(NotchGeometry.videoPoint(.zero, surfaceSize: .zero, renderSize: render) == nil)
+ #expect(NotchGeometry.videoPoint(CGPoint(x: CGFloat.nan, y: 5), surfaceSize: peek, renderSize: render) == nil)
+ #expect(NotchGeometry.videoPoint(.zero, surfaceSize: CGSize(width: CGFloat.infinity, height: CGFloat.infinity), renderSize: render) == nil)
+ #expect(NotchGeometry.videoPoint(.zero, surfaceSize: CGSize(width: CGFloat.leastNonzeroMagnitude, height: 1), renderSize: render) == nil)
+ #expect(NotchGeometry.videoPoint(.zero, surfaceSize: peek, renderSize: PixelSize(width: 70_000, height: 640)) == nil)
+ #expect(NotchGeometry.videoPoint(.zero, surfaceSize: peek, renderSize: PixelSize(width: 0, height: 640)) == nil)
+ }
+}
diff --git a/Tests/KnotchyCoreTests/RuntimeInstallerTests.swift b/Tests/KnotchyCoreTests/RuntimeInstallerTests.swift
new file mode 100644
index 0000000..c89c120
--- /dev/null
+++ b/Tests/KnotchyCoreTests/RuntimeInstallerTests.swift
@@ -0,0 +1,83 @@
+import CryptoKit
+import Foundation
+import Testing
+@testable import KnotchyCore
+
+// Serialized: each download clears leftover download folders, which other tests would still be using.
+@Suite(.serialized)
+struct RuntimeInstallerTests {
+ private func release(for data: Data, url: URL) -> ContainerRuntime.Release {
+ let hash = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ return ContainerRuntime.Release(version: "1.4.1", url: url, sha256: hash, size: Int64(data.count))
+ }
+
+ private func source(_ data: Data) throws -> URL {
+ let file = FileManager.default.temporaryDirectory.appendingPathComponent("source-\(UUID().uuidString).pkg")
+ try data.write(to: file)
+ return file
+ }
+
+ private static func downloadDirectories() -> Set {
+ let names = (try? FileManager.default.contentsOfDirectory(atPath: NSTemporaryDirectory())) ?? []
+ return Set(names.filter { $0.hasPrefix("knotchy-runtime-") })
+ }
+
+ @Test func aMatchingDownloadIsKeptUntilDiscarded() async throws {
+ let data = Data("package".utf8)
+ let url = try source(data)
+ defer { try? FileManager.default.removeItem(at: url) }
+ let installer = SystemRuntimeInstaller()
+ let package = try await installer.download(release(for: data, url: url)) { _ in }
+ #expect(try Data(contentsOf: package.file) == data)
+ #expect(package.file.lastPathComponent == url.lastPathComponent)
+ installer.discard(package)
+ #expect(!FileManager.default.fileExists(atPath: package.directory.path))
+ }
+
+ @Test func aMismatchedDownloadIsDeleted() async throws {
+ let url = try source(Data("tampered".utf8))
+ defer { try? FileManager.default.removeItem(at: url) }
+ let expected = release(for: Data("package".utf8), url: url)
+ await #expect(throws: DownloadError.mismatch) {
+ _ = try await SystemRuntimeInstaller().download(expected) { _ in }
+ }
+ #expect(Self.downloadDirectories().isEmpty)
+ }
+
+ @Test func aNewDownloadClearsLeftovers() async throws {
+ let leftover = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("knotchy-runtime-LEFTOVER")
+ try FileManager.default.createDirectory(at: leftover, withIntermediateDirectories: true)
+ let data = Data("package".utf8)
+ let url = try source(data)
+ defer { try? FileManager.default.removeItem(at: url) }
+ let installer = SystemRuntimeInstaller()
+ let package = try await installer.download(release(for: data, url: url)) { _ in }
+ #expect(!FileManager.default.fileExists(atPath: leftover.path))
+ installer.discard(package)
+ }
+
+ @Test func aFailedDownloadSaysSo() async {
+ let missing = URL(fileURLWithPath: "/nonexistent/container.pkg")
+ await #expect(throws: DownloadError.self) {
+ _ = try await SystemRuntimeInstaller().download(release(for: Data(), url: missing)) { _ in }
+ }
+ }
+
+ // Only this path of the real `openInInstaller` may run in tests: any other would open Installer.
+ @MainActor
+ @Test func aChangedPackageIsNotOpened() async throws {
+ let data = Data("package".utf8)
+ let url = try source(data)
+ defer { try? FileManager.default.removeItem(at: url) }
+ let installer = SystemRuntimeInstaller()
+ let expected = release(for: data, url: url)
+ let package = try await installer.download(expected) { _ in }
+ try Data("pAckage".utf8).write(to: package.file) // Same size, so the hash has to catch it.
+ guard case .changed = await installer.openInInstaller(package, release: expected) else {
+ Issue.record("Expected the changed package not to be opened")
+ installer.discard(package)
+ return
+ }
+ #expect(!FileManager.default.fileExists(atPath: package.directory.path))
+ }
+}
diff --git a/Tests/KnotchyCoreTests/SessionLifecycleTests.swift b/Tests/KnotchyCoreTests/SessionLifecycleTests.swift
new file mode 100644
index 0000000..95eba2b
--- /dev/null
+++ b/Tests/KnotchyCoreTests/SessionLifecycleTests.swift
@@ -0,0 +1,372 @@
+import Foundation
+import Testing
+@testable import KnotchyCore
+
+@MainActor
+@Suite(.timeLimit(.minutes(1)))
+struct SessionLifecycleTests {
+ private let first = "knotchy-00000001"
+ private let second = "knotchy-00000002"
+
+ @Test func launchSweepsOrphansFirst() async {
+ let h = Harness()
+ await h.controller.waitForBackendIdle()
+ #expect(h.backend.calls == [.sweep(keeping: [])])
+ #expect(h.state == .idle)
+ }
+
+ @Test func aLinkBootsConnectsAndLoadsThenPeeks() async {
+ let h = Harness()
+ h.backend.hold(.start)
+ h.connector.hold()
+ h.controller.open(Harness.url)
+ #expect(h.state == .starting(.booting, folded: false))
+ #expect(h.controller.openedURL == Harness.url)
+ #expect(h.controller.liveIDs.snapshot == [first])
+
+ await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
+ #expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(first)])
+ #expect(h.backend.startArguments == [.init(id: first, url: Harness.url,
+ renderSize: PixelSize(width: 1024, height: 640),
+ blockCIDRs: ["192.168.1.0/24"])])
+
+ h.backend.release(.start)
+ await waitUntil("connecting") { h.state == .starting(.connecting, folded: false) }
+ await waitUntil("connect runs") { h.connector.parkedCount == 1 }
+ #expect(h.connector.socketPaths == ["/fake/\(first).sock"])
+
+ h.connector.release()
+ await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
+ let stream = h.connector.connections[0]
+ #expect(h.controller.connection === stream)
+
+ stream.emit(.hello(version: 1, size: SessionController.renderSize))
+ stream.emit(.contentReady)
+ await waitUntil("live") { h.state == .live(.peek) }
+ }
+
+ @Test func thePageURLFollowsTheVMButNeverReplacesTheOpenedLink() async {
+ let h = Harness()
+ let stream = await h.goLive()
+ #expect(h.controller.pageURL == nil)
+ let next = URL(string: "https://example.org/next")!
+ stream.emit(.pageURL(next))
+ await waitUntil("the page URL shows") { h.controller.pageURL == next }
+ #expect(h.controller.openedURL == Harness.url)
+
+ h.controller.open(URL(string: "https://example.net/")!)
+ #expect(h.controller.pageURL == nil)
+ }
+
+ @Test func destroyIsConfirmedBeforeDestroyedShowsThenGoesIdle() async {
+ let h = Harness()
+ let stream = await h.goLive()
+ h.backend.hold(.destroy)
+ h.controller.destroy()
+ #expect(h.state == .destroying)
+ #expect(stream.isClosed)
+ #expect(h.controller.connection == nil)
+
+ await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
+ #expect(h.state == .destroying)
+ #expect(h.controller.liveIDs.snapshot == [first])
+
+ h.backend.release(.destroy)
+ await waitUntil("destroyed") { h.state == .destroyed }
+ #expect(h.controller.liveIDs.snapshot.isEmpty)
+ h.advance(by: .milliseconds(1499))
+ #expect(h.state == .destroyed)
+ h.advance(by: .milliseconds(1))
+ #expect(h.state == .idle)
+ #expect(h.controller.openedURL == nil)
+ }
+
+ @Test(arguments: [LiveMode.peek, .expanded, .collapsed])
+ func destroyWorksFromEveryLiveMode(_ mode: LiveMode) async {
+ let h = Harness()
+ await h.goLive()
+ h.enter(mode)
+ #expect(h.state == .live(mode))
+ h.controller.destroy()
+ #expect(h.state == .destroying)
+ #expect(h.controller.countdown == nil)
+ await waitUntil("destroyed") { h.state == .destroyed }
+ }
+
+ @Test func destroyDuringPreflightNeverCallsTheBackendsDestroy() async {
+ let h = Harness()
+ h.backend.hold(.preflight)
+ h.controller.open(Harness.url)
+ await waitUntil("preflight runs") { h.backend.parkedCount(.preflight) == 1 }
+ h.controller.destroy()
+ h.backend.release(.preflight)
+ await waitUntil("destroyed") { h.state == .destroyed }
+ await h.controller.waitForBackendIdle()
+ #expect(h.backend.calls == [.sweep(keeping: []), .preflight])
+ #expect(h.controller.liveIDs.snapshot.isEmpty)
+ }
+
+ @Test func destroyWhileConnectingClosesTheLateStream() async {
+ let h = Harness()
+ h.connector.hold()
+ h.controller.open(Harness.url)
+ await waitUntil("connect runs") { h.connector.parkedCount == 1 }
+ h.controller.destroy()
+ await waitUntil("destroyed") { h.state == .destroyed }
+ h.connector.release()
+ await waitUntil("the late stream is closed") { h.connector.connections.first?.isClosed == true }
+ #expect(h.state == .destroyed)
+ #expect(h.controller.connection == nil)
+ h.advance(by: .milliseconds(1500))
+ #expect(h.state == .idle)
+ }
+
+ @Test func aConnectThatFailsAfterDestroyIsIgnored() async {
+ let h = Harness()
+ h.connector.hold()
+ h.connector.failNextConnect(with: .timedOut)
+ h.backend.hold(.destroy)
+ h.controller.open(Harness.url)
+ await waitUntil("connect runs") { h.connector.parkedCount == 1 }
+ h.controller.destroy()
+ await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
+ h.connector.release()
+ await h.controller.waitForSessionTasks()
+ #expect(h.state == .destroying) // Not the failed connect's error card.
+ h.backend.release(.destroy)
+ await waitUntil("destroyed") { h.state == .destroyed }
+ }
+
+ @Test func destroyWhileBootingDestroysTheVMOnceStartReturns() async {
+ let h = Harness()
+ h.backend.hold(.start)
+ h.controller.open(Harness.url)
+ await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
+ h.controller.destroy()
+ #expect(h.state == .destroying)
+ h.backend.release(.start)
+ await waitUntil("destroyed") { h.state == .destroyed }
+ #expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(first), .destroy(first)])
+ #expect(h.connector.socketPaths.isEmpty)
+ }
+
+ @Test func aStuckDestroyShowsTheAmberPillAndCanBeRetried() async {
+ let h = Harness()
+ await h.goLive()
+ h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
+ h.controller.destroy()
+ await waitUntil("stuck") { h.state == .failed(.destroyStuck) }
+ #expect(h.controller.liveIDs.snapshot == [first])
+
+ h.backend.failDestroys(with: nil)
+ h.controller.retry()
+ #expect(h.state == .destroying)
+ await waitUntil("destroyed") { h.state == .destroyed }
+ #expect(h.backend.calls.filter { $0 == .destroy(first) }.count == 2)
+ #expect(h.controller.liveIDs.snapshot.isEmpty)
+ }
+
+ @Test func aRetryNeverRetriesItself() async {
+ let h = Harness()
+ await h.goLive()
+ h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
+ h.controller.destroy()
+ await waitUntil("stuck") { h.state == .failed(.destroyStuck) }
+
+ h.controller.retry() // Fails again: its own job must not also force-destroy the VM.
+ await h.controller.waitForBackendIdle()
+ #expect(h.state == .failed(.destroyStuck))
+
+ h.backend.failDestroys(with: nil)
+ h.controller.retry()
+ await waitUntil("destroyed") { h.state == .destroyed }
+ await h.controller.waitForBackendIdle()
+ #expect(!h.backend.calls.contains(.forceDestroy(first)))
+ }
+
+ @Test func theStuckPillGoesIdleAfter30sAndTheVMStaysListed() async {
+ let h = Harness()
+ await h.goLive()
+ h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
+ h.controller.destroy()
+ await waitUntil("stuck") { h.state == .failed(.destroyStuck) }
+ h.advance(by: .seconds(29))
+ #expect(h.state == .failed(.destroyStuck))
+ h.advance(by: .seconds(1))
+ #expect(h.state == .idle)
+ #expect(h.controller.liveIDs.snapshot == [first])
+ }
+
+ @Test func eachLaterJobRetriesAStuckVMOnce() async {
+ let h = Harness()
+ await h.goLive()
+ h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
+ h.controller.destroy()
+ await waitUntil("stuck") { h.state == .failed(.destroyStuck) }
+ await h.controller.waitForBackendIdle()
+ #expect(!h.backend.calls.contains(.forceDestroy(first)))
+
+ h.backend.failDestroys(with: nil)
+ h.backend.failForceDestroys(with: VMBackendError.destroyStuck(id: first))
+ await h.goLive(Harness.otherURL)
+ await h.controller.waitForBackendIdle()
+ #expect(h.backend.calls.filter { $0 == .forceDestroy(first) }.count == 1)
+ #expect(h.controller.liveIDs.snapshot == [first, second])
+
+ h.backend.failForceDestroys(with: nil)
+ h.controller.destroy() // Destroying `second` is a later job: `first` gets another retry.
+ await waitUntil("destroyed") { h.state == .destroyed }
+ await h.controller.waitForBackendIdle()
+ #expect(h.backend.calls.filter { $0 == .forceDestroy(first) }.count == 2)
+ #expect(h.controller.liveIDs.snapshot.isEmpty)
+ }
+
+ @Test func stuckVMsTakeTurnsForRetries() async {
+ let h = Harness()
+ h.backend.failDestroys(with: VMBackendError.destroyStuck(id: "any"))
+ h.backend.failForceDestroys(with: VMBackendError.destroyStuck(id: "any"))
+ await h.goLive()
+ h.controller.destroy()
+ await waitUntil("the first VM is stuck") { h.state == .failed(.destroyStuck) }
+ await h.goLive(Harness.otherURL)
+ h.controller.destroy()
+ await waitUntil("the second VM is stuck") { h.state == .failed(.destroyStuck) }
+ await h.goLive()
+ await h.controller.waitForBackendIdle()
+ let retries = h.backend.calls.filter { if case .forceDestroy = $0 { true } else { false } }
+ #expect(retries == [.forceDestroy(first), .forceDestroy(first), .forceDestroy(second)])
+ }
+
+ @Test func aStartFailureShowsTheErrorCardAndQueuesDestroy() async {
+ let h = Harness()
+ h.backend.failStarts(with: VMBackendError.failed("container run timed out"))
+ h.controller.open(Harness.url)
+ await waitUntil("failed") { h.state == .failed(.start("container run timed out")) }
+ await waitUntil("the VM is destroyed") { h.backend.calls.contains(.destroy(first)) }
+ await waitUntil("gone") { h.controller.liveIDs.snapshot.isEmpty }
+
+ h.backend.failStarts(with: nil)
+ h.controller.retry()
+ #expect(h.state == .starting(.booting, folded: false))
+ #expect(h.controller.openedURL == Harness.url)
+ await waitUntil("the retry starts") { h.backend.calls.contains(.start(second)) }
+ }
+
+ @Test func dismissingTheErrorCardGoesIdle() async {
+ let h = Harness()
+ h.backend.failStarts(with: VMBackendError.failed("boom"))
+ h.controller.open(Harness.url)
+ await waitUntil("failed") { h.state == .failed(.start("boom")) }
+ h.controller.dismiss()
+ #expect(h.state == .idle)
+ #expect(h.controller.openedURL == nil)
+ }
+
+ @Test func aConnectFailureFailsTheStart() async {
+ let h = Harness()
+ h.connector.failNextConnect(with: .timedOut)
+ h.controller.open(Harness.url)
+ await waitUntil("failed") { h.state == .failed(.start("The VM didn't answer in time.")) }
+ await waitUntil("the VM is destroyed") { h.backend.calls.contains(.destroy(first)) }
+ }
+
+ @Test func aVersionMismatchAsksForARebuildAndDestroysTheVM() async {
+ let h = Harness()
+ h.connector.failNextConnect(with: .versionMismatch(found: 0, need: 1))
+ h.backend.hold(.destroy)
+ h.controller.open(Harness.url)
+ await waitUntil("setup required") {
+ h.state == .setupRequired(.imageOutdated(found: 0, need: 1), error: nil)
+ }
+ await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
+ #expect(h.controller.liveIDs.snapshot == [first]) // Kept until the destroy is confirmed.
+ h.backend.release(.destroy)
+ await waitUntil("gone") { h.controller.liveIDs.snapshot.isEmpty }
+ }
+
+ @Test func theStreamEndingWhileStartingFailsTheStart() async {
+ let h = Harness()
+ let stream = await h.openAndConnect()
+ stream.emit(.ended(.noFirstFrame))
+ await waitUntil("failed") { h.state == .failed(.start("The VM didn't send a first frame in time.")) }
+ await waitUntil("the VM is destroyed") { h.backend.calls.contains(.destroy(first)) }
+ }
+
+ @Test func theStreamEndingWhileLiveShowsSessionEndedFor3s() async {
+ let h = Harness()
+ let stream = await h.goLive()
+ stream.emit(.ended(.closed))
+ await waitUntil("ended") { h.state == .failed(.ended(.closed)) }
+ await waitUntil("the VM is destroyed") { h.backend.calls.contains(.destroy(first)) }
+ h.advance(by: .milliseconds(2999))
+ #expect(h.state == .failed(.ended(.closed)))
+ h.advance(by: .milliseconds(1))
+ #expect(h.state == .idle)
+ }
+
+ @Test(arguments: [LiveMode.peek, .expanded, .collapsed])
+ func theStreamEndingInEveryLiveModeShowsSessionEnded(_ mode: LiveMode) async {
+ let h = Harness()
+ let stream = await h.goLive()
+ h.enter(mode)
+ stream.emit(.ended(.decodeErrors))
+ await waitUntil("ended") { h.state == .failed(.ended(.decodeErrors)) }
+ #expect(h.controller.countdown == nil)
+ #expect(h.controller.connection == nil)
+ }
+
+ @Test func aStreamThatFinishesWithoutEndedStillEnds() async {
+ let h = Harness()
+ let stream = await h.goLive()
+ stream.finishEvents()
+ await waitUntil("ended") { h.state == .failed(.ended(.closed)) }
+ await waitUntil("the VM is destroyed") { h.backend.calls.contains(.destroy(first)) }
+ }
+
+ @Test func theAppQuitsItselfAfter60sIdle() {
+ let h = Harness()
+ h.advance(by: .seconds(59))
+ #expect(h.recorder.quitRequests == 0)
+ h.advance(by: .seconds(1))
+ #expect(h.recorder.quitRequests == 1)
+ }
+
+ @Test func theIdleQuitTimerRestartsWheneverIdleStarts() async {
+ let h = Harness()
+ h.advance(by: .seconds(30))
+ await h.goLive()
+ h.advance(by: .seconds(60))
+ #expect(h.recorder.quitRequests == 0)
+
+ h.controller.destroy()
+ await waitUntil("destroyed") { h.state == .destroyed }
+ h.advance(by: .milliseconds(1500))
+ #expect(h.state == .idle)
+ h.advance(by: .seconds(59))
+ #expect(h.recorder.quitRequests == 0)
+ h.advance(by: .seconds(1))
+ #expect(h.recorder.quitRequests == 1)
+ }
+
+ @Test func noLinkOpensOnceTheChainIsClosedForQuitting() async {
+ let h = Harness()
+ h.controller.closeBackendChain()
+ h.controller.open(Harness.url)
+ #expect(h.state == .idle)
+ }
+
+ @Test func theQuitSnapshotIncludesAStartInFlight() async {
+ let h = Harness()
+ h.backend.hold(.start)
+ h.controller.open(Harness.url)
+ await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
+ h.controller.closeBackendChain()
+ #expect(h.controller.liveIDs.snapshot == [first])
+
+ // Nothing may destroy the VM behind the quit path's back.
+ h.backend.release(.start)
+ await h.controller.waitForBackendIdle()
+ #expect(!h.backend.calls.contains(.destroy(first)))
+ #expect(h.controller.liveIDs.snapshot == [first])
+ }
+}
diff --git a/Tests/KnotchyCoreTests/SessionNewLinkTests.swift b/Tests/KnotchyCoreTests/SessionNewLinkTests.swift
new file mode 100644
index 0000000..39b955e
--- /dev/null
+++ b/Tests/KnotchyCoreTests/SessionNewLinkTests.swift
@@ -0,0 +1,204 @@
+import Foundation
+import Testing
+@testable import KnotchyCore
+
+@MainActor
+@Suite(.timeLimit(.minutes(1)))
+struct SessionNewLinkTests {
+ private let first = "knotchy-00000001"
+ private let second = "knotchy-00000002"
+
+ @Test func fromLiveTheOldVMIsDestroyedAndTheNewOneStartsAtOnce() async {
+ let h = Harness()
+ let old = await h.goLive()
+ h.controller.open(Harness.otherURL)
+ #expect(h.state == .starting(.booting, folded: false))
+ #expect(h.controller.openedURL == Harness.otherURL)
+ #expect(old.isClosed)
+ await h.controller.waitForBackendIdle()
+ #expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(first), .destroy(first), .preflight, .start(second)])
+ }
+
+ @Test func aStartThatHasNotBegunNeverRuns() async {
+ let h = Harness()
+ h.controller.open(Harness.url)
+ h.controller.open(Harness.otherURL)
+ await h.controller.waitForBackendIdle()
+ #expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(second)])
+ #expect(h.controller.liveIDs.snapshot == [second])
+ }
+
+ @Test func aSupersededStartIsDestroyedRightAfterItReturns() async {
+ let h = Harness()
+ h.backend.hold(.start)
+ h.controller.open(Harness.url)
+ await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
+ h.controller.open(Harness.otherURL)
+ #expect(h.controller.liveIDs.snapshot == [first, second])
+
+ h.backend.release(.start)
+ await h.controller.waitForBackendIdle()
+ #expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(first), .destroy(first), .preflight, .start(second)])
+ await waitUntil("the new session connects") { h.connector.socketPaths == ["/fake/\(second).sock"] }
+ #expect(h.controller.liveIDs.snapshot == [second])
+ }
+
+ @Test func aConnectThatReturnsAfterANewLinkIsClosed() async {
+ let h = Harness()
+ h.connector.hold()
+ h.controller.open(Harness.url)
+ await waitUntil("connect runs") { h.connector.parkedCount == 1 }
+ h.backend.hold(.start)
+ h.controller.open(Harness.otherURL)
+ await waitUntil("the new start runs") { h.backend.parkedCount(.start) == 1 }
+
+ h.connector.release()
+ await waitUntil("the late stream is closed") { h.connector.connections.first?.isClosed == true }
+ #expect(h.state == .starting(.booting, folded: false))
+ #expect(h.controller.connection == nil)
+
+ h.backend.release(.start)
+ await waitUntil("the new session connects") { h.state == .starting(.loading, folded: false) }
+ #expect((h.controller.connection as? FakeStreamConnection)?.socketPath == "/fake/\(second).sock")
+ await h.controller.waitForBackendIdle()
+ #expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(first), .destroy(first), .preflight, .start(second)])
+ }
+
+ @Test func aSupersededStartThatFailsLeavesTheNewSessionAlone() async {
+ let h = Harness()
+ h.backend.hold(.start)
+ h.backend.hold(.destroy)
+ h.controller.open(Harness.url)
+ await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
+ h.controller.open(Harness.otherURL)
+ h.backend.failStarts(with: VMBackendError.failed("boom"))
+ h.backend.release(.start)
+ await waitUntil("the old VM's destroy runs") { h.backend.parkedCount(.destroy) == 1 }
+ #expect(h.state == .starting(.booting, folded: false)) // Not the old start's error card.
+ h.backend.failStarts(with: nil)
+ h.backend.release(.destroy)
+ await waitUntil("the new session connects") { h.state == .starting(.loading, folded: false) }
+ }
+
+ @Test func anOldSessionsStuckDestroyDoesNotDriveTheCurrentPill() async {
+ let h = Harness()
+ await h.goLive()
+ h.backend.hold(.destroy)
+ h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
+ h.controller.destroy()
+ await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
+ h.controller.open(Harness.otherURL)
+ h.controller.destroy() // The new session never started a VM, so this one succeeds.
+ h.backend.release(.destroy)
+ await h.controller.waitForBackendIdle()
+ #expect(h.state == .destroyed)
+ }
+
+ @Test func liveIDsCoverASupersededSessionUntilItsDestroyIsConfirmed() async {
+ let h = Harness()
+ await h.goLive()
+ h.backend.hold(.destroy)
+ h.controller.open(Harness.otherURL)
+ await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
+ #expect(h.controller.liveIDs.snapshot == [first, second])
+ h.backend.release(.destroy)
+ await h.controller.waitForBackendIdle()
+ #expect(h.controller.liveIDs.snapshot == [second])
+ }
+
+ @Test func fromDestroyingTheNewSessionStartsAfterTheDestroy() async {
+ let h = Harness()
+ await h.goLive()
+ h.backend.hold(.destroy)
+ h.controller.destroy()
+ await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
+ h.controller.open(Harness.otherURL)
+ #expect(h.state == .starting(.booting, folded: false))
+
+ h.backend.release(.destroy)
+ await h.controller.waitForBackendIdle()
+ #expect(h.backend.calls.suffix(3) == [.destroy(first), .preflight, .start(second)])
+ await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
+ }
+
+ @Test func fromTheCollapsedPillTheNewLinkOpensThePanel() async {
+ let h = Harness()
+ await h.goLive()
+ h.controller.clickedOutside()
+ h.controller.open(Harness.otherURL)
+ #expect(h.state == .starting(.booting, folded: false))
+ #expect(h.controller.countdown == nil)
+ }
+
+ @Test func fromDestroyedTheCheckmarkIsCutShort() async {
+ let h = Harness()
+ await h.goLive()
+ h.controller.destroy()
+ await waitUntil("destroyed") { h.state == .destroyed }
+ h.controller.open(Harness.otherURL)
+ #expect(h.state == .starting(.booting, folded: false))
+ h.advance(by: .seconds(2))
+ #expect(h.state != .idle)
+ }
+
+ @Test func fromSessionEndedThePillIsCutShort() async {
+ let h = Harness()
+ let stream = await h.goLive()
+ stream.emit(.ended(.closed))
+ await waitUntil("ended") { h.state == .failed(.ended(.closed)) }
+ h.controller.open(Harness.otherURL)
+ #expect(h.state == .starting(.booting, folded: false))
+ h.advance(by: .seconds(3))
+ #expect(h.state != .idle)
+ }
+
+ @Test func fromTheErrorCardTheNewLinkStarts() async {
+ let h = Harness()
+ h.backend.failStarts(with: VMBackendError.failed("boom"))
+ h.controller.open(Harness.url)
+ await waitUntil("failed") { h.state == .failed(.start("boom")) }
+ h.backend.failStarts(with: nil)
+ h.controller.open(Harness.otherURL)
+ #expect(h.state == .starting(.booting, folded: false))
+ await waitUntil("the new start runs") { h.backend.calls.contains(.start(second)) }
+ }
+
+ @Test func fromAStuckDestroyTheStuckVMStaysOnPendingCleanup() async {
+ let h = Harness()
+ await h.goLive()
+ h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
+ h.backend.failForceDestroys(with: VMBackendError.destroyStuck(id: first))
+ h.controller.destroy()
+ await waitUntil("stuck") { h.state == .failed(.destroyStuck) }
+ h.controller.open(Harness.otherURL)
+ #expect(h.state == .starting(.booting, folded: false))
+ await h.controller.waitForBackendIdle()
+ #expect(h.controller.liveIDs.snapshot == [first, second])
+ }
+
+ @Test func fromSetupRequiredPreflightRunsAgain() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.imageMissing))
+ h.controller.open(Harness.url)
+ await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
+ h.controller.open(Harness.otherURL)
+ #expect(h.state == .starting(.booting, folded: false))
+ await h.controller.waitForBackendIdle()
+ #expect(h.backend.calls == [.sweep(keeping: []), .preflight, .preflight, .start(second)])
+ }
+
+ @Test func backendCallsNeverOverlap() async {
+ let h = Harness()
+ h.backend.hold(.start)
+ h.controller.open(Harness.url)
+ await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
+ h.controller.open(Harness.otherURL)
+ h.controller.open(Harness.url)
+ h.controller.destroy()
+ h.controller.open(Harness.otherURL)
+ h.backend.release(.start)
+ await h.controller.waitForBackendIdle()
+ #expect(h.backend.maxConcurrentCalls == 1)
+ #expect(h.controller.liveIDs.snapshot == ["knotchy-00000004"])
+ }
+}
diff --git a/Tests/KnotchyCoreTests/SessionPanelTests.swift b/Tests/KnotchyCoreTests/SessionPanelTests.swift
new file mode 100644
index 0000000..ac58cc4
--- /dev/null
+++ b/Tests/KnotchyCoreTests/SessionPanelTests.swift
@@ -0,0 +1,340 @@
+import Foundation
+import Testing
+@testable import KnotchyCore
+
+@MainActor
+@Suite(.timeLimit(.minutes(1)))
+struct SessionPanelTests {
+ @Test func aFreshPanelStaysOpenUntilEnteredOrFor30s() async {
+ let h = Harness()
+ await h.goLive()
+ h.advance(by: .milliseconds(29_999))
+ #expect(h.state == .live(.peek))
+ h.advance(by: .milliseconds(1))
+ #expect(h.state == .live(.collapsed))
+ }
+
+ @Test func theFirstClickOutsideFoldsAnUnengagedPanel() async {
+ let h = Harness()
+ await h.goLive()
+ h.controller.clickedOutside()
+ #expect(h.state == .live(.collapsed))
+ }
+
+ @Test func anEnteredPeekFolds400msAfterThePointerLeaves() async {
+ let h = Harness()
+ await h.goLive()
+ h.controller.pointerEntered()
+ h.advance(by: .seconds(60))
+ #expect(h.state == .live(.peek)) // Entered: the 30 s fold no longer applies.
+
+ h.controller.pointerExited()
+ h.advance(by: .milliseconds(399))
+ #expect(h.state == .live(.peek))
+ h.advance(by: .milliseconds(1))
+ #expect(h.state == .live(.collapsed))
+ }
+
+ @Test func comingBackWithinTheGraceKeepsItOpen() async {
+ let h = Harness()
+ await h.goLive()
+ h.controller.pointerEntered()
+ h.controller.pointerExited()
+ h.advance(by: .milliseconds(200))
+ h.controller.pointerEntered()
+ h.advance(by: .seconds(1))
+ #expect(h.state == .live(.peek))
+ }
+
+ @Test func afterAClickInsideOnlyAClickOutsideFolds() async {
+ let h = Harness()
+ await h.goLive()
+ h.controller.pointerEntered()
+ h.controller.panelClicked()
+ h.controller.pointerExited()
+ h.advance(by: .seconds(60))
+ #expect(h.state == .live(.peek))
+ h.controller.clickedOutside()
+ #expect(h.state == .live(.collapsed))
+ }
+
+ @Test func expandedFoldsOnlyOnAClickOutside() async {
+ let h = Harness()
+ await h.goLive()
+ h.controller.pointerEntered()
+ h.controller.expand()
+ #expect(h.state == .live(.expanded))
+ h.controller.pointerExited()
+ h.advance(by: .seconds(60))
+ #expect(h.state == .live(.expanded))
+
+ h.controller.shrink()
+ #expect(h.state == .live(.peek))
+ h.controller.pointerEntered()
+ h.controller.pointerExited()
+ h.advance(by: .seconds(1))
+ #expect(h.state == .live(.peek)) // Still key after Expanded: pointer-away doesn't fold.
+
+ h.controller.expand()
+ h.controller.clickedOutside()
+ #expect(h.state == .live(.collapsed))
+ }
+
+ @Test func expandAndShrinkOnlyApplyToTheirModes() async {
+ let h = Harness()
+ h.controller.expand()
+ #expect(h.state == .idle)
+ await h.goLive()
+ h.controller.shrink()
+ #expect(h.state == .live(.peek))
+ h.controller.clickedOutside()
+ h.controller.expand()
+ #expect(h.state == .live(.collapsed))
+ }
+
+ @Test func hoveringThePillReopensPeek() async {
+ let h = Harness()
+ await h.goLive()
+ h.controller.clickedOutside()
+ h.controller.pointerEntered()
+ #expect(h.state == .live(.peek))
+ h.controller.pointerExited()
+ h.advance(by: .milliseconds(400))
+ #expect(h.state == .live(.collapsed))
+ }
+
+ @Test func theIdleCountdownRunsOnlyWhileCollapsedAndResetsOnReopen() async {
+ let h = Harness()
+ await h.goLive()
+ #expect(h.controller.countdown == nil)
+ h.controller.clickedOutside()
+ #expect(h.controller.countdown == .seconds(300))
+
+ h.advance(by: .seconds(240))
+ #expect(h.controller.countdown == .seconds(60))
+ h.controller.pointerEntered()
+ #expect(h.controller.countdown == nil)
+ h.advance(by: .seconds(120))
+ #expect(h.state == .live(.peek))
+
+ h.controller.clickedOutside()
+ #expect(h.controller.countdown == .seconds(300))
+ h.advance(by: .seconds(300) - .milliseconds(1))
+ #expect(h.state == .live(.collapsed))
+ h.advance(by: .milliseconds(1))
+ #expect(h.state == .destroying)
+ await waitUntil("destroyed") { h.state == .destroyed }
+ }
+
+ @Test func decodingPausesWhileCollapsed() async {
+ let h = Harness()
+ let stream = await h.goLive()
+ h.controller.clickedOutside()
+ #expect(stream.decodeChanges == [false])
+ h.controller.pointerEntered()
+ #expect(stream.decodeChanges == [false, true])
+ }
+
+ @Test func foldingWhileStartingShowsThePillAndStaysFoldedWhenReady() async {
+ let h = Harness()
+ h.connector.hold()
+ h.controller.open(Harness.url)
+ h.controller.clickedOutside()
+ #expect(h.state == .starting(.booting, folded: true))
+
+ await waitUntil("connecting") { h.state == .starting(.connecting, folded: true) }
+ h.connector.release()
+ await waitUntil("loading") { h.state == .starting(.loading, folded: true) }
+
+ let stream = h.connector.connections[0]
+ stream.emit(.contentReady)
+ await waitUntil("collapsed") { h.state == .live(.collapsed) }
+ #expect(h.controller.countdown == .seconds(300))
+ #expect(stream.decodeChanges == [false])
+ }
+
+ @Test func hoveringTheStartingPillUnfoldsIt() async {
+ let h = Harness()
+ h.backend.hold(.start)
+ h.controller.open(Harness.url)
+ h.controller.clickedOutside()
+ h.controller.pointerEntered()
+ #expect(h.state == .starting(.booting, folded: false))
+ h.controller.pointerExited()
+ h.advance(by: .milliseconds(400))
+ #expect(h.state == .starting(.booting, folded: true))
+ h.backend.release(.start)
+ }
+
+ @Test func aGraceFoldStartedWhileStartingCarriesIntoPeek() async {
+ let h = Harness()
+ let stream = await h.openAndConnect()
+ h.controller.pointerEntered()
+ h.controller.pointerExited()
+ h.advance(by: .milliseconds(200))
+ stream.emit(.contentReady)
+ await waitUntil("live") { h.state == .live(.peek) }
+ h.advance(by: .milliseconds(200))
+ #expect(h.state == .live(.collapsed))
+ }
+
+ @Test func retryKeepsThePanelEnteredWhenThePointerIsOnIt() async {
+ let h = Harness()
+ h.backend.failStarts(with: VMBackendError.failed("boom"))
+ h.controller.open(Harness.url)
+ await waitUntil("failed") { h.state == .failed(.start("boom")) }
+ h.backend.failStarts(with: nil)
+ h.controller.pointerEntered()
+ h.controller.retry()
+ await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
+ h.connector.connections[0].emit(.contentReady)
+ await waitUntil("live") { h.state == .live(.peek) }
+
+ h.advance(by: .seconds(30))
+ #expect(h.state == .live(.peek))
+ h.controller.pointerExited()
+ h.advance(by: .milliseconds(400))
+ #expect(h.state == .live(.collapsed))
+ }
+
+ @Test func pointerInputDoesNothingWithoutAnOpenPanel() async {
+ let h = Harness()
+ h.controller.clickedOutside()
+ h.controller.pointerEntered()
+ h.controller.pointerExited()
+ h.controller.panelClicked()
+ #expect(h.state == .idle)
+ }
+}
+
+@MainActor
+@Suite(.timeLimit(.minutes(1)))
+struct SessionProgressTests {
+ @Test func showsTheLatestProgressWhileStarting() async {
+ let h = Harness()
+ let stream = await h.openAndConnect()
+ stream.emit(.progress(10))
+ await waitUntil("10 %") { h.controller.progress == 10 }
+ stream.emit(.progress(60))
+ await waitUntil("60 %") { h.controller.progress == 60 }
+ h.advance(by: .seconds(10))
+ #expect(h.controller.progress == 60) // No hiding before content is ready.
+ }
+
+ @Test func hidesAtProgress100() async {
+ let h = Harness()
+ let stream = await h.openAndConnect()
+ stream.emit(.progress(30))
+ await waitUntil("30 %") { h.controller.progress == 30 }
+ stream.emit(.progress(100))
+ await waitUntil("hidden") { h.controller.progress == nil }
+ }
+
+ @Test func onceReadyItHides2sAfterTheLastProgress() async {
+ let h = Harness()
+ let stream = await h.openAndConnect()
+ stream.emit(.progress(30))
+ stream.emit(.contentReady)
+ await waitUntil("live") { h.state == .live(.peek) }
+ #expect(h.controller.progress == 30)
+ h.advance(by: .milliseconds(1999))
+ #expect(h.controller.progress == 30)
+ h.advance(by: .milliseconds(1))
+ #expect(h.controller.progress == nil)
+ }
+
+ @Test func aLoadThatNeverReports100StillHidesWhenReady() async {
+ let h = Harness()
+ let stream = await h.openAndConnect()
+ stream.emit(.progress(30))
+ await waitUntil("30 %") { h.controller.progress == 30 }
+ h.advance(by: .seconds(5))
+ stream.emit(.contentReady)
+ await waitUntil("live") { h.state == .live(.peek) }
+ #expect(h.controller.progress == nil)
+ }
+
+ @Test func endingASessionHidesTheBar() async {
+ let h = Harness()
+ let stream = await h.openAndConnect()
+ stream.emit(.progress(40))
+ await waitUntil("40 %") { h.controller.progress == 40 }
+ stream.emit(.ended(.closed))
+ await waitUntil("failed") { h.state == .failed(.start("The VM closed the connection.")) }
+ #expect(h.controller.progress == nil)
+ }
+
+ @Test func progressAfterReadyShowsAgainThenHides() async {
+ let h = Harness()
+ let stream = await h.goLive()
+ stream.emit(.progress(20))
+ await waitUntil("20 %") { h.controller.progress == 20 }
+ h.advance(by: .seconds(2))
+ #expect(h.controller.progress == nil)
+ }
+}
+
+@MainActor
+@Suite(.timeLimit(.minutes(1)))
+struct SessionToolbarTests {
+ @Test func navigateSendsTheURLAndUpdatesTheAddress() async throws {
+ let h = Harness()
+ let stream = await h.goLive()
+ let url = try #require(URL(string: "https://example.net/page"))
+ h.controller.navigate(url)
+ #expect(stream.sent == [.navigate("https://example.net/page")])
+ #expect(h.controller.openedURL == url)
+ }
+
+ @Test func backForwardAndReloadRunScripts() async {
+ let h = Harness()
+ let stream = await h.goLive()
+ h.controller.goBack()
+ h.controller.goForward()
+ h.controller.reload()
+ #expect(stream.sent == [.script("history.back()"), .script("history.forward()"), .script("location.reload()")])
+ }
+
+ @Test func toolbarActionsNeedALiveSession() async {
+ let h = Harness()
+ let stream = await h.openAndConnect()
+ h.controller.reload()
+ h.controller.navigate(Harness.otherURL)
+ #expect(stream.sent.isEmpty)
+ #expect(h.controller.openedURL == Harness.url)
+ }
+
+ @Test func aNonWebLinkShowsANoticeFor2s() async throws {
+ let h = Harness()
+ h.controller.open(try #require(URL(string: "ftp://files.example.com")))
+ #expect(h.controller.notice == .noWebLink)
+ #expect(h.state == .idle)
+ h.advance(by: .milliseconds(1999))
+ #expect(h.controller.notice == .noWebLink)
+ h.advance(by: .milliseconds(1))
+ #expect(h.controller.notice == nil)
+ }
+
+ @Test func navigateRejectsNonWebLinks() async throws {
+ let h = Harness()
+ let stream = await h.goLive()
+ h.controller.navigate(try #require(URL(string: "file:///etc/passwd")))
+ #expect(stream.sent.isEmpty)
+ #expect(h.controller.notice == .noWebLink)
+ #expect(h.controller.openedURL == Harness.url)
+ }
+
+ @Test func textWithoutALinkShowsANotice() {
+ let h = Harness()
+ h.controller.open(text: "call me maybe")
+ #expect(h.controller.notice == .noWebLink)
+ #expect(h.state == .idle)
+ }
+
+ @Test func textWithALinkOpensIt() {
+ let h = Harness()
+ h.controller.open(text: "Your parcel: https://example.com/ (tracking)")
+ #expect(h.state == .starting(.booting, folded: false))
+ #expect(h.controller.openedURL == Harness.url)
+ }
+}
diff --git a/Tests/KnotchyCoreTests/SessionRuntimeInstallTests.swift b/Tests/KnotchyCoreTests/SessionRuntimeInstallTests.swift
new file mode 100644
index 0000000..35c9431
--- /dev/null
+++ b/Tests/KnotchyCoreTests/SessionRuntimeInstallTests.swift
@@ -0,0 +1,82 @@
+import Foundation
+import Testing
+@testable import KnotchyCore
+
+@MainActor
+@Suite(.timeLimit(.minutes(1)))
+struct SessionRuntimeInstallTests {
+ @Test func aFreshInstallHoldsLinksThenStartsTheServiceAndBuilds() async {
+ let h = Harness()
+ let hold = await h.controller.beginRuntimeInstall(replacing: nil)
+ #expect((try? hold.get()) != nil)
+ #expect(h.state == .settingUp(.cliMissing))
+ #expect(h.controller.setupFolded)
+ h.controller.open(Harness.url)
+ #expect(h.state == .settingUp(.cliMissing))
+ await h.controller.waitForBackendIdle()
+ #expect(h.backend.calls == [.sweep(keeping: [])]) // The link waits: no preflight, no start.
+
+ h.backend.queuePreflight(.issue(.imageMissing))
+ #expect(await h.controller.finishRuntimeInstall(.installed) == .ready)
+ await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
+ #expect(Array(h.backend.calls.prefix(5))
+ == [.sweep(keeping: []), .fix(.serviceStopped), .preflight, .fix(.imageMissing), .preflight])
+ }
+
+ @Test func anUpdateStopsTheServiceFirst() async {
+ let h = Harness()
+ let hold = await h.controller.beginRuntimeInstall(replacing: "1.3.0")
+ #expect((try? hold.get()) != nil)
+ #expect(h.state == .settingUp(.cliOutdated(found: "1.3.0")))
+ #expect(h.backend.calls == [.sweep(keeping: []), .stopService])
+ }
+
+ @Test func aServiceThatWontStopEndsTheHold() async {
+ let h = Harness()
+ h.backend.failStopService(with: VMBackendError.failed("still running"))
+ guard case .failure(let refusal) = await h.controller.beginRuntimeInstall(replacing: "1.3.0") else {
+ Issue.record("The hold should have been refused")
+ return
+ }
+ #expect(refusal == .stopFailed("still running"))
+ #expect(h.state == .idle)
+ }
+
+ @Test func theHoldWaitsForTheBrowserToClose() async {
+ let h = Harness()
+ await h.goLive()
+ guard case .failure(let refusal) = await h.controller.beginRuntimeInstall(replacing: "1.3.0") else {
+ Issue.record("The hold should have been refused")
+ return
+ }
+ #expect(refusal == .sessionOpen)
+ #expect(h.state == .live(.peek))
+ #expect(!h.backend.calls.contains(.stopService))
+ }
+
+ @Test func theHoldIsAcceptedAfterAFailedStart() async {
+ let h = Harness()
+ h.backend.failStarts(with: VMBackendError.failed("boot failed"))
+ h.controller.open(Harness.url)
+ await waitUntil("the start fails") { if case .failed(.start) = h.state { true } else { false } }
+ let hold = await h.controller.beginRuntimeInstall(replacing: nil)
+ #expect((try? hold.get()) != nil)
+ #expect(h.state == .settingUp(.cliMissing))
+ }
+
+ @Test func aCancelledInstallGoesBackToIdle() async {
+ let h = Harness()
+ _ = await h.controller.beginRuntimeInstall(replacing: nil)
+ #expect(await h.controller.finishRuntimeInstall(.cancelled) == .needs(.cliMissing))
+ #expect(h.state == .idle)
+ }
+
+ @Test func aFailedInstallShowsItsCardToAWaitingLink() async {
+ let h = Harness()
+ _ = await h.controller.beginRuntimeInstall(replacing: nil)
+ h.controller.open(Harness.url)
+ #expect(await h.controller.finishRuntimeInstall(.failed("Couldn't install the runtime."))
+ == .failed(.cliMissing, "Couldn't install the runtime."))
+ #expect(h.state == .setupRequired(.cliMissing, error: "Couldn't install the runtime."))
+ }
+}
diff --git a/Tests/KnotchyCoreTests/SessionSetupRunTests.swift b/Tests/KnotchyCoreTests/SessionSetupRunTests.swift
new file mode 100644
index 0000000..f7b1782
--- /dev/null
+++ b/Tests/KnotchyCoreTests/SessionSetupRunTests.swift
@@ -0,0 +1,121 @@
+import Foundation
+import Testing
+@testable import KnotchyCore
+
+@MainActor
+@Suite(.timeLimit(.minutes(1)))
+struct SessionSetupRunTests {
+ @Test func aWindowRunFixesEachIssueInTurnAndEndsIdle() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.imageMissing))
+ #expect(await h.controller.runSetup(.serviceStopped, continuing: true) == .ready)
+ #expect(h.state == .idle)
+ #expect(h.backend.calls == [.sweep(keeping: []), .fix(.serviceStopped), .preflight, .fix(.imageMissing), .preflight])
+ }
+
+ @Test func aWindowRunStartsFolded() async {
+ let h = Harness()
+ h.backend.hold(.fix)
+ let run = Task { await h.controller.runSetup(.imageMissing, continuing: false) }
+ await waitUntil("building") { h.backend.parkedCount(.fix) == 1 }
+ #expect(h.state == .settingUp(.imageMissing))
+ #expect(h.controller.setupFolded)
+ h.backend.release(.fix)
+ #expect(await run.value == .ready)
+ }
+
+ @Test func aWindowRunNeverFixesTheSameIssueTwice() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.serviceStopped))
+ #expect(await h.controller.runSetup(.serviceStopped, continuing: true)
+ == .failed(.serviceStopped, SessionController.stillBrokenMessage))
+ #expect(h.state == .idle)
+ #expect(h.backend.calls.filter { $0 == .fix(.serviceStopped) }.count == 1)
+ }
+
+ @Test func anotherImageIssueAfterABuildIsTheSameFix() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.imageOutdated(found: 2, need: 3)))
+ #expect(await h.controller.runSetup(.imageMissing, continuing: true)
+ == .failed(.imageOutdated(found: 2, need: 3), SessionController.stillBrokenMessage))
+ #expect(h.backend.calls.filter { if case .fix = $0 { true } else { false } }.count == 1)
+ }
+
+ @Test func aFailedWindowRunLeavesNoCardWithoutALink() async {
+ let h = Harness()
+ h.backend.scriptFix(output: [], error: VMBackendError.failed("container build failed"))
+ #expect(await h.controller.runSetup(.imageMissing, continuing: true) == .failed(.imageMissing, "container build failed"))
+ #expect(h.state == .idle)
+ }
+
+ @Test func aLinkDuringAWindowRunWaitsForTheWholeChain() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.imageMissing))
+ h.backend.hold(.fix)
+ let run = Task { await h.controller.runSetup(.serviceStopped, continuing: true) }
+ await waitUntil("starting the service") { h.backend.parkedCount(.fix) == 1 }
+ h.controller.open(Harness.url)
+ #expect(h.state == .settingUp(.serviceStopped))
+ #expect(!h.controller.setupFolded) // A waiting link shows its card.
+ h.backend.release(.fix)
+ #expect(await run.value == .ready)
+ await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
+ #expect(Array(h.backend.calls.prefix(5))
+ == [.sweep(keeping: []), .fix(.serviceStopped), .preflight, .fix(.imageMissing), .preflight])
+ }
+
+ @Test func aBusyControllerRefusesTheWindow() async {
+ let h = Harness()
+ await h.goLive()
+ #expect(await h.controller.runSetup(.imageMissing, continuing: true) == nil)
+ #expect(await h.controller.checkSetup() == nil)
+ }
+
+ @Test func checkingStartsAStoppedServiceButNeverBuilds() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.serviceStopped), .issue(.imageMissing))
+ #expect(await h.controller.checkSetup() == .needs(.imageMissing))
+ #expect(h.state == .idle)
+ #expect(h.backend.calls == [.sweep(keeping: []), .preflight, .fix(.serviceStopped), .preflight])
+ }
+
+ @Test func checkingOpensALinkACardWasHolding() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.imageMissing))
+ h.controller.open(Harness.url)
+ await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
+ #expect(await h.controller.checkSetup() == .ready)
+ await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
+ #expect(h.controller.openedURL == Harness.url)
+ }
+
+ @Test func checkingAReadyMacFixesNothing() async {
+ let h = Harness()
+ #expect(await h.controller.checkSetup() == .ready)
+ #expect(!h.backend.calls.contains { if case .fix = $0 { true } else { false } })
+ }
+
+ @Test func setupOutputKeepsTheLast200Lines() async {
+ let h = Harness()
+ h.backend.scriptFix(output: (1...250).map { "line \($0)" })
+ _ = await h.controller.runSetup(.imageMissing, continuing: false)
+ #expect(h.controller.setupOutput.count == 200)
+ #expect(h.controller.setupOutput.first == "line 51")
+ #expect(h.controller.setupOutput.last == "line 250")
+ }
+
+ @Test func waitingForSetupReturnsOnlyWhenItEnds() async {
+ let h = Harness()
+ h.backend.hold(.fix)
+ let run = Task { await h.controller.runSetup(.imageMissing, continuing: false) }
+ await waitUntil("building") { h.backend.parkedCount(.fix) == 1 }
+ let waiter = Task {
+ await h.controller.waitWhileSettingUp()
+ return h.state
+ }
+ await Task.yield()
+ h.backend.release(.fix)
+ #expect(await waiter.value == .idle)
+ _ = await run.value
+ }
+}
diff --git a/Tests/KnotchyCoreTests/SessionSetupTests.swift b/Tests/KnotchyCoreTests/SessionSetupTests.swift
new file mode 100644
index 0000000..0bc8caa
--- /dev/null
+++ b/Tests/KnotchyCoreTests/SessionSetupTests.swift
@@ -0,0 +1,301 @@
+import Foundation
+import Testing
+@testable import KnotchyCore
+
+@MainActor
+@Suite(.timeLimit(.minutes(1)))
+struct SessionSetupTests {
+ @Test func aPreflightIssueNeedsSetupWithoutStartingAVM() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.imageMissing))
+ h.controller.open(Harness.url)
+ await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
+ await h.controller.waitForBackendIdle()
+ #expect(h.backend.calls == [.sweep(keeping: []), .preflight])
+ #expect(h.controller.liveIDs.snapshot.isEmpty)
+ }
+
+ @Test func theSetupActionShowsItsOutputThenTheLinkOpens() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.imageMissing))
+ h.backend.scriptFix(output: ["Step 1/9", "Step 9/9"])
+ h.backend.hold(.fix)
+ h.controller.open(Harness.url)
+ await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
+
+ h.controller.performSetupAction()
+ #expect(h.state == .settingUp(.imageMissing))
+ await waitUntil("the output shows") { h.controller.setupLog == "Step 9/9" }
+
+ h.backend.release(.fix)
+ await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
+ #expect(h.controller.openedURL == Harness.url)
+ #expect(h.backend.calls == [.sweep(keeping: []), .preflight, .fix(.imageMissing), .preflight, .start("knotchy-00000002")])
+ }
+
+ @Test func aSetupActionFoldsAwayAndItsLinkStartsFolded() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.imageMissing))
+ h.backend.hold(.fix)
+ h.controller.open(Harness.url)
+ await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
+ h.controller.clickedOutside()
+ #expect(!h.controller.setupFolded) // Only a running action folds.
+
+ h.controller.performSetupAction()
+ h.controller.clickedOutside()
+ #expect(h.controller.setupFolded)
+ h.controller.pointerEntered()
+ #expect(!h.controller.setupFolded)
+ h.controller.foldSetup()
+ #expect(h.controller.setupFolded)
+
+ h.backend.release(.fix)
+ await waitUntil("loading, folded") { h.state == .starting(.loading, folded: true) }
+ #expect(!h.controller.setupFolded)
+ }
+
+ @Test func aFoldedSetupOpenedByHoveringFoldsWhenThePointerLeaves() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.imageMissing))
+ h.backend.hold(.fix)
+ h.controller.open(Harness.url)
+ await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
+ h.controller.performSetupAction()
+ h.controller.foldSetup()
+
+ h.controller.pointerEntered()
+ #expect(!h.controller.setupFolded)
+ h.controller.pointerExited()
+ h.advance(by: SessionController.foldGrace)
+ #expect(h.controller.setupFolded)
+
+ h.controller.showPanel()
+ h.controller.pointerEntered()
+ h.controller.pointerExited()
+ h.advance(by: SessionController.foldGrace)
+ #expect(!h.controller.setupFolded)
+ }
+
+ @Test func aFailedSetupShowsTheErrorOnTheCard() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.imageMissing))
+ h.backend.scriptFix(output: [], error: VMBackendError.failed("container build failed"))
+ h.controller.open(Harness.url)
+ await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
+ h.controller.performSetupAction()
+ await waitUntil("the error shows") {
+ h.state == .setupRequired(.imageMissing, error: "container build failed")
+ }
+ }
+
+ @Test func aSecondIssueAfterSetupShowsItsOwnCard() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.imageMissing), .issue(.imageOutdated(found: 2, need: 3)))
+ h.controller.open(Harness.url)
+ await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
+ h.controller.performSetupAction()
+ await waitUntil("the next issue") { h.state == .setupRequired(.imageOutdated(found: 2, need: 3), error: nil) }
+ }
+
+ @Test func aMissingOrOldRuntimeOpensSetup() async {
+ for issue in [SetupIssue.cliMissing, .cliOutdated(found: "1.3.0")] {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(issue))
+ h.controller.open(Harness.url)
+ await waitUntil("setup required") { h.state == .setupRequired(issue, error: nil) }
+ h.controller.performSetupAction()
+ #expect(h.recorder.setupRequests == 1)
+ #expect(h.state == .setupRequired(issue, error: nil))
+ await h.controller.waitForBackendIdle()
+ #expect(!h.backend.calls.contains(.fix(issue)))
+ }
+ }
+
+ @Test func lowDiskNeedsSetupAndHasNoAction() async {
+ let h = Harness()
+ h.disk.available = 1_000_000_000
+ h.controller.open(Harness.url)
+ await waitUntil("setup required") { h.state == .setupRequired(.lowDisk(bytesFree: 1_000_000_000), error: nil) }
+ h.controller.performSetupAction()
+ #expect(h.state == .setupRequired(.lowDisk(bytesFree: 1_000_000_000), error: nil))
+ await h.controller.waitForBackendIdle()
+ #expect(h.backend.calls == [.sweep(keeping: []), .preflight])
+ }
+
+ @Test func dismissDropsThePendingLink() async {
+ // Only visible when setup runs again afterwards: here, a requested rebuild.
+ let h = Harness()
+ h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)),
+ .issue(.imageMissing))
+ await h.goLive()
+ h.controller.requestImageUpdate()
+ h.controller.open(Harness.otherURL)
+ await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
+
+ h.controller.dismiss()
+ #expect(h.state == .settingUp(.imageStale(builtDaysAgo: 20)))
+ await waitUntil("idle after the rebuild") { h.state == .idle }
+ #expect(!h.backend.calls.contains(.start("knotchy-00000003")))
+ }
+
+ @Test func aNewLinkDuringAnAutomaticStartOpensAfterIt() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.serviceStopped))
+ h.backend.hold(.fix)
+ h.controller.open(Harness.url)
+ await waitUntil("starting the service") { h.backend.parkedCount(.fix) == 1 }
+ #expect(h.state == .settingUp(.serviceStopped))
+ #expect(!h.controller.setupFolded)
+ h.controller.open(Harness.otherURL)
+ #expect(h.state == .settingUp(.serviceStopped))
+ h.backend.release(.fix)
+ await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
+ #expect(h.controller.openedURL == Harness.otherURL)
+ }
+
+ @Test func aStaleImageWarnsButNeverBlocks() async {
+ let h = Harness()
+ h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)))
+ await h.goLive()
+ #expect(h.controller.staleImage == StaleImage(builtDaysAgo: 20))
+ }
+
+ @Test func updateNowRebuildsOnceTheSessionEnds() async {
+ let h = Harness()
+ h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)))
+ await h.goLive()
+ h.controller.requestImageUpdate()
+ #expect(h.controller.staleImage == nil)
+
+ h.controller.destroy()
+ await waitUntil("destroyed") { h.state == .destroyed }
+ h.advance(by: .milliseconds(1500))
+ #expect(h.state == .settingUp(.imageStale(builtDaysAgo: 20)))
+ await waitUntil("idle after the rebuild") { h.state == .idle }
+ #expect(h.backend.calls.suffix(2) == [.fix(.imageStale(builtDaysAgo: 20)), .preflight])
+ }
+
+ @Test func aRebuildThatEndsInAnotherIssueShowsItsCard() async {
+ let h = Harness()
+ h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)),
+ .issue(.serviceStopped))
+ await h.goLive()
+ h.controller.requestImageUpdate()
+ h.controller.destroy()
+ await waitUntil("destroyed") { h.state == .destroyed }
+ h.advance(by: .milliseconds(1500))
+ await waitUntil("the next issue") { h.state == .setupRequired(.serviceStopped, error: nil) }
+ }
+
+ @Test func aFailedRebuildStillOpensAPendingLink() async {
+ let h = Harness()
+ h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)))
+ h.backend.scriptFix(output: [], error: VMBackendError.failed("container build failed"))
+ h.backend.hold(.fix)
+ await h.goLive()
+ h.controller.requestImageUpdate()
+ h.controller.destroy()
+ await waitUntil("destroyed") { h.state == .destroyed }
+ h.advance(by: .milliseconds(1500))
+ await waitUntil("rebuilding") { h.backend.parkedCount(.fix) == 1 }
+
+ h.controller.open(Harness.otherURL)
+ #expect(h.state == .settingUp(.imageStale(builtDaysAgo: 20)))
+ h.backend.release(.fix)
+ await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
+ #expect(h.controller.openedURL == Harness.otherURL)
+ }
+
+ @Test func aLinkOpenedAfterARebuildStartsUnengaged() async {
+ // The pointer was on the panel before it hid; nothing says it is there now.
+ let h = Harness()
+ h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)))
+ h.backend.hold(.fix)
+ await h.goLive()
+ h.controller.pointerEntered()
+ h.controller.requestImageUpdate()
+ h.controller.destroy()
+ await waitUntil("destroyed") { h.state == .destroyed }
+ h.advance(by: .milliseconds(1500))
+ await waitUntil("rebuilding") { h.backend.parkedCount(.fix) == 1 }
+ h.controller.open(Harness.otherURL)
+ h.backend.release(.fix)
+
+ await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
+ h.connector.connections.last!.emit(.contentReady)
+ await waitUntil("live") { h.state == .live(.peek) }
+ h.advance(by: .seconds(30))
+ #expect(h.state == .live(.collapsed))
+ }
+
+ @Test func aFreshImageBuildSatisfiesARequestedRebuild() async {
+ let h = Harness()
+ h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)),
+ .issue(.imageMissing))
+ await h.goLive()
+ h.controller.requestImageUpdate()
+ h.controller.open(Harness.otherURL)
+ await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
+ h.controller.performSetupAction()
+ await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
+
+ h.controller.destroy()
+ await waitUntil("destroyed") { h.state == .destroyed }
+ h.advance(by: .milliseconds(1500))
+ #expect(h.state == .idle)
+ await h.controller.waitForBackendIdle()
+ #expect(h.backend.calls.filter { if case .fix = $0 { true } else { false } } == [.fix(.imageMissing)])
+ }
+
+ @Test func laterHidesTheWarningForThisSession() async {
+ let h = Harness()
+ h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: nil)))
+ await h.goLive()
+ h.controller.dismissStaleImageWarning()
+ #expect(h.controller.staleImage == nil)
+
+ h.controller.destroy()
+ await waitUntil("destroyed") { h.state == .destroyed }
+ h.advance(by: .milliseconds(1500))
+ #expect(h.state == .idle)
+ }
+
+ @Test func aStoppedServiceStartsByItselfAndTheLinkOpens() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.serviceStopped))
+ h.controller.open(Harness.url)
+ await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
+ #expect(h.backend.calls == [.sweep(keeping: []), .preflight, .fix(.serviceStopped), .preflight,
+ .start("knotchy-00000002")])
+ }
+
+ @Test func aServiceThatWontStayUpShowsItsCardOnce() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.serviceStopped), .issue(.serviceStopped))
+ h.controller.open(Harness.url)
+ await waitUntil("setup required") {
+ h.state == .setupRequired(.serviceStopped, error: SessionController.stillBrokenMessage)
+ }
+ #expect(h.backend.calls.filter { $0 == .fix(.serviceStopped) }.count == 1)
+
+ h.backend.queuePreflight(.issue(.serviceStopped))
+ h.controller.open(Harness.url) // A new request gets its own automatic start.
+ await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
+ #expect(h.backend.calls.filter { $0 == .fix(.serviceStopped) }.count == 2)
+ }
+
+ @Test func retryStartsTheServiceAgain() async {
+ let h = Harness()
+ h.backend.queuePreflight(.issue(.serviceStopped))
+ h.backend.failStarts(with: VMBackendError.failed("boot failed"))
+ h.controller.open(Harness.url)
+ await waitUntil("the start fails") { if case .failed(.start) = h.state { true } else { false } }
+
+ h.backend.failStarts(with: nil)
+ h.backend.queuePreflight(.issue(.serviceStopped))
+ h.controller.retry()
+ await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
+ #expect(h.backend.calls.filter { $0 == .fix(.serviceStopped) }.count == 2)
+ }
+}
diff --git a/Tests/KnotchyCoreTests/SetupFlowTests.swift b/Tests/KnotchyCoreTests/SetupFlowTests.swift
new file mode 100644
index 0000000..06cc12e
--- /dev/null
+++ b/Tests/KnotchyCoreTests/SetupFlowTests.swift
@@ -0,0 +1,242 @@
+import Foundation
+import Testing
+@testable import KnotchyCore
+
+@MainActor
+@Suite(.timeLimit(.minutes(1)))
+struct SetupFlowTests {
+ let host = FakeHost()
+ let installer = FakeInstaller()
+ let driver = FakeSetupDriver()
+
+ func makeFlow() -> SetupFlow {
+ SetupFlow(host: host, installer: installer, driver: driver, retryDelay: .zero)
+ }
+
+ @Test func aReadyMacShowsEveryRowDone() async {
+ let flow = makeFlow()
+ await flow.refresh()
+ #expect(flow.mac == .done("macOS 27.0 · 212 GB free"))
+ #expect(flow.runtime == .done("container 1.4.1 installed"))
+ #expect(flow.vm == .done("Ready"))
+ #expect(flow.allRequiredDone)
+ #expect(!flow.canSetUp)
+ }
+
+ @Test func aMissingRuntimeOffersAnInstallWithoutCheckingTheVM() async {
+ driver.runtimeStatuses = [.missing]
+ let flow = makeFlow()
+ await flow.refresh()
+ #expect(flow.runtimeAction == .install)
+ #expect(flow.runtime == .pending("Apple's container 1.4.1 · 118 MB · installs with Apple's Installer"))
+ #expect(flow.vm == .pending("About 3 minutes · downloads about 225 MB"))
+ #expect(flow.canSetUp)
+ #expect(driver.calls == ["runtimeStatus"])
+ }
+
+ @Test func anOldRuntimeOffersAnUpdate() async {
+ driver.runtimeStatuses = [.outdated("1.3.0")]
+ let flow = makeFlow()
+ await flow.refresh()
+ #expect(flow.runtimeAction == .update)
+ #expect(flow.runtime == .pending("container 1.3.0 is too old · updates to 1.4.1 with Apple's Installer"))
+ }
+
+ @Test func aMacThatCantRunVirtualMachinesShowsOnlyThat() async {
+ host.current.unsupported = .noVirtualization
+ let flow = makeFlow()
+ await flow.refresh()
+ #expect(flow.unsupported == .noVirtualization)
+ #expect(driver.calls.isEmpty)
+ #expect(!flow.canSetUp)
+ }
+
+ @Test func lowDiskBlocksOnlyWhileSomethingNeedsSpace() async {
+ host.current.bytesFree = 2_500_000_000
+ driver.runtimeStatuses = [.missing]
+ let flow = makeFlow()
+ await flow.refresh()
+ #expect(flow.mac == .failed("Setting up needs about 3 GB free. 2.5 GB is free.", details: []))
+ #expect(!flow.canSetUp)
+
+ driver.runtimeStatuses = [.current("1.4.1")]
+ await flow.refresh()
+ #expect(flow.mac == .done("macOS 27.0 · 2.5 GB free"))
+ #expect(flow.allRequiredDone)
+ }
+
+ @Test func installingWithInstallerFinishesTheChain() async throws {
+ driver.runtimeStatuses = [.missing, .current("1.4.1")]
+ installer.session.hasQuit = false
+ let flow = makeFlow()
+ await flow.setUp()
+ #expect(driver.calls == ["runtimeStatus", "begin(nil)", "runtimeStatus", "finish(installed)"])
+ #expect(flow.mac == .done("macOS 27.0 · 212 GB free"))
+ #expect(flow.runtime == .done("container 1.4.1 installed"))
+ #expect(flow.vm == .done("Ready"))
+ #expect(flow.allRequiredDone)
+
+ // Installer still reads the package while its summary is open.
+ try await Task.sleep(for: .milliseconds(50))
+ #expect(installer.calls == ["download", "open"])
+ installer.session.hasQuit = true
+ await waitUntil("the package is discarded") { installer.calls.contains("discard") }
+ #expect(installer.calls == ["download", "open", "discard"])
+ }
+
+ @Test func anUpdatePassesTheOldVersionOn() async {
+ driver.runtimeStatuses = [.outdated("1.3.0"), .current("1.4.1")]
+ let flow = makeFlow()
+ await flow.setUp()
+ #expect(driver.calls.contains("begin(1.3.0)"))
+ #expect(flow.allRequiredDone)
+ }
+
+ @Test func aMismatchedDownloadIsNeverInstalled() async {
+ driver.runtimeStatuses = [.missing]
+ installer.failDownload(with: .mismatch)
+ let flow = makeFlow()
+ await flow.setUp()
+ #expect(flow.runtime == .failed("The download didn't match the expected package, so it wasn't installed.", details: []))
+ #expect(installer.calls == ["download"])
+ #expect(!driver.calls.contains { $0.hasPrefix("begin") })
+ }
+
+ @Test func anOfflineDownloadSaysSo() async {
+ driver.runtimeStatuses = [.missing]
+ installer.failDownload(with: .failed("The Internet connection appears to be offline."))
+ let flow = makeFlow()
+ await flow.setUp()
+ guard case .failed(let message, _) = flow.runtime else {
+ Issue.record("Expected a failure")
+ return
+ }
+ #expect(message == "Couldn't download the runtime. Check your connection and try again.")
+ }
+
+ @Test func quittingInstallerWithoutInstallingIsCancelled() async {
+ driver.runtimeStatuses = [.missing]
+ let flow = makeFlow()
+ await flow.setUp()
+ #expect(flow.runtime == .pending("Installing was cancelled."))
+ #expect(flow.runtimeAction == .install)
+ #expect(flow.canSetUp)
+ #expect(driver.calls == ["runtimeStatus", "begin(nil)", "runtimeStatus", "runtimeStatus", "finish(cancelled)"])
+ await waitUntil("the package is discarded") { installer.calls.contains("discard") }
+ #expect(installer.calls == ["download", "open", "discard"])
+ }
+
+ @Test func aChangedDownloadIsNotOpened() async {
+ driver.runtimeStatuses = [.missing]
+ installer.open(with: .changed)
+ let flow = makeFlow()
+ await flow.setUp()
+ #expect(flow.runtime == .failed("The download changed after it was checked, so it wasn't opened.", details: []))
+ #expect(installer.calls == ["download", "open"])
+ #expect(driver.calls.last?.hasPrefix("finish(failed") == true)
+ }
+
+ @Test func installerThatWontOpenSaysSo() async {
+ driver.runtimeStatuses = [.missing]
+ installer.open(with: .failed("x"))
+ let flow = makeFlow()
+ await flow.setUp()
+ #expect(flow.runtime == .failed("Couldn't open Installer.", details: ["x"]))
+ #expect(installer.calls == ["download", "open", "discard"])
+ #expect(flow.canSetUp)
+ #expect(driver.calls.last?.hasPrefix("finish(failed") == true)
+ }
+
+ @Test func anUpdateWaitsForTheBrowserToClose() async {
+ driver.runtimeStatuses = [.outdated("1.3.0")]
+ driver.holdResult = .failure(.sessionOpen)
+ let flow = makeFlow()
+ await flow.setUp()
+ #expect(flow.runtime == .failed("Close the Knotchy browser first.", details: []))
+ #expect(installer.calls == ["download", "discard"])
+ }
+
+ @Test func quittingWhileInstallerIsOpenLeavesItToFinish() async {
+ driver.runtimeStatuses = [.missing]
+ installer.session.hasQuit = false
+ let flow = makeFlow()
+ flow.start()
+ await waitUntil("Installer is open") { flow.runtime == .running("Finish the install in Installer…", fraction: nil) }
+ flow.cancel()
+ await waitUntil("the flow stops") { !flow.isRunning }
+ #expect(!driver.calls.contains { $0.hasPrefix("finish(") })
+ #expect(!installer.calls.contains("discard"))
+ installer.session.hasQuit = true // Ends the cleanup task.
+ }
+
+ @Test func aClosingBrowserIsWaitedFor() async {
+ driver.runtimeStatuses = [.outdated("1.3.0")]
+ for state in [SessionState.destroying, .failed(.destroyStuck)] {
+ driver.state = state
+ let flow = makeFlow()
+ await flow.setUp()
+ #expect(flow.runtime == .failed("Wait for the Knotchy browser to finish closing, then try again.", details: []))
+ }
+ #expect(installer.calls.isEmpty)
+ }
+
+ @Test func aFailedServiceStartStopsSetUp() async {
+ driver.checkResults = [.failed(.serviceStopped, "Couldn't start the service: x")]
+ driver.setupOutput = ["x"]
+ let flow = makeFlow()
+ await flow.setUp()
+ #expect(flow.runtime == .failed("Couldn't start the service: x", details: ["x"]))
+ #expect(driver.calls.filter { $0 == "checkSetup" }.count == 1)
+ #expect(!driver.calls.contains { $0.hasPrefix("runSetup") })
+ }
+
+ @Test func aBuildFailureShowsItsOutput() async {
+ driver.checkResults = [.needs(.imageMissing), .needs(.imageMissing)]
+ driver.runResults = [.failed(.imageMissing, "The image build failed: out of space")]
+ driver.setupOutput = ["Step 7/9", "error: out of space"]
+ let flow = makeFlow()
+ await flow.setUp()
+ #expect(flow.vm == .failed("The image build failed: out of space", details: ["Step 7/9", "error: out of space"]))
+ #expect(driver.calls.contains("runSetup(imageMissing, continuing: true)"))
+ }
+
+ @Test func aSetupTheNotchStartedIsFollowed() async {
+ driver.state = .settingUp(.imageMissing)
+ driver.checkResults = [nil, nil, .ready]
+ let flow = makeFlow()
+ await flow.setUp()
+ #expect(driver.calls.contains("wait"))
+ #expect(flow.vm == .done("Ready"))
+ }
+
+ @Test func theRowsShowTheControllersProgress() {
+ let flow = makeFlow()
+ driver.state = .settingUp(.imageMissing)
+ driver.setupLog = "Step 3/9"
+ #expect(flow.vm == .running("Step 3/9", fraction: nil))
+ driver.state = .settingUp(.serviceStopped)
+ #expect(flow.runtime == .running("Starting the runtime…", fraction: nil))
+ }
+
+ @Test func anOpenBrowserCountsAsAReadyVM() async {
+ driver.state = .live(.peek)
+ driver.checkResults = [nil]
+ let flow = makeFlow()
+ await flow.refresh()
+ #expect(flow.vm == .done("Ready"))
+ #expect(flow.allRequiredDone)
+ }
+
+ @Test func anOpenBrowserIsReportedBeforeDownloadingAnUpdate() async {
+ driver.state = .live(.peek)
+ driver.runtimeStatuses = [.outdated("1.3.0")]
+ let flow = makeFlow()
+ await flow.setUp()
+ #expect(flow.runtime == .failed("Close the Knotchy browser first.", details: []))
+ #expect(installer.calls.isEmpty)
+ }
+
+ @Test func downloadProgressReadsInMegabytes() {
+ #expect(SetupFlow.downloadText(58_886_932, of: ContainerRuntime.pinned.size) == "Downloading… 59 of 118 MB")
+ }
+}
diff --git a/Tests/KnotchyCoreTests/Support/Fakes.swift b/Tests/KnotchyCoreTests/Support/Fakes.swift
new file mode 100644
index 0000000..1d293c1
--- /dev/null
+++ b/Tests/KnotchyCoreTests/Support/Fakes.swift
@@ -0,0 +1,336 @@
+import Foundation
+import Synchronization
+import Testing
+@testable import KnotchyCore
+
+/// A forgotten `release` fails the test after this instead of hanging the run.
+private let heldCallLimit: Duration = .seconds(10)
+
+final class FakeVMBackend: VMBackend {
+ enum Call: Equatable, Sendable {
+ case sweep(keeping: Set)
+ case preflight
+ case start(String)
+ case destroy(String)
+ case forceDestroy(String)
+ case fix(SetupIssue)
+ case destroyBlocking([String])
+ case stopService
+
+ var kind: Kind {
+ switch self {
+ case .sweep: .sweep
+ case .preflight: .preflight
+ case .start: .start
+ case .destroy: .destroy
+ case .forceDestroy: .forceDestroy
+ case .fix: .fix
+ case .destroyBlocking: .destroyBlocking
+ case .stopService: .stopService
+ }
+ }
+ }
+
+ enum Kind: Hashable, Sendable {
+ case sweep, preflight, start, destroy, forceDestroy, fix, destroyBlocking, stopService
+ }
+
+ struct StartArguments: Equatable, Sendable {
+ var id: String
+ var url: URL
+ var renderSize: PixelSize
+ var blockCIDRs: [String]
+ }
+
+ private struct State {
+ var calls: [Call] = []
+ var startArguments: [StartArguments] = []
+ var active = 0
+ var maxActive = 0
+ var held: Set = []
+ var nextParkID = 0
+ var parked: [Kind: [(id: Int, continuation: CheckedContinuation)]] = [:]
+ var preflightResults: [PreflightResult] = []
+ var startError: (any Error)?
+ var destroyError: (any Error)?
+ var forceDestroyError: (any Error)?
+ var fixOutput: [String] = []
+ var fixError: (any Error)?
+ var runtimeStatus: RuntimeStatus = .current("1.4.1")
+ var stopError: (any Error)?
+ }
+
+ private let state = Mutex(State())
+
+ var calls: [Call] { state.withLock { $0.calls } }
+ var startArguments: [StartArguments] { state.withLock { $0.startArguments } }
+ var maxConcurrentCalls: Int { state.withLock { $0.maxActive } }
+
+ /// Used in order; after them, preflight is ready.
+ func queuePreflight(_ results: PreflightResult...) {
+ state.withLock { $0.preflightResults += results }
+ }
+
+ func failStarts(with error: (any Error)?) { state.withLock { $0.startError = error } }
+ func failDestroys(with error: (any Error)?) { state.withLock { $0.destroyError = error } }
+ func failForceDestroys(with error: (any Error)?) { state.withLock { $0.forceDestroyError = error } }
+
+ func scriptFix(output: [String], error: (any Error)? = nil) {
+ state.withLock {
+ $0.fixOutput = output
+ $0.fixError = error
+ }
+ }
+
+ func hold(_ kind: Kind) {
+ precondition(kind != .destroyBlocking, "destroyBlocking is synchronous and can't be held")
+ _ = state.withLock { $0.held.insert(kind) }
+ }
+
+ func release(_ kind: Kind) {
+ let parked = state.withLock { s in
+ s.held.remove(kind)
+ return s.parked.removeValue(forKey: kind) ?? []
+ }
+ for call in parked { call.continuation.resume() }
+ }
+
+ func parkedCount(_ kind: Kind) -> Int {
+ state.withLock { $0.parked[kind]?.count ?? 0 }
+ }
+
+ func sweepOrphans(keeping: Set) async {
+ await begin(.sweep(keeping: keeping))
+ end()
+ }
+
+ func preflight() async -> PreflightResult {
+ await begin(.preflight)
+ defer { end() }
+ return state.withLock { s in
+ s.preflightResults.isEmpty ? .ready(appRoot: "/fake/appRoot", staleImage: nil) : s.preflightResults.removeFirst()
+ }
+ }
+
+ func start(id: String, url: URL, renderSize: PixelSize, blockCIDRs: [String]) async throws -> VMSession {
+ state.withLock {
+ $0.startArguments.append(StartArguments(id: id, url: url, renderSize: renderSize, blockCIDRs: blockCIDRs))
+ }
+ await begin(.start(id))
+ defer { end() }
+ if let error = state.withLock({ $0.startError }) { throw error }
+ return VMSession(id: id, socketPath: "/fake/\(id).sock")
+ }
+
+ func destroy(id: String) async throws {
+ await begin(.destroy(id))
+ defer { end() }
+ if let error = state.withLock({ $0.destroyError }) { throw error }
+ }
+
+ func forceDestroy(id: String) async throws {
+ await begin(.forceDestroy(id))
+ defer { end() }
+ if let error = state.withLock({ $0.forceDestroyError }) { throw error }
+ }
+
+ func fix(_ issue: SetupIssue, output: @escaping @Sendable (String) -> Void) async throws {
+ record(.fix(issue))
+ state.withLock { $0.fixOutput }.forEach(output)
+ await parkIfHeld(.fix)
+ defer { end() }
+ if let error = state.withLock({ $0.fixError }) { throw error }
+ }
+
+ func destroyBlocking(ids: [String], deadline: Duration) {
+ state.withLock { $0.calls.append(.destroyBlocking(ids)) }
+ }
+
+ func setRuntimeStatus(_ status: RuntimeStatus) { state.withLock { $0.runtimeStatus = status } }
+ func failStopService(with error: (any Error)?) { state.withLock { $0.stopError = error } }
+
+ func runtimeStatus() async -> RuntimeStatus {
+ state.withLock { $0.runtimeStatus }
+ }
+
+ func stopService() async throws {
+ await begin(.stopService)
+ defer { end() }
+ if let error = state.withLock({ $0.stopError }) { throw error }
+ }
+
+ private func begin(_ call: Call) async {
+ record(call)
+ await parkIfHeld(call.kind)
+ }
+
+ private func record(_ call: Call) {
+ state.withLock { s in
+ s.calls.append(call)
+ s.active += 1
+ s.maxActive = max(s.maxActive, s.active)
+ }
+ }
+
+ private func end() {
+ state.withLock { $0.active -= 1 }
+ }
+
+ private func parkIfHeld(_ kind: Kind) async {
+ await withCheckedContinuation { (continuation: CheckedContinuation) in
+ let id: Int? = state.withLock { s in
+ guard s.held.contains(kind) else { return nil }
+ s.nextParkID += 1
+ s.parked[kind, default: []].append((s.nextParkID, continuation))
+ return s.nextParkID
+ }
+ guard let id else { return continuation.resume() }
+ Task {
+ try? await Task.sleep(for: heldCallLimit)
+ let forgotten = self.state.withLock { s -> CheckedContinuation? in
+ guard let index = s.parked[kind]?.firstIndex(where: { $0.id == id }) else { return nil }
+ return s.parked[kind]?.remove(at: index).continuation
+ }
+ if let forgotten {
+ Issue.record("A held \(kind) call was never released")
+ forgotten.resume()
+ }
+ }
+ }
+ }
+}
+
+/// Queued errors go to connects in call order; `connections` is in completion order.
+final class FakeStreamConnector: StreamConnector {
+ private struct State {
+ var socketPaths: [String] = []
+ var connections: [FakeStreamConnection] = []
+ var errors: [StreamError] = []
+ var held = false
+ var nextParkID = 0
+ var parked: [(id: Int, continuation: CheckedContinuation)] = []
+ }
+
+ private let state = Mutex(State())
+
+ var socketPaths: [String] { state.withLock { $0.socketPaths } }
+ var connections: [FakeStreamConnection] { state.withLock { $0.connections } }
+ var parkedCount: Int { state.withLock { $0.parked.count } }
+
+ func failNextConnect(with error: StreamError) {
+ state.withLock { $0.errors.append(error) }
+ }
+
+ func hold() {
+ state.withLock { $0.held = true }
+ }
+
+ func release() {
+ let parked = state.withLock { s in
+ s.held = false
+ defer { s.parked = [] }
+ return s.parked
+ }
+ for call in parked { call.continuation.resume() }
+ }
+
+ func connect(socketPath: String) async throws -> any StreamConnection {
+ let scriptedError = state.withLock { s -> StreamError? in
+ s.socketPaths.append(socketPath)
+ return s.errors.isEmpty ? nil : s.errors.removeFirst()
+ }
+ await withCheckedContinuation { (continuation: CheckedContinuation) in
+ let id: Int? = state.withLock { s in
+ guard s.held else { return nil }
+ s.nextParkID += 1
+ s.parked.append((s.nextParkID, continuation))
+ return s.nextParkID
+ }
+ guard let id else { return continuation.resume() }
+ Task {
+ try? await Task.sleep(for: heldCallLimit)
+ let forgotten = self.state.withLock { s -> CheckedContinuation? in
+ guard let index = s.parked.firstIndex(where: { $0.id == id }) else { return nil }
+ return s.parked.remove(at: index).continuation
+ }
+ if let forgotten {
+ Issue.record("A held connect was never released")
+ forgotten.resume()
+ }
+ }
+ }
+ if let scriptedError { throw scriptedError }
+ let connection = FakeStreamConnection(socketPath: socketPath)
+ state.withLock { $0.connections.append(connection) }
+ return connection
+ }
+}
+
+final class FakeStreamConnection: StreamConnection {
+ let socketPath: String
+ let events: AsyncStream
+ private let continuation: AsyncStream.Continuation
+
+ private struct State {
+ var decodeEnabled = true
+ var decodeChanges: [Bool] = []
+ var sent: [InputEvent] = []
+ var closed = false
+ }
+
+ private let state = Mutex(State())
+
+ init(socketPath: String) {
+ self.socketPath = socketPath
+ let (stream, continuation) = AsyncStream.makeStream(of: StreamEvent.self)
+ events = stream
+ self.continuation = continuation
+ }
+
+ var decodeEnabled: Bool {
+ get { state.withLock { $0.decodeEnabled } }
+ set {
+ state.withLock {
+ $0.decodeEnabled = newValue
+ $0.decodeChanges.append(newValue)
+ }
+ }
+ }
+
+ var decodeChanges: [Bool] { state.withLock { $0.decodeChanges } }
+ var sent: [InputEvent] { state.withLock { $0.sent } }
+ var isClosed: Bool { state.withLock { $0.closed } }
+
+ func setFrameHandler(_ handler: @escaping @Sendable (DecodedFrame) -> Void) {}
+
+ func send(_ input: InputEvent) {
+ state.withLock { $0.sent.append(input) }
+ }
+
+ func close() {
+ state.withLock { $0.closed = true }
+ continuation.finish()
+ }
+
+ func emit(_ event: StreamEvent) {
+ continuation.yield(event)
+ }
+
+ /// Without an `.ended` event, as a buggy client might.
+ func finishEvents() {
+ continuation.finish()
+ }
+}
+
+final class FakeDiskSpace: DiskSpaceChecking {
+ private let free = Mutex(100_000_000_000)
+
+ var available: Int64? {
+ get { free.withLock { $0 } }
+ set { free.withLock { $0 = newValue } }
+ }
+
+ func bytesFree(atPath path: String) -> Int64? {
+ free.withLock { $0 }
+ }
+}
diff --git a/Tests/KnotchyCoreTests/Support/Harness.swift b/Tests/KnotchyCoreTests/Support/Harness.swift
new file mode 100644
index 0000000..31214f9
--- /dev/null
+++ b/Tests/KnotchyCoreTests/Support/Harness.swift
@@ -0,0 +1,87 @@
+import Foundation
+import Testing
+@testable import KnotchyCore
+
+@MainActor
+final class Recorder {
+ var quitRequests = 0
+ var setupRequests = 0
+}
+
+@MainActor
+final class Harness {
+ static let url = URL(string: "https://example.com/")!
+ static let otherURL = URL(string: "https://example.org/")!
+
+ let backend = FakeVMBackend()
+ let connector = FakeStreamConnector()
+ let disk = FakeDiskSpace()
+ let clock = TestClock()
+ let recorder: Recorder
+ let controller: SessionController
+
+ init(hostNetworks: [String] = ["192.168.1.0/24"]) {
+ let recorder = Recorder()
+ self.recorder = recorder
+ var sessions = 0
+ controller = SessionController(
+ backend: backend,
+ connector: connector,
+ diskSpace: disk,
+ clock: clock,
+ hostNetworks: { hostNetworks },
+ requestQuit: { recorder.quitRequests += 1 },
+ makeSessionID: {
+ sessions += 1
+ return String(format: "knotchy-%08x", sessions)
+ }
+ )
+ controller.showSetup = { recorder.setupRequests += 1 }
+ }
+
+ /// Lets go of held calls, so the fakes' 10 s check can't fire later against another test.
+ deinit {
+ for kind in [FakeVMBackend.Kind.sweep, .preflight, .start, .destroy, .forceDestroy, .fix, .stopService] {
+ backend.release(kind)
+ }
+ connector.release()
+ for stream in connector.connections { stream.close() }
+ }
+
+ var state: SessionState { controller.state }
+
+ func advance(by duration: Duration) {
+ clock.advance(by: duration)
+ controller.fireDueTimers()
+ }
+
+ @discardableResult
+ func openAndConnect(_ url: URL = Harness.url) async -> FakeStreamConnection {
+ let before = connector.connections.count
+ controller.open(url)
+ await waitUntil("the stream connects") {
+ self.connector.connections.count > before && self.state == .starting(.loading, folded: false)
+ }
+ return connector.connections.last!
+ }
+
+ func enter(_ mode: LiveMode) {
+ switch mode {
+ case .peek:
+ break
+ case .expanded:
+ controller.pointerEntered()
+ controller.expand()
+ case .collapsed:
+ controller.clickedOutside()
+ }
+ }
+
+ @discardableResult
+ func goLive(_ url: URL = Harness.url) async -> FakeStreamConnection {
+ let stream = await openAndConnect(url)
+ stream.emit(.contentReady)
+ await waitUntil("the session is live") { self.state == .live(.peek) }
+ return stream
+ }
+}
diff --git a/Tests/KnotchyCoreTests/Support/SetupFakes.swift b/Tests/KnotchyCoreTests/Support/SetupFakes.swift
new file mode 100644
index 0000000..394a745
--- /dev/null
+++ b/Tests/KnotchyCoreTests/Support/SetupFakes.swift
@@ -0,0 +1,117 @@
+import Foundation
+import Synchronization
+@testable import KnotchyCore
+
+final class FakeHost: HostChecking {
+ private let value = Mutex(HostFacts(unsupported: nil, osVersion: "macOS 27.0", bytesFree: 212_000_000_000))
+
+ var current: HostFacts {
+ get { value.withLock { $0 } }
+ set { value.withLock { $0 = newValue } }
+ }
+
+ func facts() async -> HostFacts { current }
+}
+
+@MainActor
+final class FakeInstallerSession: InstallerSession {
+ var hasQuit: Bool
+
+ nonisolated init(hasQuit: Bool = true) {
+ self.hasQuit = hasQuit
+ }
+}
+
+final class FakeInstaller: RuntimeInstalling {
+ enum Opening: Sendable {
+ case opened
+ case changed
+ case failed(String)
+ }
+
+ private struct State {
+ var downloadError: DownloadError?
+ var opening: Opening = .opened
+ var calls: [String] = []
+ }
+
+ private let state = Mutex(State())
+ /// What `.opened` hands out; it has already quit unless a test says otherwise.
+ let session = FakeInstallerSession()
+
+ var calls: [String] { state.withLock { $0.calls } }
+ func failDownload(with error: DownloadError) { state.withLock { $0.downloadError = error } }
+ func open(with opening: Opening) { state.withLock { $0.opening = opening } }
+
+ func download(_ release: ContainerRuntime.Release,
+ progress: @escaping @Sendable (Int64) -> Void) async throws -> DownloadedPackage {
+ let error = state.withLock { s -> DownloadError? in
+ s.calls.append("download")
+ return s.downloadError
+ }
+ if let error { throw error }
+ return DownloadedPackage(directory: URL(fileURLWithPath: "/fake/knotchy-runtime"), name: release.url.lastPathComponent)
+ }
+
+ @MainActor
+ func openInInstaller(_ package: DownloadedPackage, release: ContainerRuntime.Release) async -> InstallerOpening {
+ let opening = state.withLock { s -> Opening in
+ s.calls.append("open")
+ return s.opening
+ }
+ switch opening {
+ case .opened: return .opened(session)
+ case .changed: return .changed
+ case .failed(let message): return .failed(message)
+ }
+ }
+
+ func discard(_ package: DownloadedPackage) {
+ state.withLock { $0.calls.append("discard") }
+ }
+}
+
+@MainActor
+final class FakeSetupDriver: SetupDriving {
+ var state: SessionState = .idle
+ var setupLog: String?
+ var setupOutput: [String] = []
+ /// Used in order; the last one repeats.
+ var runtimeStatuses: [RuntimeStatus] = [.current("1.4.1")]
+ /// Used in order; then `.ready`.
+ var checkResults: [SetupRunOutcome?] = []
+ var runResults: [SetupRunOutcome?] = []
+ var holdResult: Result = .success(())
+ var finishResult: SetupRunOutcome = .ready
+ private(set) var calls: [String] = []
+
+ func runtimeStatus() async -> RuntimeStatus {
+ calls.append("runtimeStatus")
+ return runtimeStatuses.count > 1 ? runtimeStatuses.removeFirst() : runtimeStatuses[0]
+ }
+
+ func checkSetup() async -> SetupRunOutcome? {
+ calls.append("checkSetup")
+ return checkResults.isEmpty ? .ready : checkResults.removeFirst()
+ }
+
+ func runSetup(_ issue: SetupIssue, continuing: Bool) async -> SetupRunOutcome? {
+ calls.append("runSetup(\(issue), continuing: \(continuing))")
+ return runResults.isEmpty ? .ready : runResults.removeFirst()
+ }
+
+ func beginRuntimeInstall(replacing found: String?) async -> Result {
+ calls.append("begin(\(found ?? "nil"))")
+ return holdResult
+ }
+
+ func finishRuntimeInstall(_ result: RuntimeInstallResult) async -> SetupRunOutcome {
+ calls.append("finish(\(result))")
+ return finishResult
+ }
+
+ func waitWhileSettingUp() async {
+ calls.append("wait")
+ state = .idle
+ }
+}
diff --git a/Tests/KnotchyCoreTests/Support/TestClock.swift b/Tests/KnotchyCoreTests/Support/TestClock.swift
new file mode 100644
index 0000000..884723a
--- /dev/null
+++ b/Tests/KnotchyCoreTests/Support/TestClock.swift
@@ -0,0 +1,66 @@
+import Synchronization
+@testable import KnotchyCore
+
+final class TestClock: KnotchyClock {
+ private struct Sleeper {
+ let id: Int
+ let deadline: ContinuousClock.Instant
+ let continuation: CheckedContinuation
+ }
+
+ private struct State {
+ var now: ContinuousClock.Instant
+ var nextID = 0
+ var sleepers: [Sleeper] = []
+ }
+
+ private let state: Mutex
+
+ init(now: ContinuousClock.Instant = .now) {
+ state = Mutex(State(now: now))
+ }
+
+ var now: ContinuousClock.Instant {
+ state.withLock { $0.now }
+ }
+
+ var sleeperCount: Int {
+ state.withLock { $0.sleepers.count }
+ }
+
+ func sleep(until deadline: ContinuousClock.Instant) async throws {
+ let id = state.withLock { s in
+ s.nextID += 1
+ return s.nextID
+ }
+ try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ let outcome: Result? = state.withLock { s in
+ if Task.isCancelled { return .failure(CancellationError()) }
+ if deadline <= s.now { return .success(()) }
+ s.sleepers.append(Sleeper(id: id, deadline: deadline, continuation: continuation))
+ return nil
+ }
+ if let outcome { continuation.resume(with: outcome) }
+ }
+ } onCancel: {
+ let sleeper = state.withLock { s -> Sleeper? in
+ guard let index = s.sleepers.firstIndex(where: { $0.id == id }) else { return nil }
+ return s.sleepers.remove(at: index)
+ }
+ sleeper?.continuation.resume(throwing: CancellationError())
+ }
+ }
+
+ /// The woken tasks run concurrently, in no particular order.
+ func advance(by duration: Duration) {
+ let due = state.withLock { s -> [Sleeper] in
+ s.now += duration
+ let now = s.now
+ let due = s.sleepers.filter { $0.deadline <= now }.sorted { $0.deadline < $1.deadline }
+ s.sleepers.removeAll { $0.deadline <= now }
+ return due
+ }
+ for sleeper in due { sleeper.continuation.resume() }
+ }
+}
diff --git a/Tests/KnotchyCoreTests/Support/Waiting.swift b/Tests/KnotchyCoreTests/Support/Waiting.swift
new file mode 100644
index 0000000..d753101
--- /dev/null
+++ b/Tests/KnotchyCoreTests/Support/Waiting.swift
@@ -0,0 +1,19 @@
+import Testing
+
+/// For work on other executors; controller timers run on the `TestClock` and never need it.
+@MainActor
+func waitUntil(
+ _ description: String,
+ timeout: Duration = .seconds(5),
+ sourceLocation: SourceLocation = #_sourceLocation,
+ _ condition: @MainActor () -> Bool
+) async {
+ let deadline = ContinuousClock.now + timeout
+ while !condition() {
+ if ContinuousClock.now >= deadline {
+ Issue.record("Timed out waiting until \(description)", sourceLocation: sourceLocation)
+ return
+ }
+ do { try await Task.sleep(for: .milliseconds(1)) } catch { return }
+ }
+}
diff --git a/Tests/KnotchyCoreTests/WireProtocolTests.swift b/Tests/KnotchyCoreTests/WireProtocolTests.swift
new file mode 100644
index 0000000..eb33ff6
--- /dev/null
+++ b/Tests/KnotchyCoreTests/WireProtocolTests.swift
@@ -0,0 +1,216 @@
+import Foundation
+import Testing
+@testable import KnotchyCore
+
+struct WireProtocolTests {
+ private let size = PixelSize(width: 1024, height: 640)
+
+ private func decodeAll(_ bytes: Data, into decoder: inout AgentMessageDecoder) throws -> [AgentMessage] {
+ decoder.append(bytes)
+ var messages: [AgentMessage] = []
+ while let message = try decoder.next() { messages.append(message) }
+ return messages
+ }
+
+ private func decodeAll(_ bytes: Data) throws -> [AgentMessage] {
+ var decoder = AgentMessageDecoder()
+ return try decodeAll(bytes, into: &decoder)
+ }
+
+ private func violation(_ bytes: Data) -> AgentMessageDecoder.ProtocolViolation? {
+ do {
+ _ = try decodeAll(bytes)
+ return nil
+ } catch {
+ return error as? AgentMessageDecoder.ProtocolViolation
+ }
+ }
+
+ private func raw(_ type: UInt8, _ payload: [UInt8]) -> Data {
+ framed(type, Data(payload))
+ }
+
+ @Test func matchesTheImageProtocolVersion() throws {
+ let root = URL(filePath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
+ let containerfile = try String(contentsOf: root.appending(path: "vm/Containerfile"), encoding: .utf8)
+ let arg = containerfile.split(separator: "\n").first { $0.hasPrefix("ARG KNOTCHY_PROTOCOL=") }
+ #expect(arg == "ARG KNOTCHY_PROTOCOL=\(WireProtocol.version)")
+ }
+
+ @Test func pageURLMustBeShortUTF8() throws {
+ let hello = raw(MessageType.hello, [1, 0x04, 0x00, 0x02, 0x80])
+ #expect(try decodeAll(hello + raw(MessageType.pageURL, Array("https://a.test/ü".utf8)))
+ == [.hello(version: 1, size: size), .pageURL("https://a.test/ü")])
+ #expect(violation(hello + raw(MessageType.pageURL, [0x68, 0xC3])) != nil) // cut-off UTF-8
+ #expect(violation(hello + raw(MessageType.pageURL, [])) != nil)
+ let long = [UInt8](repeating: 0x61, count: WireProtocol.maxPageURL + 1)
+ #expect(violation(hello + raw(MessageType.pageURL, long)) != nil)
+ }
+
+ @Test func helloAndVideoBytesAreAsSpecified() throws {
+ #expect(AgentMessage.hello(version: 1, size: size).encoded() == Data([0x01, 0, 0, 0, 5, 1, 0x04, 0x00, 0x02, 0x80]))
+ let hello = raw(MessageType.hello, [1, 0x04, 0x00, 0x02, 0x80])
+ let video = raw(MessageType.video, [0x04, 0x00, 0x02, 0x80, 0xAA])
+ #expect(try decodeAll(hello + video) == [.hello(version: 1, size: size), .video(size: size, accessUnit: Data([0xAA]))])
+ }
+
+ @Test func roundTripsEveryAgentMessage() throws {
+ let messages: [AgentMessage] = [
+ .hello(version: 1, size: size),
+ .navigated,
+ .progress(0),
+ .progress(100),
+ .video(size: size, accessUnit: Data([0, 0, 0, 1, 0x09, 0xF0])),
+ .pageURL("https://example.com/a?b=c#d"),
+ .unknown(type: 0x7F),
+ ]
+ let stream = messages.reduce(into: Data()) { $0.append($1.encoded()) }
+ #expect(try decodeAll(stream) == messages)
+ }
+
+ @Test func decodesAcrossArbitraryChunkBoundaries() throws {
+ let messages: [AgentMessage] = [
+ .hello(version: 1, size: size),
+ .video(size: size, accessUnit: Data(repeating: 0xAB, count: 3000)),
+ .progress(42),
+ ]
+ let stream = messages.reduce(into: Data()) { $0.append($1.encoded()) }
+ var decoder = AgentMessageDecoder()
+ var decoded: [AgentMessage] = []
+ for chunkStart in stride(from: 0, to: stream.count, by: 7) {
+ decoded += try decodeAll(stream.subdata(in: chunkStart..
diff --git a/assets/knotchy-mark.svg b/assets/knotchy-mark.svg
new file mode 100644
index 0000000..d479352
--- /dev/null
+++ b/assets/knotchy-mark.svg
@@ -0,0 +1 @@
+
diff --git a/extensions/chrome/README.md b/extensions/chrome/README.md
new file mode 100644
index 0000000..650bcb2
--- /dev/null
+++ b/extensions/chrome/README.md
@@ -0,0 +1,30 @@
+# Open in Knotchy (Chrome extension)
+
+Adds **Open in Knotchy** to the right-click menu of links in Chrome (and other Chromium
+browsers such as Edge, Brave and Arc).
+
+Chrome gives the macOS Services menu only a link's words, never its address, so Services
+can't open Chrome links in Knotchy. This extension hands the address to the Knotchy app
+through its `knotchy://open?url=…` URL scheme instead. It only passes on `http` and
+`https` links, and it needs no permission beyond adding the menu item.
+
+## Install
+
+1. Open `chrome://extensions` and turn on **Developer mode**.
+2. Click **Load unpacked** and choose this folder.
+3. Right-click any link and choose **Open in Knotchy**. The first time, Chrome asks
+ whether to open Knotchy; allow it.
+
+The Knotchy app must be installed.
+
+## Publishing
+
+[STORE.md](STORE.md) has everything the Chrome Web Store asks for: the package, listing text, graphics and privacy
+answers. Once the store assigns the item ID, set `ChromeExtension.storeURL` in the app and link the listing here.
+
+## Files
+
+- `manifest.json`: Manifest V3, with the `contextMenus` permission only
+- `background.js`: creates the menu item and opens the `knotchy://` link
+- `icons/`: generated by `scripts/make-icons.sh` at the repository root
+- `store/`: the store's promo images (also generated) and framed screenshots; not part of the package
diff --git a/extensions/chrome/STORE.md b/extensions/chrome/STORE.md
new file mode 100644
index 0000000..7683bed
--- /dev/null
+++ b/extensions/chrome/STORE.md
@@ -0,0 +1,76 @@
+# Chrome Web Store listing
+
+Everything the developer dashboard asks for, in the order it asks.
+
+## Package
+
+`scripts/package-extension.sh` at the repository root builds `build/open-in-knotchy-.zip`. It holds only
+`manifest.json`, `background.js` and `icons/`. Upload that zip.
+
+## Store listing
+
+**Title** (from the manifest): Open in Knotchy
+
+**Summary** (from the manifest): Adds "Open in Knotchy" to the right-click menu of links: opens them in Knotchy's
+disposable, isolated browser.
+
+**Description:**
+
+> Right-click any link and choose Open in Knotchy to open it in Knotchy's disposable browser instead of Chrome.
+>
+> Knotchy is a free, open-source Mac app. Each link opens in its own throwaway Linux virtual machine, shown in a
+> panel under your MacBook's notch. The page can reach the internet but not your Mac or your local network, and the
+> whole machine is destroyed when you're done. It's meant for links you don't trust: unexpected email links,
+> shortened URLs, and sites you'd rather not open in your own browser.
+>
+> This extension does one thing: it passes the address of the link you right-click to the Knotchy app on your Mac.
+> It passes only http and https links, can't see the pages you visit, and sends nothing anywhere else.
+>
+> Requires the Knotchy app, on a Mac with Apple silicon and macOS 26 or later. The first time, Chrome asks whether to
+> open Knotchy; allow it.
+
+**Category:** Privacy & Security. **Language:** English.
+
+**Graphics:**
+
+| Asset | File |
+|---|---|
+| Store icon, 128 × 128 | `icons/icon-128.png` |
+| Screenshots, 1280 × 800 (at least one) | `store/screenshot-*.png`, made as below |
+| Small promo tile, 440 × 280 | `store/promo-440x280.png` |
+| Marquee, 1400 × 560 (optional) | `store/marquee-1400x560.png` |
+
+`scripts/make-icons.sh` regenerates the promo images with the icons.
+
+**Screenshots** must be real captures:
+
+1. With the extension loaded in Chrome, press ⌘⇧5, set a 5-second timer and capture the screen. During the countdown,
+ right-click a link so the menu shows **Open in Knotchy**.
+2. Open a link that way and capture the Knotchy panel under the notch with ⌘⇧4.
+3. Fit each capture to the store's frame:
+ `swift scripts/icons/frame.swift ~/Desktop/capture.png extensions/chrome/store/screenshot-1.png`
+
+**Homepage and support URLs:** the GitHub repository and its issues page, once the repository is public.
+
+## Privacy practices
+
+**Single purpose:** Open a link in the Knotchy Mac app from Chrome's right-click menu.
+
+**Permission justification, `contextMenus`:** Adds the Open in Knotchy item to the right-click menu of links, and
+nothing else.
+
+**Host permissions:** none requested.
+
+**Remote code:** No. All code ships in the package.
+
+**Data usage:** tick none of the data types. The extension collects nothing: the clicked link goes only to the
+Knotchy app on the same Mac, through its `knotchy://` URL scheme, and never to a server. Tick the three
+certifications (no selling or transferring data, no use unrelated to the single purpose, no use for creditworthiness).
+
+**Privacy policy:** not required, since no user data is collected. If the dashboard asks anyway, link a page that
+says: "Open in Knotchy collects no data. It passes the link you choose to the Knotchy app on your Mac and sends
+nothing to any server."
+
+## Distribution
+
+Public, free, all regions.
diff --git a/extensions/chrome/background.js b/extensions/chrome/background.js
new file mode 100644
index 0000000..d027fc0
--- /dev/null
+++ b/extensions/chrome/background.js
@@ -0,0 +1,13 @@
+// Chrome gives the Services menu a link's text, never its address, hence this extension.
+
+chrome.runtime.onInstalled.addListener(() => {
+ chrome.contextMenus.create({ id: "open-in-knotchy", title: "Open in Knotchy", contexts: ["link"] });
+});
+
+chrome.contextMenus.onClicked.addListener((info, tab) => {
+ if (info.menuItemId !== "open-in-knotchy" || !info.linkUrl || !tab || tab.id === undefined) return;
+ if (!/^https?:\/\//i.test(info.linkUrl)) return;
+ const target = "knotchy://open?url=" + encodeURIComponent(info.linkUrl);
+ // Hands the URL to the app without leaving the page.
+ chrome.tabs.update(tab.id, { url: target });
+});
diff --git a/extensions/chrome/icons/icon-128.png b/extensions/chrome/icons/icon-128.png
new file mode 100644
index 0000000..653cb88
Binary files /dev/null and b/extensions/chrome/icons/icon-128.png differ
diff --git a/extensions/chrome/icons/icon-16.png b/extensions/chrome/icons/icon-16.png
new file mode 100644
index 0000000..07c558c
Binary files /dev/null and b/extensions/chrome/icons/icon-16.png differ
diff --git a/extensions/chrome/icons/icon-32.png b/extensions/chrome/icons/icon-32.png
new file mode 100644
index 0000000..6c23034
Binary files /dev/null and b/extensions/chrome/icons/icon-32.png differ
diff --git a/extensions/chrome/icons/icon-48.png b/extensions/chrome/icons/icon-48.png
new file mode 100644
index 0000000..c3b04e5
Binary files /dev/null and b/extensions/chrome/icons/icon-48.png differ
diff --git a/extensions/chrome/manifest.json b/extensions/chrome/manifest.json
new file mode 100644
index 0000000..94d9a21
--- /dev/null
+++ b/extensions/chrome/manifest.json
@@ -0,0 +1,14 @@
+{
+ "manifest_version": 3,
+ "name": "Open in Knotchy",
+ "description": "Adds \"Open in Knotchy\" to the right-click menu of links: opens them in Knotchy's disposable, isolated browser.",
+ "version": "1.0.0",
+ "icons": {
+ "16": "icons/icon-16.png",
+ "32": "icons/icon-32.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ },
+ "permissions": ["contextMenus"],
+ "background": { "service_worker": "background.js" }
+}
diff --git a/extensions/chrome/store/marquee-1400x560.png b/extensions/chrome/store/marquee-1400x560.png
new file mode 100644
index 0000000..aaa8cb5
Binary files /dev/null and b/extensions/chrome/store/marquee-1400x560.png differ
diff --git a/extensions/chrome/store/promo-440x280.png b/extensions/chrome/store/promo-440x280.png
new file mode 100644
index 0000000..068a412
Binary files /dev/null and b/extensions/chrome/store/promo-440x280.png differ
diff --git a/scripts/build-vm.sh b/scripts/build-vm.sh
new file mode 100755
index 0000000..08280cd
--- /dev/null
+++ b/scripts/build-vm.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+# --dns: the vmnet DNS forwarder isn't reachable on every network.
+set -euo pipefail
+root="$(cd "$(dirname "$0")/.." && pwd)"
+container build --cpus 4 --memory 4g --dns 1.1.1.1 --pull \
+ --build-arg WERROR=-Werror \
+ --build-arg APT_REFRESH="$(date -u +%Y-%m-%d)" \
+ --build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
+ -f "$root/vm/Containerfile" -t local/knotchy-vm:latest "$root/vm"
diff --git a/scripts/bundle-app.sh b/scripts/bundle-app.sh
new file mode 100755
index 0000000..5908860
--- /dev/null
+++ b/scripts/bundle-app.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+# Builds Knotchy.app, installs it to ~/Applications and refreshes the Services menu.
+# Signs with the keychain's Developer ID certificate (or KNOTCHY_SIGN_IDENTITY), ad hoc if there is none.
+# --release skips the install and adds the secure timestamp notarization needs; see scripts/release.sh.
+set -euo pipefail
+root="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$root"
+
+release=0
+[[ ${1:-} == --release ]] && release=1
+identity="${KNOTCHY_SIGN_IDENTITY:-$(security find-identity -v -p codesigning | awk '/"Developer ID Application: / && !id { id = $2 } END { print id }')}"
+if (( release )) && [[ -z $identity ]]; then
+ echo "A release needs a Developer ID Application certificate in the keychain." >&2
+ exit 1
+fi
+
+swift build -c release --product Knotchy
+bin="$(swift build -c release --product Knotchy --show-bin-path)/Knotchy"
+
+app="$root/build/Knotchy.app"
+rm -rf "$app"
+mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
+cp "$bin" "$app/Contents/MacOS/Knotchy"
+cp Sources/Knotchy/Info.plist "$app/Contents/Info.plist"
+cp assets/AppIcon.icns "$app/Contents/Resources/AppIcon.icns"
+# The image is built from these on the user's Mac.
+cp -R vm "$app/Contents/Resources/vm"
+find "$app" -name .DS_Store -delete
+
+if [[ -z $identity ]]; then
+ codesign --force --sign - "$app"
+elif (( release )); then
+ codesign --force --options runtime --timestamp --sign "$identity" "$app"
+else
+ codesign --force --options runtime --timestamp=none --sign "$identity" "$app"
+fi
+if (( release )); then
+ exit 0
+fi
+
+dest="$HOME/Applications/Knotchy.app"
+mkdir -p "$HOME/Applications"
+pkill -x Knotchy 2>/dev/null || true
+rm -rf "$dest"
+cp -R "$app" "$dest"
+/System/Library/CoreServices/pbs -update
+echo "Installed $dest"
diff --git a/scripts/icons/frame.swift b/scripts/icons/frame.swift
new file mode 100644
index 0000000..05e6043
--- /dev/null
+++ b/scripts/icons/frame.swift
@@ -0,0 +1,28 @@
+// swift scripts/icons/frame.swift : fits a screenshot into the 1280×800 frame the
+// Chrome Web Store requires, on the icon's navy, as a 24-bit PNG. Window captures keep their own shadow.
+import AppKit
+import ImageIO
+import UniformTypeIdentifiers
+
+let args = CommandLine.arguments
+guard args.count == 3, let shot = NSImage(contentsOfFile: args[1]),
+ let image = shot.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+ print("usage: swift scripts/icons/frame.swift ")
+ exit(1)
+}
+let width = 1280, height = 800, margin: CGFloat = 40
+let ctx = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0,
+ space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)!
+let colors = [CGColor(srgbRed: 0x1C / 255, green: 0x35 / 255, blue: 0x63 / 255, alpha: 1),
+ CGColor(srgbRed: 0x0D / 255, green: 0x1A / 255, blue: 0x33 / 255, alpha: 1)] as CFArray
+let gradient = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), colors: colors, locations: [0, 1])!
+ctx.drawLinearGradient(gradient, start: CGPoint(x: 0, y: CGFloat(height)), end: .zero, options: [])
+let scale = min((CGFloat(width) - 2 * margin) / CGFloat(image.width), (CGFloat(height) - 2 * margin) / CGFloat(image.height))
+let size = CGSize(width: CGFloat(image.width) * scale, height: CGFloat(image.height) * scale)
+ctx.interpolationQuality = .high
+ctx.draw(image, in: CGRect(x: (CGFloat(width) - size.width) / 2, y: (CGFloat(height) - size.height) / 2,
+ width: size.width, height: size.height))
+let dest = CGImageDestinationCreateWithURL(URL(fileURLWithPath: args[2]) as CFURL, UTType.png.identifier as CFString, 1, nil)!
+CGImageDestinationAddImage(dest, ctx.makeImage()!, nil)
+CGImageDestinationFinalize(dest)
+print("wrote \(args[2])")
diff --git a/scripts/icons/knot.json b/scripts/icons/knot.json
new file mode 100644
index 0000000..ec10948
--- /dev/null
+++ b/scripts/icons/knot.json
@@ -0,0 +1 @@
+{"icon": [[[43.147358872228004, 35.597261122187355], [42.61532507762965, 36.304681642555074], [42.09814885584686, 37.02497342074467], [41.59628295059067, 37.75754407139881], [41.11016314262925, 38.501788171305485], [40.64020783440553, 39.25708782787771], [40.186817652259094, 40.02281326017288], [39.7503750666409, 40.798323391885674], [39.331244030691536, 41.5829664557369], [38.92976963753638, 42.37608060866765], [38.54627779663111, 43.17699455723982], [38.18107492947324, 43.985028192631795], [37.83444768497557, 44.79949323460966], [37.506662674778056, 45.61969388384524], [37.19796622875481, 46.444927481943765], [36.90858417095287, 47.274485178536395], [36.638721616179424, 48.107652604785535], [36.38856278743295, 48.94371055264535], [36.158270854354186, 49.78193565921238], [35.94798779285065, 50.6216010954986], [35.757834266028794, 51.46197725895178], [35.58790952654568, 52.30233246904732], [35.43829134047141, 53.14193366527007], [35.3090359327321, 53.980047106803596], [35.20017795418167, 54.81593907324261], [35.11173047032945, 55.64887656564209], [35.04368497172906, 56.47812800721761], [34.99601140601252, 57.30296394301044], [34.96865823153224, 58.12265773783302], [34.961552492551895, 58.93648627181062], [34.974599915906126, 59.743730632839], [35.00768502902749, 60.54367680527923], [35.06067129921806, 61.335616354215425], [35.13340129402195, 62.11884710460527], [35.225696862534214, 62.89267381465754], [35.337359337460796, 63.65640884277764], [35.46816975772352, 64.40937280742695], [35.617889111384144, 65.15089523925003], [35.786258598640785, 65.88031522483004], [35.97299991463079, 66.59698204144215], [36.17781555175401, 67.30025578218226], [36.40038912121131, 67.98950797085851], [36.64038569343427, 68.66412216604321], [36.89745215706282, 69.32349455369211], [37.17121759611, 69.96703452775148], [37.46129368493396, 70.5941652581821], [37.767275100620964, 71.20432424584489], [38.08873995236454, 71.79696386370284], [38.425250227410274, 72.37155188380925], [38.776352253118695, 72.92757198956546], [39.14157717468284, 73.46452427274542], [39.52044144802173, 73.98192571480013], [39.91244734735576, 74.47931065196943], [40.31708348695592, 74.95623122374559], [40.73382535654387, 75.41225780424828], [41.1621358698074, 75.84697941608812], [41.60146592548216, 76.26000412631285], [42.051254980438095, 76.65095942404778], [42.510931634197796, 77.01949257945994], [42.97991422430148, 77.36527098369383], [43.4576114319241, 77.68798246944569], [43.94342289713845, 77.98733561186113], [44.43673984320961, 78.26306000946163], [44.9369457092966, 78.5149065448237], [45.443416790929255, 78.74264762475569], [45.95552288761989, 78.94607739973573], [46.47262795696309, 79.1250119623963], [46.99409077457018, 79.27928952486037], [47.51926559917878, 79.408770574755], [48.04750284227361, 79.5133380097496], [48.578149741549126, 79.59289725048669], [49.1105510375421, 79.64737633179448], [49.644049652757644, 79.67672597209153], [50.177987372610914, 79.68091962091582], [50.711705527504435, 79.65995348453107], [51.24454567535968, 79.61384652958573], [51.77585028392153, 79.54264046482056], [52.304963412154024, 79.44639970084314], [52.83123139004737, 79.32521128800849], [53.354003496156544, 79.1791848324669], [53.87263263219571, 79.00845239046137], [54.38647599401408, 78.81316834097822], [54.89489573828321, 78.59350923687589], [55.3972596442302, 78.34967363463801], [55.89294176975501, 78.0818819029179], [56.38132310127728, 77.79037601006189], [56.86179219666286, 77.47541929082043], [57.33374582058782, 77.13729619247562], [57.79658957170505, 76.7763120006342], [58.24973850098674, 76.39279254495501], [58.69261772062432, 75.98708388509974], [59.12466300287774, 75.55955197721482], [59.545321368274685, 75.11058232127185], [59.954051662571985, 74.64057958961236], [60.3503251219019, 74.14996723706122], [60.733625925537865, 73.63918709299146], [61.10345173572674, 73.10869893573992], [61.459314224046835, 72.55898004979225], [61.800739583765235, 71.99052476617032], [62.127269027680796, 71.40384398647382], [62.438459270954354, 70.79946469104226], [62.7338829984421, 70.17792943171975], [63.013129316063434, 69.53979580972054], [63.27580418575093, 68.88563593910635], [63.521530843545385, 68.21603589640245], [63.74995020041658, 67.53159515689163], [63.96072122540671, 66.83292601813906], [64.15352131071153, 66.1206530113129], [64.32804661833163, 65.39541230087809], [64.48401240794503, 64.65785107325169], [64.57754692112395, 64.1596231815178]], [[62.165552315024435, 44.799493234609656], [61.818925070526774, 43.98502819263181], [61.4537222033689, 43.17699455723984], [61.07023036246362, 42.376080608667664], [60.66875596930847, 41.582966455736916], [60.24962493335912, 40.79832339188571], [59.81318234774089, 40.022813260172846], [59.35979216559447, 39.257087827877704], [58.88983685737078, 38.50178817130552], [58.40371704940935, 37.75754407139884], [57.901851144153156, 37.024973420744686], [57.38467492237035, 36.30468164255507], [56.85264112777201, 35.59726112218736], [56.30621903443227, 34.9032906517588], [55.74589399743413, 34.22333488839543], [55.172166987180994, 33.55794382664324], [54.58555410783025, 32.907652285554676], [53.986586100320984, 32.27297941094935], [53.375807830483375, 31.654428193332976], [52.753777762731595, 31.05248500194228], [52.12106741985713, 30.467619135368725], [51.47826082945333, 29.900282389197127], [50.82595395751379, 29.350908641077307], [50.1647541297622, 28.81991345363178], [49.495279441281866, 28.307693695582955], [48.81815815502556, 27.814627181466406], [48.134028089796494, 27.341072330277534], [47.44353599830385, 26.887367843381604], [46.74733693590314, 26.45383240199623], [46.04609362064407, 26.040764384537795], [45.34047578525469, 25.64844160410232], [44.6311595217006, 25.277121066332374], [43.91882661896497, 24.927038747901083], [43.204163894700294, 24.59840939582345], [42.48786252141196, 24.291426347785666], [41.77061734783661, 24.006261373661463], [41.05312621618472, 23.743064538364063], [40.33608927591963, 23.501964086160815], [39.62020829475091, 23.28306634655721], [38.9061859675205, 23.086455661834638], [38.194725223663454, 22.912194336305582], [37.486528533927164, 22.760322607328465], [36.782297217032124, 22.63085863810238], [36.08273074695987, 22.52379853224128], [35.388526061550124, 22.439116370104653], [34.70037687309133, 22.376764266841334], [34.01897298158442, 22.336672452080812], [33.3449995913586, 22.318749371185568], [32.679136631713384, 22.322881807956453], [32.02205808225935, 22.348935028662023], [31.374431303623368, 22.39675294724177], [30.736916374179863, 22.46615831151222], [30.110165433464502, 22.556952910184215], [29.49482203291886, 22.66891780047913], [28.891520494608617, 22.80181355611157], [28.300885278549444, 22.95538053538582], [27.723530359266707, 23.129339169133825], [27.16005861220617, 23.32339026820241], [26.611061210602607, 23.53721535017884], [26.07711703340416, 23.77047698502431], [25.558792084838696, 24.022819159266472], [25.056638926197046, 24.293867658384112], [24.571196120396298, 24.583230466998845], [24.10298768987413, 24.890498186471135], [23.65252258835141, 25.215244469481203], [23.220294186987243, 25.55702647115816], [22.806779775436514, 25.9153853163048], [22.412440078305128, 26.28984658224953], [22.03771878748339, 26.679920796841316], [21.683042110822115, 27.085103951089152], [21.3488183376002, 27.504878025932673], [21.03543742121634, 27.938711532616452], [20.743270579519386, 28.38606006612839], [20.47266991317637, 28.846366871147918], [20.223968042457997, 29.319063419939067], [19.997477762804195, 29.80357000161061], [19.79349171951343, 30.29929632215548], [19.61228210188124, 30.805642114669403], [19.454100357093697, 31.32199775914065], [19.319176924162726, 31.847744911191885], [19.207720988170085, 32.38225713914731], [19.119920255067218, 32.92490056878961], [19.055940747257797, 33.47503453516345], [19.01592662016937, 34.03201224077684], [19.0, 34.59518141954324], [19.008260842805047, 35.16388500580364], [19.040786815067964, 35.737461807761576], [19.097633195878394, 36.31524718466111], [19.17883280081904, 36.89657372703226], [19.28439592764196, 37.480771939327504], [19.414310323793167, 38.06717092426952], [19.568541175823217, 38.655099068228616], [19.747031120699727, 39.243884726948316], [19.949700279016277, 39.83285691093582], [20.17644631007104, 40.42134596983662], [20.427144488766167, 41.008684275111264], [20.701647804258357, 41.59420690033578], [20.99978708026906, 42.17725229844872], [21.32137111694193, 42.75716297527204], [21.666186854113388, 43.333286158634806], [22.033999555841714, 43.90497446243537], [22.424553016018862, 44.47158654498168], [22.83756978486848, 45.03248776095462], [23.272751416112964, 45.587050806346845], [23.729778734572456, 46.134656355735835], [24.208312123938228, 46.674693691257794], [24.707991834443124, 47.20656132265726], [25.228438310132095, 47.729667597796116], [25.769252535417213, 48.24343130301578], [26.33001640058141, 48.74728225275548], [26.7147271551075, 49.077399463166024]], [[44.68708881274753, 56.66861583394685], [45.56574985184354, 56.775660355557], [46.44812894078427, 56.86340221275936], [47.33348668694572, 56.931745510677416], [48.22108088806224, 56.98061556370149], [49.11016723223537, 57.00995897098046], [50.0, 57.01974367039814], [50.88983276776463, 57.00995897098046], [51.77891911193771, 56.98061556370149], [52.66651331305429, 56.931745510677416], [53.55187105921574, 56.86340221275936], [54.43425014815642, 56.77566035555701], [55.31291118725243, 56.66861583394686], [56.187118290789684, 56.54238565513985], [57.05613977381108, 56.39710782040469], [57.91924884186615, 56.23294118556428], [58.775724275990356, 56.05006530040367], [59.624851112246056, 55.848680227149174], [60.46592131516243, 55.629006338198494], [61.298234444417794, 55.39128409330302], [62.121098314114086, 55.13577379642336], [62.933829644000994, 54.86275533249942], [63.735754702014816, 54.572527884396514], [64.52620993750571, 54.265409630308504], [65.30454260453645, 53.94173742191829], [66.07011137464502, 53.60186644363538], [66.82228693847448, 53.24616985324875], [67.56045259568367, 52.875038404351834], [68.28400483256462, 52.48888005091462], [68.99235388680404, 52.08811953439546], [69.68492429883923, 51.67319795380258], [70.3611554492719, 51.24457231913228], [71.02050208181697, 50.80271508862735], [71.66243481127778, 50.34811369031516], [72.28644061605385, 49.88127002830067], [72.89202331470258, 49.40269997430477], [73.47870402609178, 48.91293284495286], [74.04602161269625, 48.41251086533305], [74.5935331066083, 47.90198861935663], [75.12081411784871, 47.38193248746709], [75.62745922458254, 46.85292007225605], [76.11308234486157, 46.31553961255689], [76.57731708953362, 45.77038938659829], [77.01981709597732, 45.21807710481046], [77.4402563423399, 44.65921929288774], [77.83832944197474, 44.09444066572044], [78.2137519177946, 43.52437349281817], [78.5662604562769, 42.949656955855474], [78.89561314087638, 42.370936498978175], [79.20158966462195, 41.7888631725164], [79.48399152169378, 41.20409297075668], [79.74264217779842, 40.61728616443153], [79.97738721917975, 40.029106628590235], [80.18809448012523, 39.440221166519144], [80.37465414884753, 38.851298830384025], [80.53697885164317, 38.26301023926993], [80.67500371525114, 37.6760268952972], [80.78868640735571, 37.09102049849368], [80.87800715519963, 36.5086622611051], [80.94296874229438, 35.92962222202573], [80.98359648323719, 35.354568562031716], [80.9999381766645, 34.784166920498635], [80.99206403639411, 34.21907971428342], [80.96006660082928, 33.659965459449026], [80.90406062071935, 33.10747809650698], [80.8241829253929, 32.56226631984999], [80.72059226760041, 32.02497291204277], [80.5934691471247, 31.49623408363398], [80.4430156133378, 30.976678819147573], [80.2694550469043, 30.46692822990511], [80.07303192085067, 29.967594914324515], [79.85401154124156, 29.47928232633295], [79.61267976772298, 29.00258415252394], [79.34934271421272, 28.538083698680133], [79.06432643003757, 28.086353286273756], [78.75797656183613, 27.64795365954753], [78.43065799656506, 27.223433403767814], [78.08275448596473, 26.81332837523132], [77.71466825285893, 26.418161143594748], [77.32681957968073, 26.0384404470851], [76.9196463796342, 25.674660661135167], [76.49360375091872, 25.327301280976048], [76.049163514459, 24.99682641870455], [75.58681373560043, 24.68368431532901], [75.10705823024499, 24.38830686828272], [74.61041605591768, 24.11110917487837], [74.09742098826918, 23.852489092161864], [73.56862098353379, 23.61282681360712], [73.0245776274759, 23.39248446307742], [72.46586557137132, 23.191805706461363], [71.89307195558251, 23.011115381374623], [71.30679582129903, 22.850719145300406], [70.7076475110256, 22.710903142523705], [70.09624805841176, 22.591933690195702], [69.47322856802707, 22.494056983846015], [68.83922958569595, 22.41749882264116], [68.1949004600149, 22.3624643546682], [67.54089869568409, 22.329137842502895], [66.87788929929283, 22.31768244930149], [66.20654411820583, 22.328240045635255], [65.52754117320394, 22.360931037266276], [64.84156398553904, 22.415854214042454], [64.14930089906808, 22.493086620068716], [63.45144439813611, 22.592683445290675], [62.74869042188215, 22.714677938605586], [62.04173767564518, 22.859081342594436], [61.33128694015019, 23.02588284994759], [60.6180403791564, 23.21504958163489], [59.90270084625114, 23.42652658684998], [59.18597119147354, 23.66023686473668], [58.707725923768564, 23.82834754606007]]], "iconStroke": 8.5, "glyph": [[[7.268804586047381, 5.9889671601204615], [7.151512068695885, 6.154889856987612], [7.037759813448099, 6.3235789166785805], [6.9276463117984415, 6.494894711173865], [6.8212660442238064, 6.6686948006094475], [6.718709390443457, 6.844834067277683], [6.620062543828681, 7.023164852326628], [6.525407430045709, 7.2035370950246325], [6.434821630011341, 7.38579847445423], [6.348378307236283, 7.569794553497245], [6.266146139627067, 7.755368924970324], [6.188189255812729, 7.942363359768516], [6.114567176058159, 8.130617956872005], [6.0453347578213865, 8.31997129506956], [5.9805421460074175, 8.510260586250556], [5.920234727966726, 8.701321830115532], [5.864453093281597, 8.892989970154366], [5.813232998378965, 9.085099050739347], [5.7666053360034715, 9.277482375179671], [5.724596109579711, 9.46997266458273], [5.687226412487755, 9.662402217366875], [5.654512412271216, 9.854603069269537], [5.626465339792161, 10.046407153694162], [5.6030914833423475, 10.237646462239173], [5.584392187715297, 10.428153205251597], [5.570363858238807, 10.617759972248544], [5.56099796976264, 10.806299892049134], [5.5562810805911305, 10.993606792460424], [5.556194851345619, 11.179515359360748], [5.560716068736735, 11.363861295024765], [5.569816674221631, 11.546481475535211], [5.583463797516497, 11.727214107127065], [5.601619794929846, 11.905898881311062], [5.624242292477249, 12.08237712862455], [5.651284233733566, 12.256491970859123], [5.682693932373881, 12.428088471615709], [5.718415129349853, 12.59701378503982], [5.7583870546434905, 12.763117302591015], [5.802544493535883, 12.92625079770281], [5.850817857323946, 13.086268568191308], [5.903133258413799, 13.243027576272818], [5.95941258971511, 13.396387586053388], [6.019573608256437, 13.546211298355345], [6.083530022937456, 13.692364482748715], [6.151191586329807, 13.83471610665806], [6.222464190434399, 13.973138461418198], [6.297249966298933, 14.10750728515514], [6.375447387395724, 14.23770188237177], [6.456951376656134, 14.363605240121078], [6.541653417054225, 14.485104140652876], [6.629441665628921, 14.6020892704237], [6.720201070830288, 14.714455325362827], [6.813813493072572, 14.822101112291294], [6.910157828373164, 14.924929646394363], [7.009110134953784, 15.022848244651875], [7.110543762677231, 15.115768615134858], [7.214329485190131, 15.203606942080734], [7.320335634639566, 15.28628396666377], [7.428428238828825, 15.36372506338145], [7.538471160675202, 15.435860311981916], [7.650326239830303, 15.502624564861843], [7.763853436321468, 15.563957509868708], [7.878910976070619, 15.619803728445767], [7.995355498145162, 15.670112749062753], [8.113042203593885, 15.714839095879864], [8.231825005718985, 15.753942332597257], [8.351556681634312, 15.787387101447106], [8.472089024958267, 15.815143157289882], [8.593272999488944, 15.837185396781484], [8.714958893707943, 15.853493882582537], [8.83699647595866, 15.86405386258615], [8.959235150143954, 15.868855784145236], [9.081524111787765, 15.867895303285481], [9.203712504304873, 15.861173288894864], [9.325649575322615, 15.848695821885663], [9.447184832898625, 15.830474189329747], [9.568168201478404, 15.806524873572918], [9.68845017743715, 15.776869536338989], [9.80788198405039, 15.741534997839153], [9.926315725738672, 15.700553210907216], [10.04360454143233, 15.653961230185981], [10.159602756903036, 15.601801176395108], [10.274166035910062, 15.544120195715402], [10.387151530010222, 15.480970414329487], [10.498418026881899, 15.41240888816337], [10.607826097014822, 15.338497547878266], [10.715238238619214, 15.25930313916663], [10.820519020609337, 15.174897158410946], [10.923535223518602, 15.085355783768431], [11.024155978205403, 14.990759801749212], [11.122252902210917, 14.891194529360096], [11.217700233632675, 14.786749731890122], [11.310374962379807, 14.677519536418806], [11.400156958678897, 14.563602341131642], [11.486929098701673, 14.445100720532018], [11.570577387188942, 14.322121326642417], [11.65099107694797, 14.194774786291799], [11.728062785103605, 14.063175594590007], [11.801688605986811, 13.927442004693509], [11.87176822054738, 13.78769591397074], [11.938205002181261, 13.644062746678564], [12.000906118866256, 13.496671333264977], [12.059782631503765, 13.345653786416353], [12.114749588367733, 13.191145373970826], [12.165726115566018, 13.033284388822388], [12.212635503423272, 12.872212015943198], [12.2554052886984, 12.70807219665463], [12.281584870650146, 12.597013785039822]], [[11.679503582469131, 7.631485089223647], [11.594456570593497, 7.44694544108984], [11.505243687184501, 7.264088568462001], [11.411936607906908, 7.083069581486111], [11.314611449063003, 6.904041522587298], [11.213348696414519, 6.727155221752264], [11.108233129592698, 6.552559154056167], [10.999353742170143, 6.3803992995730106], [10.886803657472498, 6.210819005806306], [10.77068004021205, 6.043958852774065], [10.65108400402964, 5.879956520879686], [10.528120515035264, 5.718946661697501], [10.40189829144172, 5.561060771798851], [10.272529699389633, 5.406427069741595], [10.140130645065751, 5.255170376342668], [10.004820463220366, 5.107411998350284], [9.866721802193188, 4.963269615628922], [9.725960505560238, 4.822857171966504], [9.582665490518163, 4.686284769610084], [9.43696862312517, 4.5536585676322385], [9.289004590521207, 4.425080684226811], [9.138910770252766, 4.30064910302855], [8.986827096830948, 4.1804575835475895], [8.8328959256537, 4.064595575805131], [8.677261894426229, 3.9531481392530123], [8.520071782215693, 3.8461958660552513], [8.361474366278976, 3.7438148088055776], [8.201620276804432, 3.6460764127505687], [8.040661849710354, 3.5530474525832503], [7.878752977645344, 3.464789973867979], [7.716048959337141, 3.38136123915249], [7.552706347438359, 3.3028136788185023], [7.388882795018861, 3.22919484671751], [7.224736900856312, 3.1605473806338606], [7.060428053677098, 3.096908967612194], [6.896116275501223, 3.0383123141816277], [6.731962064245717, 2.9847851215043546], [6.5681262357416035, 2.9363500654712196], [6.404769765320444, 2.8930247817621915], [6.2420536291264295, 2.854821855884534], [6.080138645310866, 2.821748818196764], [5.919185315265597, 2.7938081439214137], [5.759353665052156, 2.7709972581447717], [5.600803087183003, 2.7533085457968225], [5.443692182911358, 2.7407293665996963], [5.288178605185118, 2.733242074968075], [5.13441890242003, 2.73082404484002], [4.982568363246557, 2.733447699411954], [4.832780862383687, 2.7410805457465894], [4.6852087077922215, 2.753685214217814], [4.540002489258323, 2.77121950275183], [4.3973109285573075, 2.7936364258190167], [4.257280731345748, 2.820884268126398], [4.120056440928293, 2.852906642955899], [3.9857802940440132, 2.8896425550890257], [3.8545920788149584, 2.931026468254055], [3.7266289949974567, 2.9769883770274106], [3.602025516674523, 3.027453883116471], [3.480913257525449, 3.082344275946748], [3.3634208388059568, 3.1415766174721425], [3.249673760169702, 3.205063831122815], [3.13979427345926, 3.2727147948011597], [3.0339012595916506, 3.3444344378323336], [2.9321101086605514, 3.420123841771936], [2.8345326033741056, 3.499680344969664], [2.7412768059440955, 3.5829976507840033], [2.6524469485387607, 3.669965939339444], [2.5681433274080803, 3.760471982714268], [2.4884622007867145, 3.8543992634435957], [2.41349569067624, 3.951628096218954], [2.3433316886043025, 4.052035752662697], [2.2780537654545086, 4.155496589052545], [2.217741085456997, 4.261882176868385], [2.1624683244253218, 4.371061436031097], [2.1123055923212988, 4.482900770700133], [2.0673183602251477, 4.5972642074943915], [2.027567391783922, 4.7140135359983155], [1.9931086792069284, 4.833008451412944], [1.9639933838721673, 4.954106699209794], [1.9402677816035645, 5.077164221643061], [1.921973212673957, 5.202035305974182], [1.9091460365842643, 5.328572734260904], [1.9018175916645754, 5.456627934561682], [1.9000141595381308, 5.58605113340452], [1.9037569344843845, 5.716691509368557], [1.9130619977325782, 5.848397347625249], [1.9279402967123431, 5.9810161952853536], [1.948397629283046, 6.114395017396939], [1.9744346329586113, 6.2483803534390345], [2.0060467791397896, 6.3828184741553695], [2.0432243723607266, 6.517555538571822], [2.085952554551967, 6.652437751041486], [2.1342113143168966, 6.787311518160985], [2.18797550121391, 6.922023605402057], [2.247214845031422, 7.056421293302339], [2.3118939800381977, 7.190352533060208], [2.3819724741863793, 7.323666101378873], [2.4574048632398053, 7.456211754405601], [2.5381406897954317, 7.587840380613193], [2.624124547160627, 7.718404152471454], [2.7152961280447316, 7.8477566767581575], [2.8115902780180955, 7.975753143359812], [2.9129370536874637, 8.10225047241445], [3.0192617855316994, 8.227107459650123], [3.130485145337543, 8.350184919774767], [3.246523218170135, 8.471345827774723], [3.3672875788090693, 8.59045545798168], [3.450377193595556, 8.668656846819744]], [[8.051691831483492, 10.630003504019816], [8.254031360710616, 10.648620455286464], [8.456996499367397, 10.662788268223332], [8.66041708029465, 10.672491460703954], [8.864122506713203, 10.677719430167183], [9.067941913142025, 10.678466464333974], [9.271704326578623, 10.674731746981124], [9.475238827784148, 10.666519358766282], [9.678374712516167, 10.653838273103382], [9.880941652551671, 10.636702347092614], [10.082769856343297, 10.615130307513914], [10.283690229152013, 10.589145731897908], [10.48353453250012, 10.558777024693066], [10.682135542788982, 10.524057388552762], [10.87932720892684, 10.485024790770705], [11.07494480881291, 10.441721924898102], [11.268825104525218, 10.394196167580631], [11.460806496060801, 10.342499530658069], [11.650729173478368, 10.286688608574169], [11.838435267295115, 10.226824521148949], [12.023768996991041, 10.162972851770238], [12.206576817476023, 10.095203581065842], [12.386707563376898, 10.023591016122172], [12.564012591003952, 9.948213715319618], [12.738345917858476, 9.869154408859304], [12.909564359545511, 9.786499915060132], [13.077527663958385, 9.700341052509208], [13.242098642604441, 9.610772548152926], [13.40314329894403, 9.517892941419928], [13.560530953617924, 9.42180448447118], [13.714134366441224, 9.322613038676216], [13.86382985504515, 9.220427967418356], [14.0094974100513, 9.115362025335356], [14.151020806666438, 9.007531244105504], [14.288287712589334, 8.897054814892606], [14.421189792124899, 8.784054967566584], [14.549622806404443, 8.668656846819749], [14.67348670961491, 8.55098838530169], [14.792685741143675, 8.431180173898918], [14.90712851354963, 8.309365329288081], [15.01672809627534, 8.18567935889434], [15.121402095019288, 8.06026002338912], [15.221072726691412, 7.933247196863803], [15.31566688987955, 7.804782724818387], [15.405116230758836, 7.675010280106166], [15.489357204380482, 7.544075216977649], [15.568331131281038, 7.412124423368765], [15.64198424935773, 7.279306171580196], [15.710267760960186, 7.145769967496257], [15.773137875153555, 7.011666398493231], [15.830555845112762, 6.877146980188391], [15.882488000612401, 6.742364002182077], [15.928905775581677, 6.607470372946229], [15.96978573069855, 6.472619464013659], [16.005109571002208, 6.33796495362302], [16.034864158507812, 6.20366066997501], [16.059041519812414, 6.0698604342557765], [16.077638848685915, 5.936717903583681], [16.090658503645724, 5.804386414035722], [16.09810800051884, 5.673018823909864], [16.1, 5.542767357379262], [16.096352290219276, 5.413783448694053], [16.087187764337735, 5.286217587085826], [16.072534393194285, 5.160219162529235], [16.052425193032015, 5.035936312514391], [16.026898188336922, 4.9135157699826575], [15.995996369826926, 4.793102712577375], [15.959767647633655, 4.674840613359772], [15.918264799724344, 4.5588710931388405], [15.871545415615815, 4.445333774562434], [15.819671835437038, 4.334366138115074], [15.762711084401541, 4.22610338016614], [15.700734802755239, 4.120678273210053], [15.633819171269808, 4.0182210284379645], [15.562044832356083, 3.91885916077813], [15.485496806876231, 3.822717356539778], [15.404264406737674, 3.729917343792687], [15.318441143355923, 3.6405777656119884], [15.228124632077446, 3.5548140563149735], [15.133416492657766, 3.4727383208136464], [15.034422245893714, 3.394459217203763], [14.931251206512702, 3.320081842707908], [14.824016372425366, 3.2497076230868327], [14.712834310451644, 3.1834342056299105], [14.597825038633719, 3.1213553558319997], [14.4791119052526, 3.063560857860409], [14.356821464668439, 3.010136418911931], [14.231083350107617, 2.9611635775560368], [14.102030143522786, 2.916719616156458], [13.96979724265481, 2.8768774774593355], [13.834522725428354, 2.8417056854320064], [13.696347211815361, 2.8112682704323193], [13.555413723303293, 2.7856246987841278], [13.411867540107192, 2.7648298068302193], [13.265856056266909, 2.7489337395295665], [13.11752863277286, 2.7379818936613027], [12.967036448865652, 2.7320148656932535], [12.814532351656585, 2.7310684043683082], [12.66017070421776, 2.735173368057219], [12.504107232291991, 2.7443556869217254], [12.346498869774006, 2.758636329927196], [12.187503603115646, 2.7780312767391315], [12.027280314808776, 2.802551494533126], [11.865988626100563, 2.8322029197429694], [11.703788739096435, 2.8669864447667637], [11.540841278406596, 2.9068979096459984], [11.377307132492534, 2.9519280987276204], [11.268037935754293, 2.984785121504352]]], "glyphStroke": 1.9, "navy": "#132441", "coral": "#FF8A5B"}
\ No newline at end of file
diff --git a/scripts/icons/knot.py b/scripts/icons/knot.py
new file mode 100755
index 0000000..7f9ce0f
--- /dev/null
+++ b/scripts/icons/knot.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""Generates the knot icons' geometry; run via scripts/make-icons.sh."""
+import json
+import math
+import os
+
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+NAVY = "#132441"
+CORAL = "#FF8A5B"
+
+
+def trefoil(cx, cy, size, gap):
+ n = 1200
+ pts = []
+ for i in range(n):
+ t = 2 * math.pi * i / n
+ pts.append((math.sin(t) + 2 * math.sin(2 * t), -(math.cos(t) - 2 * math.cos(2 * t))))
+ xs = [p[0] for p in pts]
+ ys = [p[1] for p in pts]
+ mx, my = (max(xs) + min(xs)) / 2, (max(ys) + min(ys)) / 2
+ span = max(max(xs) - min(xs), max(ys) - min(ys))
+ p = [(cx + (x - mx) / span * size, cy + (y - my) / span * size) for x, y in pts]
+
+ # Self-crossings: points far apart along the curve that land on each other.
+ cands = []
+ for i in range(n):
+ for j in range(i + n // 6, n):
+ if j - i > n - n // 6:
+ continue
+ d = (p[i][0] - p[j][0]) ** 2 + (p[i][1] - p[j][1]) ** 2
+ if d < (size * 0.004) ** 2:
+ cands.append((d, i, j))
+ cands.sort()
+ crossings = []
+ for _, i, j in cands:
+ far = lambda a, b: min(abs(a - b) % n, n - abs(a - b) % n) > 40
+ if all(far(i, a) and far(j, b) for a, b in crossings):
+ crossings.append((i, j))
+ if len(crossings) == 3:
+ break
+ assert len(crossings) == 3, crossings
+
+ # Alternate crossings go under; hide points within `gap` (straight-line) of each.
+ visits = sorted([i for i, _ in crossings] + [j for _, j in crossings])
+ under = visits[1::2]
+ hidden = set()
+ for u in under:
+ for d in range(-n // 10, n // 10 + 1):
+ k = (u + d) % n
+ if math.dist(p[k], p[u]) < gap:
+ hidden.add(k)
+
+ strands, cur = [], []
+ start = next(i for i in range(n) if i in hidden)
+ for k in range(1, n + 1):
+ i = (start + k) % n
+ if i in hidden:
+ if len(cur) > 1:
+ strands.append(cur)
+ cur = []
+ else:
+ cur.append(p[i])
+ if len(cur) > 1:
+ strands.append(cur)
+ assert len(strands) == 3
+ return [s[::3] + [s[-1]] for s in strands]
+
+
+def path(strand):
+ return "M" + " L".join(f"{x:.2f},{y:.2f}" for x, y in strand)
+
+
+# Each gap is half the crossing strand's width, plus the round cap, plus clear space.
+ICON_STROKE, GLYPH_STROKE = 8.5, 1.9
+icon = trefoil(50, 51, 62, ICON_STROKE + 1.3)
+glyph = trefoil(9, 9.3, 14.2, GLYPH_STROKE + 0.6)
+
+os.makedirs(os.path.join(ROOT, "assets"), exist_ok=True)
+json.dump({"icon": icon, "iconStroke": ICON_STROKE, "glyph": glyph, "glyphStroke": GLYPH_STROKE,
+ "navy": NAVY, "coral": CORAL},
+ open(os.path.join(ROOT, "scripts/icons/knot.json"), "w"))
+
+strokes = "".join(f'' for s in icon)
+open(os.path.join(ROOT, "assets/knotchy-icon.svg"), "w").write(
+ f'\n')
+open(os.path.join(ROOT, "assets/knotchy-mark.svg"), "w").write(
+ f'\n')
+
+points = ",\n".join(
+ " [" + ", ".join(f"CGPoint(x: {x:.2f}, y: {y:.2f})" for x, y in s) + "]" for s in glyph)
+open(os.path.join(ROOT, "Sources/Knotchy/KnotGlyph.swift"), "w").write(f'''// Generated by scripts/icons/knot.py; run scripts/make-icons.sh to change it.
+import AppKit
+
+enum KnotGlyph {{
+ static let strands: [[CGPoint]] = [
+{points},
+ ]
+
+ static let menuBarImage: NSImage = {{
+ let image = NSImage(size: NSSize(width: 18, height: 18), flipped: true) {{ _ in
+ NSColor.black.setStroke()
+ for strand in strands {{
+ let path = NSBezierPath()
+ path.move(to: strand[0])
+ for point in strand.dropFirst() {{ path.line(to: point) }}
+ path.lineWidth = 1.9
+ path.lineCapStyle = .round
+ path.lineJoinStyle = .round
+ path.stroke()
+ }}
+ return true
+ }}
+ image.isTemplate = true
+ image.accessibilityDescription = "Knotchy"
+ return image
+ }}()
+}}
+''')
+print("knot: 3 strands; wrote knot.json, assets/*.svg, Sources/Knotchy/KnotGlyph.swift")
diff --git a/scripts/icons/render.swift b/scripts/icons/render.swift
new file mode 100644
index 0000000..11394c6
--- /dev/null
+++ b/scripts/icons/render.swift
@@ -0,0 +1,183 @@
+// swift scripts/icons/render.swift ; run via scripts/make-icons.sh.
+import AppKit
+import CoreGraphics
+import ImageIO
+import UniformTypeIdentifiers
+
+struct Knot: Decodable {
+ let icon: [[[Double]]]
+ let iconStroke: Double
+}
+
+let args = CommandLine.arguments
+let here = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
+let knot = try JSONDecoder().decode(Knot.self, from: Data(contentsOf: here.appendingPathComponent("knot.json")))
+let out = URL(fileURLWithPath: args.count > 1 ? args[1] : ".")
+
+func color(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor {
+ CGColor(srgbRed: CGFloat(hex >> 16 & 0xFF) / 255, green: CGFloat(hex >> 8 & 0xFF) / 255,
+ blue: CGFloat(hex & 0xFF) / 255, alpha: alpha)
+}
+
+/// Apple's icon shape is close to a superellipse with exponent 5.
+func squircle(_ r: CGRect) -> CGPath {
+ let path = CGMutablePath()
+ let steps = 720
+ for i in 0...steps {
+ let t = Double(i) / Double(steps) * 2 * .pi
+ let c = cos(t), s = sin(t)
+ let x = r.midX + r.width / 2 * CGFloat(copysign(pow(abs(c), 2.0 / 5), c))
+ let y = r.midY + r.height / 2 * CGFloat(copysign(pow(abs(s), 2.0 / 5), s))
+ if i == 0 { path.move(to: CGPoint(x: x, y: y)) } else { path.addLine(to: CGPoint(x: x, y: y)) }
+ }
+ path.closeSubpath()
+ return path
+}
+
+func verticalGradient(_ ctx: CGContext, in rect: CGRect, top: CGColor, bottom: CGColor) {
+ let gradient = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), colors: [top, bottom] as CFArray, locations: [0, 1])!
+ ctx.drawLinearGradient(gradient, start: CGPoint(x: rect.midX, y: rect.minY), end: CGPoint(x: rect.midX, y: rect.maxY), options: [])
+}
+
+/// The knot as drawn on the icon: `box` is the icon's body, and the knot keeps the icon's margins inside it.
+func drawKnot(_ ctx: CGContext, in box: CGRect) {
+ let scale = box.width / 100
+ let width = knot.iconStroke * scale
+ for strand in knot.icon {
+ let line = CGMutablePath()
+ for (i, p) in strand.enumerated() {
+ let point = CGPoint(x: box.minX + p[0] * scale, y: box.minY + p[1] * scale)
+ if i == 0 { line.move(to: point) } else { line.addLine(to: point) }
+ }
+ let stroked = line.copy(strokingWithWidth: width, lineCap: .round, lineJoin: .round, miterLimit: 4)
+ ctx.saveGState()
+ ctx.addPath(stroked)
+ ctx.clip()
+ verticalGradient(ctx, in: box, top: color(0xFFA37A), bottom: color(0xFF7345))
+ ctx.restoreGState()
+ }
+}
+
+func render(size: Int, body: CGRect, shadow: Bool, to url: URL) {
+ let s = CGFloat(size)
+ let ctx = CGContext(data: nil, width: size, height: size, bitsPerComponent: 8, bytesPerRow: 0,
+ space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
+ ctx.translateBy(x: 0, y: s)
+ ctx.scaleBy(x: 1, y: -1)
+ ctx.setShouldAntialias(true)
+ ctx.interpolationQuality = .high
+ let shape = squircle(body)
+
+ if shadow {
+ ctx.saveGState()
+ ctx.setShadow(offset: CGSize(width: 0, height: s * 0.012), blur: s * 0.028, color: color(0x000000, 0.32))
+ ctx.addPath(shape)
+ ctx.setFillColor(color(0x132441))
+ ctx.fillPath()
+ ctx.restoreGState()
+ }
+
+ ctx.saveGState()
+ ctx.addPath(shape)
+ ctx.clip()
+ verticalGradient(ctx, in: body, top: color(0x1C3563), bottom: color(0x0D1A33))
+ drawKnot(ctx, in: body)
+ ctx.restoreGState()
+
+ let image = ctx.makeImage()!
+ let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)!
+ CGImageDestinationAddImage(dest, image, nil)
+ CGImageDestinationFinalize(dest)
+}
+
+// Apple's grid: an 824-point body on a 1024-point canvas.
+let iconset = out.appendingPathComponent("AppIcon.iconset")
+try FileManager.default.createDirectory(at: iconset, withIntermediateDirectories: true)
+for (points, scale) in [(16, 1), (16, 2), (32, 1), (32, 2), (128, 1), (128, 2), (256, 1), (256, 2), (512, 1), (512, 2)] {
+ let size = points * scale
+ let inset = CGFloat(size) * 100 / 1024
+ let body = CGRect(x: inset, y: inset, width: CGFloat(size) - 2 * inset, height: CGFloat(size) - 2 * inset)
+ let name = scale == 1 ? "icon_\(points)x\(points).png" : "icon_\(points)x\(points)@2x.png"
+ render(size: size, body: body, shadow: size >= 64, to: iconset.appendingPathComponent(name))
+}
+
+// The Chrome Web Store wants 96 points of art with 16 of padding at 128.
+for (size, pad) in [(128, 16.0), (48, 2.0), (32, 1.0), (16, 0.0)] {
+ let body = CGRect(x: pad, y: pad, width: Double(size) - 2 * pad, height: Double(size) - 2 * pad)
+ render(size: size, body: body, shadow: false, to: out.appendingPathComponent("chrome-\(size).png"))
+}
+// The disk image window: Finder puts Knotchy at (180, 175) and Applications at (460, 175).
+// It's drawn taller than the 400-point window so a slightly taller window never shows an edge, and it's light
+// because Finder always draws the file names in black over a background picture, even in dark mode.
+func renderBackground(scale: Int, to url: URL) {
+ let width = 640, height = 440
+ let ctx = CGContext(data: nil, width: width * scale, height: height * scale, bitsPerComponent: 8, bytesPerRow: 0,
+ space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
+ ctx.scaleBy(x: CGFloat(scale), y: CGFloat(scale))
+ ctx.translateBy(x: 0, y: CGFloat(height))
+ ctx.scaleBy(x: 1, y: -1)
+ ctx.setFillColor(color(0xF5F5F7))
+ ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
+
+ let arrow = CGMutablePath()
+ arrow.move(to: CGPoint(x: 272, y: 175))
+ arrow.addLine(to: CGPoint(x: 366, y: 175))
+ arrow.move(to: CGPoint(x: 356, y: 165))
+ arrow.addLine(to: CGPoint(x: 368, y: 175))
+ arrow.addLine(to: CGPoint(x: 356, y: 185))
+ ctx.addPath(arrow)
+ ctx.setStrokeColor(color(0xFF8456))
+ ctx.setLineWidth(3)
+ ctx.setLineCap(.round)
+ ctx.setLineJoin(.round)
+ ctx.strokePath()
+
+ NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true)
+ let caption = NSAttributedString(string: "Drag Knotchy to Applications", attributes: [
+ .font: NSFont.systemFont(ofSize: 12, weight: .medium),
+ .foregroundColor: NSColor(cgColor: color(0x86868B))!,
+ ])
+ let size = caption.size()
+ caption.draw(at: CGPoint(x: (CGFloat(width) - size.width) / 2, y: 300))
+ NSGraphicsContext.current = nil
+
+ let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)!
+ CGImageDestinationAddImage(dest, ctx.makeImage()!, [kCGImagePropertyDPIWidth: 72 * scale, kCGImagePropertyDPIHeight: 72 * scale] as CFDictionary)
+ CGImageDestinationFinalize(dest)
+}
+renderBackground(scale: 1, to: out.appendingPathComponent("dmg-background.png"))
+renderBackground(scale: 2, to: out.appendingPathComponent("dmg-background@2x.png"))
+// Chrome Web Store promo images. The store takes 24-bit PNGs only, hence no alpha.
+func renderPromo(width: Int, height: Int, to url: URL) {
+ let w = CGFloat(width), h = CGFloat(height), unit = h / 280
+ let ctx = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0,
+ space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)!
+ ctx.translateBy(x: 0, y: h)
+ ctx.scaleBy(x: 1, y: -1)
+ verticalGradient(ctx, in: CGRect(x: 0, y: 0, width: w, height: h), top: color(0x1C3563), bottom: color(0x0D1A33))
+
+ let title = NSAttributedString(string: "Open in Knotchy", attributes: [
+ .font: NSFont.systemFont(ofSize: 28 * unit, weight: .bold),
+ .foregroundColor: NSColor.white,
+ ])
+ let subtitle = NSAttributedString(string: "Open suspicious links in a\ndisposable, isolated browser.", attributes: [
+ .font: NSFont.systemFont(ofSize: 14 * unit, weight: .medium),
+ .foregroundColor: NSColor(white: 1, alpha: 0.72),
+ ])
+ let knotSide = 140 * unit, gap = 14 * unit, lineGap = 8 * unit
+ let left = (w - knotSide - gap - max(title.size().width, subtitle.size().width)) / 2
+ drawKnot(ctx, in: CGRect(x: left, y: (h - knotSide) / 2, width: knotSide, height: knotSide))
+
+ let top = (h - title.size().height - lineGap - subtitle.size().height) / 2
+ NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true)
+ title.draw(at: CGPoint(x: left + knotSide + gap, y: top))
+ subtitle.draw(at: CGPoint(x: left + knotSide + gap, y: top + title.size().height + lineGap))
+ NSGraphicsContext.current = nil
+
+ let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)!
+ CGImageDestinationAddImage(dest, ctx.makeImage()!, nil)
+ CGImageDestinationFinalize(dest)
+}
+renderPromo(width: 440, height: 280, to: out.appendingPathComponent("promo-440x280.png"))
+renderPromo(width: 1400, height: 560, to: out.appendingPathComponent("marquee-1400x560.png"))
+print("rendered AppIcon.iconset, chrome-{16,32,48,128}.png, dmg-background{,@2x}.png and the store promos into \(out.path)")
diff --git a/scripts/make-icons.sh b/scripts/make-icons.sh
new file mode 100755
index 0000000..dfa89d9
--- /dev/null
+++ b/scripts/make-icons.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Regenerates every icon (app, SVGs, Chrome extension, menu bar glyph) from scripts/icons/knot.py,
+# the disk image background and the Chrome Web Store promo images.
+set -euo pipefail
+root="$(cd "$(dirname "$0")/.." && pwd)"
+work="$(mktemp -d)"
+trap 'rm -rf "$work"' EXIT
+python3 "$root/scripts/icons/knot.py"
+swift "$root/scripts/icons/render.swift" "$work"
+iconutil -c icns "$work/AppIcon.iconset" -o "$root/assets/AppIcon.icns"
+for size in 16 32 48 128; do
+ cp "$work/chrome-$size.png" "$root/extensions/chrome/icons/icon-$size.png"
+done
+mkdir -p "$root/extensions/chrome/store"
+cp "$work/promo-440x280.png" "$work/marquee-1400x560.png" "$root/extensions/chrome/store/"
+tiffutil -cathidpicheck "$work/dmg-background.png" "$work/dmg-background@2x.png" -out "$root/assets/dmg-background.tiff"
+echo "Wrote assets/AppIcon.icns, assets/dmg-background.tiff, extensions/chrome/icons/ and extensions/chrome/store/"
diff --git a/scripts/package-extension.sh b/scripts/package-extension.sh
new file mode 100755
index 0000000..a1de7df
--- /dev/null
+++ b/scripts/package-extension.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+# Zips extensions/chrome for the Chrome Web Store: build/open-in-knotchy-.zip.
+set -euo pipefail
+root="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$root/extensions/chrome"
+version="$(plutil -extract version raw manifest.json)"
+out="$root/build/open-in-knotchy-$version.zip"
+mkdir -p "$root/build"
+rm -f "$out"
+zip -q -r -X "$out" manifest.json background.js icons -x '*.DS_Store'
+echo "$out"
diff --git a/scripts/release.sh b/scripts/release.sh
new file mode 100755
index 0000000..ed79c24
--- /dev/null
+++ b/scripts/release.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+# Builds a signed, notarized build/Knotchy-.dmg for a GitHub release.
+# Needs a Developer ID Application certificate and notarization credentials saved once with
+# xcrun notarytool store-credentials knotchy --apple-id --team-id
+set -euo pipefail
+root="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$root"
+profile="${KNOTCHY_NOTARY_PROFILE:-knotchy}"
+identity="${KNOTCHY_SIGN_IDENTITY:-$(security find-identity -v -p codesigning | awk '/"Developer ID Application: / && !id { id = $2 } END { print id }')}"
+if [[ -z $identity ]]; then
+ echo "A release needs a Developer ID Application certificate in the keychain." >&2
+ exit 1
+fi
+if [[ -e /Volumes/Knotchy ]]; then
+ echo "Eject the mounted Knotchy disk first; Finder finds the new one by name." >&2
+ exit 1
+fi
+
+notarize() {
+ local result status
+ result="$(xcrun notarytool submit "$1" --keychain-profile "$profile" --wait --output-format json)"
+ status="$(plutil -extract status raw - <<<"$result")"
+ if [[ $status != Accepted ]]; then
+ xcrun notarytool log "$(plutil -extract id raw - <<<"$result")" --keychain-profile "$profile" >&2 || true
+ echo "Notarization of $1 failed: $status" >&2
+ exit 1
+ fi
+}
+
+KNOTCHY_SIGN_IDENTITY="$identity" scripts/bundle-app.sh --release
+app="build/Knotchy.app"
+version="$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$app/Contents/Info.plist")"
+
+# The app gets its own ticket so it still opens offline once it's copied out of the disk image.
+ditto -c -k --keepParent "$app" build/Knotchy.zip
+notarize build/Knotchy.zip
+rm build/Knotchy.zip
+xcrun stapler staple -q "$app"
+
+dmg="build/Knotchy-$version.dmg"
+rw="build/Knotchy-rw.dmg"
+stage="build/dmg"
+rm -rf "$stage" "$dmg" "$rw"
+mkdir -p "$stage/.background"
+ditto "$app" "$stage/Knotchy.app"
+ln -s /Applications "$stage/Applications"
+cp assets/dmg-background.tiff "$stage/.background/background.tiff"
+hdiutil create -quiet -volname Knotchy -fs HFS+ -format UDRW -size "$(( $(du -sm "$stage" | cut -f1) + 20 ))m" \
+ -srcfolder "$stage" "$rw"
+rm -rf "$stage"
+
+mnt="$(hdiutil attach -readwrite -noverify -noautoopen "$rw" | awk -F'\t' '/\/Volumes\// { print $NF }')"
+trap 'hdiutil detach -quiet -force "$mnt" 2>/dev/null || true' EXIT
+# Finder saves the window layout into the disk's .DS_Store; macOS asks once to let this script control Finder.
+osascript - <<'EOF'
+tell application "Finder"
+ tell disk "Knotchy"
+ open
+ set current view of container window to icon view
+ set toolbar visible of container window to false
+ set statusbar visible of container window to false
+ set bounds of container window to {200, 120, 840, 548}
+ set viewOptions to icon view options of container window
+ set arrangement of viewOptions to not arranged
+ set icon size of viewOptions to 128
+ set text size of viewOptions to 13
+ set background picture of viewOptions to file ".background:background.tiff"
+ set position of item "Knotchy.app" of container window to {180, 175}
+ set position of item "Applications" of container window to {460, 175}
+ close
+ open
+ update without registering applications
+ delay 1
+ close
+ end tell
+end tell
+EOF
+for _ in {1..20}; do
+ [[ -f $mnt/.DS_Store ]] && break
+ sleep 0.5
+done
+# Added after Finder is done, since laying out the window deletes the icon and clears the custom-icon flag.
+cp assets/AppIcon.icns "$mnt/.VolumeIcon.icns"
+xattr -wx com.apple.FinderInfo 0000000000000000040000000000000000000000000000000000000000000000 "$mnt"
+rm -rf "$mnt/.fseventsd"
+sync
+hdiutil detach -quiet "$mnt" || { sleep 2; hdiutil detach -quiet -force "$mnt"; }
+trap - EXIT
+hdiutil convert -quiet "$rw" -format UDZO -imagekey zlib-level=9 -o "$dmg"
+rm "$rw"
+codesign --sign "$identity" --timestamp "$dmg"
+notarize "$dmg"
+xcrun stapler staple -q "$dmg"
+
+spctl --assess --type open --context context:primary-signature --verbose "$dmg"
+shasum -a 256 "$dmg"
diff --git a/scripts/route-server/Containerfile b/scripts/route-server/Containerfile
new file mode 100644
index 0000000..be3e986
--- /dev/null
+++ b/scripts/route-server/Containerfile
@@ -0,0 +1,8 @@
+# Test fixture for scripts/test-routes.sh: HTTP and SOCKS5 proxies and a WireGuard server.
+FROM debian:trixie-slim
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ tinyproxy microsocks wireguard-go wireguard-tools iproute2 nftables && \
+ rm -rf /var/lib/apt/lists/*
+COPY --chmod=755 serve.sh /usr/local/bin/serve.sh
+ENTRYPOINT ["/usr/local/bin/serve.sh"]
diff --git a/scripts/route-server/serve.sh b/scripts/route-server/serve.sh
new file mode 100644
index 0000000..8c8f312
--- /dev/null
+++ b/scripts/route-server/serve.sh
@@ -0,0 +1,49 @@
+#!/bin/sh
+set -eu
+me=$(ip -4 -o address show dev eth0 | awk '{ split($4, a, "/"); print a[1]; exit }')
+
+cat > /tmp/tinyproxy.conf < /tmp/microsocks.log 2>&1 &
+
+umask 077
+wg genkey > /tmp/server.key
+wg pubkey < /tmp/server.key > /tmp/server.pub
+wg genkey > /tmp/client.key
+wg pubkey < /tmp/client.key > /tmp/client.pub
+WG_I_PREFER_BUGGY_USERSPACE_TO_POLISHED_KMOD=1 wireguard-go wg0
+wg set wg0 listen-port 51820 private-key /tmp/server.key \
+ peer "$(cat /tmp/client.pub)" allowed-ips 10.99.0.2/32
+ip address add 10.99.0.1/24 dev wg0
+ip link set dev wg0 up
+echo 1 > /proc/sys/net/ipv4/ip_forward
+nft -f - < /tmp/client.conf < so the app's orphan sweep ignores them.
+set -euo pipefail
+name="${1:?usage: run-vm.sh NAME URL}"
+url="${2:?usage: run-vm.sh NAME URL}"
+if [[ ! "$name" =~ ^[a-z0-9][a-z0-9-]{0,40}$ ]]; then
+ echo "run-vm.sh: invalid VM name '$name' (lowercase letters, digits, dashes)" >&2; exit 2
+fi
+if [[ "$name" =~ ^knotchy-[0-9a-f]{8}$ ]]; then
+ echo "run-vm.sh: '$name' uses the app's naming; use knotchyctl-<8 hex>" >&2; exit 2
+fi
+
+tmp="${TMPDIR:-/tmp}"
+run_dir="${tmp%/}/knotchy"
+mkdir -p -m 700 "$run_dir"
+if [[ ! -d "$run_dir" || -L "$run_dir" || ! -O "$run_dir" ]]; then
+ echo "run-vm.sh: refusing unsafe socket directory $run_dir" >&2; exit 1
+fi
+chmod 700 "$run_dir"
+sock="$run_dir/$name.sock"
+rm -f "$sock"
+
+# The Mac's networks may be public addresses, so the VM blocks them explicitly.
+block=$(python3 - <<'PY'
+import ipaddress, re, subprocess
+out = subprocess.run(["ifconfig"], capture_output=True, text=True).stdout
+nets = set()
+for ip, mask in re.findall(r"inet (\d+\.\d+\.\d+\.\d+)(?: --> \S+)? netmask 0x([0-9a-f]{8})", out):
+ if ip.startswith("127."):
+ continue
+ net = ipaddress.ip_network(f"{ip}/{bin(int(mask, 16)).count('1')}", strict=False)
+ if net.prefixlen >= 8:
+ nets.add(str(net))
+print(" ".join(sorted(nets)))
+PY
+)
+if [[ -z "$block" ]]; then
+ echo "run-vm.sh: warning: found no Mac IPv4 networks to block" >&2
+fi
+
+container run -d --rm --name "$name" \
+ --cpus 4 --memory 2g --shm-size 512m \
+ --dns 1.1.1.1 \
+ --cap-add NET_ADMIN \
+ --masked-path NONE --read-only-path NONE \
+ --publish-socket "$sock:/tmp/knotchy/knotchy.sock" \
+ -e "KNOTCHY_URL=$url" \
+ -e "KNOTCHY_SIZE=1024x640" \
+ -e "KNOTCHY_BLOCK_CIDRS=$block" \
+ -e "KNOTCHY_DEBUG=${KNOTCHY_DEBUG:-0}" \
+ -e "KNOTCHY_FIRST_VIEWER_TIMEOUT=${KNOTCHY_FIRST_VIEWER_TIMEOUT:-60}" \
+ ${KNOTCHY_ENV_FILE:+--env-file "$KNOTCHY_ENV_FILE"} \
+ ${KNOTCHY_SECRETS_DIR:+--cap-add SYS_ADMIN --mount "type=bind,source=$KNOTCHY_SECRETS_DIR,target=/run/knotchy-secrets"} \
+ local/knotchy-vm:latest >/dev/null
+echo "$sock"
diff --git a/scripts/test-egress.sh b/scripts/test-egress.sh
new file mode 100755
index 0000000..484f916
--- /dev/null
+++ b/scripts/test-egress.sh
@@ -0,0 +1,247 @@
+#!/usr/bin/env bash
+# Isolation regression test: from inside a VM, as the browser's user, the Mac, the
+# LAN, link-local and vmnet DNS are rejected and the internet isn't; IPv6 stays shut;
+# PID 1 is unprivileged. Skips fail unless KNOTCHY_EGRESS_ALLOW_SKIP=1. Bash 3.2-safe.
+set -euo pipefail
+root="$(cd "$(dirname "$0")/.." && pwd)"
+name="knotchyctl-$(openssl rand -hex 4)"
+work="$(mktemp -d)"
+n_ok=0 n_fail=0 n_skip=0 skipped=""
+http_pid="" udp_pid=""
+
+# macOS has no timeout(1); rc 142 on timeout. The command's stderr goes via fd 3,
+# so only bash's "Alarm clock" is discarded.
+deadline() {
+ { ( perl -e 'alarm shift; exec @ARGV or die "exec $ARGV[0]: $!\n"' "$@" 2>&3 3>&-; exit $? ) 2>/dev/null; } 3>&2
+}
+cx() { deadline 20 container exec "$@"; }
+
+cleanup() {
+ trap '' INT TERM
+ for pid in $http_pid $udp_pid; do
+ kill "$pid" 2>/dev/null || true
+ wait "$pid" 2>/dev/null || true
+ done
+ deadline 30 container stop "$name" >/dev/null 2>&1 || true
+ deadline 30 container delete --force "$name" >/dev/null 2>&1 || true
+ rm -rf "$work"
+}
+trap cleanup EXIT
+# `container exec` swallows ^C and exits 0; don't carry on to "all checks passed".
+trap 'exit 130' INT
+trap 'exit 143' TERM
+
+ok() { printf " ok %s\n" "$1"; n_ok=$((n_ok + 1)); }
+bad() { printf " FAIL %s\n" "$1"; n_fail=$((n_fail + 1)); }
+skip() { printf " skip %s\n" "$1"; n_skip=$((n_skip + 1)); skipped="$skipped - $1"$'\n'; }
+check() {
+ if [[ "$2" == "$3" ]]; then ok "$1: $2"; else bad "$1: got '$2', want '$3'"; fi
+}
+summary() {
+ printf "\ntest-egress: %d ok, %d fail, %d skip\n" "$n_ok" "$n_fail" "$n_skip"
+ if (( n_skip )); then printf "test-egress: skipped:\n%s" "$skipped"; fi
+ if (( n_skip )) && [[ "${KNOTCHY_EGRESS_ALLOW_SKIP:-}" != 1 ]]; then
+ echo "test-egress: skipped checks count as failures (KNOTCHY_EGRESS_ALLOW_SKIP=1 allows them)"
+ exit 1
+ fi
+ if (( n_fail )); then exit 1; fi
+ echo "test-egress: all checks passed"
+}
+poll() {
+ local i
+ for ((i = 0; i < $1 * 2; i++)); do "${@:2}" && return 0; sleep 0.5; done
+ return 1
+}
+# Blocked means rc 1 plus the nft reject's error: TCP/IPv4 "No route to host", TCP/IPv6
+# "Permission denied", UDP "Operation not permitted". With IPv6 disabled nft never sees
+# the packet: blocked-no-ipv6. Anything else (timeout, refused, exec error) fails, so a
+# Mac firewall that drops SYNs can't pass for the VM's.
+signature() {
+ [[ "$1" == 1 && "$2" == *"$3"* ]] && why=$3
+}
+classify_tcp() {
+ cls=inconclusive why=""
+ if [[ "$1" == 0 ]]; then cls=reachable
+ elif [[ "$3" != *:* ]]; then
+ if signature "$1" "$2" "connect: No route to host"; then cls=blocked; fi
+ elif signature "$1" "$2" "connect: Permission denied"; then cls=blocked
+ elif signature "$1" "$2" "connect: Cannot assign requested address" ||
+ signature "$1" "$2" "connect: Network is unreachable"; then cls=blocked-no-ipv6
+ fi
+}
+classify_udp() {
+ cls=inconclusive why=""
+ if [[ "$1" == 0 ]]; then cls=reachable
+ elif signature "$1" "$2" "write error: Operation not permitted"; then cls=blocked
+ fi
+}
+verdict() { # verdict EXPECT LABEL RC OUTPUT (EXPECT may be "a|b")
+ case "|$1|" in
+ *"|$cls|"*) ok "$cls${why:+ ($why)}: $2" ;;
+ *) local err; err=$(printf '%s' "$4" | tr '\n' ' ')
+ bad "$cls (expected ${1//|/ or }): $2 [rc $3${err:+: ${err:0:240}}]" ;;
+ esac
+}
+tcp() {
+ local rc=0 out
+ if [[ -z "$3" ]]; then skip "$2 (address unknown)"; return; fi
+ out=$(cx -u knotchy "$name" timeout 3 bash -c \
+ "exec 3<>/dev/tcp/$3/$4 && printf 'GET / HTTP/1.0\r\n\r\n' >&3" 2>&1) || rc=$?
+ classify_tcp "$rc" "$out" "$3"
+ verdict "$1" "$2" "$rc" "$out"
+}
+udp() {
+ local rc=0 out
+ if [[ -z "$3" ]]; then skip "$2 (address unknown)"; return; fi
+ out=$(cx -u knotchy "$name" timeout 3 bash -c "printf x > /dev/udp/$3/$4" 2>&1) || rc=$?
+ classify_udp "$rc" "$out"
+ verdict "$1" "$2" "$rc" "$out"
+}
+
+# Append mode, so truncating a log doesn't leave the writer at a stale offset.
+hport=$(python3 -c 'import socket; s = socket.socket(socket.AF_INET6); s.bind(("::", 0)); print(s.getsockname()[1])')
+echo "MAC-REACHED" > "$work/index.html"
+(cd "$work" && exec python3 -u -m http.server "$hport" --bind :: >> "$work/http.log" 2>&1) &
+http_pid=$!
+python3 -u -c '
+import socket
+s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
+s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
+s.bind(("::", 0))
+print("READY", s.getsockname()[1])
+while True:
+ data, addr = s.recvfrom(2048)
+ print("DGRAM", addr[0], data[:64])
+' >> "$work/udp.log" 2>&1 &
+udp_pid=$!
+http_up() { curl -fsS --noproxy '*' -o /dev/null --max-time 2 "http://127.0.0.1:$hport/" 2>/dev/null; }
+udp_up() { uport=$(awk '$1 == "READY" {print $2}' "$work/udp.log"); [[ -n "$uport" ]]; }
+if ! poll 10 http_up || ! poll 10 udp_up; then bad "Mac-side HTTP server / UDP listener did not start"; summary; fi
+
+# Before the address lookups: the vmnet bridge only exists while a VM runs.
+if ! out=$(KNOTCHY_FIRST_VIEWER_TIMEOUT=300 "$root/scripts/run-vm.sh" "$name" about:blank 2>&1 >/dev/null); then
+ printf '%s\n' "$out"; bad "could not start $name"; summary
+fi
+streaming() { [[ "$(deadline 10 container logs "$name" 2>/dev/null)" == *"streaming on"* ]]; }
+if ! poll 30 streaming; then bad "$name did not log 'streaming on' within 30 s"; summary; fi
+
+gw4=$(container network inspect default | python3 -c 'import json, sys; print(json.load(sys.stdin)[0]["status"]["ipv4Gateway"])')
+bridge=""
+for i in $(ifconfig -l); do
+ if ifconfig "$i" | grep -F "inet $gw4 " >/dev/null; then bridge=$i; break; fi
+done
+# The bridge's fd… address appears ~15 s after the VM starts, tentative at first.
+bridge_v6() {
+ gw6=$(ifconfig "$bridge" 2>/dev/null |
+ awk '$1 == "inet6" && $2 ~ /^fd/ && $0 !~ / tentative/ && !n++ {print $2}')
+ [[ -n "$gw6" ]]
+}
+gw6=""
+if [[ -n "$bridge" ]]; then poll 30 bridge_v6 || true; fi
+mac4=$( (echo "$gw4"; ifconfig | awk '$1 == "inet" && $2 !~ /^127\./ {print $2}') | sort -u)
+router=$(route -n get default 2>/dev/null | awk '/gateway:/ {print $2}' || true)
+echo "Targets:"
+echo " Mac IPv4:$(ifconfig | awk '/^[^ \t]/ {i = substr($1, 1, length($1) - 1)}
+ $1 == "inet" && $2 !~ /^127\./ {printf " %s (%s)", $2, i}')"
+echo " VM gateway $gw4 on ${bridge:-?}, bridge IPv6 ${gw6:-none}, router ${router:-none}"
+echo " HTTP port $hport, UDP port $uport"
+
+# Otherwise "blocked" below could just mean nothing was listening.
+echo "Control (from the Mac, each target must answer):"
+udp_seen() { grep -F "'ctl-$1'" "$work/udp.log" >/dev/null; }
+for ip in $mac4; do
+ if curl -fsS --noproxy '*' -o /dev/null --max-time 3 "http://$ip:$hport/" 2>/dev/null; then
+ ok "HTTP answers on $ip:$hport"
+ else bad "HTTP does not answer on $ip:$hport"; fi
+ { printf "ctl-%s" "$ip" > "/dev/udp/$ip/$uport"; } 2>/dev/null || true
+ if poll 2 udp_seen "$ip"; then ok "UDP listener receives on $ip:$uport"
+ else bad "UDP listener got nothing sent to $ip:$uport"; fi
+done
+if [[ -z "$gw6" ]]; then
+ skip "HTTP on the bridge IPv6 (no settled fd… address on ${bridge:-the vmnet bridge} within 30 s)"
+elif curl -g -fsS --noproxy '*' -o /dev/null --max-time 3 "http://[$gw6]:$hport/" 2>/dev/null; then
+ ok "HTTP answers on [$gw6]:$hport"
+else bad "HTTP does not answer on [$gw6]:$hport"; fi
+if (( n_fail )); then echo "test-egress: control checks failed; the probes would be meaningless"; summary; fi
+: > "$work/http.log"
+: > "$work/udp.log"
+
+echo "Phase A, IPv6 disabled; from inside $name as the browser user:"
+for ip in $mac4; do tcp blocked "TCP Mac $ip:$hport" "$ip" "$hport"; done
+tcp blocked "TCP LAN router ${router:-?}:80" "$router" 80
+tcp blocked "TCP cloud metadata 169.254.169.254:80" 169.254.169.254 80
+tcp reachable "TCP internet 1.1.1.1:443" 1.1.1.1 443
+tcp reachable "TCP internet example.com:443" example.com 443
+for ip in $mac4; do udp blocked "UDP Mac $ip:$uport" "$ip" "$uport"; done
+udp blocked "UDP vmnet DNS $gw4:53" "$gw4" 53
+udp blocked "UDP LAN router ${router:-?}:53" "$router" 53
+udp blocked "UDP cloud metadata 169.254.169.254:53" 169.254.169.254 53
+udp reachable "UDP internet DNS 1.1.1.1:53" 1.1.1.1 53
+tcp "blocked|blocked-no-ipv6" "TCP Mac IPv6 [${gw6:-?}]:$hport" "$gw6" "$hport"
+tcp "blocked|blocked-no-ipv6" "TCP internet IPv6 [2606:4700:4700::1111]:443" 2606:4700:4700::1111 443
+
+echo "DNS and IPv6 state:"
+check "resolv.conf nameservers" \
+ "$(cx "$name" awk '$1 == "nameserver" {print $2}' /etc/resolv.conf 2>&1 | paste -sd ' ' -)" "1.1.1.1"
+if cx -u knotchy "$name" getent ahostsv4 example.com >/dev/null 2>&1; then
+ ok "browser user resolves example.com"
+else bad "browser user cannot resolve example.com"; fi
+check "all/disable_ipv6" "$(cx "$name" cat /proc/sys/net/ipv6/conf/all/disable_ipv6 2>&1)" 1
+
+echo "Privileges of the browser process (PID 1):"
+status=$(cx "$name" cat /proc/1/status 2>&1 || true)
+field() { awk -v f="$1:" '$1 == f {$1 = ""; print substr($0, 2)}' <<<"$status"; }
+check "Name" "$(field Name)" knotchy-agent
+check "Uid (real effective saved fs)" "$(field Uid)" "1000 1000 1000 1000"
+check "Gid (real effective saved fs)" "$(field Gid)" "1000 1000 1000 1000"
+for c in CapInh CapPrm CapEff CapBnd CapAmb; do check "$c" "$(field $c)" 0000000000000000; done
+check "NoNewPrivs" "$(field NoNewPrivs)" 1
+rc=0
+out=$(cx -u knotchy "$name" nft flush ruleset 2>&1) || rc=$?
+if (( rc != 0 )) && [[ "$out" == *"Operation not permitted"* ]]; then
+ ok "browser user cannot flush the firewall (Operation not permitted)"
+else bad "nft flush ruleset as the browser user: rc $rc: $(printf '%s' "$out" | tr '\n' ' ')"; fi
+
+# IPv6 back on so probes reach nft, and NDP let through so SLAAC can assign an address.
+echo "Phase B, IPv6 re-enabled by root (proves the nft IPv6 rule):"
+gw6_a=$gw6
+if [[ -n "$bridge" ]]; then poll 30 bridge_v6 || true; fi
+if [[ "$gw6" != "$gw6_a" ]]; then echo " bridge IPv6 is now ${gw6:-none}"; fi
+vm_v6() { # not link-local (scope 20), not tentative (0x40)
+ local addr idx plen scope flags dev
+ while read -r addr idx plen scope flags dev; do
+ if [[ "$dev" == eth0 && "$scope" != 20 ]] && (( (0x$flags & 0x40) == 0 )); then
+ vm6=$addr; return 0
+ fi
+ done <<<"$(cx "$name" cat /proc/net/if_inet6 2>/dev/null || true)"
+ return 1
+}
+vm6="" rc=0
+out=$(cx "$name" sh -c '
+ nft insert rule inet knotchy output icmpv6 type { nd-router-solicit, nd-neighbor-solicit, nd-neighbor-advert } accept &&
+ nft insert rule inet knotchy input icmpv6 type { nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept &&
+ echo 0 > /proc/sys/net/ipv6/conf/all/disable_ipv6 && echo 0 > /proc/sys/net/ipv6/conf/eth0/disable_ipv6' 2>&1) || rc=$?
+if (( rc != 0 )); then
+ bad "root could not open NDP and re-enable IPv6: rc $rc: $(printf '%s' "$out" | tr '\n' ' ')"
+elif ! poll 15 vm_v6; then
+ skip "no routable IPv6 address on eth0 within 15 s; the nft IPv6 rule is unproven"
+else
+ ok "eth0 has IPv6 $(sed 's/..../&:/g; s/:$//' <<<"$vm6")"
+ tcp blocked "TCP Mac IPv6 [${gw6:-?}]:$hport" "$gw6" "$hport"
+ tcp blocked "TCP internet IPv6 [2606:4700:4700::1111]:443" 2606:4700:4700::1111 443
+ udp blocked "UDP Mac IPv6 [${gw6:-?}]:$uport" "$gw6" "$uport"
+ udp blocked "UDP internet IPv6 [2606:4700:4700::1111]:53" 2606:4700:4700::1111 53
+fi
+
+echo "Mac-side evidence:"
+evidence() {
+ local hits
+ hits=$(grep -cF "$3" "$2" || true)
+ if [[ "$hits" == 0 ]]; then ok "$1 received nothing from the VM"; return; fi
+ bad "$1 received $hits from the VM:"
+ grep -F "$3" "$2" | sed 's/^/ /'
+}
+evidence "HTTP server" "$work/http.log" '"GET '
+evidence "UDP listener" "$work/udp.log" 'DGRAM '
+
+summary
diff --git a/scripts/test-routes.sh b/scripts/test-routes.sh
new file mode 100755
index 0000000..4cc9ab5
--- /dev/null
+++ b/scripts/test-routes.sh
@@ -0,0 +1,189 @@
+#!/usr/bin/env bash
+# Proxy and VPN route test (vm/knotchy-init): each route lets out only what it should,
+# secrets stay out of the environment and `container inspect`, and pages load through
+# knotchyctl. Bash 3.2-safe. Needs local/knotchy-vm:latest and the fixture:
+# container build --dns 1.1.1.1 -t local/knotchy-route-server scripts/route-server
+set -euo pipefail
+root="$(cd "$(dirname "$0")/.." && pwd)"
+server="knotchyctl-$(openssl rand -hex 4)"
+vm=""
+work="$(mktemp -d)"
+n_ok=0 n_fail=0
+
+deadline() {
+ { ( perl -e 'alarm shift; exec @ARGV or die "exec $ARGV[0]: $!\n"' "$@" 2>&3 3>&-; exit $? ) 2>/dev/null; } 3>&2
+}
+cleanup() {
+ trap '' INT TERM
+ for name in $vm $server; do
+ deadline 30 container stop "$name" >/dev/null 2>&1 || true
+ deadline 30 container delete --force "$name" >/dev/null 2>&1 || true
+ done
+ rm -rf "$work"
+}
+trap cleanup EXIT
+trap 'exit 130' INT
+trap 'exit 143' TERM
+
+ok() { printf " ok %s\n" "$1"; n_ok=$((n_ok + 1)); }
+bad() { printf " FAIL %s\n" "$1"; n_fail=$((n_fail + 1)); }
+summary() {
+ printf "\ntest-routes: %d ok, %d fail\n" "$n_ok" "$n_fail"
+ if (( n_fail )); then exit 1; fi
+ echo "test-routes: all checks passed"
+}
+poll() {
+ local i
+ for ((i = 0; i < $1 * 2; i++)); do "${@:2}" && return 0; sleep 0.5; done
+ return 1
+}
+
+# probe USER VM tcp|udp HOST PORT -> "reachable", "blocked" (the nft reject),
+# "unreachable" (no route) or "other: …"
+probe() {
+ local rc=0 out cmd
+ if [[ "$3" == tcp ]]; then cmd="exec 3<>/dev/tcp/$4/$5 && printf 'GET / HTTP/1.0\r\n\r\n' >&3"
+ else cmd="printf x > /dev/udp/$4/$5"; fi
+ out=$(deadline 20 container exec -u "$1" "$2" timeout 3 bash -c "$cmd" 2>&1) || rc=$?
+ if [[ $rc == 0 ]]; then echo reachable
+ elif [[ $rc == 1 && ( "$out" == *"No route to host"* || "$out" == *"Operation not permitted"* ) ]]; then echo blocked
+ elif [[ $rc == 1 && "$out" == *"Network is unreachable"* ]]; then echo unreachable
+ else echo "other: rc $rc $(printf '%s' "$out" | tr '\n' ' ' | cut -c1-160)"; fi
+}
+expect() { # expect "WANT|WANT2" LABEL USER tcp|udp HOST PORT
+ local got
+ got=$(probe "$3" "$vm" "$4" "$5" "$6")
+ case "|$1|" in *"|$got|"*) ok "$got: $2" ;; *) bad "$got (expected ${1//|/ or }): $2" ;; esac
+}
+
+start_vm() {
+ vm="knotchyctl-$(openssl rand -hex 4)"
+ if ! KNOTCHY_ENV_FILE="$1" KNOTCHY_SECRETS_DIR="${2:-}" KNOTCHY_FIRST_VIEWER_TIMEOUT=300 \
+ "$root/scripts/run-vm.sh" "$vm" about:blank >/dev/null; then
+ bad "could not start $vm"; summary
+ fi
+ streaming() { [[ "$(deadline 10 container logs "$vm" 2>/dev/null)" == *"streaming on"* ]]; }
+ if ! poll 30 streaming; then
+ bad "$vm did not start streaming"; deadline 10 container logs "$vm" 2>&1 | tail -5; summary
+ fi
+}
+stop_vm() {
+ deadline 30 container stop "$vm" >/dev/null 2>&1 || true
+ deadline 30 container delete --force "$vm" >/dev/null 2>&1 || true
+ vm=""
+}
+
+if ! deadline 60 container run -d --rm --name "$server" --dns 1.1.1.1 --cap-add NET_ADMIN \
+ --masked-path NONE --read-only-path NONE local/knotchy-route-server:latest >/dev/null; then
+ bad "could not start the route server (build it: see the top of this script)"; summary
+fi
+ready() { [[ "$(deadline 10 container logs "$server" 2>/dev/null)" == *"route-server ready"* ]]; }
+if ! poll 30 ready; then bad "route server did not come up"; summary; fi
+srv=$(deadline 10 container logs "$server" | awk '/route-server ready/ {print $NF}')
+gw4=$(container network inspect default | python3 -c 'import json, sys; print(json.load(sys.stdin)[0]["status"]["ipv4Gateway"])')
+echo "Route server $srv, Mac (vmnet gateway) $gw4"
+
+echo "Proxy route (HTTP proxy $srv:8888):"
+printf 'KNOTCHY_ROUTE=proxy\nKNOTCHY_PROXY_ADDR=%s:8888\n' "$srv" > "$work/proxy.env"
+mkdir -m 700 "$work/secrets"
+printf 'KNOTCHY_PROXY_URL=http://knotchy:s3cr3t@%s:8888\n' "$srv" > "$work/secrets/route"
+start_vm "$work/proxy.env" "$work/secrets"
+if [[ ! -e "$work/secrets/route" ]]; then ok "the VM deleted the secrets file"; else bad "the secrets file is still on the Mac"; fi
+if ! deadline 10 container exec "$vm" grep -q knotchy-secrets /proc/mounts; then ok "the secrets share is unmounted"
+else bad "the secrets share is still mounted"; fi
+if ! deadline 10 container inspect "$vm" | grep -q s3cr3t; then ok "container inspect doesn't show the password"
+else bad "container inspect shows the password"; fi
+capbnd=$(deadline 10 container exec "$vm" awk '/^CapBnd/ {print $2}' /proc/1/status)
+if [[ "$capbnd" == 0000000000000000 ]]; then ok "the browser has no capabilities (SYS_ADMIN dropped)"; else bad "CapBnd $capbnd"; fi
+expect reachable "the proxy port" knotchy tcp "$srv" 8888
+expect blocked "the fixture's other port" knotchy tcp "$srv" 1080
+expect blocked "the internet directly (1.1.1.1:443)" knotchy tcp 1.1.1.1 443
+expect blocked "DNS directly (1.1.1.1:53)" knotchy udp 1.1.1.1 53
+expect blocked "the Mac" knotchy tcp "$gw4" 22
+environ=$(deadline 10 container exec -u knotchy "$vm" cat /proc/1/environ | tr '\0' '\n')
+if [[ "$environ" == *"https_proxy=http://knotchy:s3cr3t@$srv:8888"* && "$environ" != *KNOTCHY_PROXY* ]]; then
+ ok "the browser gets https_proxy and none of the KNOTCHY_PROXY_* variables"
+else bad "the browser's environment is wrong"; fi
+stop_vm
+
+echo "WireGuard route (server $srv:51820):"
+client=$(deadline 10 container exec "$server" cat /tmp/client.conf)
+key=$(awk '$1 == "PrivateKey" {print $3}' <<< "$client")
+pub=$(awk '$1 == "PublicKey" {print $3}' <<< "$client")
+setconf=$(printf '[Interface]\nPrivateKey = %s\n\n[Peer]\nPublicKey = %s\nEndpoint = %s:51820\nAllowedIPs = 0.0.0.0/0\nPersistentKeepalive = 25\n' "$key" "$pub" "$srv" | base64 | tr -d '\n')
+# A private resolver on the VPN side, as Mullvad and Proton VPN use.
+printf 'KNOTCHY_ROUTE=wireguard\nKNOTCHY_WG_SETCONF=%s\nKNOTCHY_WG_ENDPOINT=%s:51820\nKNOTCHY_WG_ADDRESS=10.99.0.2/32\nKNOTCHY_WG_DNS=10.99.0.1\nKNOTCHY_WG_MTU=1420\n' \
+ "$setconf" "$srv" > "$work/wg.env"
+start_vm "$work/wg.env"
+route=$(deadline 10 container exec "$vm" ip -4 route get 1.1.1.1)
+if [[ "$route" == *"dev wg0"* ]]; then ok "the internet routes through wg0"; else bad "the internet routes: $route"; fi
+before=$(deadline 10 container exec "$server" wg show wg0 transfer | awk '{print $2}')
+expect reachable "the internet through the tunnel (1.1.1.1:443)" knotchy tcp 1.1.1.1 443
+after=$(deadline 10 container exec "$server" wg show wg0 transfer | awk '{print $2}')
+if [[ -n "$after" && "${after:-0}" -gt "${before:-0}" ]]; then ok "the server saw the traffic ($before -> $after bytes)"
+else bad "the server's received bytes didn't grow ($before -> $after)"; fi
+expect blocked "the server's other port, outside the tunnel" knotchy tcp "$srv" 8888
+expect blocked "the endpoint, for the browser's user" knotchy udp "$srv" 51820
+expect blocked "the Mac" knotchy tcp "$gw4" 22
+expect blocked "a private address on the VPN side (10.99.0.2 -> 10.99.0.1:22)" knotchy tcp 10.99.0.1 22
+expect reachable "DNS to the VPN's resolver (10.99.0.1:53)" knotchy udp 10.99.0.1 53
+expect blocked "another port on the VPN's resolver (10.99.0.1:80)" knotchy tcp 10.99.0.1 80
+resolv=$(deadline 10 container exec "$vm" cat /etc/resolv.conf)
+if [[ "$resolv" == "nameserver 10.99.0.1" ]]; then ok "resolv.conf names the VPN's DNS only"; else bad "resolv.conf: $resolv"; fi
+environ=$(deadline 10 container exec -u knotchy "$vm" cat /proc/1/environ | tr '\0' '\n')
+if [[ "$environ" != *KNOTCHY_WG* ]]; then ok "the browser doesn't get the WireGuard keys"; else bad "KNOTCHY_WG_* reached the browser"; fi
+deadline 10 container exec "$vm" pkill wireguard-go || true
+sleep 1
+expect "blocked|unreachable" "the internet with the tunnel gone" knotchy tcp 1.1.1.1 443
+via=$(deadline 10 container exec "$vm" ip -4 route get "$srv" | awk '{for (i = 1; i < NF; i++) if ($i == "via") print $(i + 1)}')
+deadline 10 container exec "$vm" ip -4 route replace default via "${via:-$gw4}" dev eth0 || true
+expect blocked "the internet over a restored direct route" knotchy tcp 1.1.1.1 443
+stop_vm
+
+echo "Missing route settings:"
+for route in proxy wireguard; do
+ printf 'KNOTCHY_ROUTE=%s\n' "$route" > "$work/broken.env"
+ rc=0
+ out=$(deadline 60 container run --rm --dns 1.1.1.1 --cap-add NET_ADMIN --masked-path NONE --read-only-path NONE \
+ --env-file "$work/broken.env" -e KNOTCHY_URL=about:blank local/knotchy-vm:latest 2>&1) || rc=$?
+ if [[ $rc != 0 && "$out" == *"knotchy-init: "* && "$out" != *"streaming on"* ]]; then
+ ok "a $route route without its settings stops the VM ($(grep -o 'knotchy-init: .*' <<< "$out" | head -1))"
+ else bad "a $route route without its settings didn't stop the VM (rc $rc)"; fi
+done
+
+# The app refuses a proxy on the local network without --allow-local-proxy.
+echo "End to end (knotchyctl):"
+swift build --package-path "$root" --product knotchyctl >/dev/null
+ctl="$(swift build --package-path "$root" --product knotchyctl --show-bin-path)/knotchyctl"
+snap() {
+ if deadline 120 "$ctl" snapshot https://example.com "$2" --after 2 "${@:3}" >/dev/null 2>"$work/ctl.err"; then return 0; fi
+ bad "$1: knotchyctl failed: $(tail -1 "$work/ctl.err")"; return 1
+}
+if snap "HTTP proxy" "$work/http.png" --allow-local-proxy --proxy "http://knotchy:s3cr3t@$srv:8888"; then
+ if deadline 10 container exec "$server" grep -q 'Established connection to host "example.com"' /tmp/tinyproxy.log; then
+ ok "a page loads through the HTTP proxy"
+ else bad "the HTTP proxy didn't carry the page"; fi
+fi
+if snap "SOCKS5 proxy" "$work/socks.png" --allow-local-proxy --proxy "socks5://knotchy:s3cr3t@$srv:1080"; then
+ if deadline 10 container exec "$server" grep -q 'connected to example.com:443' /tmp/microsocks.log; then
+ ok "a page loads through the SOCKS5 proxy"
+ else bad "the SOCKS5 proxy didn't carry the page"; fi
+fi
+deadline 10 container exec "$server" sh -c ': > /tmp/tinyproxy.log'
+if snap "wrong password" "$work/wrong.png" --allow-local-proxy --proxy "http://knotchy:wrong@$srv:8888"; then
+ if deadline 10 container exec "$server" grep -q 'Established connection' /tmp/tinyproxy.log; then
+ bad "a wrong proxy password still got through"
+ else ok "a wrong proxy password gets nowhere"; fi
+fi
+if ! deadline 120 "$ctl" snapshot https://example.com "$work/local.png" --proxy "http://knotchy:s3cr3t@$srv:8888" \
+ >/dev/null 2>"$work/ctl.err" && grep -q "use a proxy on this Mac or your local network" "$work/ctl.err"; then
+ ok "a proxy on the local network is refused"
+else bad "a proxy on the local network wasn't refused: $(tail -1 "$work/ctl.err")"; fi
+deadline 10 container exec "$server" cat /tmp/client.conf > "$work/client.conf"
+before=$(deadline 10 container exec "$server" wg show wg0 transfer | awk '{print $2}')
+if snap "WireGuard" "$work/wg.png" --wireguard "$work/client.conf"; then
+ after=$(deadline 10 container exec "$server" wg show wg0 transfer | awk '{print $2}')
+ if [[ "${after:-0}" -gt $(( ${before:-0} + 2000 )) ]]; then ok "a page loads through the WireGuard tunnel"
+ else bad "the tunnel didn't carry the page ($before -> $after bytes)"; fi
+fi
+summary
diff --git a/vm/Containerfile b/vm/Containerfile
new file mode 100644
index 0000000..0c84e37
--- /dev/null
+++ b/vm/Containerfile
@@ -0,0 +1,52 @@
+# The only definition of the protocol version, so preflight and HELLO agree.
+# 2: honours proxy and VPN routes (older images would browse directly); 3: PAGE_URL.
+ARG KNOTCHY_PROTOCOL=3
+
+FROM debian:trixie-slim AS build
+ARG KNOTCHY_PROTOCOL
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ gcc libc6-dev pkg-config libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
+# Only developer builds use -Werror, so a new compiler warning can't break setup.
+ARG WERROR=
+COPY knotchy-agent.c progress-gate.c progress-gate.h /src/
+COPY tests/ /src/tests/
+RUN gcc -O2 -Wall $WERROR -I/src -o /tmp/test-progress-gate \
+ /src/tests/test-progress-gate.c /src/progress-gate.c -lm && \
+ /tmp/test-progress-gate
+RUN gcc -O2 -Wall $WERROR -DPROTOCOL_VERSION=$KNOTCHY_PROTOCOL -I/src -o /knotchy-agent /src/knotchy-agent.c /src/progress-gate.c \
+ $(pkg-config --cflags --libs gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0 glib-2.0) -lm && \
+ strip /knotchy-agent
+
+FROM debian:trixie-slim
+ARG KNOTCHY_PROTOCOL
+LABEL aculix.knotchy.protocol="$KNOTCHY_PROTOCOL"
+# A new APT_REFRESH (the build date) re-runs apt, so rebuilds get security updates.
+ARG APT_REFRESH=unset
+# Without autoaudiosink (plugins-good) any page with media crashes the web process.
+# Plugins that can't work in the VM are deleted to shrink the attack surface;
+# plain rm fails the build unless each name matches exactly one file.
+RUN echo "apt refresh: $APT_REFRESH" && apt-get update && apt-get -y upgrade && \
+ apt-get install -y --no-install-recommends \
+ gstreamer1.0-wpe gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-ugly \
+ gstreamer1.0-libav gstreamer1.0-plugins-bad \
+ libgles2 glib-networking ca-certificates nftables \
+ wireguard-go wireguard-tools iproute2 \
+ fonts-dejavu-core fonts-liberation2 fonts-inter-variable fonts-noto-core fonts-noto-color-emoji && \
+ rm -rf /var/lib/apt/lists/* /usr/share/doc /usr/share/man /usr/share/locale && \
+ for p in video4linux2 rtsp rtp rtpmanager udp jack oss4 ossaudio ximagesrc shout2 1394 pulseaudio; do \
+ f="$(dpkg -L gstreamer1.0-plugins-good | grep "/libgst$p\.so\$")" && rm "$f" || \
+ { echo "prune: libgst$p.so not found in gstreamer1.0-plugins-good" >&2; exit 1; }; \
+ done && \
+ useradd --create-home --uid 1000 knotchy
+COPY --from=build /knotchy-agent /usr/local/bin/knotchy-agent
+COPY --chmod=755 knotchy-init /usr/local/bin/knotchy-init
+COPY --chmod=644 fonts.conf /etc/fonts/local.conf
+
+# WebKit's bubblewrap sandbox needs the non-root user knotchy-init switches to.
+WORKDIR /home/knotchy
+ENTRYPOINT ["/usr/local/bin/knotchy-init"]
+
+# Last, so the per-build timestamp doesn't invalidate cached layers.
+ARG BUILD_DATE=unknown
+LABEL aculix.knotchy.built="$BUILD_DATE"
diff --git a/vm/fonts.conf b/vm/fonts.conf
new file mode 100644
index 0000000..912dae6
--- /dev/null
+++ b/vm/fonts.conf
@@ -0,0 +1,32 @@
+
+
+
+
+ system-uiInter VariableInter
+ -apple-systemInter VariableInter
+ BlinkMacSystemFontInter VariableInter
+ ui-sans-serifInter VariableInter
+ SF ProInter VariableInter
+ SF Pro TextInter VariableInter
+ SF Pro DisplayInter VariableInter
+ San FranciscoInter VariableInter
+ Helvetica NeueInter VariableInter
+ Segoe UIInter VariableInter
+ RobotoInter VariableInter
+
+ sans-serifInter VariableInterNoto SansDejaVu Sans
+ serifNoto SerifDejaVu Serif
+
+
+ ui-monospaceDejaVu Sans Mono
+ SF MonoDejaVu Sans Mono
+ SFMono-RegularDejaVu Sans Mono
+ MenloDejaVu Sans Mono
+ MonacoDejaVu Sans Mono
+ ConsolasDejaVu Sans Mono
+
+ emojiNoto Color Emoji
+ Apple Color EmojiNoto Color Emoji
+ Segoe UI EmojiNoto Color Emoji
+ Segoe UI SymbolNoto Color Emoji
+
diff --git a/vm/knotchy-agent.c b/vm/knotchy-agent.c
new file mode 100644
index 0000000..6c0b0f3
--- /dev/null
+++ b/vm/knotchy-agent.c
@@ -0,0 +1,697 @@
+/*
+ * knotchy-agent: streams one WPE WebKit view as H.264 to a single viewer over a
+ * Unix socket and injects the viewer's input.
+ *
+ * Messages: [u8 type][u32 BE payload length][payload]
+ * agent -> viewer
+ * 0x01 HELLO u8 protocol version, u16 width, u16 height
+ * 0x02 VIDEO u16 width, u16 height, H.264 Annex-B access unit
+ * 0x03 PROGRESS u8 percent
+ * 0x04 NAVIGATED empty; every navigation (the error page resets progress without one)
+ * 0x05 PAGE_URL UTF-8, at most 16 KiB, never about:blank
+ * A new viewer gets HELLO, then NAVIGATED, PROGRESS and PAGE_URL as last sent,
+ * then an IDR frame.
+ * viewer -> agent
+ * 0x10 POINTER_MOVE u16 x, u16 y, u8 mods
+ * 0x11 POINTER_BUTTON u8 button (1 left, 2 right, 3 middle), u8 pressed, u16 x, u16 y, u8 mods
+ * 0x12 SCROLL i16 ticks_x, i16 ticks_y (negative = right/down), u16 x, u16 y
+ * 0x13 KEY u8 pressed, u8 mods, UTF-8 character or XKB keysym name
+ * 0x14 NAVIGATE UTF-8 URL
+ * 0x15 SCRIPT UTF-8 JavaScript
+ * 0x16 KEYFRAME empty
+ * mods: bit 0 shift, 1 control, 2 alt, 3 meta, 4/5/6 left/right/middle button held
+ */
+#define _GNU_SOURCE
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include "progress-gate.h"
+
+#ifndef PROTOCOL_VERSION
+#error "build with -DPROTOCOL_VERSION=; vm/Containerfile passes it from KNOTCHY_PROTOCOL"
+#endif
+
+_Static_assert (PROTOCOL_VERSION >= 1 && PROTOCOL_VERSION <= 255,
+ "PROTOCOL_VERSION must fit HELLO's u8 version field");
+
+enum {
+ MSG_HELLO = 0x01,
+ MSG_VIDEO = 0x02,
+ MSG_PROGRESS = 0x03,
+ MSG_NAVIGATED = 0x04,
+ MSG_PAGE_URL = 0x05,
+ MSG_POINTER_MOVE = 0x10,
+ MSG_POINTER_BUTTON = 0x11,
+ MSG_SCROLL = 0x12,
+ MSG_KEY = 0x13,
+ MSG_NAVIGATE = 0x14,
+ MSG_SCRIPT = 0x15,
+ MSG_KEYFRAME = 0x16,
+};
+
+#define MAX_INPUT_MESSAGE (64 * 1024)
+#define MAX_PAGE_URL (16 * 1024)
+
+typedef struct {
+ GMainLoop *loop;
+ GstElement *pipeline;
+ GstElement *src;
+ GstElement *sink;
+ GstPad *src_pad;
+ int width, height;
+ int listen_fd;
+ int client_fd; /* guarded by write_lock */
+ gboolean viewer_dead; /* guarded by write_lock */
+ gboolean viewer_present; /* main loop only */
+ guint client_watch;
+ GMutex write_lock;
+ GByteArray *inbuf;
+ gboolean need_keyframe;
+ gint64 started_us;
+ gboolean first_frame_logged;
+ guint watchdog;
+ int orphan_timeout_s;
+ gchar *initial_url;
+ gchar *page_url; /* guarded by write_lock */
+ gboolean debug;
+ ProgressGate progress; /* main loop only */
+} Agent;
+
+static int
+env_int (const char *name, int fallback)
+{
+ const char *v = g_getenv (name);
+ return v && *v ? atoi (v) : fallback;
+}
+
+static guint16
+rd16 (const guint8 * p)
+{
+ return (guint16) (p[0] << 8 | p[1]);
+}
+
+static void
+wr16 (guint8 * p, guint16 v)
+{
+ p[0] = v >> 8;
+ p[1] = v & 0xff;
+}
+
+static void
+wr32 (guint8 * p, guint32 v)
+{
+ p[0] = v >> 24;
+ p[1] = (v >> 16) & 0xff;
+ p[2] = (v >> 8) & 0xff;
+ p[3] = v & 0xff;
+}
+
+static gboolean
+writev_all (int fd, struct iovec *iov, int iovcnt)
+{
+ while (iovcnt > 0) {
+ ssize_t n = writev (fd, iov, iovcnt);
+ if (n < 0) {
+ if (errno == EINTR)
+ continue;
+ return FALSE;
+ }
+ while (iovcnt > 0 && (size_t) n >= iov->iov_len) {
+ n -= iov->iov_len;
+ iov++;
+ iovcnt--;
+ }
+ if (iovcnt > 0) {
+ iov->iov_base = (guint8 *) iov->iov_base + n;
+ iov->iov_len -= n;
+ }
+ }
+ return TRUE;
+}
+
+static void
+drop_client_locked (Agent * a)
+{
+ if (a->client_fd >= 0) {
+ shutdown (a->client_fd, SHUT_RDWR);
+ close (a->client_fd);
+ a->client_fd = -1;
+ a->viewer_dead = FALSE;
+ g_print ("knotchy-agent: viewer disconnected\n");
+ }
+}
+
+static void
+send_locked (Agent * a, guint8 type, const guint8 * p1, gsize n1,
+ const guint8 * p2, gsize n2)
+{
+ guint8 header[5];
+ struct iovec iov[3];
+
+ if (a->client_fd < 0 || a->viewer_dead)
+ return;
+ header[0] = type;
+ wr32 (header + 1, (guint32) (n1 + n2));
+ iov[0] = (struct iovec) {header, sizeof header};
+ iov[1] = (struct iovec) {(void *) p1, n1};
+ iov[2] = (struct iovec) {(void *) p2, n2};
+ if (!writev_all (a->client_fd, iov, n2 ? 3 : 2)) {
+ /* Not close(): on the streaming thread, a reused fd number would end up
+ * under the main loop's watch. The main loop sees EOF and cleans up. */
+ a->viewer_dead = TRUE;
+ shutdown (a->client_fd, SHUT_RDWR);
+ }
+}
+
+static void
+send_message (Agent * a, guint8 type, const guint8 * payload, gsize len)
+{
+ g_mutex_lock (&a->write_lock);
+ send_locked (a, type, payload, len, NULL, 0);
+ g_mutex_unlock (&a->write_lock);
+}
+
+static void
+request_keyframe (Agent * a)
+{
+ g_mutex_lock (&a->write_lock);
+ a->need_keyframe = TRUE;
+ g_mutex_unlock (&a->write_lock);
+ gst_element_send_event (a->sink,
+ gst_video_event_new_upstream_force_key_unit (GST_CLOCK_TIME_NONE, TRUE,
+ 0));
+}
+
+static void
+navigate_to (Agent * a, const char *url)
+{
+ if (a->debug)
+ g_print ("knotchy-agent: navigating to %s\n", url);
+ progress_gate_navigated (&a->progress);
+ send_message (a, MSG_NAVIGATED, NULL, 0);
+ g_object_set (a->src, "location", url, NULL);
+}
+
+static void
+on_page_uri (GObject * view, GParamSpec * pspec, gpointer user_data)
+{
+ Agent *a = user_data;
+ gchar *uri = NULL;
+ g_object_get (view, "uri", &uri, NULL);
+ if (uri && *uri && strcmp (uri, "about:blank") != 0 && strlen (uri) <= MAX_PAGE_URL
+ && g_utf8_validate (uri, -1, NULL)) {
+ g_mutex_lock (&a->write_lock);
+ if (g_strcmp0 (uri, a->page_url) != 0) {
+ g_free (a->page_url);
+ a->page_url = g_strdup (uri);
+ send_locked (a, MSG_PAGE_URL, (const guint8 *) uri, strlen (uri), NULL, 0);
+ }
+ g_mutex_unlock (&a->write_lock);
+ }
+ g_free (uri);
+}
+
+/* Plain GObject calls, so the agent needn't link against WebKit. */
+static void
+on_configure_web_view (GstElement * src, GObject * view, gpointer user_data)
+{
+ g_signal_connect (view, "notify::uri", G_CALLBACK (on_page_uri), user_data);
+}
+
+static gboolean
+navigate_to_initial_url (gpointer user_data)
+{
+ Agent *a = user_data;
+ navigate_to (a, a->initial_url);
+ return G_SOURCE_REMOVE;
+}
+
+static GstFlowReturn
+on_new_sample (GstAppSink * sink, gpointer user_data)
+{
+ Agent *a = user_data;
+ GstSample *sample = gst_app_sink_pull_sample (sink);
+ GstBuffer *buf;
+ GstMapInfo map;
+ guint8 dims[4];
+
+ if (!sample)
+ return GST_FLOW_EOS;
+ buf = gst_sample_get_buffer (sample);
+
+ if (!a->first_frame_logged) {
+ a->first_frame_logged = TRUE;
+ g_print ("knotchy-agent: first encoded frame %" G_GINT64_FORMAT " ms after start\n",
+ (g_get_monotonic_time () - a->started_us) / 1000);
+ g_idle_add (navigate_to_initial_url, a);
+ }
+
+ g_mutex_lock (&a->write_lock);
+ if (a->client_fd >= 0) {
+ gboolean is_key = !GST_BUFFER_FLAG_IS_SET (buf, GST_BUFFER_FLAG_DELTA_UNIT);
+ if (is_key)
+ a->need_keyframe = FALSE;
+ if (!a->need_keyframe && gst_buffer_map (buf, &map, GST_MAP_READ)) {
+ wr16 (dims, a->width);
+ wr16 (dims + 2, a->height);
+ send_locked (a, MSG_VIDEO, dims, sizeof dims, map.data, map.size);
+ gst_buffer_unmap (buf, &map);
+ }
+ }
+ g_mutex_unlock (&a->write_lock);
+
+ gst_sample_unref (sample);
+ return GST_FLOW_OK;
+}
+
+static GstNavigationModifierType
+to_gst_mods (guint8 m)
+{
+ GstNavigationModifierType s = 0;
+ if (m & 0x01) s |= GST_NAVIGATION_MODIFIER_SHIFT_MASK;
+ if (m & 0x02) s |= GST_NAVIGATION_MODIFIER_CONTROL_MASK;
+ if (m & 0x04) s |= GST_NAVIGATION_MODIFIER_MOD1_MASK;
+ if (m & 0x08) s |= GST_NAVIGATION_MODIFIER_META_MASK;
+ if (m & 0x10) s |= GST_NAVIGATION_MODIFIER_BUTTON1_MASK;
+ if (m & 0x20) s |= GST_NAVIGATION_MODIFIER_BUTTON2_MASK;
+ if (m & 0x40) s |= GST_NAVIGATION_MODIFIER_BUTTON3_MASK;
+ return s;
+}
+
+static void
+inject (Agent * a, GstEvent * ev)
+{
+ /* WebKit uses event time for double-click detection. */
+ GST_EVENT_TIMESTAMP (ev) = g_get_monotonic_time () * 1000;
+ gst_pad_send_event (a->src_pad, ev);
+}
+
+static void
+handle_message (Agent * a, guint8 type, const guint8 * p, guint32 len)
+{
+ switch (type) {
+ case MSG_POINTER_MOVE:
+ if (len >= 5)
+ inject (a, gst_navigation_event_new_mouse_move (rd16 (p), rd16 (p + 2),
+ to_gst_mods (p[4])));
+ break;
+ case MSG_POINTER_BUTTON:
+ if (len >= 7) {
+ int button = p[0];
+ double x = rd16 (p + 2), y = rd16 (p + 4);
+ GstNavigationModifierType mods = to_gst_mods (p[6]);
+ inject (a, p[1]
+ ? gst_navigation_event_new_mouse_button_press (button, x, y, mods)
+ : gst_navigation_event_new_mouse_button_release (button, x, y, mods));
+ }
+ break;
+ case MSG_SCROLL:
+ if (len >= 8) {
+ gint16 tx = (gint16) rd16 (p), ty = (gint16) rd16 (p + 2);
+ double x = rd16 (p + 4), y = rd16 (p + 6);
+ /* wpevideosrc sends one axis per event and WebKit uses only the sign. */
+ for (int i = 0; i < ABS (tx) && i < 50; i++)
+ inject (a, gst_navigation_event_new_mouse_scroll (x, y, tx > 0 ? 1 : -1, 0, 0));
+ for (int i = 0; i < ABS (ty) && i < 50; i++)
+ inject (a, gst_navigation_event_new_mouse_scroll (x, y, 0, ty > 0 ? 1 : -1, 0));
+ }
+ break;
+ case MSG_KEY:
+ if (len >= 3) {
+ char *key = g_strndup ((const char *) p + 2, len - 2);
+ GstNavigationModifierType mods = to_gst_mods (p[1]);
+ if (g_utf8_validate (key, -1, NULL))
+ inject (a, p[0]
+ ? gst_navigation_event_new_key_press (key, mods)
+ : gst_navigation_event_new_key_release (key, mods));
+ g_free (key);
+ }
+ break;
+ case MSG_NAVIGATE:{
+ char *url = g_strndup ((const char *) p, len);
+ if (g_utf8_validate (url, -1, NULL)) {
+ if (!a->progress.has_navigated) {
+ if (a->debug)
+ g_print ("knotchy-agent: early NAVIGATE replaces the initial URL\n");
+ g_free (a->initial_url);
+ a->initial_url = g_strdup (url);
+ } else {
+ navigate_to (a, url);
+ }
+ }
+ g_free (url);
+ break;
+ }
+ case MSG_SCRIPT:{
+ char *js = g_strndup ((const char *) p, len);
+ if (g_utf8_validate (js, -1, NULL))
+ g_signal_emit_by_name (a->src, "run-javascript", js);
+ g_free (js);
+ break;
+ }
+ case MSG_KEYFRAME:
+ request_keyframe (a);
+ break;
+ default:
+ break;
+ }
+}
+
+/* PID 1 exiting stops the VM and --rm deletes it, so a crashed app leaves no
+ * session behind. */
+static gboolean
+on_watchdog (gpointer user_data)
+{
+ Agent *a = user_data;
+ a->watchdog = 0;
+ g_printerr ("knotchy-agent: no viewer; shutting the session down\n");
+ g_main_loop_quit (a->loop);
+ return G_SOURCE_REMOVE;
+}
+
+static void
+arm_watchdog (Agent * a, int seconds)
+{
+ if (a->watchdog)
+ g_source_remove (a->watchdog);
+ a->watchdog = g_timeout_add_seconds (seconds, on_watchdog, a);
+}
+
+static void
+disarm_watchdog (Agent * a)
+{
+ if (a->watchdog) {
+ g_source_remove (a->watchdog);
+ a->watchdog = 0;
+ }
+}
+
+static void
+close_client (Agent * a)
+{
+ gboolean had_client;
+
+ had_client = a->viewer_present;
+ a->viewer_present = FALSE;
+ if (a->client_watch) {
+ g_source_remove (a->client_watch);
+ a->client_watch = 0;
+ }
+ g_mutex_lock (&a->write_lock);
+ drop_client_locked (a);
+ g_mutex_unlock (&a->write_lock);
+ g_byte_array_set_size (a->inbuf, 0);
+ if (had_client)
+ arm_watchdog (a, a->orphan_timeout_s);
+}
+
+static gboolean
+on_client_readable (gint fd, GIOCondition cond, gpointer user_data)
+{
+ Agent *a = user_data;
+ guint8 chunk[16384];
+ ssize_t n = read (fd, chunk, sizeof chunk);
+
+ if (n <= 0) {
+ if (n < 0 && errno == EINTR)
+ return G_SOURCE_CONTINUE;
+ a->client_watch = 0;
+ close_client (a);
+ return G_SOURCE_REMOVE;
+ }
+ g_byte_array_append (a->inbuf, chunk, n);
+
+ while (a->inbuf->len >= 5) {
+ guint8 *d = a->inbuf->data;
+ guint32 len = (guint32) d[1] << 24 | d[2] << 16 | d[3] << 8 | d[4];
+ if (len > MAX_INPUT_MESSAGE) {
+ a->client_watch = 0;
+ close_client (a);
+ return G_SOURCE_REMOVE;
+ }
+ if (a->inbuf->len < 5 + len)
+ break;
+ handle_message (a, d[0], d + 5, len);
+ g_byte_array_remove_range (a->inbuf, 0, 5 + len);
+ }
+ return G_SOURCE_CONTINUE;
+}
+
+static gboolean
+on_accept (gint fd, GIOCondition cond, gpointer user_data)
+{
+ Agent *a = user_data;
+ guint8 hello[5];
+ int cfd = accept4 (fd, NULL, NULL, SOCK_CLOEXEC);
+
+ if (cfd < 0)
+ return G_SOURCE_CONTINUE;
+
+ /* CLOEXEC keeps the socket from WebKit's children. The timeout keeps a stalled
+ * viewer from blocking the encoder thread and, via write_lock, the main loop. */
+ struct timeval send_timeout = { .tv_sec = 2, .tv_usec = 0 };
+ setsockopt (cfd, SOL_SOCKET, SO_SNDTIMEO, &send_timeout, sizeof send_timeout);
+
+ close_client (a);
+ disarm_watchdog (a);
+
+ g_mutex_lock (&a->write_lock);
+ a->client_fd = cfd;
+ a->viewer_present = TRUE;
+ a->need_keyframe = TRUE;
+ hello[0] = PROTOCOL_VERSION;
+ wr16 (hello + 1, a->width);
+ wr16 (hello + 3, a->height);
+ send_locked (a, MSG_HELLO, hello, sizeof hello, NULL, 0);
+ /* Same critical section as HELLO, so no VIDEO frame can come between. */
+ if (a->progress.has_navigated)
+ send_locked (a, MSG_NAVIGATED, NULL, 0, NULL, 0);
+ if (a->progress.last_sent >= 0) {
+ guint8 pct = (guint8) a->progress.last_sent;
+ send_locked (a, MSG_PROGRESS, &pct, 1, NULL, 0);
+ }
+ if (a->page_url)
+ send_locked (a, MSG_PAGE_URL, (const guint8 *) a->page_url, strlen (a->page_url), NULL, 0);
+ g_mutex_unlock (&a->write_lock);
+
+ a->client_watch = g_unix_fd_add (cfd, G_IO_IN | G_IO_HUP | G_IO_ERR,
+ on_client_readable, a);
+ request_keyframe (a);
+ g_print ("knotchy-agent: viewer connected %" G_GINT64_FORMAT " ms after start\n",
+ (g_get_monotonic_time () - a->started_us) / 1000);
+ return G_SOURCE_CONTINUE;
+}
+
+static int
+listen_unix (const char *path)
+{
+ struct sockaddr_un addr = { .sun_family = AF_UNIX };
+ char *dir = g_path_get_dirname (path);
+ int fd;
+
+ g_mkdir_with_parents (dir, 0700);
+ g_free (dir);
+ unlink (path);
+ if (strlen (path) >= sizeof addr.sun_path)
+ return -1;
+ strcpy (addr.sun_path, path);
+
+ fd = socket (AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
+ if (fd < 0)
+ return -1;
+ if (bind (fd, (struct sockaddr *) &addr, sizeof addr) < 0
+ || listen (fd, 4) < 0) {
+ close (fd);
+ return -1;
+ }
+ return fd;
+}
+
+/* A failed navigation is a normal outcome, not a reason to end the session. */
+static gboolean
+is_load_failure (GstMessage * msg, const GError * err, const gchar * dbg)
+{
+ return GST_IS_ELEMENT (GST_MESSAGE_SRC (msg))
+ && g_error_matches (err, GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_FAILED)
+ && dbg && strstr (dbg, "Failed to load") != NULL;
+}
+
+static void
+show_error_page (Agent * a, const gchar * detail)
+{
+ gchar *escaped = g_markup_escape_text (detail, -1);
+ gchar *html = g_strdup_printf (""
+ ""
+ "This page couldn't be loaded
%s
",
+ escaped);
+ GBytes *bytes = g_bytes_new_take (html, strlen (html));
+
+ /* WebKit reports the failed load's final 100 after load-failed; resetting
+ * the gate drops it, so a failed page never reads as loaded. */
+ progress_gate_navigated (&a->progress);
+ g_signal_emit_by_name (a->src, "load-bytes", bytes);
+ g_bytes_unref (bytes);
+ g_free (escaped);
+}
+
+static gboolean
+on_bus_message (GstBus * bus, GstMessage * msg, gpointer user_data)
+{
+ Agent *a = user_data;
+ GError *err = NULL;
+ gchar *dbg = NULL;
+
+ switch (GST_MESSAGE_TYPE (msg)) {
+ case GST_MESSAGE_ERROR:
+ gst_message_parse_error (msg, &err, &dbg);
+ if (is_load_failure (msg, err, dbg)) {
+ g_printerr ("knotchy-agent: %s\n", dbg);
+ show_error_page (a, strstr (dbg, "Failed to load"));
+ } else {
+ /* Includes WEBKIT_WEB_PROCESS_CRASHED. Fail closed: the VM goes away. */
+ g_printerr ("knotchy-agent: pipeline error: %s (%s)\n", err->message,
+ dbg ? dbg : "");
+ g_main_loop_quit (a->loop);
+ }
+ g_clear_error (&err);
+ g_free (dbg);
+ break;
+ case GST_MESSAGE_ELEMENT:{
+ const GstStructure *s = gst_message_get_structure (msg);
+ gdouble value;
+ if (s && gst_structure_has_name (s, "wpe-stats")
+ && gst_structure_get_double (s, "estimated-load-progress", &value)) {
+ int pct = progress_gate_feed (&a->progress, value);
+ if (a->debug)
+ g_print ("knotchy-agent: wpe-stats %.1f (%s)\n", value,
+ pct < 0 ? "dropped" : "sent");
+ if (pct >= 0) {
+ guint8 b = (guint8) pct;
+ send_message (a, MSG_PROGRESS, &b, 1);
+ }
+ }
+ break;
+ }
+ case GST_MESSAGE_EOS:
+ g_main_loop_quit (a->loop);
+ break;
+ default:
+ break;
+ }
+ return G_SOURCE_CONTINUE;
+}
+
+static gboolean
+on_terminate (gpointer user_data)
+{
+ g_main_loop_quit (((Agent *) user_data)->loop);
+ return G_SOURCE_REMOVE;
+}
+
+int
+main (int argc, char **argv)
+{
+ Agent a = { .client_fd = -1 };
+ const char *url = g_getenv ("KNOTCHY_URL");
+ const char *size = g_getenv ("KNOTCHY_SIZE");
+ const char *sock = g_getenv ("KNOTCHY_SOCKET");
+ int fps = env_int ("KNOTCHY_FPS", 30);
+ int qp = env_int ("KNOTCHY_QP", 21);
+ GError *err = NULL;
+ gchar *desc;
+ GstBus *bus;
+
+ a.started_us = g_get_monotonic_time ();
+ a.debug = env_int ("KNOTCHY_DEBUG", 0) != 0;
+ progress_gate_init (&a.progress);
+ signal (SIGPIPE, SIG_IGN);
+ gst_init (&argc, &argv);
+
+ if (!url || !*url)
+ url = "about:blank";
+ if (!sock || !*sock)
+ sock = "/tmp/knotchy/knotchy.sock";
+ if (!size || sscanf (size, "%dx%d", &a.width, &a.height) != 2) {
+ a.width = 1024;
+ a.height = 640;
+ }
+ /* The cap keeps an IDR frame well under the viewer's 2 MiB VIDEO limit. */
+ a.width = CLAMP (a.width, 320, 2048) & ~1;
+ a.height = CLAMP (a.height, 240, 1280) & ~1;
+
+ /* Constant quality: bandwidth is free over a local socket, sharp text isn't. */
+ desc = g_strdup_printf ("wpevideosrc name=src draw-background=true "
+ "! video/x-raw,width=%d,height=%d,framerate=%d/1 "
+ "! videoconvert ! video/x-raw,format=I420 "
+ "! x264enc tune=zerolatency speed-preset=ultrafast pass=qual quantizer=%d "
+ "key-int-max=%d byte-stream=true option-string=repeat-headers=1 "
+ "! video/x-h264,stream-format=byte-stream,alignment=au "
+ "! appsink name=sink sync=false max-buffers=8",
+ a.width, a.height, fps, qp, fps * 4);
+ a.pipeline = gst_parse_launch (desc, &err);
+ g_free (desc);
+ if (!a.pipeline) {
+ g_printerr ("knotchy-agent: %s\n", err->message);
+ return 1;
+ }
+
+ a.src = gst_bin_get_by_name (GST_BIN (a.pipeline), "src");
+ a.sink = gst_bin_get_by_name (GST_BIN (a.pipeline), "sink");
+ a.src_pad = gst_element_get_static_pad (a.src, "src");
+ /* wpevideosrc creates the view only once a page paints, which a failed first
+ * load never does. The URL is a property, never parsed as pipeline syntax. */
+ a.initial_url = g_strdup (url);
+ g_object_set (a.src, "location", "about:blank", NULL);
+ g_signal_connect (a.src, "configure-web-view", G_CALLBACK (on_configure_web_view), &a);
+
+ gst_app_sink_set_callbacks (GST_APP_SINK (a.sink),
+ &(GstAppSinkCallbacks) {.new_sample = on_new_sample}, &a, NULL);
+
+ a.inbuf = g_byte_array_new ();
+ g_mutex_init (&a.write_lock);
+ a.loop = g_main_loop_new (NULL, FALSE);
+
+ a.listen_fd = listen_unix (sock);
+ if (a.listen_fd < 0) {
+ g_printerr ("knotchy-agent: cannot listen on %s: %s\n", sock, g_strerror (errno));
+ return 1;
+ }
+ g_unix_fd_add (a.listen_fd, G_IO_IN, on_accept, &a);
+
+ bus = gst_element_get_bus (a.pipeline);
+ gst_bus_add_watch (bus, on_bus_message, &a);
+ gst_object_unref (bus);
+
+ /* As PID 1, SIGTERM has no default action. */
+ g_unix_signal_add (SIGTERM, on_terminate, &a);
+ g_unix_signal_add (SIGINT, on_terminate, &a);
+
+ a.orphan_timeout_s = MAX (1, env_int ("KNOTCHY_ORPHAN_TIMEOUT", 10));
+ arm_watchdog (&a, MAX (1, env_int ("KNOTCHY_FIRST_VIEWER_TIMEOUT", 60)));
+
+ g_print ("knotchy-agent: %dx%d@%d streaming on %s\n", a.width, a.height, fps, sock);
+ gst_element_set_state (a.pipeline, GST_STATE_PLAYING);
+ g_main_loop_run (a.loop);
+ disarm_watchdog (&a);
+
+ gst_element_set_state (a.pipeline, GST_STATE_NULL);
+ close_client (&a);
+ close (a.listen_fd);
+ unlink (sock);
+ return 0;
+}
diff --git a/vm/knotchy-init b/vm/knotchy-init
new file mode 100755
index 0000000..d7a301e
--- /dev/null
+++ b/vm/knotchy-init
@@ -0,0 +1,197 @@
+#!/bin/sh
+# PID 1 of a session VM: firewalls egress to the public internet (or only the
+# proxy, or only the WireGuard server and its tunnel), then runs knotchy-agent as
+# the knotchy user with no capabilities, so nothing in the browser can undo it.
+# Fails closed: a missing or malformed setting stops the VM. Needs NET_ADMIN.
+# Env: KNOTCHY_ROUTE (direct|proxy|wireguard), KNOTCHY_BLOCK_CIDRS, KNOTCHY_PROXY_ADDR,
+# KNOTCHY_PROXY_URL*, KNOTCHY_WG_SETCONF*, KNOTCHY_WG_ENDPOINT, KNOTCHY_WG_ADDRESS,
+# KNOTCHY_WG_DNS, KNOTCHY_WG_MTU. *Secrets, normally in /run/knotchy-secrets/route.
+set -eu
+
+fail() {
+ echo "knotchy-init: $*" >&2
+ exit 1
+}
+
+blocked="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.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 192.168.0.0/16,
+ 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4"
+
+is_ipv4() {
+ case "$1" in *[!0-9.]* | "" | *..* | .* | *.) return 1 ;; esac
+ old_ifs=$IFS; IFS=.
+ # shellcheck disable=SC2086 # splitting on dots is the point
+ set -- $1
+ IFS=$old_ifs
+ [ $# -eq 4 ] || return 1
+ for octet in "$@"; do
+ [ "${#octet}" -le 3 ] && [ "$octet" -le 255 ] || return 1
+ done
+}
+
+is_port() {
+ case "$1" in *[!0-9]* | "") return 1 ;; esac
+ [ "${#1}" -le 5 ] && [ "$1" -ge 1 ] && [ "$1" -le 65535 ]
+}
+
+is_cidr() {
+ case "$1" in */*) ;; *) return 1 ;; esac
+ prefix=${1##*/}
+ case "$prefix" in *[!0-9]* | "") return 1 ;; esac
+ is_ipv4 "${1%/*}" && [ "${#prefix}" -le 2 ] && [ "$prefix" -le 32 ]
+}
+
+set -f
+for cidr in ${KNOTCHY_BLOCK_CIDRS:-}; do
+ case "$cidr" in
+ *[!0-9./]* | "") fail "malformed CIDR '$cidr' in KNOTCHY_BLOCK_CIDRS" ;;
+ *) blocked="$blocked, $cidr" ;;
+ esac
+done
+
+route=${KNOTCHY_ROUTE:-direct}
+case $route in
+ direct | proxy | wireguard) ;;
+ *) fail "unknown KNOTCHY_ROUTE '$route'" ;;
+esac
+
+# Secrets come on a share because `container` keeps -e values on disk. Delete
+# the file (on the Mac too) and unmount before anything else runs.
+proxy_url=${KNOTCHY_PROXY_URL:-}
+wg_setconf=${KNOTCHY_WG_SETCONF:-}
+secrets=/run/knotchy-secrets
+if [ -e "$secrets/route" ]; then
+ while IFS= read -r line; do
+ case "$line" in
+ KNOTCHY_PROXY_URL=*) proxy_url=${line#*=} ;;
+ KNOTCHY_WG_SETCONF=*) wg_setconf=${line#*=} ;;
+ esac
+ done < "$secrets/route"
+ rm -f "$secrets/route" || fail "could not delete the secrets file"
+fi
+if grep -q " $secrets " /proc/mounts; then
+ umount "$secrets" || fail "could not unmount the secrets share"
+fi
+
+case $route in
+ direct)
+ [ -z "${KNOTCHY_PROXY_ADDR:-}${KNOTCHY_WG_ENDPOINT:-}$proxy_url$wg_setconf" ] ||
+ fail "proxy or VPN settings given for a direct route"
+ egress="ip daddr @blocked4 reject with icmpx type admin-prohibited" ;;
+ proxy)
+ proxy_addr=${KNOTCHY_PROXY_ADDR:-}
+ proxy_ip=${proxy_addr%:*}
+ proxy_port=${proxy_addr##*:}
+ is_ipv4 "$proxy_ip" && is_port "$proxy_port" || fail "malformed KNOTCHY_PROXY_ADDR"
+ case "$proxy_url" in
+ http://*@"$proxy_addr" | http://"$proxy_addr" | socks5://*@"$proxy_addr" | socks5://"$proxy_addr") ;;
+ *) fail "malformed or missing KNOTCHY_PROXY_URL" ;;
+ esac
+ egress="ip daddr $proxy_ip tcp dport $proxy_port accept
+ reject with icmpx type admin-prohibited" ;;
+ wireguard)
+ [ -n "$wg_setconf" ] || fail "KNOTCHY_WG_SETCONF is missing"
+ wg_endpoint=${KNOTCHY_WG_ENDPOINT:-}
+ wg_ip=${wg_endpoint%:*}
+ wg_port=${wg_endpoint##*:}
+ is_ipv4 "$wg_ip" && is_port "$wg_port" || fail "malformed KNOTCHY_WG_ENDPOINT"
+ [ -n "${KNOTCHY_WG_ADDRESS:-}" ] || fail "KNOTCHY_WG_ADDRESS is empty"
+ for cidr in $KNOTCHY_WG_ADDRESS; do is_cidr "$cidr" || fail "malformed KNOTCHY_WG_ADDRESS"; done
+ wg_dns=${KNOTCHY_WG_DNS:-1.1.1.1}
+ [ -n "$wg_dns" ] || wg_dns=1.1.1.1
+ for ns in $wg_dns; do is_ipv4 "$ns" || fail "malformed KNOTCHY_WG_DNS"; done
+ wg_mtu=${KNOTCHY_WG_MTU:-1420}
+ case "$wg_mtu" in *[!0-9]* | "") fail "malformed KNOTCHY_WG_MTU" ;; esac
+ [ "${#wg_mtu}" -le 4 ] && [ "$wg_mtu" -ge 576 ] || fail "malformed KNOTCHY_WG_MTU"
+ wg_dns_set=$(echo $wg_dns | sed 's/ /, /g')
+ # DNS only to the VPN's (often private) resolvers; outside the tunnel, only wireguard-go.
+ egress="oifname \"wg0\" ip daddr { $wg_dns_set } meta l4proto { tcp, udp } th dport 53 accept
+ oifname \"wg0\" ip daddr @blocked4 reject with icmpx type admin-prohibited
+ oifname \"wg0\" accept
+ ip daddr $wg_ip udp dport $wg_port meta skuid 0 accept
+ reject with icmpx type admin-prohibited" ;;
+esac
+
+# No DNS exception in the firewall: a --dns resolver in a blocked range can't answer.
+dns=$(awk '$1 == "nameserver" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ {
+ printf "%s%s", sep, $2; sep = ", " }' /etc/resolv.conf)
+bad_ns=$(awk '$1 == "nameserver" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ {
+ split($2, o, "."); a = o[1] + 0; b = o[2] + 0
+ if (a == 10 || a == 127 || (a == 169 && b == 254) || (a == 172 && b >= 16 && b <= 31) ||
+ (a == 192 && b == 168) || (a == 100 && b >= 64 && b <= 127)) { print $2; exit }
+}' /etc/resolv.conf)
+[ -z "$bad_ns" ] || fail "resolver $bad_ns is in a blocked range; it cannot be reached through the egress firewall"
+
+nft -f - < /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null || true
+
+nft list table inet knotchy >/dev/null 2>&1 || fail "egress firewall is not in place"
+
+case $route in
+ direct)
+ echo "knotchy-init: egress locked to the public internet${dns:+ (DNS via $dns)}" ;;
+ proxy)
+ echo "knotchy-init: egress locked to the proxy at $KNOTCHY_PROXY_ADDR"
+ export http_proxy="$proxy_url" https_proxy="$proxy_url" \
+ HTTP_PROXY="$proxy_url" HTTPS_PROXY="$proxy_url" no_proxy="" ;;
+ wireguard)
+ # The firewall is already in place, so a half-built tunnel leaks nothing.
+ old_umask=$(umask)
+ umask 077
+ printf '%s' "$wg_setconf" | base64 -d > /run/knotchy-wg.conf 2>/dev/null ||
+ fail "malformed KNOTCHY_WG_SETCONF"
+ # Without SYS_ADMIN, which init only needed to unmount the secrets share.
+ WG_I_PREFER_BUGGY_USERSPACE_TO_POLISHED_KMOD=1 setpriv --bounding-set -sys_admin \
+ wireguard-go wg0 >/dev/null 2>&1 ||
+ fail "could not start wireguard-go"
+ wg setconf wg0 /run/knotchy-wg.conf || fail "the WireGuard settings were rejected"
+ rm -f /run/knotchy-wg.conf
+ umask "$old_umask"
+ for cidr in $KNOTCHY_WG_ADDRESS; do
+ ip -4 address add "$cidr" dev wg0 || fail "could not give the tunnel its address"
+ done
+ ip link set dev wg0 mtu "$wg_mtu" up || fail "could not bring the tunnel up"
+ via=$(ip -4 route get "$wg_ip" | awk '{ for (i = 1; i < NF; i++) if ($i == "via") { print $(i + 1); exit } }')
+ dev=$(ip -4 route get "$wg_ip" | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i + 1); exit } }')
+ [ -n "$dev" ] || fail "no route to the VPN server"
+ ip -4 route add "$wg_ip/32" ${via:+via "$via"} dev "$dev" || fail "could not route to the VPN server"
+ ip -4 route replace default dev wg0 || fail "could not route through the tunnel"
+ printf 'nameserver %s\n' $wg_dns > /etc/resolv.conf || fail "could not set the VPN's DNS"
+ echo "knotchy-init: egress locked to the WireGuard tunnel via $KNOTCHY_WG_ENDPOINT" ;;
+esac
+
+# The browser has no business knowing the Mac's networks or the VPN's keys.
+unset KNOTCHY_ROUTE KNOTCHY_BLOCK_CIDRS KNOTCHY_PROXY_ADDR KNOTCHY_PROXY_URL KNOTCHY_WG_SETCONF \
+ KNOTCHY_WG_ENDPOINT KNOTCHY_WG_ADDRESS KNOTCHY_WG_DNS KNOTCHY_WG_MTU
+export HOME=/home/knotchy USER=knotchy LOGNAME=knotchy
+exec setpriv --reuid=knotchy --regid=knotchy --init-groups \
+ --inh-caps=-all --bounding-set=-all --no-new-privs \
+ /usr/local/bin/knotchy-agent
diff --git a/vm/progress-gate.c b/vm/progress-gate.c
new file mode 100644
index 0000000..77e1e3f
--- /dev/null
+++ b/vm/progress-gate.c
@@ -0,0 +1,53 @@
+#include
+
+#include "progress-gate.h"
+
+void
+progress_gate_init (ProgressGate * g)
+{
+ g->has_navigated = false;
+ g->saw_below_100 = false;
+ g->last_sent = -1;
+}
+
+void
+progress_gate_navigated (ProgressGate * g)
+{
+ g->has_navigated = true;
+ g->saw_below_100 = false;
+ g->last_sent = -1;
+}
+
+int
+progress_gate_feed (ProgressGate * g, double value)
+{
+ if (!isfinite (value))
+ return -1;
+
+ /* Floor, not round: 99.6 % is not loaded. */
+ int v = value <= 0 ? 0 : value >= 100 ? 100 : (int) floor (value);
+
+ if (!g->has_navigated)
+ return -1;
+
+ /* A drop means a new load started. */
+ if (g->last_sent >= 0 && v < g->last_sent) {
+ g->saw_below_100 = true;
+ g->last_sent = v;
+ return v;
+ }
+
+ /* A 100 before any lower value is the previous page finishing. */
+ if (v < 100)
+ g->saw_below_100 = true;
+
+ if (v == 100) {
+ if (!g->saw_below_100 || g->last_sent == 100)
+ return -1;
+ } else if (g->last_sent >= 0 && v < g->last_sent + 5) {
+ return -1;
+ }
+
+ g->last_sent = v;
+ return v;
+}
diff --git a/vm/progress-gate.h b/vm/progress-gate.h
new file mode 100644
index 0000000..473d0f4
--- /dev/null
+++ b/vm/progress-gate.h
@@ -0,0 +1,21 @@
+/* Decides which WebKit load-progress values the agent forwards as PROGRESS. */
+#ifndef KNOTCHY_PROGRESS_GATE_H
+#define KNOTCHY_PROGRESS_GATE_H
+
+#include
+
+typedef struct {
+ bool has_navigated; /* false while the about:blank bootstrap loads */
+ bool saw_below_100;
+ int last_sent;
+} ProgressGate;
+
+void progress_gate_init (ProgressGate * g);
+
+/* On every new top-level load, the error page included. */
+void progress_gate_navigated (ProgressGate * g);
+
+/* Returns the percent to send, or -1 to drop the value. */
+int progress_gate_feed (ProgressGate * g, double value);
+
+#endif
diff --git a/vm/tests/test-progress-gate.c b/vm/tests/test-progress-gate.c
new file mode 100644
index 0000000..7dc3173
--- /dev/null
+++ b/vm/tests/test-progress-gate.c
@@ -0,0 +1,155 @@
+/* cc -Wall -Werror -I vm -o /tmp/test-progress-gate \
+ * vm/tests/test-progress-gate.c vm/progress-gate.c -lm && /tmp/test-progress-gate */
+#include
+#include
+
+#include "progress-gate.h"
+
+static int failures;
+
+#define EXPECT(got, want) \
+ do { \
+ int g_ = (got), w_ = (want); \
+ if (g_ != w_) { \
+ printf ("FAIL %s:%d: %s = %d, want %d\n", __FILE__, __LINE__, #got, g_, \
+ w_); \
+ failures++; \
+ } \
+ } while (0)
+
+static ProgressGate
+navigated_gate (void)
+{
+ ProgressGate g;
+ progress_gate_init (&g);
+ progress_gate_navigated (&g);
+ return g;
+}
+
+static void
+drops_everything_before_navigation (void)
+{
+ ProgressGate g;
+ progress_gate_init (&g);
+ EXPECT (progress_gate_feed (&g, 10), -1);
+ EXPECT (progress_gate_feed (&g, 100), -1);
+ EXPECT (g.last_sent, -1);
+}
+
+static void
+late_bootstrap_100_is_not_page_loaded (void)
+{
+ ProgressGate g = navigated_gate ();
+ EXPECT (progress_gate_feed (&g, 100), -1);
+ EXPECT (progress_gate_feed (&g, 10), 10);
+ EXPECT (progress_gate_feed (&g, 100), 100);
+}
+
+static void
+rate_limits_increases_but_always_sends_100_once (void)
+{
+ ProgressGate g = navigated_gate ();
+ EXPECT (progress_gate_feed (&g, 10), 10);
+ EXPECT (progress_gate_feed (&g, 12), -1);
+ EXPECT (progress_gate_feed (&g, 15), 15);
+ EXPECT (progress_gate_feed (&g, 98), 98);
+ EXPECT (progress_gate_feed (&g, 100), 100);
+ EXPECT (progress_gate_feed (&g, 100), -1);
+}
+
+static void
+decrease_is_a_new_navigation_and_sent_immediately (void)
+{
+ ProgressGate g = navigated_gate ();
+ EXPECT (progress_gate_feed (&g, 10), 10);
+ EXPECT (progress_gate_feed (&g, 100), 100);
+ EXPECT (progress_gate_feed (&g, 30), 30);
+ EXPECT (progress_gate_feed (&g, 32), -1);
+ EXPECT (progress_gate_feed (&g, 100), 100);
+}
+
+static void
+first_value_after_navigation_is_always_sent (void)
+{
+ ProgressGate g = navigated_gate ();
+ EXPECT (progress_gate_feed (&g, 0), 0);
+ EXPECT (progress_gate_feed (&g, 3), -1);
+}
+
+static void
+floors_and_clamps_values (void)
+{
+ ProgressGate g = navigated_gate ();
+ EXPECT (progress_gate_feed (&g, -5), 0);
+ EXPECT (progress_gate_feed (&g, 99.9), 99);
+ EXPECT (progress_gate_feed (&g, 150), 100);
+}
+
+static void
+navigating_again_rearms_the_bootstrap_guard (void)
+{
+ ProgressGate g = navigated_gate ();
+ EXPECT (progress_gate_feed (&g, 50), 50);
+ progress_gate_navigated (&g);
+ EXPECT (progress_gate_feed (&g, 100), -1);
+}
+
+static void
+observed_wikipedia_sequence (void)
+{
+ ProgressGate g;
+ progress_gate_init (&g);
+ EXPECT (progress_gate_feed (&g, 10.0), -1);
+ EXPECT (progress_gate_feed (&g, 100.0), -1);
+ progress_gate_navigated (&g);
+ EXPECT (progress_gate_feed (&g, 10.0), 10);
+ EXPECT (progress_gate_feed (&g, 10.3), -1);
+ EXPECT (progress_gate_feed (&g, 48.7), 48);
+ EXPECT (progress_gate_feed (&g, 63.7), 63);
+ EXPECT (progress_gate_feed (&g, 84.4), 84);
+ EXPECT (progress_gate_feed (&g, 86.0), -1);
+ EXPECT (progress_gate_feed (&g, 86.4), -1);
+ EXPECT (progress_gate_feed (&g, 100.0), 100);
+}
+
+static void
+non_finite_values_are_dropped (void)
+{
+ ProgressGate g = navigated_gate ();
+ EXPECT (progress_gate_feed (&g, 10), 10);
+ EXPECT (progress_gate_feed (&g, NAN), -1);
+ EXPECT (progress_gate_feed (&g, INFINITY), -1);
+ EXPECT (progress_gate_feed (&g, -INFINITY), -1);
+ EXPECT (g.last_sent, 10);
+}
+
+static void
+navigation_starts_a_fresh_load (void)
+{
+ ProgressGate g = navigated_gate ();
+ EXPECT (progress_gate_feed (&g, 50), 50);
+ progress_gate_navigated (&g);
+ EXPECT (g.last_sent, -1);
+ EXPECT (progress_gate_feed (&g, 52), 52);
+}
+
+int
+main (void)
+{
+ drops_everything_before_navigation ();
+ late_bootstrap_100_is_not_page_loaded ();
+ rate_limits_increases_but_always_sends_100_once ();
+ decrease_is_a_new_navigation_and_sent_immediately ();
+ first_value_after_navigation_is_always_sent ();
+ floors_and_clamps_values ();
+ navigating_again_rearms_the_bootstrap_guard ();
+ observed_wikipedia_sequence ();
+ non_finite_values_are_dropped ();
+ navigation_starts_a_fresh_load ();
+ if (failures) {
+ printf ("progress-gate: %d failure(s)\n", failures);
+ return 1;
+ }
+ printf ("progress-gate: all tests passed\n");
+ return 0;
+}