Initial release

This commit is contained in:
aculix committed 2026-09-28 18:28:32 +05:30
commit 4b67728cce
108 files changed
+13310

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
.DS_Store
.build/
build/
DerivedData/
xcuserdata/
.swiftpm/
*.app
snapshots/
a.out
*.o
*.dSYM/
__pycache__/
*.pyc
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Aculix Technologies LLP
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+14
View File
@@ -0,0 +1,14 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "Knotchy",
platforms: [.macOS(.v26)],
targets: [
// Everything that isn't UI: session logic, protocol, geometry, VM backend.
.target(name: "KnotchyCore"),
.executableTarget(name: "Knotchy", dependencies: ["KnotchyCore"], exclude: ["Info.plist"]),
.executableTarget(name: "knotchyctl", dependencies: ["KnotchyCore"]),
.testTarget(name: "KnotchyCoreTests", dependencies: ["KnotchyCore"]),
]
)
+192
View File
@@ -0,0 +1,192 @@
<img src="assets/knotchy-icon.svg" width="96" alt="">
# Knotchy
Open suspicious links in a throwaway browser that lives in your Mac's notch.
> **Status:** 0.1.0, the first release. It works, but it's young. If something breaks, please open an issue.
Some links you'd rather not open. A shortened link in a text from a number you don't know. An "invoice" from a
company you've never dealt with. Knotchy opens them somewhere else: a small Linux virtual machine that exists for
that one link. The page appears in a panel under the notch, you look at it, and when you're done the whole machine
is deleted, along with anything the page did inside it.
The VM can reach the internet. It can't reach your Mac or anything else on your network.
## Requirements
- A Mac with Apple silicon
- macOS 26 or later
- About 3 GB of free space for the first setup
- An administrator password, once, for Apple's installer
## Install
1. Download `Knotchy-0.1.0.dmg` from [Releases](https://github.com/aculix/knotchy/releases/latest). It's signed
and notarized, so macOS opens it without warnings.
2. Open the disk image and drag Knotchy to Applications.
3. Open Knotchy. A knot appears in the menu bar and the setup window opens.
4. Click **Set Up**, and Knotchy works through the list:
- It checks that your Mac can run virtual machines and has enough space.
- It downloads Apple's [`container`](https://github.com/apple/container) runtime (version 1.4.1, 118 MB),
checks it against the SHA-256 built into Knotchy, and opens it in Apple's Installer. Click through Installer
and enter your password there. Knotchy never asks for administrator rights itself.
- It starts the runtime, which downloads a Linux kernel the first time.
- It builds the browser VM image on your Mac. That takes about 3 minutes and downloads about 225 MB.
5. When every row has a green tick, click **Done**.
If `container` 1.4 or newer is already installed, Knotchy uses it and goes straight to the build. You can close the
window at any point and setup carries on; **Set Up Knotchy…** in the menu bar brings it back.
### The browser extension
Safari and Mail need nothing extra. Chrome, Edge, Brave and Arc do, because they pass the Services menu a link's
text instead of its address. Until the extension is on the Chrome Web Store, install it by hand:
1. Download `open-in-knotchy-1.0.0.zip` from [Releases](https://github.com/aculix/knotchy/releases/latest) and unzip
it somewhere you'll keep it. (The same files are in [`extensions/chrome`](extensions/chrome).)
2. Open `chrome://extensions` and turn on **Developer mode** in the top-right corner.
3. Click **Load unpacked** and choose the unzipped folder.
4. Right-click any link and choose **Open in Knotchy**. The first time, the browser asks whether to open Knotchy.
Allow it.
The extension has one permission, adding that menu item, and only passes `http` and `https` links to the app.
It won't update itself, and deleting the folder removes it from the browser.
## Opening links
- In Safari, Mail and most other apps, right-click a link and choose **Services → Open in Knotchy**. It also works
on selected text that contains a link.
- In Chrome and other Chromium browsers, right-click a link and choose **Open in Knotchy**.
- Anywhere else, copy the link and press **⌃⌥⌘L**, or choose **Open Copied Link** from the menu bar.
Only web links open. Anything else gets a brief "No web link found" in the notch.
## Using the panel
The page opens in a panel that drops down from the notch. Scroll, click and type as you would in any browser. The
toolbar has back, forward and reload, an address field, and a button that makes the panel bigger.
Click anywhere outside the panel and it folds back into the notch, with a countdown beside it. Hover over the notch
to bring the page back. Leave it folded and the VM is destroyed when the countdown runs out, after 5 minutes unless
you change it. **Destroy** in the toolbar, or ⌘W, ends it right away.
Knotchy shows one page at a time. Opening a new link replaces the current VM with a fresh one.
## Settings
The knot in the menu bar opens a menu with the open page (show it or destroy it), **Open Copied Link**, the
connection picker and **Rebuild VM**. **Settings…** has three sections.
General sets how long a folded page lives, whether pages open in the small or large panel, the ⌃⌥⌘L shortcut, and
whether Knotchy opens at login.
Network sends pages through an HTTP or SOCKS5 proxy, with a username and password if it needs them, or through a
WireGuard VPN. For a VPN, import the `.conf` files or the `.zip` your provider gives you; Mullvad, Proton VPN and IVPN
all offer them. If the proxy or VPN is down, pages don't load. They never fall back to your normal connection.
Proxies on your Mac or your local network are refused, because a page could reach your network through them.
Passwords and VPN keys are kept in your keychain.
VM shows the runtime version and when the image was built, and has **Rebuild Now**. Rebuilding picks up WebKit
security fixes, and Knotchy suggests it once the image is two weeks old.
## How it works
There are two programs: the Mac app, and a small agent inside each VM.
When a link comes in, the app checks that it's `http` or `https` and asks Apple's `container` tool to start a new VM
from the `local/knotchy-vm` image. That's Debian with WPE WebKit and GStreamer, given 4 CPU cores and 2 GB of memory.
Each VM gets a random name (`knotchy-` and 8 hex digits) and is deleted when it stops.
The VM's first process is [`vm/knotchy-init`](vm/knotchy-init), a shell script. Before anything else runs, it sets up
an nftables firewall. On a direct connection the firewall allows the public internet and blocks private and
link-local addresses, carrier-grade NAT, IPv6, and the networks your Mac is on. With a proxy, the proxy is the only
thing the VM can reach. With WireGuard, it's the VPN server and the tunnel. If the firewall can't be put in place,
the VM stops instead of starting without it. Init then drops all its capabilities and starts the agent as an
unprivileged user, so nothing the browser runs can change the firewall.
The agent, [`vm/knotchy-agent.c`](vm/knotchy-agent.c), loads the page in WPE WebKit at 1024 × 640, encodes what it
draws as H.264 with x264, and writes the frames to a Unix socket. `container` exposes that socket to the Mac as a
file, so there's no network connection between the two. The app decodes the video with VideoToolbox and draws it
in the panel. Clicks, scrolls, keys and address changes go back over the same socket. The message format is at the
top of the agent's source.
Destroying a page stops and deletes its VM. If Knotchy quits with a VM running, it deletes it on the way out, and
any VM left behind by a crash is deleted at the next launch.
The image is built on your Mac from the [`vm`](vm) folder that ships inside the app, so what runs in the VM is what's
in this repository. Proxy passwords and VPN keys reach the VM through a shared folder that init reads, deletes and
unmounts before the browser starts. They're never passed as environment variables, which `container` saves to disk.
### What's where
| Path | Contents |
|---|---|
| `Sources/Knotchy` | The Mac app: menu bar, notch panel, setup window and Settings |
| `Sources/KnotchyCore` | The session state machine, the VM backend, the stream protocol and decoder, setup logic |
| `Sources/knotchyctl` | A command-line tool that opens a link in a VM and saves a screenshot, for testing without the UI |
| `Tests` | Unit tests for KnotchyCore; none of them need a VM |
| `vm` | The VM image: `Containerfile`, `knotchy-init` and `knotchy-agent.c` |
| `extensions/chrome` | The Open in Knotchy extension |
| `scripts` | Build, release, test and icon scripts |
## Known limitations
- The "Isolated" badge means isolated, not safe. A page can still try to trick you inside the VM.
- There's no sound. Clipboard, downloads, popups and links that open a new window stay inside the VM or don't work.
- The picture is streamed at 1×, so it's slightly soft on Retina screens.
- Proxy and VPN routes are IPv4 only, a WireGuard config can have only one peer, and OpenVPN isn't supported.
- The firewall runs in the VM's own kernel, so a kernel exploit inside the VM could get around it.
- A site can still reach your router through your network's public address (hairpin NAT), and routes that a VPN
on your Mac adds for public addresses aren't blocked. Knotchy reads your Mac's networks when each page starts.
- Unprivileged user namespaces are on in the VM, because WebKit's sandbox needs them.
## Building from source
You need a Mac with Apple silicon, macOS 26 or later, Xcode 26 or later, and Apple's `container` installed.
```bash
git clone https://github.com/aculix/knotchy.git
cd knotchy
scripts/bundle-app.sh
```
`bundle-app.sh` builds Knotchy.app and installs it in `~/Applications`. It signs with your Developer ID certificate
if you have one and ad hoc if you don't, which is fine for your own Mac. The app builds its VM image the first time
it opens, the same as a downloaded copy.
The other scripts:
- `swift test` runs the unit tests.
- `scripts/build-vm.sh` builds the VM image from the command line.
- `scripts/test-egress.sh` starts a VM and checks what it can and can't reach. Run it after any change in `vm`.
- `scripts/test-routes.sh` checks the proxy and VPN routes, kill switch included. Build its test server first with
`container build --dns 1.1.1.1 -t local/knotchy-route-server scripts/route-server`.
- `scripts/release.sh` builds a signed, notarized disk image. It needs a Developer ID certificate and notarization
credentials saved with `xcrun notarytool store-credentials`.
- `scripts/package-extension.sh` zips the browser extension.
- `scripts/make-icons.sh` redraws every icon from the knot in `scripts/icons/knot.py`.
A VM image you build yourself is exactly as trustworthy as the checkout you built it from.
## Uninstalling
Quit Knotchy and move it to the Trash. Then remove its VM image in Terminal:
```bash
container image rm local/knotchy-vm
```
If nothing else on your Mac uses Apple's `container`, you can remove it as well. This deletes all of its data:
```bash
/usr/local/bin/uninstall-container.sh -d
```
## Security
To report a security problem, please use GitHub's private reporting. [SECURITY.md](SECURITY.md) has the details.
## License
MIT. See [LICENSE](LICENSE).
+25
View File
@@ -0,0 +1,25 @@
# Security
Knotchy exists to keep untrusted pages away from your Mac, so a security bug here is the worst kind of bug. If you
find one, thank you.
## Reporting a vulnerability
Please report it privately: on this repository's **Security** tab, choose **Report a vulnerability**. Don't open a
public issue for it.
Tell us what you found, how to reproduce it, and which versions of Knotchy and macOS you used. We'll reply within a
few days, and we'll have a fix or a plan before anything is made public.
## What counts
- A page in the VM reaching your Mac, your local network, or anything else the firewall should block
- Getting around a proxy or VPN route's kill switch
- Knotchy running code on the Mac, or installing anything other than Apple's pinned `container` package
- Proxy passwords or VPN keys leaking from the keychain or from the VM's secrets share
A page taking over the browser inside the VM is expected; that's what the VM is for. Escaping the VM is what counts.
## Supported versions
Only the latest release gets fixes.
+249
View File
@@ -0,0 +1,249 @@
import AppKit
import KnotchyCore
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
let backend: ContainerCLIBackend
let controller: SessionController
let services: ServicesProvider
let settings = AppSettings()
let vmStatus: VMStatus
let unsupported: Unsupported?
let setupFlow: SetupFlow
var isSupportedMac: Bool { unsupported == nil }
private(set) lazy var setupWindow = SetupWindowController(
unsupported.map(SetupWindowController.Content.unsupported) ?? .setup(setupFlow))
private(set) lazy var settingsWindow = SettingsWindowController(app: self)
private var notch: NotchWindowController?
private var copiedLinkHotKey: GlobalHotKey?
private var wantsCopiedLinkHotKey = false
private var signalSources: [DispatchSourceSignal] = []
private var quitting = false
override init() {
unsupported = Self.detectUnsupported()
let vmSources = Bundle.main.resourceURL?.appendingPathComponent("vm")
let hasSources = vmSources.map { FileManager.default.fileExists(atPath: $0.appendingPathComponent("Containerfile").path) } ?? false
backend = ContainerCLIBackend(vmSources: hasSources ? vmSources : nil)
controller = SessionController(
backend: backend,
connector: StreamClient(renderSize: SessionController.renderSize),
diskSpace: SystemDiskSpace(),
hostNetworks: { HostNetworks.current() },
requestQuit: { NSApp.terminate(nil) }
)
services = ServicesProvider(controller: controller)
vmStatus = VMStatus(backend: backend, controller: controller)
setupFlow = SetupFlow(host: SystemHost(), installer: SystemRuntimeInstaller(), driver: controller)
super.init()
controller.quitsWhenIdle = false
controller.showSetup = { [weak self] in self?.setupWindow.show() }
if isSupportedMac {
settings.onChange = { [weak self] in self?.applySettings() }
applySettings()
} else {
services.redirect = { [weak self] in self?.setupWindow.show() }
}
}
private static func detectUnsupported() -> Unsupported? {
#if DEBUG
if ProcessInfo.processInfo.environment["KNOTCHY_PRETEND"] == "no-virtualization" { return .noVirtualization }
#endif
return SystemHost.unsupported()
}
private func applySettings() {
settings.apply(to: controller)
backend.setNetworkRoute(settings.route)
guard settings.copiedLinkShortcut != wantsCopiedLinkHotKey else { return }
wantsCopiedLinkHotKey = settings.copiedLinkShortcut
if wantsCopiedLinkHotKey {
copiedLinkHotKey = GlobalHotKey.openCopiedLink { [services] in services.openCopiedLink() }
if copiedLinkHotKey == nil { ServicesLog.write("\(GlobalHotKey.openCopiedLinkLabel) is taken by another app") }
} else {
copiedLinkHotKey = nil
}
}
func applicationWillFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
// Registered before launch finishes, so a Services request that launched the app isn't lost.
NSApp.servicesProvider = services
NSUpdateDynamicServices()
}
func applicationDidFinishLaunching(_ notification: Notification) {
#if DEBUG
if let directory = DebugTour.argument(after: "--setup-snapshot") {
Task {
await DebugTour.snapshotSetup(app: self, to: URL(fileURLWithPath: directory))
NSApp.terminate(nil)
}
return
}
#endif
guard isSupportedMac else { return setupWindow.show() }
notch = NotchWindowController(controller: controller, routeLabel: { [backend] in backend.lastStartedRoute.label })
vmStatus.refresh()
// Route Ctrl-C, kill and logout through applicationShouldTerminate so the VMs get destroyed.
for sig in [SIGINT, SIGTERM, SIGHUP] {
signal(sig, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
source.setEventHandler { NSApp.terminate(nil) }
source.resume()
signalSources.append(source)
}
#if DEBUG
let all = CommandLine.arguments
if let index = all.firstIndex(of: "--tour"), index + 2 < all.count, let url = URL(string: all[index + 2]),
let notch {
let directory = URL(fileURLWithPath: all[index + 1])
Task { await DebugTour.run(controller: controller, snapshot: { notch.snapshot(to: directory, name: $0) }, url: url) }
return
}
if all.contains("--settings-timing") {
Task {
await DebugTour.timeNetworkSwitching(app: self)
NSApp.terminate(nil)
}
return
}
if let index = all.firstIndex(of: "--settings-snapshot"), index + 1 < all.count {
let directory = URL(fileURLWithPath: all[index + 1])
Task {
await DebugTour.snapshotSettings(app: self, to: directory)
NSApp.terminate(nil)
}
return
}
if let index = all.firstIndex(of: "--tour-timing"), index + 1 < all.count, let url = URL(string: all[index + 1]) {
Task { await DebugTour.run(controller: controller, snapshot: nil, url: url) }
return
}
#endif
Task { await openSetupIfNeeded() }
let arguments = CommandLine.arguments.dropFirst().filter { !$0.hasPrefix("-") }
if let first = arguments.first { controller.open(text: first) }
}
private func openSetupIfNeeded() async {
let runtime = await backend.runtimeStatus()
let completed = UserDefaults.standard.bool(forKey: SetupLaunchRule.completedKey)
if SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: completed, runtime: runtime) {
setupWindow.show()
}
}
// From the browser extensions: Chrome's Services menu never sees a link's address.
func application(_ application: NSApplication, open urls: [URL]) {
guard isSupportedMac else { return setupWindow.show() }
for url in urls where url.scheme == "knotchy" && url.host() == "open" {
let link = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "url" }?.value
ServicesLog.write("knotchy:// request, link \(link == nil ? "missing" : "present")")
if let link, let target = URL(string: link) {
controller.open(target)
} else {
controller.open(text: "")
}
}
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard !quitting else { return .terminateNow }
quitting = true
setupFlow.cancel()
if setupFlow.isDownloading { SystemRuntimeInstaller.removeLeftovers() }
// Not Now mid-build closes the window before the build ends.
if setupFlow.allRequiredDone { UserDefaults.standard.set(true, forKey: SetupLaunchRule.completedKey) }
// Synchronous on purpose: terminate: often runs inside a non-reentrant main-queue block; awaiting here deadlocked.
notch?.hide()
CATransaction.flush()
CLIRunner.stopBuilds()
controller.closeBackendChain()
let ids = Array(controller.liveIDs.snapshot)
if !ids.isEmpty { backend.destroyBlocking(ids: ids, deadline: .seconds(20)) }
return .terminateNow
}
}
@MainActor
final class ServicesProvider: NSObject {
private let controller: SessionController
/// In minimal mode, requests only bring the explanation window forward, so the calling app gets a normal reply.
var redirect: (@MainActor () -> Void)?
init(controller: SessionController) {
self.controller = controller
}
@objc func openLink(_ pasteboard: NSPasteboard, userData: String?, error: AutoreleasingUnsafeMutablePointer<NSString?>) {
open(from: pasteboard, service: "Open in Knotchy")
}
@objc func openCopiedLink(_ pasteboard: NSPasteboard, userData: String?, error: AutoreleasingUnsafeMutablePointer<NSString?>) {
open(from: .general, service: "Open Copied Link in Knotchy")
}
func openCopiedLink() {
open(from: .general, service: "Shortcut")
}
private func open(from pasteboard: NSPasteboard, service: String) {
if let redirect { return redirect() }
let types = pasteboard.types?.map(\.rawValue) ?? []
let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] ?? []
let url: URL?
let source: String
if let found = urls.first(where: { LinkIntake.validate($0) != nil }) {
(url, source) = (found, "url")
} else if let html = pasteboard.string(forType: .html), let found = LinkIntake.extractURL(fromHTML: html) {
// A selected hyperlink's text rarely holds the address; its HTML does.
(url, source) = (found, "html")
} else if let found = Self.firstLink(inRTF: pasteboard.data(forType: .rtf)) {
(url, source) = (found, "rtf")
} else if let text = pasteboard.string(forType: .string), let found = LinkIntake.extractURL(from: text) {
(url, source) = (found, "text")
} else {
(url, source) = (nil, "none")
}
ServicesLog.write("\(service): types \(types), link from \(source)")
if let url {
controller.open(url)
} else {
controller.open(text: "")
}
}
private static func firstLink(inRTF data: Data?) -> URL? {
guard let data, let text = NSAttributedString(rtf: data, documentAttributes: nil) else { return nil }
var found: URL?
text.enumerateAttribute(.link, in: NSRange(location: 0, length: text.length)) { value, _, stop in
let url = (value as? URL) ?? (value as? String).flatMap(URL.init(string:))
if let url, let valid = LinkIntake.validate(url) {
found = valid
stop.pointee = true
}
}
return found
}
}
// Pasteboard types and sources only; never log the links themselves.
enum ServicesLog {
static func write(_ line: String) {
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Logs/Knotchy.log")
let entry = "\(ISO8601DateFormatter().string(from: Date())) \(line)\n"
if let handle = try? FileHandle(forWritingTo: url) {
handle.seekToEndOfFile()
handle.write(Data(entry.utf8))
try? handle.close()
} else {
try? Data(entry.utf8).write(to: url)
}
}
}
+293
View File
@@ -0,0 +1,293 @@
import AppKit
import Carbon.HIToolbox
import KnotchyCore
import Observation
import ServiceManagement
@MainActor
@Observable
final class AppSettings {
static let autoDestroyChoices = [1, 2, 5, 10, 15, 30]
private let defaults = UserDefaults.standard
@ObservationIgnored var onChange: () -> Void = {}
var autoDestroyMinutes: Int {
didSet { save(autoDestroyMinutes, "autoDestroyMinutes") }
}
var opensLarge: Bool {
didSet { save(opensLarge, "opensLarge") }
}
var copiedLinkShortcut: Bool {
didSet { save(copiedLinkShortcut, "copiedLinkShortcut") }
}
// MARK: Network
enum NetworkMode: String, CaseIterable {
case direct, proxy, vpn
}
var networkMode: NetworkMode {
didSet { save(networkMode.rawValue, "networkMode") }
}
var proxyKind: ProxySettings.Kind {
didSet { save(proxyKind.rawValue, "proxyKind") }
}
var proxyHost: String {
didSet { save(proxyHost, "proxyHost") }
}
var proxyPort: Int {
didSet { save(proxyPort, "proxyPort") }
}
var proxyUsername: String {
didSet { save(proxyUsername, "proxyUsername") }
}
// Debounced: after an app update, every keychain write can raise a keychain prompt.
var proxyPassword: String {
didSet {
passwordSave?.cancel()
let password = proxyPassword
passwordSave = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled, let self else { return }
Keychain.write(password, for: "proxy-password")
self.defaults.set(!password.isEmpty, forKey: "proxyHasPassword")
}
onChange()
}
}
@ObservationIgnored private var passwordSave: Task<Void, Never>?
// In the keychain, not UserDefaults: WireGuard configs hold private keys.
private(set) var wireGuardConfigs: [String: String] {
didSet {
let json = (try? JSONEncoder().encode(wireGuardConfigs)).flatMap { String(data: $0, encoding: .utf8) } ?? ""
Keychain.write(wireGuardConfigs.isEmpty ? "" : json, for: "wireguard-configs")
onChange()
}
}
var wireGuardServer: String {
didSet { save(wireGuardServer, "wireGuardServer") }
}
var selectedWireGuard: WireGuardConfig? {
wireGuardConfigs[wireGuardServer].flatMap { try? WireGuardConfig.parse($0) }
}
var wireGuardNames: [String] {
wireGuardConfigs.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
// An unusable proxy or VPN must fail the start, never fall back to a direct connection.
var route: NetworkRoute {
switch networkMode {
case .direct:
return .direct
case .proxy:
let host = proxyHost.trimmingCharacters(in: .whitespaces)
guard !host.isEmpty, (1...65535).contains(proxyPort) else {
return .unusable(reason: "The proxy isn't set up. Add its server and port in Settings → Network, or switch to Direct.")
}
return .proxy(ProxySettings(kind: proxyKind, host: host, port: proxyPort,
username: proxyUsername, password: proxyPassword))
case .vpn:
guard let text = wireGuardConfigs[wireGuardServer] else {
return .unusable(reason: "No VPN server is chosen. Import a WireGuard config in Settings → Network, or switch to Direct.")
}
do {
var config = try WireGuardConfig.parse(text)
config.name = wireGuardServer
return .wireGuard(config)
} catch {
return .unusable(reason: "The WireGuard config “\(wireGuardServer)” \(error.message).")
}
}
}
/// A hint for Settings only; sessions enforce `ProxySettings.isLocal` on the resolved address.
var proxyIsLocal: Bool {
let host = proxyHost.trimmingCharacters(in: .whitespaces).lowercased()
if host == "localhost" || [".local", ".lan", ".home", ".internal"].contains(where: host.hasSuffix) { return true }
let octets = host.split(separator: ".", omittingEmptySubsequences: false).compactMap { Int($0) }
guard octets.count == 4 else { return false }
let (a, b) = (octets[0], octets[1])
return a == 10 || a == 127 || (a == 172 && (16...31).contains(b)) || (a == 192 && b == 168)
|| (a == 100 && (64...127).contains(b)) || (a == 169 && b == 254)
}
func importWireGuard(from urls: [URL]) async -> String? {
var found: [(String, String)] = []
var problems: [String] = []
for url in urls {
let files: [(String, String)]
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
if url.pathExtension.lowercased() == "zip" {
files = await Task.detached { Self.confFiles(inZip: url) }.value
if files.isEmpty { problems.append("\(url.lastPathComponent) has no .conf files") }
} else if size <= 64 * 1024, let text = try? String(contentsOf: url, encoding: .utf8) {
files = [(url.deletingPathExtension().lastPathComponent, text)]
} else {
problems.append("couldn't read \(url.lastPathComponent)")
continue
}
for (name, text) in files {
do {
_ = try WireGuardConfig.parse(text)
found.append((name, text))
} catch {
problems.append("“\(name)” \(error.message)")
}
}
}
guard !found.isEmpty else { return problems.first.map { "Nothing imported: \($0)." } ?? "Nothing imported." }
var configs = wireGuardConfigs
for (name, text) in found { configs[name] = text }
wireGuardConfigs = configs
wireGuardServer = found.map(\.0).sorted { $0.localizedStandardCompare($1) == .orderedAscending }[0]
networkMode = .vpn
return problems.isEmpty ? nil : "Imported \(found.count); skipped: \(problems.joined(separator: "; "))."
}
func removeWireGuardConfigs() {
wireGuardConfigs = [:]
wireGuardServer = ""
}
private nonisolated static func confFiles(inZip url: URL) -> [(String, String)] {
// Output past `limit` fails the read, so a zip bomb can't exhaust memory.
func unzip(_ arguments: [String], limit: Int) -> Data? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
guard (try? process.run()) != nil else { return nil }
let pid = process.processIdentifier
let watchdog = DispatchWorkItem { kill(pid, SIGKILL) }
DispatchQueue.global().asyncAfter(deadline: .now() + 10, execute: watchdog)
defer { watchdog.cancel() }
var data = Data()
while let chunk = try? pipe.fileHandleForReading.read(upToCount: 16 * 1024), !chunk.isEmpty {
data.append(chunk)
if data.count > limit {
process.terminate()
process.waitUntilExit()
return nil
}
}
process.waitUntilExit()
return process.terminationStatus == 0 ? data : nil
}
guard let listing = unzip(["-Z1", url.path], limit: 1024 * 1024).flatMap({ String(data: $0, encoding: .utf8) })
else { return [] }
let entries = listing.split(whereSeparator: \.isNewline).map(String.init)
.filter { $0.lowercased().hasSuffix(".conf") && !$0.hasPrefix("__MACOSX") }
.prefix(500)
return entries.compactMap { entry in
guard let data = unzip(["-p", url.path, entry], limit: 64 * 1024),
let text = String(data: data, encoding: .utf8) else { return nil }
return (URL(fileURLWithPath: entry).deletingPathExtension().lastPathComponent, text)
}
}
init() {
defaults.register(defaults: ["autoDestroyMinutes": 5, "opensLarge": false, "copiedLinkShortcut": true,
"networkMode": "direct", "proxyKind": "http", "proxyPort": 8080])
autoDestroyMinutes = defaults.integer(forKey: "autoDestroyMinutes")
opensLarge = defaults.bool(forKey: "opensLarge")
copiedLinkShortcut = defaults.bool(forKey: "copiedLinkShortcut")
networkMode = NetworkMode(rawValue: defaults.string(forKey: "networkMode") ?? "") ?? .direct
proxyKind = ProxySettings.Kind(rawValue: defaults.string(forKey: "proxyKind") ?? "") ?? .http
proxyHost = defaults.string(forKey: "proxyHost") ?? ""
proxyPort = defaults.integer(forKey: "proxyPort")
let username = defaults.string(forKey: "proxyUsername") ?? ""
let server = defaults.string(forKey: "wireGuardServer") ?? ""
proxyUsername = username
wireGuardServer = server
// Skip the keychain (and its possible prompt) unless a secret was saved.
proxyPassword = defaults.bool(forKey: "proxyHasPassword") || !username.isEmpty
? Keychain.read("proxy-password") ?? "" : ""
wireGuardConfigs = server.isEmpty ? [:] :
(Keychain.read("wireguard-configs")?.data(using: .utf8))
.flatMap { try? JSONDecoder().decode([String: String].self, from: $0) } ?? [:]
}
private func save(_ value: Any, _ key: String) {
defaults.set(value, forKey: key)
onChange()
}
func apply(to controller: SessionController) {
controller.idleDestroyDelay = .seconds(autoDestroyMinutes * 60)
controller.opensExpanded = opensLarge
}
}
@MainActor
enum LoginItem {
static var isEnabled: Bool {
SMAppService.mainApp.status == .enabled
}
static func set(_ enabled: Bool) -> String? {
do {
if enabled {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
return nil
} catch {
return error.localizedDescription
}
}
}
// Carbon, because its hot keys need no Accessibility permission.
@MainActor
final class GlobalHotKey {
static let openCopiedLinkLabel = "⌃⌥⌘L"
private var hotKey: EventHotKeyRef?
private var handler: EventHandlerRef?
private let action: () -> Void
static func openCopiedLink(_ action: @escaping () -> Void) -> GlobalHotKey? {
GlobalHotKey(keyCode: UInt32(kVK_ANSI_L), modifiers: UInt32(controlKey | optionKey | cmdKey), action: action)
}
private init?(keyCode: UInt32, modifiers: UInt32, action: @escaping () -> Void) {
self.action = action
var type = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
let installed = InstallEventHandler(GetApplicationEventTarget(), { _, _, userData in
guard let userData else { return OSStatus(eventNotHandledErr) }
let hotKey = Unmanaged<GlobalHotKey>.fromOpaque(userData).takeUnretainedValue()
MainActor.assumeIsolated { hotKey.action() }
return noErr
}, 1, &type, Unmanaged.passUnretained(self).toOpaque(), &handler)
guard installed == noErr else { return nil }
let id = EventHotKeyID(signature: OSType(0x4B4E_4F54), id: 1)
// Exclusive, so a shortcut another app owns fails instead of silently sharing.
guard RegisterEventHotKey(keyCode, modifiers, id, GetApplicationEventTarget(),
OptionBits(kEventHotKeyExclusive), &hotKey) == noErr else {
RemoveEventHandler(handler)
handler = nil // deinit also runs after a failed init; don't remove it twice.
return nil
}
}
isolated deinit {
if let hotKey { UnregisterEventHotKey(hotKey) }
if let handler { RemoveEventHandler(handler) }
}
}
+28
View File
@@ -0,0 +1,28 @@
import AppKit
enum ChromeExtension {
/// Set once the Chrome Web Store assigns the item ID; until then the setup window hides the row.
static let storeURL: URL? = nil
struct Browser: Equatable {
let name: String
let app: URL
}
// Chromium browsers all install from the Chrome Web Store.
private static let candidates = [("Chrome", "com.google.Chrome"), ("Edge", "com.microsoft.edgemac"),
("Brave", "com.brave.Browser"), ("Arc", "company.thebrowser.Browser")]
static func installedBrowser() -> Browser? {
guard storeURL != nil else { return nil }
for (name, id) in candidates {
if let app = NSWorkspace.shared.urlForApplication(withBundleIdentifier: id) { return Browser(name: name, app: app) }
}
return nil
}
static func open(in browser: Browser) {
guard let storeURL else { return }
NSWorkspace.shared.open([storeURL], withApplicationAt: browser.app, configuration: NSWorkspace.OpenConfiguration())
}
}
+164
View File
@@ -0,0 +1,164 @@
#if DEBUG
import AppKit
import KnotchyCore
import QuartzCore
import SwiftUI
// `Knotchy --tour <dir> <url>` saves PNGs of each state; `--tour-timing <url>` reports dropped frames.
// Checks the UI without screen recording permission; video doesn't appear in the snapshots.
@MainActor
enum DebugTour {
static func run(controller: SessionController, snapshot: ((String) -> Void)?, url: URL) async {
let meter = FrameMeter()
func wait(_ seconds: Double) async { try? await Task.sleep(for: .seconds(seconds)) }
func step(_ name: String, _ times: [Double], _ action: () -> Void) async {
if snapshot == nil { meter.start() }
action()
var elapsed = 0.0
for time in times {
await wait(time - elapsed)
elapsed = time
snapshot?("\(name)-\(Int(time * 1000))ms")
}
if snapshot == nil { print("\(name): \(meter.stop())") }
}
await step("01-no-link", [0.08, 0.2, 0.6, 1.0]) { controller.open(text: "no link here") }
await wait(2.5)
await step("02-opening", [0.06, 0.12, 0.2, 0.3, 0.45, 0.7, 1.2]) { controller.open(url) }
for _ in 0..<300 where controller.state != .live(.peek) { await wait(0.1) }
await wait(1)
await step("04-expand", [0.08, 0.16, 0.25, 0.4, 1.0]) { controller.expand() }
await step("05-shrink", [0.08, 0.16, 0.3, 1.0]) { controller.shrink() }
await step("06-fold", [0.06, 0.12, 0.2, 0.35, 1.0]) { controller.clickedOutside() }
await wait(0.5)
await step("07-unfold", [0.06, 0.12, 0.2, 0.35, 1.0]) { controller.pointerEntered() }
await step("08-destroy", [0.08, 0.2, 0.4, 0.8, 1.6, 2.2, 2.6]) { controller.destroy() }
await wait(1)
await step("09-reopen", [0.06, 0.12, 0.2, 0.3, 0.45, 0.7, 1.2]) { controller.open(url) }
for _ in 0..<300 where controller.state != .live(.peek) { await wait(0.1) }
await step("10-destroy", [0.3, 2.6]) { controller.destroy() }
await wait(1)
NSApp.terminate(nil)
}
static func argument(after flag: String) -> String? {
let all = CommandLine.arguments
guard let index = all.firstIndex(of: flag), index + 1 < all.count else { return nil }
return all[index + 1]
}
/// `Knotchy --setup-snapshot <dir>`; with `KNOTCHY_PRETEND=no-virtualization` it shows minimal mode.
static func snapshotSetup(app: AppDelegate, to directory: URL) async {
app.setupWindow.show()
try? await Task.sleep(for: .seconds(5))
guard let window = app.setupWindow.window, let view = window.contentView else { return }
print("setup: window \(window.frame.size)")
DebugSnapshot.write(view, to: directory.appendingPathComponent("setup.png"))
}
}
extension DebugTour {
static func snapshotSettings(app: AppDelegate, to directory: URL) async {
let settings = app.settings
let saved = (settings.networkMode, settings.proxyHost)
let cases: [(String, SettingsTab, AppSettings.NetworkMode)] = [
("general", .general, .direct), ("network-direct", .network, .direct),
("network-proxy", .network, .proxy), ("network-vpn", .network, .vpn), ("vm", .vm, .direct),
]
for (name, section, mode) in cases {
settings.networkMode = mode
if mode == .proxy { settings.proxyHost = "192.168.1.20" }
app.settingsWindow.show(section)
try? await Task.sleep(for: .seconds(0.8))
guard let window = app.settingsWindow.window, let view = window.contentView else { continue }
print("\(name): window \(window.frame.size)")
DebugSnapshot.write(view, to: directory.appendingPathComponent("settings-\(name).png"))
}
app.settingsWindow.window?.orderOut(nil)
(settings.networkMode, settings.proxyHost) = saved
}
static func timeNetworkSwitching(app: AppDelegate) async {
let settings = app.settings
let saved = settings.networkMode
settings.networkMode = .direct
app.settingsWindow.show(.general)
try? await Task.sleep(for: .seconds(1))
let meter = FrameMeter()
let steps: [(String, () -> Void)] = [
("section network", { app.settingsWindow.show(.network) }),
("mode proxy", { settings.networkMode = .proxy }),
("mode vpn", { settings.networkMode = .vpn }),
("mode direct", { settings.networkMode = .direct }),
("section vm", { app.settingsWindow.show(.vm) }),
("section general", { app.settingsWindow.show(.general) }),
("section network", { app.settingsWindow.show(.network) }),
]
for (name, step) in steps {
meter.start()
step()
try? await Task.sleep(for: .seconds(0.8))
let size = app.settingsWindow.window?.frame.size ?? .zero
print("\(name) (window \(Int(size.width))x\(Int(size.height))): \(meter.stop())")
}
app.settingsWindow.window?.orderOut(nil)
settings.networkMode = saved
}
}
enum DebugSnapshot {
@MainActor
static func write(_ view: NSView, to url: URL) {
guard let layer = view.layer, let window = view.window else { return }
let scale = window.backingScaleFactor
let size = view.bounds.size
guard let context = CGContext(data: nil, width: Int(size.width * scale), height: Int(size.height * scale),
bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpace(name: CGColorSpace.sRGB)!,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return }
context.setFillColor(CGColor(gray: 0.55, alpha: 1))
context.fill(CGRect(x: 0, y: 0, width: size.width * scale, height: size.height * scale))
context.scaleBy(x: scale, y: scale)
if view.isFlipped || !layer.isGeometryFlipped {
context.translateBy(x: 0, y: size.height)
context.scaleBy(x: 1, y: -1)
}
(layer.presentation() ?? layer).render(in: context)
guard let image = context.makeImage(),
let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else { return }
CGImageDestinationAddImage(destination, image, nil)
CGImageDestinationFinalize(destination)
}
}
// SwiftUI animates on the main thread, so a late display-link callback is a dropped frame.
@MainActor
final class FrameMeter: NSObject {
private var link: CADisplayLink?
private var stamps: [CFTimeInterval] = []
private var nominal: CFTimeInterval = 1.0 / 60
func start() {
stamps = []
link = (NSScreen.main ?? NSScreen.screens[0]).displayLink(target: self, selector: #selector(tick(_:)))
link?.add(to: .main, forMode: .common)
}
@objc private func tick(_ link: CADisplayLink) {
stamps.append(CACurrentMediaTime())
nominal = link.targetTimestamp - link.timestamp
}
func stop() -> String {
link?.invalidate()
link = nil
let intervals = zip(stamps.dropFirst(), stamps).map { $0 - $1 }
func ms(_ t: CFTimeInterval) -> String { String(format: "%.0f", t * 1000) }
// "duration@end", both in ms, end measured from the start.
let late = zip(intervals, stamps.dropFirst()).filter { $0.0 > nominal * 1.5 }
.map { "\(ms($0.0))@\(ms($0.1 - (stamps.first ?? 0)))" }
return "\(stamps.count) frames at \(ms(nominal)) ms, worst \(ms(intervals.max() ?? 0)) ms, "
+ "\(late.count) late \(late)"
}
}
#endif
+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>aculix.knotchy.app</string>
<key>CFBundleName</key>
<string>Knotchy</string>
<key>CFBundleDisplayName</key>
<string>Knotchy</string>
<key>CFBundleExecutable</key>
<string>Knotchy</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>26.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>© 2026 Aculix Technologies LLP. MIT License.</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>aculix.knotchy.open</string>
<key>CFBundleURLSchemes</key>
<array>
<string>knotchy</string>
</array>
</dict>
</array>
<key>NSServices</key>
<array>
<dict>
<key>NSMenuItem</key>
<dict>
<key>default</key>
<string>Open in Knotchy</string>
</dict>
<key>NSMessage</key>
<string>openLink</string>
<key>NSPortName</key>
<string>Knotchy</string>
<key>NSSendTypes</key>
<array>
<string>public.url</string>
<string>public.html</string>
<string>public.rtf</string>
<string>public.utf8-plain-text</string>
</array>
<key>NSRequiredContext</key>
<dict/>
</dict>
<dict>
<key>NSMenuItem</key>
<dict>
<key>default</key>
<string>Open Copied Link in Knotchy</string>
</dict>
<key>NSMessage</key>
<string>openCopiedLink</string>
<key>NSPortName</key>
<string>Knotchy</string>
<key>NSRequiredContext</key>
<dict/>
</dict>
</array>
</dict>
</plist>
+38
View File
@@ -0,0 +1,38 @@
import Foundation
import Security
enum Keychain {
private static let service = "aculix.knotchy.app"
static func read(_ account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func write(_ value: String, for account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
guard !value.isEmpty else {
SecItemDelete(query as CFDictionary)
return
}
let data = Data(value.utf8)
if SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) == errSecItemNotFound {
var item = query
item[kSecValueData as String] = data
item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked
SecItemAdd(item as CFDictionary, nil)
}
}
}
+29
View File
@@ -0,0 +1,29 @@
// Generated by scripts/icons/knot.py; run scripts/make-icons.sh to change it.
import AppKit
enum KnotGlyph {
static let strands: [[CGPoint]] = [
[CGPoint(x: 7.27, y: 5.99), CGPoint(x: 7.15, y: 6.15), CGPoint(x: 7.04, y: 6.32), CGPoint(x: 6.93, y: 6.49), CGPoint(x: 6.82, y: 6.67), CGPoint(x: 6.72, y: 6.84), CGPoint(x: 6.62, y: 7.02), CGPoint(x: 6.53, y: 7.20), CGPoint(x: 6.43, y: 7.39), CGPoint(x: 6.35, y: 7.57), CGPoint(x: 6.27, y: 7.76), CGPoint(x: 6.19, y: 7.94), CGPoint(x: 6.11, y: 8.13), CGPoint(x: 6.05, y: 8.32), CGPoint(x: 5.98, y: 8.51), CGPoint(x: 5.92, y: 8.70), CGPoint(x: 5.86, y: 8.89), CGPoint(x: 5.81, y: 9.09), CGPoint(x: 5.77, y: 9.28), CGPoint(x: 5.72, y: 9.47), CGPoint(x: 5.69, y: 9.66), CGPoint(x: 5.65, y: 9.85), CGPoint(x: 5.63, y: 10.05), CGPoint(x: 5.60, y: 10.24), CGPoint(x: 5.58, y: 10.43), CGPoint(x: 5.57, y: 10.62), CGPoint(x: 5.56, y: 10.81), CGPoint(x: 5.56, y: 10.99), CGPoint(x: 5.56, y: 11.18), CGPoint(x: 5.56, y: 11.36), CGPoint(x: 5.57, y: 11.55), CGPoint(x: 5.58, y: 11.73), CGPoint(x: 5.60, y: 11.91), CGPoint(x: 5.62, y: 12.08), CGPoint(x: 5.65, y: 12.26), CGPoint(x: 5.68, y: 12.43), CGPoint(x: 5.72, y: 12.60), CGPoint(x: 5.76, y: 12.76), CGPoint(x: 5.80, y: 12.93), CGPoint(x: 5.85, y: 13.09), CGPoint(x: 5.90, y: 13.24), CGPoint(x: 5.96, y: 13.40), CGPoint(x: 6.02, y: 13.55), CGPoint(x: 6.08, y: 13.69), CGPoint(x: 6.15, y: 13.83), CGPoint(x: 6.22, y: 13.97), CGPoint(x: 6.30, y: 14.11), CGPoint(x: 6.38, y: 14.24), CGPoint(x: 6.46, y: 14.36), CGPoint(x: 6.54, y: 14.49), CGPoint(x: 6.63, y: 14.60), CGPoint(x: 6.72, y: 14.71), CGPoint(x: 6.81, y: 14.82), CGPoint(x: 6.91, y: 14.92), CGPoint(x: 7.01, y: 15.02), CGPoint(x: 7.11, y: 15.12), CGPoint(x: 7.21, y: 15.20), CGPoint(x: 7.32, y: 15.29), CGPoint(x: 7.43, y: 15.36), CGPoint(x: 7.54, y: 15.44), CGPoint(x: 7.65, y: 15.50), CGPoint(x: 7.76, y: 15.56), CGPoint(x: 7.88, y: 15.62), CGPoint(x: 8.00, y: 15.67), CGPoint(x: 8.11, y: 15.71), CGPoint(x: 8.23, y: 15.75), CGPoint(x: 8.35, y: 15.79), CGPoint(x: 8.47, y: 15.82), CGPoint(x: 8.59, y: 15.84), CGPoint(x: 8.71, y: 15.85), CGPoint(x: 8.84, y: 15.86), CGPoint(x: 8.96, y: 15.87), CGPoint(x: 9.08, y: 15.87), CGPoint(x: 9.20, y: 15.86), CGPoint(x: 9.33, y: 15.85), CGPoint(x: 9.45, y: 15.83), CGPoint(x: 9.57, y: 15.81), CGPoint(x: 9.69, y: 15.78), CGPoint(x: 9.81, y: 15.74), CGPoint(x: 9.93, y: 15.70), CGPoint(x: 10.04, y: 15.65), CGPoint(x: 10.16, y: 15.60), CGPoint(x: 10.27, y: 15.54), CGPoint(x: 10.39, y: 15.48), CGPoint(x: 10.50, y: 15.41), CGPoint(x: 10.61, y: 15.34), CGPoint(x: 10.72, y: 15.26), CGPoint(x: 10.82, y: 15.17), CGPoint(x: 10.92, y: 15.09), CGPoint(x: 11.02, y: 14.99), CGPoint(x: 11.12, y: 14.89), CGPoint(x: 11.22, y: 14.79), CGPoint(x: 11.31, y: 14.68), CGPoint(x: 11.40, y: 14.56), CGPoint(x: 11.49, y: 14.45), CGPoint(x: 11.57, y: 14.32), CGPoint(x: 11.65, y: 14.19), CGPoint(x: 11.73, y: 14.06), CGPoint(x: 11.80, y: 13.93), CGPoint(x: 11.87, y: 13.79), CGPoint(x: 11.94, y: 13.64), CGPoint(x: 12.00, y: 13.50), CGPoint(x: 12.06, y: 13.35), CGPoint(x: 12.11, y: 13.19), CGPoint(x: 12.17, y: 13.03), CGPoint(x: 12.21, y: 12.87), CGPoint(x: 12.26, y: 12.71), CGPoint(x: 12.28, y: 12.60)],
[CGPoint(x: 11.68, y: 7.63), CGPoint(x: 11.59, y: 7.45), CGPoint(x: 11.51, y: 7.26), CGPoint(x: 11.41, y: 7.08), CGPoint(x: 11.31, y: 6.90), CGPoint(x: 11.21, y: 6.73), CGPoint(x: 11.11, y: 6.55), CGPoint(x: 11.00, y: 6.38), CGPoint(x: 10.89, y: 6.21), CGPoint(x: 10.77, y: 6.04), CGPoint(x: 10.65, y: 5.88), CGPoint(x: 10.53, y: 5.72), CGPoint(x: 10.40, y: 5.56), CGPoint(x: 10.27, y: 5.41), CGPoint(x: 10.14, y: 5.26), CGPoint(x: 10.00, y: 5.11), CGPoint(x: 9.87, y: 4.96), CGPoint(x: 9.73, y: 4.82), CGPoint(x: 9.58, y: 4.69), CGPoint(x: 9.44, y: 4.55), CGPoint(x: 9.29, y: 4.43), CGPoint(x: 9.14, y: 4.30), CGPoint(x: 8.99, y: 4.18), CGPoint(x: 8.83, y: 4.06), CGPoint(x: 8.68, y: 3.95), CGPoint(x: 8.52, y: 3.85), CGPoint(x: 8.36, y: 3.74), CGPoint(x: 8.20, y: 3.65), CGPoint(x: 8.04, y: 3.55), CGPoint(x: 7.88, y: 3.46), CGPoint(x: 7.72, y: 3.38), CGPoint(x: 7.55, y: 3.30), CGPoint(x: 7.39, y: 3.23), CGPoint(x: 7.22, y: 3.16), CGPoint(x: 7.06, y: 3.10), CGPoint(x: 6.90, y: 3.04), CGPoint(x: 6.73, y: 2.98), CGPoint(x: 6.57, y: 2.94), CGPoint(x: 6.40, y: 2.89), CGPoint(x: 6.24, y: 2.85), CGPoint(x: 6.08, y: 2.82), CGPoint(x: 5.92, y: 2.79), CGPoint(x: 5.76, y: 2.77), CGPoint(x: 5.60, y: 2.75), CGPoint(x: 5.44, y: 2.74), CGPoint(x: 5.29, y: 2.73), CGPoint(x: 5.13, y: 2.73), CGPoint(x: 4.98, y: 2.73), CGPoint(x: 4.83, y: 2.74), CGPoint(x: 4.69, y: 2.75), CGPoint(x: 4.54, y: 2.77), CGPoint(x: 4.40, y: 2.79), CGPoint(x: 4.26, y: 2.82), CGPoint(x: 4.12, y: 2.85), CGPoint(x: 3.99, y: 2.89), CGPoint(x: 3.85, y: 2.93), CGPoint(x: 3.73, y: 2.98), CGPoint(x: 3.60, y: 3.03), CGPoint(x: 3.48, y: 3.08), CGPoint(x: 3.36, y: 3.14), CGPoint(x: 3.25, y: 3.21), CGPoint(x: 3.14, y: 3.27), CGPoint(x: 3.03, y: 3.34), CGPoint(x: 2.93, y: 3.42), CGPoint(x: 2.83, y: 3.50), CGPoint(x: 2.74, y: 3.58), CGPoint(x: 2.65, y: 3.67), CGPoint(x: 2.57, y: 3.76), CGPoint(x: 2.49, y: 3.85), CGPoint(x: 2.41, y: 3.95), CGPoint(x: 2.34, y: 4.05), CGPoint(x: 2.28, y: 4.16), CGPoint(x: 2.22, y: 4.26), CGPoint(x: 2.16, y: 4.37), CGPoint(x: 2.11, y: 4.48), CGPoint(x: 2.07, y: 4.60), CGPoint(x: 2.03, y: 4.71), CGPoint(x: 1.99, y: 4.83), CGPoint(x: 1.96, y: 4.95), CGPoint(x: 1.94, y: 5.08), CGPoint(x: 1.92, y: 5.20), CGPoint(x: 1.91, y: 5.33), CGPoint(x: 1.90, y: 5.46), CGPoint(x: 1.90, y: 5.59), CGPoint(x: 1.90, y: 5.72), CGPoint(x: 1.91, y: 5.85), CGPoint(x: 1.93, y: 5.98), CGPoint(x: 1.95, y: 6.11), CGPoint(x: 1.97, y: 6.25), CGPoint(x: 2.01, y: 6.38), CGPoint(x: 2.04, y: 6.52), CGPoint(x: 2.09, y: 6.65), CGPoint(x: 2.13, y: 6.79), CGPoint(x: 2.19, y: 6.92), CGPoint(x: 2.25, y: 7.06), CGPoint(x: 2.31, y: 7.19), CGPoint(x: 2.38, y: 7.32), CGPoint(x: 2.46, y: 7.46), CGPoint(x: 2.54, y: 7.59), CGPoint(x: 2.62, y: 7.72), CGPoint(x: 2.72, y: 7.85), CGPoint(x: 2.81, y: 7.98), CGPoint(x: 2.91, y: 8.10), CGPoint(x: 3.02, y: 8.23), CGPoint(x: 3.13, y: 8.35), CGPoint(x: 3.25, y: 8.47), CGPoint(x: 3.37, y: 8.59), CGPoint(x: 3.45, y: 8.67)],
[CGPoint(x: 8.05, y: 10.63), CGPoint(x: 8.25, y: 10.65), CGPoint(x: 8.46, y: 10.66), CGPoint(x: 8.66, y: 10.67), CGPoint(x: 8.86, y: 10.68), CGPoint(x: 9.07, y: 10.68), CGPoint(x: 9.27, y: 10.67), CGPoint(x: 9.48, y: 10.67), CGPoint(x: 9.68, y: 10.65), CGPoint(x: 9.88, y: 10.64), CGPoint(x: 10.08, y: 10.62), CGPoint(x: 10.28, y: 10.59), CGPoint(x: 10.48, y: 10.56), CGPoint(x: 10.68, y: 10.52), CGPoint(x: 10.88, y: 10.49), CGPoint(x: 11.07, y: 10.44), CGPoint(x: 11.27, y: 10.39), CGPoint(x: 11.46, y: 10.34), CGPoint(x: 11.65, y: 10.29), CGPoint(x: 11.84, y: 10.23), CGPoint(x: 12.02, y: 10.16), CGPoint(x: 12.21, y: 10.10), CGPoint(x: 12.39, y: 10.02), CGPoint(x: 12.56, y: 9.95), CGPoint(x: 12.74, y: 9.87), CGPoint(x: 12.91, y: 9.79), CGPoint(x: 13.08, y: 9.70), CGPoint(x: 13.24, y: 9.61), CGPoint(x: 13.40, y: 9.52), CGPoint(x: 13.56, y: 9.42), CGPoint(x: 13.71, y: 9.32), CGPoint(x: 13.86, y: 9.22), CGPoint(x: 14.01, y: 9.12), CGPoint(x: 14.15, y: 9.01), CGPoint(x: 14.29, y: 8.90), CGPoint(x: 14.42, y: 8.78), CGPoint(x: 14.55, y: 8.67), CGPoint(x: 14.67, y: 8.55), CGPoint(x: 14.79, y: 8.43), CGPoint(x: 14.91, y: 8.31), CGPoint(x: 15.02, y: 8.19), CGPoint(x: 15.12, y: 8.06), CGPoint(x: 15.22, y: 7.93), CGPoint(x: 15.32, y: 7.80), CGPoint(x: 15.41, y: 7.68), CGPoint(x: 15.49, y: 7.54), CGPoint(x: 15.57, y: 7.41), CGPoint(x: 15.64, y: 7.28), CGPoint(x: 15.71, y: 7.15), CGPoint(x: 15.77, y: 7.01), CGPoint(x: 15.83, y: 6.88), CGPoint(x: 15.88, y: 6.74), CGPoint(x: 15.93, y: 6.61), CGPoint(x: 15.97, y: 6.47), CGPoint(x: 16.01, y: 6.34), CGPoint(x: 16.03, y: 6.20), CGPoint(x: 16.06, y: 6.07), CGPoint(x: 16.08, y: 5.94), CGPoint(x: 16.09, y: 5.80), CGPoint(x: 16.10, y: 5.67), CGPoint(x: 16.10, y: 5.54), CGPoint(x: 16.10, y: 5.41), CGPoint(x: 16.09, y: 5.29), CGPoint(x: 16.07, y: 5.16), CGPoint(x: 16.05, y: 5.04), CGPoint(x: 16.03, y: 4.91), CGPoint(x: 16.00, y: 4.79), CGPoint(x: 15.96, y: 4.67), CGPoint(x: 15.92, y: 4.56), CGPoint(x: 15.87, y: 4.45), CGPoint(x: 15.82, y: 4.33), CGPoint(x: 15.76, y: 4.23), CGPoint(x: 15.70, y: 4.12), CGPoint(x: 15.63, y: 4.02), CGPoint(x: 15.56, y: 3.92), CGPoint(x: 15.49, y: 3.82), CGPoint(x: 15.40, y: 3.73), CGPoint(x: 15.32, y: 3.64), CGPoint(x: 15.23, y: 3.55), CGPoint(x: 15.13, y: 3.47), CGPoint(x: 15.03, y: 3.39), CGPoint(x: 14.93, y: 3.32), CGPoint(x: 14.82, y: 3.25), CGPoint(x: 14.71, y: 3.18), CGPoint(x: 14.60, y: 3.12), CGPoint(x: 14.48, y: 3.06), CGPoint(x: 14.36, y: 3.01), CGPoint(x: 14.23, y: 2.96), CGPoint(x: 14.10, y: 2.92), CGPoint(x: 13.97, y: 2.88), CGPoint(x: 13.83, y: 2.84), CGPoint(x: 13.70, y: 2.81), CGPoint(x: 13.56, y: 2.79), CGPoint(x: 13.41, y: 2.76), CGPoint(x: 13.27, y: 2.75), CGPoint(x: 13.12, y: 2.74), CGPoint(x: 12.97, y: 2.73), CGPoint(x: 12.81, y: 2.73), CGPoint(x: 12.66, y: 2.74), CGPoint(x: 12.50, y: 2.74), CGPoint(x: 12.35, y: 2.76), CGPoint(x: 12.19, y: 2.78), CGPoint(x: 12.03, y: 2.80), CGPoint(x: 11.87, y: 2.83), CGPoint(x: 11.70, y: 2.87), CGPoint(x: 11.54, y: 2.91), CGPoint(x: 11.38, y: 2.95), CGPoint(x: 11.27, y: 2.98)],
]
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
}()
}
+15
View File
@@ -0,0 +1,15 @@
import KnotchyCore
import SwiftUI
@main
struct KnotchyApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var app
var body: some Scene {
MenuBarExtra(isInserted: .constant(app.isSupportedMac)) {
KnotchyMenu(app: app, settings: app.settings)
} label: {
Image(nsImage: KnotGlyph.menuBarImage)
}
}
}
+212
View File
@@ -0,0 +1,212 @@
import AppKit
import KnotchyCore
import Observation
import SwiftUI
struct KnotchyMenu: View {
let app: AppDelegate
@Bindable var settings: AppSettings
private var controller: SessionController { app.controller }
var body: some View {
if app.vmStatus.needsSetup {
Button("Set Up Knotchy…") { app.setupWindow.show() }
Divider()
}
if let session = SessionSummary(controller.state, url: controller.pageURL ?? controller.openedURL,
setupFolded: controller.setupFolded) {
Section(session.title) {
if session.canShow {
Button("Show Panel") { controller.showPanel() }
}
if session.canDestroy {
Button("Destroy VM") { controller.destroy() }
}
}
Divider()
}
Button("Open Copied Link") { app.services.openCopiedLink() }
.keyboardShortcut("l", modifiers: [.control, .option, .command])
Divider()
Picker("Auto-Destroy Folded Pages", selection: $settings.autoDestroyMinutes) {
ForEach(AppSettings.autoDestroyChoices, id: \.self) { minutes in
Text(minutes == 1 ? "After 1 Minute" : "After \(minutes) Minutes").tag(minutes)
}
}
Picker("Open Pages In", selection: $settings.opensLarge) {
Text("Small Panel").tag(false)
Text("Large Panel").tag(true)
}
Menu("Connection") {
Picker("Connection", selection: connection) {
Text("Direct").tag(Connection.direct)
if !settings.proxyHost.isEmpty {
Text("Proxy (\(settings.proxyHost):\(String(settings.proxyPort)))").tag(Connection.proxy)
}
if !settings.wireGuardNames.isEmpty {
Section("WireGuard VPN") {
ForEach(settings.wireGuardNames, id: \.self) { Text($0).tag(Connection.vpn($0)) }
}
}
}
.pickerStyle(.inline)
.labelsHidden()
Divider()
Button("Network Settings…") { showSettings(.network) }
}
Divider()
Button {
controller.rebuildImage()
} label: {
Text("Rebuild VM")
Text(app.vmStatus.summary)
}
.disabled(isSettingUp || app.vmStatus.image == .noContainerTool || app.vmStatus.needsSetup)
Divider()
if !app.vmStatus.needsSetup {
Button("Set Up Knotchy…") { app.setupWindow.show() }
}
Button("Settings…") { showSettings(nil) }
.keyboardShortcut(",")
Button("Quit Knotchy") { NSApp.terminate(nil) }
.keyboardShortcut("q")
}
private enum Connection: Hashable {
case direct, proxy, vpn(String)
}
private var connection: Binding<Connection> {
Binding {
switch settings.networkMode {
case .direct: .direct
case .proxy: .proxy
case .vpn: .vpn(settings.wireGuardServer)
}
} set: { choice in
switch choice {
case .direct:
settings.networkMode = .direct
case .proxy:
settings.networkMode = .proxy
case .vpn(let server):
settings.wireGuardServer = server
settings.networkMode = .vpn
}
}
}
private var isSettingUp: Bool {
if case .settingUp = controller.state { true } else { false }
}
private func showSettings(_ section: SettingsTab?) {
app.settingsWindow.show(section)
}
}
@MainActor
@Observable
final class VMStatus {
private(set) var image: ContainerCLIBackend.ImageStatus?
private(set) var runtime: RuntimeStatus?
@ObservationIgnored private let backend: ContainerCLIBackend
@ObservationIgnored private let controller: SessionController
init(backend: ContainerCLIBackend, controller: SessionController) {
self.backend = backend
self.controller = controller
watchSetup()
}
var needsSetup: Bool {
switch runtime {
case .missing?, .outdated?: true
default: false
}
}
var runtimeSummary: String {
switch runtime {
case nil: "Checking…"
case .missing: "Not installed"
case .outdated(let version): "Apple container \(version) (too old)"
case .current(let version?): "Apple container \(version)"
case .current(nil): "Apple container (version unknown)"
}
}
func refresh() {
Task {
let runtime = await backend.runtimeStatus()
if runtime != self.runtime { self.runtime = runtime }
let status = await backend.imageStatus()
if status != image { image = status }
}
}
var summary: String {
if needsSetup { return "Setup isn't finished" }
return switch image {
case nil: "Checking…"
case .noContainerTool: "Setup isn't finished"
case .missing: "Not built yet"
case .built(nil): "Built"
case .built(let date?): "Built " + RelativeDateTimeFormatter().localizedString(for: date, relativeTo: Date())
}
}
private func watchSetup() {
let wasSettingUp: Bool
if case .settingUp = controller.state { wasSettingUp = true } else { wasSettingUp = false }
withObservationTracking {
_ = controller.state
} onChange: { [weak self] in
Task { @MainActor in
guard let self else { return }
if wasSettingUp { self.refresh() }
self.watchSetup()
}
}
}
}
struct SessionSummary {
var title: String
var canShow: Bool
var canDestroy: Bool
init?(_ state: SessionState, url: URL?, setupFolded: Bool) {
let host = url?.host().map { " — \($0)" } ?? ""
switch state {
case .starting(_, let folded):
(title, canShow, canDestroy) = ("Opening\(host)", folded, true)
case .live(let mode):
(title, canShow, canDestroy) = ("Open\(host)", mode == .collapsed, true)
case .settingUp(let issue):
(title, canShow, canDestroy) = (Self.setupTitle(issue), setupFolded, false)
case .destroying:
(title, canShow, canDestroy) = ("Destroying the VM…", false, false)
default:
return nil
}
}
static func setupTitle(_ issue: SetupIssue) -> String {
switch issue {
case .cliMissing: "Installing the container runtime…"
case .cliOutdated: "Updating the container runtime…"
case .serviceStopped, .kernelMissing: "Starting the container runtime…"
default: "Building the VM…"
}
}
}
+196
View File
@@ -0,0 +1,196 @@
import SwiftUI
/// Flared top "shoulders" like the notch's own; `rect` includes them.
struct NotchShape: Shape {
var topRadius: CGFloat
var bottomRadius: CGFloat
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { AnimatablePair(topRadius, bottomRadius) }
set { (topRadius, bottomRadius) = (newValue.first, newValue.second) }
}
func path(in rect: CGRect) -> Path {
let top = max(0, min(topRadius, rect.width / 4, rect.height / 2))
let bottom = max(0, min(bottomRadius, (rect.width - 2 * top) / 2, rect.height - top))
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
path.addQuadCurve(to: CGPoint(x: rect.minX + top, y: rect.minY + top),
control: CGPoint(x: rect.minX + top, y: rect.minY))
path.addLine(to: CGPoint(x: rect.minX + top, y: rect.maxY - bottom))
path.addQuadCurve(to: CGPoint(x: rect.minX + top + bottom, y: rect.maxY),
control: CGPoint(x: rect.minX + top, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX - top - bottom, y: rect.maxY))
path.addQuadCurve(to: CGPoint(x: rect.maxX - top, y: rect.maxY - bottom),
control: CGPoint(x: rect.maxX - top, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX - top, y: rect.minY + top))
path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.minY),
control: CGPoint(x: rect.maxX - top, y: rect.minY))
path.closeSubpath()
return path
}
}
struct Morph: ViewModifier, Animatable {
var progress: CGFloat
var smallest: CGFloat = 0.55
var blur: CGFloat = 10
var clearUntil: CGFloat = 0.3
var opaqueFrom: CGFloat = 0.85
nonisolated var animatableData: CGFloat {
get { progress }
set { progress = newValue }
}
func body(content: Content) -> some View {
content
.scaleEffect(smallest + (1 - smallest) * progress, anchor: .top)
.blur(radius: max(0, 1 - progress) * blur)
.opacity(Double(min(1, max(0, (progress - clearUntil) / (opaqueFrom - clearUntil)))))
}
}
extension AnyTransition {
/// Old content is gone early so it barely overlaps the new.
static var island: AnyTransition {
.asymmetric(
insertion: .modifier(active: Morph(progress: 0), identity: Morph(progress: 1)),
removal: .modifier(active: Morph(progress: 0, smallest: 0.8, blur: 8, clearUntil: 0.6, opaqueFrom: 0.95),
identity: Morph(progress: 1, smallest: 0.8, blur: 8, clearUntil: 0.6, opaqueFrom: 0.95))
.animation(.spring(response: 0.2, dampingFraction: 1))
)
}
static var swap: AnyTransition {
.modifier(active: Morph(progress: 0, smallest: 0.92, blur: 6, clearUntil: 0.2, opaqueFrom: 0.8),
identity: Morph(progress: 1, smallest: 0.92, blur: 6, clearUntil: 0.2, opaqueFrom: 0.8))
.animation(.spring(response: 0.3, dampingFraction: 0.9))
}
}
enum Motion {
static let open = Animation.spring(response: 0.46, dampingFraction: 0.74)
static let resize = Animation.spring(response: 0.42, dampingFraction: 0.8)
static let fold = Animation.spring(response: 0.4, dampingFraction: 0.84)
static let small = Animation.spring(response: 0.4, dampingFraction: 0.7)
static let hide = Animation.spring(response: 0.36, dampingFraction: 0.9)
}
struct KnotMark: View {
var size: CGFloat
var color: Color = Palette.coral
var body: some View {
KnotShape()
.stroke(color, style: StrokeStyle(lineWidth: size * 1.9 / 18, lineCap: .round, lineJoin: .round))
.frame(width: size, height: size)
.accessibilityHidden(true)
}
}
/// `KnotGlyph.strands` are in an 18-point box.
struct KnotShape: Shape {
func path(in rect: CGRect) -> Path {
let scale = min(rect.width, rect.height) / 18
var path = Path()
for strand in KnotGlyph.strands {
path.move(to: CGPoint(x: rect.minX + strand[0].x * scale, y: rect.minY + strand[0].y * scale))
for point in strand.dropFirst() {
path.addLine(to: CGPoint(x: rect.minX + point.x * scale, y: rect.minY + point.y * scale))
}
}
return path
}
}
struct Breathing: ViewModifier {
func body(content: Content) -> some View {
content.phaseAnimator([1.0, 0.55]) { view, opacity in
view.opacity(opacity)
} animation: { _ in .easeInOut(duration: 1.6) }
}
}
enum Palette {
static let coral = Color(red: 1, green: 0.54, blue: 0.36)
static let green = Color(red: 0.25, green: 0.86, blue: 0.47)
static let red = Color(red: 1, green: 0.38, blue: 0.36)
static let amber = Color(red: 1, green: 0.72, blue: 0.2)
static let surface = LinearGradient(colors: [Color(white: 0.1), Color(white: 0.055)],
startPoint: .top, endPoint: .bottom)
}
struct IconButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
Styled(configuration: configuration)
}
struct Styled: View {
let configuration: Configuration
@State private var hovering = false
@Environment(\.isEnabled) private var isEnabled
var body: some View {
configuration.label
.foregroundStyle(.white.opacity(isEnabled ? (hovering ? 1 : 0.72) : 0.22))
.background(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(.white.opacity(configuration.isPressed ? 0.18 : (hovering && isEnabled ? 0.1 : 0)))
)
.contentShape(Rectangle())
.onHover { hovering = $0 }
.animation(.easeOut(duration: 0.12), value: hovering)
}
}
}
struct CapsuleButtonStyle: ButtonStyle {
enum Kind { case primary, secondary, destructive }
var kind: Kind = .secondary
var small = false
func makeBody(configuration: Configuration) -> some View {
Styled(configuration: configuration, kind: kind, small: small)
}
struct Styled: View {
let configuration: Configuration
let kind: Kind
let small: Bool
@State private var hovering = false
var body: some View {
configuration.label
.font(.system(size: small ? 11 : 12, weight: .semibold))
.lineLimit(1)
.padding(.horizontal, small ? 9 : 14)
.frame(height: small ? 20 : 28)
.foregroundStyle(foreground)
.background(Capsule().fill(fill))
.contentShape(Capsule())
.scaleEffect(configuration.isPressed ? 0.96 : 1)
.onHover { hovering = $0 }
.animation(.easeOut(duration: 0.12), value: hovering)
.animation(.easeOut(duration: 0.08), value: configuration.isPressed)
}
private var foreground: Color {
switch kind {
case .primary: .black
case .secondary: .white
case .destructive: Palette.red
}
}
private var fill: Color {
let boost = configuration.isPressed ? 0.1 : (hovering ? 0.06 : 0)
switch kind {
case .primary: return .white.opacity(0.92 + boost)
case .secondary: return .white.opacity(0.12 + boost)
case .destructive: return Palette.red.opacity(0.16 + boost * 1.5)
}
}
}
}
+603
View File
@@ -0,0 +1,603 @@
import KnotchyCore
import SwiftUI
struct NotchRootView: View {
let controller: SessionController
let chrome: NotchChrome
var body: some View {
let size = chrome.bodySize
ZStack(alignment: .top) {
switch chrome.content {
case .hidden:
Color.clear.frame(width: 0, height: 0)
case .compact:
CompactView(controller: controller, state: chrome.state, notchWidth: chrome.notchSize.width)
.frame(width: NotchChrome.bodySize(.compact, notch: chrome.notchSize, panel: chrome.panelSize,
hasNotch: chrome.hasNotch).width,
height: chrome.notchSize.height)
.transition(.island)
case .banner:
BannerView(controller: controller, state: chrome.state, notice: chrome.notice,
notchHeight: chrome.notchSize.height)
.frame(width: NotchChrome.bodySize(.banner, notch: chrome.notchSize, panel: chrome.panelSize,
hasNotch: chrome.hasNotch).width)
.transition(.island)
case .panel:
PanelView(controller: controller, chrome: chrome)
.frame(width: chrome.panelSize.width, height: chrome.panelSize.height)
.transition(.island)
}
}
.modifier(Morph(progress: chrome.reveal))
.frame(width: size.width, height: size.height, alignment: .top)
.padding(.horizontal, chrome.topRadius)
.clipShape(NotchShape(topRadius: chrome.topRadius, bottomRadius: chrome.bottomRadius))
.background {
// Shadow from the outline alone, not from everything inside it.
NotchShape(topRadius: chrome.topRadius, bottomRadius: chrome.bottomRadius)
.fill(.black)
.shadow(color: .black.opacity(chrome.presentation == .panel ? 0.5 : 0), radius: 14, y: 6)
}
.offset(x: chrome.offsetX)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.environment(\.colorScheme, .dark)
}
}
// MARK: - Compact
private struct CompactView: View {
let controller: SessionController
let state: SessionState
let notchWidth: CGFloat
var body: some View {
HStack(spacing: 0) {
leading.frame(maxWidth: .infinity)
Color.clear.frame(width: notchWidth)
trailing.frame(maxWidth: .infinity)
}
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.contentShape(Rectangle())
.contextMenu { PillMenu(controller: controller, state: state) }
}
@ViewBuilder private var leading: some View {
switch state {
case .starting, .settingUp:
ProgressView().controlSize(.small).scaleEffect(0.75)
default:
KnotMark(size: 15)
.modifier(Breathing())
.help("A page is open in a disposable VM")
}
}
@ViewBuilder private var trailing: some View {
switch state {
case .starting:
Text("Opening").foregroundStyle(.white.opacity(0.7))
case .settingUp(let issue):
Text(Self.setupWord(issue)).foregroundStyle(.white.opacity(0.7))
.help("Setting up the browser VM. Hover to see progress.")
default:
TimelineView(.periodic(from: .now, by: 1)) { _ in
let text = Self.format(controller.countdown)
Text(text)
.monospacedDigit()
.foregroundStyle(.white.opacity(0.7))
.contentTransition(.numericText(countsDown: true))
.animation(.snappy, value: text)
.help("Destroyed automatically when this reaches 0:00")
}
}
}
private static func setupWord(_ issue: SetupIssue) -> String {
switch issue {
case .imageMissing, .imageOutdated: "Building"
case .imageStale, .cliOutdated: "Updating"
case .cliMissing: "Installing"
default: "Starting"
}
}
private static func format(_ remaining: Duration?) -> String {
guard let remaining else { return "" }
let seconds = Int(remaining.components.seconds)
return String(format: "%d:%02d", seconds / 60, seconds % 60)
}
}
// MARK: - Banner
private struct BannerView: View {
let controller: SessionController
let state: SessionState
let notice: Notice?
let notchHeight: CGFloat
var body: some View {
VStack(spacing: 0) {
Color.clear.frame(height: notchHeight)
ZStack {
row.id(kind).transition(.swap)
}
.frame(maxWidth: .infinity)
.frame(height: NotchChrome.bannerRowHeight - 4)
}
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.contentShape(Rectangle())
.contextMenu { PillMenu(controller: controller, state: state) }
}
private enum Kind: Hashable { case noWebLink, destroying, destroyed, ended, stuck, other }
private var kind: Kind {
if notice == .noWebLink, state == .idle { return .noWebLink }
switch state {
case .destroying: return .destroying
case .destroyed: return .destroyed
case .failed(.ended): return .ended
case .failed(.destroyStuck): return .stuck
default: return .other
}
}
@ViewBuilder private var row: some View {
HStack(spacing: 7) {
switch kind {
case .noWebLink:
Image(systemName: "link").foregroundStyle(Palette.amber)
Text("No web link found")
case .destroying:
ProgressView().controlSize(.small).scaleEffect(0.7).frame(width: 16, height: 16)
Text("Destroying the VM…").foregroundStyle(.white.opacity(0.8))
case .destroyed:
BouncingSymbol(name: "checkmark.circle.fill", color: Palette.green)
Text("VM destroyed")
case .ended:
Image(systemName: "xmark.octagon.fill").foregroundStyle(Palette.red)
Text("Session ended")
case .stuck:
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Palette.amber)
Text("VM may still be running")
Button("Retry") { controller.retry() }
.buttonStyle(CapsuleButtonStyle(kind: .secondary, small: true))
.padding(.leading, 4)
case .other:
EmptyView()
}
}
}
}
private struct PillMenu: View {
let controller: SessionController
let state: SessionState
var body: some View {
if case .live = state { Button("Destroy Now") { controller.destroy() } }
Button("Quit Knotchy") { NSApp.terminate(nil) }
}
}
private struct BouncingSymbol: View {
let name: String
let color: Color
@State private var appeared = false
var body: some View {
Image(systemName: name)
.foregroundStyle(color)
.symbolEffect(.bounce, value: appeared)
.onAppear { appeared = true }
}
}
// MARK: - Panel
private struct PanelView: View {
let controller: SessionController
let chrome: NotchChrome
private var innerRadius: CGFloat { NotchChrome.bottomRadius(.panel) - NotchGeometry.margin }
var body: some View {
VStack(spacing: 0) {
TopStrip(controller: controller, state: chrome.state, notchWidth: chrome.notchSize.width,
routeLabel: chrome.routeLabel)
.frame(height: chrome.notchSize.height)
Toolbar(controller: controller, state: chrome.state)
.frame(height: NotchGeometry.toolbarHeight)
ZStack(alignment: .bottom) {
content
.id(ContentKind(chrome.state))
.transition(.swap)
if let stale = controller.staleImage, case .live = chrome.state {
StaleBanner(controller: controller, stale: stale)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.overlay(alignment: .top) {
if case .live = chrome.state { ProgressLine(progress: controller.progress) }
}
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 8, bottomLeadingRadius: innerRadius,
bottomTrailingRadius: innerRadius, topTrailingRadius: 8,
style: .continuous))
.padding([.horizontal, .bottom], NotchGeometry.margin)
}
.foregroundStyle(.white)
}
private enum ContentKind: Hashable {
case starting, live, failed, setup, settingUp, other
init(_ state: SessionState) {
switch state {
case .starting: self = .starting
case .live: self = .live
case .failed(.start): self = .failed
case .setupRequired: self = .setup
case .settingUp: self = .settingUp
default: self = .other
}
}
}
@ViewBuilder private var content: some View {
switch chrome.state {
case .live:
VideoSurface(controller: controller)
case .starting(let step, _):
StartingView(step: step, host: controller.openedURL?.host())
case .failed(.start(let message)):
MessageCard(symbol: "exclamationmark.triangle.fill", tint: Palette.amber,
title: "Couldn't open the link", message: message) {
Button("Dismiss") { controller.dismiss() }
.buttonStyle(CapsuleButtonStyle(kind: .secondary))
Button("Try Again") { controller.retry() }
.buttonStyle(CapsuleButtonStyle(kind: .primary))
.keyboardShortcut(.defaultAction)
}
case .setupRequired(let issue, let error):
MessageCard(symbol: Setup.symbol(issue), tint: error == nil ? .white : Palette.amber,
title: Setup.title(issue), message: error ?? Setup.text(issue)) {
Button("Not Now") { controller.dismiss() }
.buttonStyle(CapsuleButtonStyle(kind: .secondary))
if let action = Setup.action(issue) {
Button(action) { controller.performSetupAction() }
.buttonStyle(CapsuleButtonStyle(kind: .primary))
.keyboardShortcut(.defaultAction)
}
}
case .settingUp(let issue):
MessageCard(symbol: Setup.symbol(issue), tint: .white, title: Setup.progressTitle(issue),
message: Self.lastLine(controller.setupLog) ?? "Working…", monospaced: true) {
ProgressView().controlSize(.small)
Button("Hide") { controller.foldSetup() }
.buttonStyle(CapsuleButtonStyle(kind: .secondary))
.help("Keep going in the background; hover over the notch to check on it")
}
default:
Color.black
}
}
private static func lastLine(_ log: String?) -> String? {
log?.split(whereSeparator: \.isNewline).last.map(String.init)
}
}
private struct TopStrip: View {
let controller: SessionController
let state: SessionState
let notchWidth: CGFloat
let routeLabel: String?
var body: some View {
HStack(spacing: 0) {
HStack(spacing: 5) {
KnotMark(size: 13)
Text("Isolated").foregroundStyle(.white.opacity(0.75))
if let routeLabel {
Text("· via \(routeLabel)").foregroundStyle(.white.opacity(0.45)).lineLimit(1)
}
}
.font(.system(size: 11, weight: .semibold, design: .rounded))
.help("This page runs in a disposable Linux VM, cut off from your Mac and your network")
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 14)
Color.clear.frame(width: notchWidth)
Group {
if Self.canDestroy(state) {
Button { controller.destroy() } label: {
HStack(spacing: 4) {
Image(systemName: "power").font(.system(size: 9, weight: .bold))
Text("Destroy")
}
}
.buttonStyle(CapsuleButtonStyle(kind: .destructive, small: true))
.help("Destroy the VM and everything in it (⌘W)")
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.trailing, 10)
}
}
static func canDestroy(_ state: SessionState) -> Bool {
switch state {
case .starting, .live: true
default: false
}
}
}
private struct Toolbar: View {
let controller: SessionController
let state: SessionState
private var isLive: Bool {
if case .live = state { true } else { false }
}
var body: some View {
HStack(spacing: 2) {
IconButton(symbol: "chevron.left", help: "Back (⌘[)") { controller.goBack() }
IconButton(symbol: "chevron.right", help: "Forward (⌘])") { controller.goForward() }
IconButton(symbol: "arrow.clockwise", help: "Reload (⌘R)") { controller.reload() }
AddressField(url: controller.pageURL ?? controller.openedURL)
.padding(.horizontal, 6)
if state == .live(.expanded) {
IconButton(symbol: "arrow.down.right.and.arrow.up.left", help: "Make smaller (⇧⌘E)") { controller.shrink() }
} else {
IconButton(symbol: "arrow.up.left.and.arrow.down.right", help: "Make larger (⇧⌘E)") { controller.expand() }
}
}
.disabled(!isLive)
.padding(.horizontal, NotchGeometry.margin)
}
}
private struct IconButton: View {
let symbol: String
let help: String
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: symbol)
.font(.system(size: 12, weight: .semibold))
.frame(width: 28, height: 24)
}
.buttonStyle(IconButtonStyle())
.help(help)
}
}
private struct AddressField: View {
let url: URL?
var body: some View {
HStack(spacing: 6) {
Image(systemName: url?.scheme == "https" ? "lock.fill" : "globe")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.white.opacity(0.45))
Text(Self.display(url))
.lineLimit(1)
.truncationMode(.tail)
}
.font(.system(size: 12))
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 24)
.background(RoundedRectangle(cornerRadius: 7, style: .continuous).fill(.white.opacity(0.08)))
.help(url?.absoluteString ?? "")
}
static func display(_ url: URL?) -> AttributedString {
guard let url else { return AttributedString("") }
var host = AttributedString(url.host() ?? url.absoluteString)
host.foregroundColor = .white
var rest = url.path()
if let query = url.query() { rest += "?" + query }
if rest == "/" { rest = "" }
var tail = AttributedString(rest)
tail.foregroundColor = .white.opacity(0.4)
return host + tail
}
}
private struct ProgressLine: View {
let progress: Int?
@State private var shown: Int = 0
var body: some View {
GeometryReader { proxy in
Capsule()
.fill(LinearGradient(colors: [Color(red: 0.35, green: 0.7, blue: 1), Color(red: 0.2, green: 0.5, blue: 1)],
startPoint: .leading, endPoint: .trailing))
.frame(width: max(4, proxy.size.width * CGFloat(shown) / 100), height: 3)
}
.frame(height: 3)
.opacity(progress == nil || progress == 100 ? 0 : 1)
.animation(.easeOut(duration: 0.35), value: progress == nil || progress == 100)
.onChange(of: progress, initial: true) { _, value in
guard let value else { return }
withAnimation(value < shown ? nil : .easeOut(duration: 0.25)) { shown = value }
}
}
}
private struct StartingView: View {
let step: StartStep
let host: String?
var body: some View {
VStack(spacing: 18) {
ZStack {
Circle().fill(Palette.coral.opacity(0.14)).frame(width: 64, height: 64)
KnotMark(size: 32)
.modifier(Breathing())
}
VStack(spacing: 4) {
Text("Opening in a disposable VM").font(.system(size: 15, weight: .semibold))
if let host {
Text(host).font(.system(size: 12)).foregroundStyle(.white.opacity(0.5))
}
}
VStack(alignment: .leading, spacing: 9) {
StepRow(title: "Starting the VM", done: step != .booting, active: step == .booting)
StepRow(title: "Connecting to its browser", done: step == .loading, active: step == .connecting)
StepRow(title: "Loading the page", done: false, active: step == .loading)
}
.padding(.top, 2)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Palette.surface)
}
}
private struct StepRow: View {
let title: String
let done: Bool
let active: Bool
var body: some View {
HStack(spacing: 9) {
ZStack {
if done {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Palette.green)
.transition(.scale(scale: 0.4).combined(with: .opacity))
} else if active {
ProgressView().controlSize(.small).scaleEffect(0.75)
.transition(.opacity)
} else {
Image(systemName: "circle").foregroundStyle(.white.opacity(0.22))
}
}
.frame(width: 16, height: 16)
Text(title)
.foregroundStyle(.white.opacity(done || active ? 0.9 : 0.4))
}
.font(.system(size: 13))
.animation(.spring(duration: 0.35, bounce: 0.4), value: done)
.animation(.easeOut(duration: 0.2), value: active)
}
}
private struct MessageCard<Actions: View>: View {
let symbol: String
let tint: Color
let title: String
let message: String
var monospaced = false
@ViewBuilder let actions: Actions
var body: some View {
VStack(spacing: 14) {
ZStack {
Circle().fill(tint.opacity(0.14)).frame(width: 52, height: 52)
Image(systemName: symbol).font(.system(size: 21, weight: .semibold)).foregroundStyle(tint)
}
VStack(spacing: 6) {
Text(title).font(.system(size: 15, weight: .semibold))
Text(message)
.font(monospaced ? .system(size: 11, design: .monospaced) : .system(size: 12))
.foregroundStyle(.white.opacity(0.6))
.multilineTextAlignment(.center)
.lineLimit(5)
.frame(maxWidth: 400)
}
HStack(spacing: 8) { actions }
.padding(.top, 4)
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Palette.surface)
}
}
private struct StaleBanner: View {
let controller: SessionController
let stale: StaleImage
var body: some View {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Palette.amber)
Text(stale.builtDaysAgo.map { "The browser VM is \($0) days old." } ?? "The browser VM's age is unknown.")
.foregroundStyle(.white.opacity(0.85))
Spacer(minLength: 8)
Button("Later") { controller.dismissStaleImageWarning() }
.buttonStyle(CapsuleButtonStyle(kind: .secondary, small: true))
Button("Update Now") { controller.requestImageUpdate() }
.buttonStyle(CapsuleButtonStyle(kind: .primary, small: true))
}
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color(white: 0.1).opacity(0.94)))
.padding(8)
}
}
private enum Setup {
static func symbol(_ issue: SetupIssue) -> String {
switch issue {
case .cliMissing, .cliOutdated: "shippingbox"
case .serviceStopped, .kernelMissing: "power"
case .imageMissing, .imageOutdated: "square.and.arrow.down"
case .imageStale: "arrow.triangle.2.circlepath"
case .lowDisk: "internaldrive"
}
}
static func title(_ issue: SetupIssue) -> String {
switch issue {
case .cliMissing: "Knotchy needs Apple's container runtime"
case .cliOutdated: "Knotchy needs a newer container runtime"
case .serviceStopped, .kernelMissing: "The container service isn't running"
case .imageMissing, .imageOutdated: "The browser VM image needs to be built"
case .imageStale: "Updating the browser VM"
case .lowDisk: "Not enough disk space"
}
}
static func progressTitle(_ issue: SetupIssue) -> String {
switch issue {
case .cliMissing: "Installing the container runtime…"
case .cliOutdated: "Updating the container runtime…"
case .serviceStopped, .kernelMissing: "Starting the container runtime…"
default: title(issue)
}
}
static func text(_ issue: SetupIssue) -> String {
switch issue {
case .cliMissing: "Set it up in one step; the link opens when it's done."
case .cliOutdated: "Update it in one step; the link opens when it's done."
case .serviceStopped, .kernelMissing: "Start it to run the isolated browser."
case .imageMissing, .imageOutdated: "This takes about 3 minutes and downloads about 225 MB."
case .imageStale: "Rebuilding gets the latest WebKit security fixes."
case .lowDisk(let free): "Only \(free / 1_000_000_000) GB free; a session needs about 2 GB."
}
}
static func action(_ issue: SetupIssue) -> String? {
switch issue {
case .cliMissing, .cliOutdated: "Set Up…"
case .serviceStopped, .kernelMissing: "Start Service"
case .imageMissing, .imageOutdated: "Build Image"
case .imageStale: "Update Now"
case .lowDisk: nil
}
}
}
+418
View File
@@ -0,0 +1,418 @@
import AppKit
import KnotchyCore
import Observation
import SwiftUI
final class NotchPanel: NSPanel {
init() {
super.init(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
level = NSWindow.Level(rawValue: NSWindow.Level.mainMenu.rawValue + 3)
collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
isOpaque = false
backgroundColor = .clear
hasShadow = false
becomesKeyOnlyIfNeeded = true
isMovable = false
isReleasedWhenClosed = false
hidesOnDeactivate = false
}
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }
}
enum Presentation: Equatable {
case hidden
case compact
case banner
case panel
}
@MainActor
@Observable
final class NotchChrome {
static let earWidth: CGFloat = 64
static let bannerWidth: CGFloat = 320
static let bannerRowHeight: CGFloat = 34
var presentation: Presentation = .hidden
/// Lags `presentation` while retracting so the content shrinks into the notch with the shape.
var content: Presentation = .hidden
var reveal: CGFloat = 0
var state: SessionState = .idle
var notice: Notice?
var notchSize = CGSize(width: 200, height: 32)
var hasNotch = true
var panelSize = CGSize(width: 652, height: 472)
var panelOffsetX: CGFloat = 0
var routeLabel: String?
var bodySize: CGSize { Self.bodySize(presentation, notch: notchSize, panel: panelSize, hasNotch: hasNotch) }
var topRadius: CGFloat { Self.topRadius(presentation) }
var bottomRadius: CGFloat { Self.bottomRadius(presentation) }
var offsetX: CGFloat { presentation == .panel ? panelOffsetX : 0 }
static func bodySize(_ presentation: Presentation, notch: CGSize, panel: CGSize, hasNotch: Bool) -> CGSize {
switch presentation {
case .hidden: CGSize(width: notch.width, height: hasNotch ? notch.height : 0)
case .compact: CGSize(width: notch.width + 2 * earWidth, height: notch.height)
case .banner: CGSize(width: max(notch.width + 2 * earWidth, bannerWidth), height: notch.height + bannerRowHeight)
case .panel: panel
}
}
static func topRadius(_ presentation: Presentation) -> CGFloat {
switch presentation {
case .hidden: 0
case .compact: 6
case .banner: 8
case .panel: 10
}
}
static func bottomRadius(_ presentation: Presentation) -> CGFloat {
switch presentation {
case .hidden: 10
case .compact: 12
case .banner: 18
case .panel: 22
}
}
}
/// Tracks the shape, not the window, which is larger while the shape animates.
final class TrackingHostingView<Content: View>: NSHostingView<Content> {
var onEnter: () -> Void = {}
var onExit: () -> Void = {}
var trackingRect: CGRect = .zero {
didSet { if trackingRect != oldValue { updateTrackingAreas() } }
}
/// Remove only this one; NSHostingView's own areas drive SwiftUI hover effects.
private var shapeArea: NSTrackingArea?
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let shapeArea { removeTrackingArea(shapeArea) }
shapeArea = nil
guard !trackingRect.isEmpty else { return }
let area = NSTrackingArea(rect: trackingRect, options: [.mouseEnteredAndExited, .activeAlways], owner: self)
addTrackingArea(area)
shapeArea = area
}
override func mouseEntered(with event: NSEvent) {
if event.trackingArea === shapeArea { onEnter() } else { super.mouseEntered(with: event) }
}
override func mouseExited(with event: NSEvent) {
if event.trackingArea === shapeArea { onExit() } else { super.mouseExited(with: event) }
}
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
}
@MainActor
final class NotchWindowController {
private static let shadowPadding: CGFloat = 36
private let controller: SessionController
private let chrome = NotchChrome()
private let panel = NotchPanel()
private let host: TrackingHostingView<NotchRootView>
private var screen: NSScreen?
private var anchor = CGPoint.zero
private var clickMonitor: Any?
private var lastState: SessionState = .idle
/// A finished animation only tidies up if no newer one has started.
private var generation = 0
private var trackingExtent: Extent?
private let currentRouteLabel: () -> String?
private var sessionRouteLabel: String?
init(controller: SessionController, routeLabel: @escaping () -> String? = { nil }) {
self.controller = controller
currentRouteLabel = routeLabel
host = TrackingHostingView(rootView: NotchRootView(controller: controller, chrome: chrome))
host.sizingOptions = []
host.onEnter = { [weak self] in self?.pointerEntered() }
host.onExit = { [weak self] in self?.pointerExited() }
panel.contentView = host
// Global mouse monitors need no Accessibility permission.
clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { _ in
MainActor.assumeIsolated { controller.clickedOutside() }
}
NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification,
object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.screen = nil
self.panel.orderOut(nil)
self.update()
}
}
observe()
update()
}
func hide() {
panel.orderOut(nil)
}
/// So crossing the notch on the way to the menu bar doesn't open the folded pill.
private static let hoverIntent: Duration = .milliseconds(150)
private var pendingEnter: Task<Void, Never>?
private func pointerEntered() {
pendingEnter?.cancel()
guard chrome.presentation == .compact else {
controller.pointerEntered()
return
}
pendingEnter = Task { [weak self] in
try? await Task.sleep(for: Self.hoverIntent)
guard !Task.isCancelled else { return }
self?.controller.pointerEntered()
}
}
private func pointerExited() {
pendingEnter?.cancel()
pendingEnter = nil
controller.pointerExited()
}
/// A key non-activating panel keeps the keyboard until it leaves the screen; `resignKey()` only notifies.
private func relinquishKey() {
guard panel.isKeyWindow else { return }
panel.orderOut(nil)
panel.orderFrontRegardless()
}
private func observe() {
withObservationTracking {
_ = controller.state
_ = controller.notice
_ = controller.setupFolded
} onChange: { [weak self] in
Task { @MainActor in
self?.update()
self?.observe()
}
}
}
static func presentation(for state: SessionState, notice: Notice?, setupFolded: Bool) -> Presentation {
switch state {
case .settingUp where setupFolded:
.compact
case .idle:
notice == nil ? .hidden : .banner
case .starting(_, folded: true), .live(.collapsed):
.compact
case .destroying, .destroyed, .failed(.ended), .failed(.destroyStuck):
.banner
case .starting(_, folded: false), .live(.peek), .live(.expanded), .failed(.start), .setupRequired, .settingUp:
.panel
}
}
private func update() {
let state = controller.state
let notice = controller.notice
defer { lastState = state }
let target = Self.presentation(for: state, notice: notice, setupFolded: controller.setupFolded)
guard target != .hidden else {
retract()
return
}
if screen == nil || (state == .starting(.booting, folded: false) && lastState != state) {
let next = Self.screenUnderPointer()
if next != screen { panel.orderOut(nil) }
screen = next
}
guard let screen else { return }
// The route is chosen as the VM boots, so read it once connecting.
if case .starting(.booting, _) = state, lastState != state { sessionRouteLabel = nil }
if case .starting(.connecting, _) = state, lastState != state { sessionRouteLabel = currentRouteLabel() }
let routeLabel = sessionRouteLabel
let info = ScreenInfo(screen)
let large: Bool
switch state {
case .live(.expanded): large = true
case .starting(_, folded: false): large = controller.opensExpanded
default: large = false
}
let layout = NotchGeometry.layout(for: info, mode: large ? .expanded : .peek)
let notchSize = layout.notch.size
let panelSize = layout.panel.size
let panelOffsetX = layout.panel.midX - layout.notch.midX
anchor = CGPoint(x: layout.notch.midX, y: info.frame.maxY)
generation += 1
let current = generation
let apply = { [chrome] in
chrome.presentation = target
chrome.content = target
chrome.reveal = 1
chrome.state = state
chrome.notice = notice
chrome.notchSize = notchSize
chrome.panelSize = panelSize
chrome.panelOffsetX = panelOffsetX
chrome.hasNotch = info.hasNotch
chrome.routeLabel = routeLabel
}
let targetExtent = Self.extent(target, notch: notchSize, panel: panelSize, offset: panelOffsetX, hasNotch: info.hasNotch)
setTracking(targetExtent)
if !panel.isVisible {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
apply()
chrome.presentation = .hidden
chrome.reveal = 0
}
let hiddenExtent = Self.extent(.hidden, notch: notchSize, panel: panelSize, offset: 0, hasNotch: info.hasNotch)
fitWindow(hiddenExtent, targetExtent)
panel.orderFrontRegardless()
// Draw while invisible and start ~2 frames later, or the first draw (fonts, symbols) stalls the animation.
withTransaction(transaction) { host.layoutSubtreeIfNeeded() }
panel.displayIfNeeded()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(35)) { [weak self] in
guard let self, self.generation == current else { return }
self.animate(from: .hidden, to: target, apply: apply, generation: current, extent: targetExtent)
}
} else {
let from = chrome.presentation
let currentExtent = Self.extent(from, notch: chrome.notchSize, panel: chrome.panelSize,
offset: chrome.offsetX, hasNotch: chrome.hasNotch)
fitWindow(currentExtent, targetExtent)
animate(from: from, to: target, apply: apply, generation: current, extent: targetExtent)
}
// Only a click into the page (VideoView) makes the panel key; opening never does.
if target != .panel { relinquishKey() }
}
private func animate(from: Presentation, to target: Presentation, apply: @escaping () -> Void,
generation current: Int, extent: Extent) {
withAnimation(Self.animation(from: from, to: target), completionCriteria: .removed) {
apply()
} completion: { [weak self] in
guard let self, self.generation == current else { return }
self.fitWindow(extent)
}
}
private func retract() {
guard panel.isVisible else {
screen = nil
return
}
generation += 1
let current = generation
setTracking(nil)
relinquishKey()
withAnimation(Motion.hide, completionCriteria: .removed) {
chrome.presentation = .hidden
chrome.reveal = 0
} completion: { [weak self] in
guard let self, self.generation == current else { return }
self.panel.orderOut(nil)
self.screen = nil
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) { self.chrome.content = .hidden }
}
}
private static func animation(from: Presentation, to target: Presentation) -> Animation {
switch (from, target) {
case (.panel, .panel): Motion.resize
case (_, .panel): Motion.open
case (.panel, _): Motion.fold
default: Motion.small
}
}
// MARK: - Geometry
struct Extent {
/// Reach on the wider side of the notch's centre line.
var halfWidth: CGFloat
var height: CGFloat
var offset: CGFloat = 0
var width: CGFloat = 0
}
static func extent(_ presentation: Presentation, notch: CGSize, panel: CGSize, offset: CGFloat, hasNotch: Bool) -> Extent {
let body = NotchChrome.bodySize(presentation, notch: notch, panel: panel, hasNotch: hasNotch)
let width = body.width + 2 * NotchChrome.topRadius(presentation)
let shift = presentation == .panel ? offset : 0
return Extent(halfWidth: width / 2 + abs(shift), height: body.height, offset: shift, width: width)
}
private func fitWindow(_ extents: Extent...) {
let half = (extents.map(\.halfWidth).max() ?? 0) + Self.shadowPadding
let height = (extents.map(\.height).max() ?? 0) + Self.shadowPadding
let frame = CGRect(x: (anchor.x - half).rounded(), y: anchor.y - height.rounded(.up),
width: (2 * half).rounded(.up), height: height.rounded(.up))
if panel.frame != frame {
panel.setFrame(frame, display: false)
// Lay out outside any animation, or the shape animates across from the old window position.
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) { host.layoutSubtreeIfNeeded() }
}
refreshTracking()
}
private func setTracking(_ extent: Extent?) {
trackingExtent = extent
refreshTracking()
}
private func refreshTracking() {
guard let extent = trackingExtent else {
host.trackingRect = .zero
return
}
let bounds = host.bounds
let y = host.isFlipped ? bounds.minY : bounds.maxY - extent.height
host.trackingRect = CGRect(x: bounds.midX + extent.offset - extent.width / 2, y: y,
width: extent.width, height: extent.height)
}
#if DEBUG
func snapshot(to directory: URL, name: String) {
guard panel.isVisible else {
FileHandle.standardError.write(Data("snapshot \(name): window hidden\n".utf8))
return
}
DebugSnapshot.write(host, to: directory.appendingPathComponent("\(name).png"))
}
#endif
private static func screenUnderPointer() -> NSScreen? {
let pointer = NSEvent.mouseLocation
return NSScreen.screens.first { NSMouseInRect(pointer, $0.frame, false) } ?? NSScreen.main
}
}
extension ScreenInfo {
/// The only code that reads `NSScreen`.
@MainActor
init(_ screen: NSScreen) {
func global(_ rect: CGRect?) -> CGRect {
guard let rect, !rect.isEmpty else { return .zero }
// The auxiliary areas are documented in screen coordinates; make them global.
return screen.frame.contains(rect.origin) ? rect : rect.offsetBy(dx: screen.frame.minX, dy: screen.frame.minY)
}
self.init(frame: screen.frame, visibleFrame: screen.visibleFrame,
safeAreaTop: screen.safeAreaInsets.top,
auxLeft: global(screen.auxiliaryTopLeftArea), auxRight: global(screen.auxiliaryTopRightArea),
menuBarThickness: NSStatusBar.system.thickness)
}
}
+291
View File
@@ -0,0 +1,291 @@
import KnotchyCore
import SwiftUI
import UniformTypeIdentifiers
enum SettingsTab: String, CaseIterable, Identifiable {
case general, network, vm
var id: Self { self }
var title: String {
switch self {
case .general: "General"
case .network: "Network"
case .vm: "VM"
}
}
var symbol: String {
switch self {
case .general: "gearshape"
case .network: "network"
case .vm: "shippingbox"
}
}
}
@MainActor
@Observable
final class SettingsNavigation {
var section: SettingsTab? = .general
}
@MainActor
final class SettingsWindowController {
static let size = CGSize(width: 700, height: 470)
private let app: AppDelegate
private let navigation = SettingsNavigation()
private(set) var window: NSWindow?
init(app: AppDelegate) {
self.app = app
}
func show(_ section: SettingsTab? = nil) {
if let section { navigation.section = section }
let window = self.window ?? makeWindow()
self.window = window
// An accessory app's windows open behind the frontmost app unless it activates first.
NSApp.activate()
window.makeKeyAndOrderFront(nil)
}
private func makeWindow() -> NSWindow {
let host = NSHostingController(rootView: SettingsView(app: app, settings: app.settings, navigation: navigation))
host.sceneBridgingOptions = [.title, .toolbars]
let window = NSWindow(contentViewController: host)
window.styleMask = [.titled, .closable, .miniaturizable, .fullSizeContentView]
window.toolbarStyle = .unified
window.title = "Knotchy Settings"
window.isReleasedWhenClosed = false
window.setContentSize(Self.size)
window.center()
window.setFrameAutosaveName("KnotchySettings")
return window
}
}
struct SettingsView: View {
let app: AppDelegate
@Bindable var settings: AppSettings
@Bindable var navigation: SettingsNavigation
var body: some View {
NavigationSplitView {
List(SettingsTab.allCases, selection: $navigation.section) { section in
Label(section.title, systemImage: section.symbol)
}
.navigationSplitViewColumnWidth(180)
.toolbar(removing: .sidebarToggle)
} detail: {
Group {
switch navigation.section ?? .general {
case .general: GeneralSettings(settings: settings)
case .network: NetworkSettings(settings: settings)
case .vm: VMSettings(controller: app.controller, status: app.vmStatus)
}
}
.navigationTitle((navigation.section ?? .general).title)
}
.frame(width: SettingsWindowController.size.width, height: SettingsWindowController.size.height)
}
}
private struct GeneralSettings: View {
@Bindable var settings: AppSettings
@State private var opensAtLogin = LoginItem.isEnabled
@State private var loginError: String?
var body: some View {
Form {
Section {
Picker("Destroy folded pages", selection: $settings.autoDestroyMinutes) {
ForEach(AppSettings.autoDestroyChoices, id: \.self) { minutes in
Text(minutes == 1 ? "After 1 minute" : "After \(minutes) minutes").tag(minutes)
}
}
Picker("Open pages in", selection: $settings.opensLarge) {
Text("Small panel").tag(false)
Text("Large panel").tag(true)
}
} footer: {
Text("A page folded into the notch keeps running until it's destroyed.")
}
Section {
Toggle("Open copied link with \(GlobalHotKey.openCopiedLinkLabel)", isOn: $settings.copiedLinkShortcut)
} footer: {
Text("Copy a link anywhere (in Chrome: right-click › Copy Link Address), then press \(GlobalHotKey.openCopiedLinkLabel).")
}
Section {
Toggle("Open Knotchy at login", isOn: Binding {
opensAtLogin
} set: { enabled in
loginError = LoginItem.set(enabled)
opensAtLogin = LoginItem.isEnabled
})
} footer: {
if let loginError { Text(loginError).foregroundStyle(.red) }
}
}
.formStyle(.grouped)
.scrollDisabled(true)
.onAppear { opensAtLogin = LoginItem.isEnabled }
}
}
// Every mode's form stays laid out, inactive ones hidden, so switching crossfades instead of resizing the window.
private struct NetworkSettings: View {
@Bindable var settings: AppSettings
@State private var importing = false
@State private var importMessage: String?
var body: some View {
ZStack(alignment: .top) {
ForEach([AppSettings.NetworkMode.direct, .proxy, .vpn], id: \.self) { mode in
let active = settings.networkMode == mode
NetworkForm(settings: settings, mode: mode, importMessage: importMessage, importing: $importing)
.opacity(active ? 1 : 0)
.allowsHitTesting(active)
.accessibilityHidden(!active)
.animation(active ? .easeOut(duration: 0.2).delay(0.05) : .easeOut(duration: 0.1), value: active)
}
}
.fileImporter(isPresented: $importing,
allowedContentTypes: [UTType(filenameExtension: "conf") ?? .data, .zip],
allowsMultipleSelection: true) { result in
if case .success(let urls) = result {
Task { importMessage = await settings.importWireGuard(from: urls) }
}
}
}
}
private struct NetworkForm: View {
@Bindable var settings: AppSettings
let mode: AppSettings.NetworkMode
let importMessage: String?
@Binding var importing: Bool
var body: some View {
Form {
Section {
Picker("Connection", selection: $settings.networkMode) {
Text("Direct").tag(AppSettings.NetworkMode.direct)
Text("Proxy").tag(AppSettings.NetworkMode.proxy)
Text("WireGuard VPN").tag(AppSettings.NetworkMode.vpn)
}
.pickerStyle(.radioGroup)
} footer: {
Text(description)
}
details
.disabled(settings.networkMode != mode) // No tabbing into a hidden form.
}
.formStyle(.grouped)
.scrollDisabled(true)
}
private var description: String {
switch mode {
case .direct: "Pages go out through this Mac's internet connection, and can reach nothing else."
case .proxy: "Pages can only go out through the proxy. If it's down, they don't load."
case .vpn: "Pages can only go out through the VPN. If it's down, they don't load. The rest of your Mac isn't affected."
}
}
@ViewBuilder private var details: some View {
switch mode {
case .direct:
Section {
LabeledContent("Internet", value: "This Mac's connection")
LabeledContent("Blocked", value: "Your Mac, your network, IPv6")
LabeledContent("DNS", value: "1.1.1.1 (Cloudflare)")
} header: {
Text("Direct")
}
case .proxy:
Section {
Picker("Type", selection: $settings.proxyKind) {
Text("HTTP").tag(ProxySettings.Kind.http)
Text("SOCKS5").tag(ProxySettings.Kind.socks5)
}
TextField("Server", text: $settings.proxyHost, prompt: Text("proxy.example.com"))
TextField("Port", value: $settings.proxyPort, format: .number.grouping(.never), prompt: Text("8080"))
TextField("Username", text: $settings.proxyUsername, prompt: Text("Optional"))
SecureField("Password", text: $settings.proxyPassword, prompt: Text("Optional"))
} header: {
Text("Proxy")
} footer: {
if settings.proxyIsLocal {
Text("Proxies on this Mac or your network aren't allowed: pages could reach your network through them.")
.foregroundStyle(.orange)
} else {
Text("The password is kept in your keychain.")
}
}
case .vpn:
Section {
if settings.wireGuardNames.isEmpty {
LabeledContent("Server", value: "None")
LabeledContent("Endpoint", value: "—")
LabeledContent("DNS", value: "—")
LabeledContent("No configs imported") {
Button("Import…") { importing = true }
}
} else {
Picker("Server", selection: $settings.wireGuardServer) {
ForEach(settings.wireGuardNames, id: \.self) { Text($0).tag($0) }
}
let config = settings.selectedWireGuard
LabeledContent("Endpoint", value: config.map { "\($0.endpointHost):\(String($0.endpointPort))" } ?? "—")
LabeledContent("DNS", value: config.map { $0.dns.isEmpty ? "1.1.1.1" : $0.dns.joined(separator: ", ") } ?? "—")
LabeledContent(settings.wireGuardNames.count == 1 ? "1 config" : "\(settings.wireGuardNames.count) configs") {
HStack {
Button("Remove All") { settings.removeWireGuardConfigs() }
Button("Import…") { importing = true }
}
}
}
} header: {
Text("WireGuard")
} footer: {
if let importMessage {
Text(importMessage).foregroundStyle(.orange).lineLimit(2)
} else {
Text("Import the .conf files, or the .zip, from Mullvad, Proton VPN, IVPN or others.")
}
}
}
}
}
private struct VMSettings: View {
let controller: SessionController
let status: VMStatus
var body: some View {
Form {
Section {
LabeledContent("Container runtime", value: status.runtimeSummary)
LabeledContent("VM image", value: status.summary)
LabeledContent("Rebuild") {
Button(status.image == .missing ? "Build Now" : "Rebuild Now") { controller.rebuildImage() }
.disabled(isSettingUp || status.image == .noContainerTool)
}
} footer: {
Text("Rebuilding takes about 3 minutes and picks up the latest WebKit security fixes. Progress shows in the notch.")
}
}
.formStyle(.grouped)
.scrollDisabled(true)
.onAppear { status.refresh() }
}
private var isSettingUp: Bool {
if case .settingUp = controller.state { true } else { false }
}
}
+245
View File
@@ -0,0 +1,245 @@
import AppKit
import KnotchyCore
import SwiftUI
@MainActor
final class SetupWindowController: NSObject, NSWindowDelegate {
enum Content {
case setup(SetupFlow)
case unsupported(Unsupported)
}
private let content: Content
private(set) var window: NSWindow?
init(_ content: Content) {
self.content = content
}
func show() {
let window = self.window ?? makeWindow()
self.window = window
// An accessory app's windows open behind the frontmost app unless it activates first.
NSApp.activate()
window.makeKeyAndOrderFront(nil)
if case .setup(let flow) = content { Task { await flow.refresh() } }
}
private func makeWindow() -> NSWindow {
let root = switch content {
case .setup(let flow): AnyView(SetupView(flow: flow) { [weak self] in self?.window?.close() })
case .unsupported(let reason): AnyView(UnsupportedView(reason: reason))
}
let host = NSHostingController(rootView: root)
let window = NSWindow(contentViewController: host)
window.styleMask = [.titled, .closable, .fullSizeContentView]
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.title = "Welcome to Knotchy"
window.isReleasedWhenClosed = false
window.setContentSize(host.view.fittingSize)
window.center()
window.delegate = self
return window
}
func windowWillClose(_ notification: Notification) {
switch content {
case .unsupported:
NSApp.terminate(nil)
case .setup(let flow):
if flow.allRequiredDone { UserDefaults.standard.set(true, forKey: SetupLaunchRule.completedKey) }
}
}
}
struct SetupView: View {
let flow: SetupFlow
let close: () -> Void
@State private var browser = ChromeExtension.installedBrowser()
@State private var addedToBrowser = false
var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack(spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 56, height: 56)
VStack(alignment: .leading, spacing: 2) {
Text("Welcome to Knotchy").font(.title2.weight(.semibold))
Text("A few things to set up before your first link.").foregroundStyle(.secondary)
}
}
GroupBox {
VStack(spacing: 0) {
SetupRow(title: "This Mac", status: flow.mac) { Task { await flow.refresh() } }
Divider()
SetupRow(title: "Container runtime", status: flow.runtime) { flow.start() }
Divider()
SetupRow(title: "Browser VM", status: flow.vm) { flow.start() }
if let browser {
Divider()
BrowserRow(browser: browser, added: $addedToBrowser)
}
}
.padding(.horizontal, 6)
}
HStack {
Spacer()
if flow.allRequiredDone {
Button("Done", action: close)
.keyboardShortcut(.defaultAction)
} else {
Button("Not Now", action: close)
Button("Set Up") { flow.start() }
.keyboardShortcut(.defaultAction)
.disabled(!flow.canSetUp)
}
}
}
.padding(24)
.frame(width: 540)
}
}
private struct SetupRow: View {
let title: String
let status: SetupFlow.Status
let tryAgain: () -> Void
@State private var showsDetails = false
// Buttons sit on the title line and the detail reserves two lines, so no state changes the height.
var body: some View {
HStack(alignment: .top, spacing: 12) {
symbol.frame(width: 20, height: 22)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 8) {
Text(title)
Spacer(minLength: 8)
if case .failed(_, let details) = status {
if !details.isEmpty {
Button("Show Details") { showsDetails = true }
.popover(isPresented: $showsDetails, arrowEdge: .bottom) { DetailsView(lines: details) }
}
Button("Try Again", action: tryAgain)
}
}
.controlSize(.small)
.frame(minHeight: 22)
Text(detail)
.font(.callout)
.foregroundStyle(isFailed ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary))
.lineLimit(2, reservesSpace: true)
}
}
.padding(.vertical, 10)
}
private var detail: String {
switch status {
case .pending(let text), .running(let text, _), .done(let text), .failed(let text, _): text
}
}
private var isFailed: Bool {
if case .failed = status { true } else { false }
}
@ViewBuilder private var symbol: some View {
switch status {
case .pending:
Image(systemName: "circle.dashed").imageScale(.large).foregroundStyle(.tertiary)
case .running(_, let fraction?):
ProgressView(value: fraction).progressViewStyle(.circular).controlSize(.small)
case .running:
ProgressView().controlSize(.small)
case .done:
Image(systemName: "checkmark.circle.fill").imageScale(.large).foregroundStyle(.green)
case .failed:
Image(systemName: "exclamationmark.circle.fill").imageScale(.large).foregroundStyle(.red)
}
}
}
private struct BrowserRow: View {
let browser: ChromeExtension.Browser
@Binding var added: Bool
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "puzzlepiece.extension")
.imageScale(.large)
.foregroundStyle(.secondary)
.frame(width: 20, height: 22)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text("Open links from \(browser.name)")
Text("Optional").font(.caption).foregroundStyle(.tertiary)
Spacer(minLength: 8)
Button("Add to \(browser.name)") {
ChromeExtension.open(in: browser)
added = true
}
.controlSize(.small)
}
.frame(minHeight: 22)
Text(added ? "In \(browser.name), right-click a link and choose Open in Knotchy."
: "Safari and Mail already work from the right-click menu.")
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2, reservesSpace: true)
}
}
.padding(.vertical, 10)
}
}
private struct DetailsView: View {
let lines: [String]
var body: some View {
ScrollView {
Text(lines.joined(separator: "\n"))
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
}
.frame(width: 440, height: 220)
}
}
private struct UnsupportedView: View {
let reason: Unsupported
var body: some View {
VStack(spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 72, height: 72)
Text(title).font(.title2.weight(.semibold))
Text(message)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button("Quit") { NSApp.terminate(nil) }
.keyboardShortcut(.defaultAction)
.padding(.top, 6)
}
.padding(32)
.frame(width: 440)
}
private var title: String {
switch reason {
case .noVirtualization: "This Mac can't run virtual machines right now"
}
}
private var message: String {
switch reason {
case .noVirtualization:
"Knotchy opens every link in its own virtual machine. If macOS is itself running in a virtual machine, run Knotchy on the host Mac instead."
}
}
}
+234
View File
@@ -0,0 +1,234 @@
import AVFoundation
import AppKit
import CoreMedia
import KnotchyCore
import SwiftUI
import Synchronization
final class LatestFrame: Sendable {
private let frame = Mutex<DecodedFrame?>(nil)
func put(_ new: DecodedFrame) -> Bool {
frame.withLock { slot in
defer { slot = new }
return slot == nil
}
}
func take() -> DecodedFrame? {
frame.withLock { slot in
defer { slot = nil }
return slot
}
}
}
struct VideoSurface: NSViewRepresentable {
let controller: SessionController
func makeNSView(context: Context) -> VideoView {
let view = VideoView()
view.controller = controller
return view
}
func updateNSView(_ view: VideoView, context: Context) {
view.attach(controller.connection)
}
}
final class VideoView: NSView {
weak var controller: SessionController?
private var connection: (any StreamConnection)?
private let displayLayer = AVSampleBufferDisplayLayer()
private var scrollRemainder = CGPoint.zero
/// WebKit scrolls about 40 px per wheel tick.
private let scrollStep: CGFloat = 40
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
layer?.backgroundColor = NSColor.black.cgColor
displayLayer.videoGravity = .resizeAspect
displayLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
layer?.addSublayer(displayLayer)
}
required init?(coder: NSCoder) { fatalError("not used") }
override var isFlipped: Bool { true }
override var acceptsFirstResponder: Bool { true }
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
override func layout() {
super.layout()
CATransaction.begin()
CATransaction.setDisableActions(true)
displayLayer.frame = bounds
CATransaction.commit()
#if DEBUG
if ProcessInfo.processInfo.environment["KNOTCHY_LOG_LAYOUT"] != nil {
print(String(format: "%.3f video %.0fx%.0f", CACurrentMediaTime(), bounds.width, bounds.height))
}
#endif
}
func attach(_ connection: (any StreamConnection)?) {
guard self.connection !== connection else { return }
self.connection = connection
displayLayer.sampleBufferRenderer.flush()
let slot = LatestFrame()
connection?.setFrameHandler { [weak self] frame in
// Keep only the newest frame so a busy main thread drops frames instead of queueing multi-MB buffers.
guard slot.put(frame) else { return }
DispatchQueue.main.async {
guard let frame = slot.take() else { return }
MainActor.assumeIsolated { self?.show(frame.pixelBuffer) }
}
}
}
private func show(_ pixelBuffer: CVPixelBuffer) {
var format: CMVideoFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(allocator: nil, imageBuffer: pixelBuffer, formatDescriptionOut: &format)
guard let format else { return }
var timing = CMSampleTimingInfo(duration: .invalid, presentationTimeStamp: .zero, decodeTimeStamp: .invalid)
var sample: CMSampleBuffer?
CMSampleBufferCreateReadyWithImageBuffer(allocator: nil, imageBuffer: pixelBuffer, formatDescription: format,
sampleTiming: &timing, sampleBufferOut: &sample)
guard let sample else { return }
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: true),
CFArrayGetCount(attachments) > 0 {
let dictionary = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self)
CFDictionarySetValue(dictionary,
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
}
let renderer = displayLayer.sampleBufferRenderer
if renderer.status == .failed { renderer.flush() }
renderer.enqueue(sample)
}
// MARK: - Mouse
override func updateTrackingAreas() {
super.updateTrackingAreas()
for area in trackingAreas where area.owner === self { removeTrackingArea(area) }
addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseMoved, .activeAlways, .inVisibleRect], owner: self))
}
private func videoPoint(_ event: NSEvent) -> (x: UInt16, y: UInt16)? {
NotchGeometry.videoPoint(convert(event.locationInWindow, from: nil), surfaceSize: bounds.size,
renderSize: SessionController.renderSize)
}
private func mods(_ event: NSEvent) -> Modifiers {
var mods: Modifiers = []
let flags = event.modifierFlags
if flags.contains(.shift) { mods.insert(.shift) }
// ⌘ maps to Ctrl so ⌘A/C/V/X/Z work like Linux shortcuts in the VM.
if flags.contains(.control) || flags.contains(.command) { mods.insert(.control) }
if flags.contains(.option) { mods.insert(.alt) }
let buttons = NSEvent.pressedMouseButtons
if buttons & 1 != 0 { mods.insert(.leftButton) }
if buttons & 2 != 0 { mods.insert(.rightButton) }
if buttons & 4 != 0 { mods.insert(.middleButton) }
return mods
}
private func sendMove(_ event: NSEvent) {
guard let point = videoPoint(event) else { return }
connection?.send(.pointerMove(x: point.x, y: point.y, mods: mods(event)))
}
private func sendButton(_ event: NSEvent, _ button: PointerButton, pressed: Bool) {
guard let point = videoPoint(event) else { return }
connection?.send(.pointerButton(button, pressed: pressed, x: point.x, y: point.y, mods: mods(event)))
}
override func mouseMoved(with event: NSEvent) { sendMove(event) }
override func mouseDragged(with event: NSEvent) { sendMove(event) }
override func rightMouseDragged(with event: NSEvent) { sendMove(event) }
override func otherMouseDragged(with event: NSEvent) { sendMove(event) }
override func mouseDown(with event: NSEvent) {
controller?.panelClicked()
window?.makeKey()
window?.makeFirstResponder(self)
sendButton(event, .left, pressed: true)
}
override func mouseUp(with event: NSEvent) { sendButton(event, .left, pressed: false) }
override func rightMouseDown(with event: NSEvent) { sendButton(event, .right, pressed: true) }
override func rightMouseUp(with event: NSEvent) { sendButton(event, .right, pressed: false) }
override func otherMouseDown(with event: NSEvent) { sendButton(event, .middle, pressed: true) }
override func otherMouseUp(with event: NSEvent) { sendButton(event, .middle, pressed: false) }
override func scrollWheel(with event: NSEvent) {
var dx = event.scrollingDeltaX
var dy = event.scrollingDeltaY
if event.hasPreciseScrollingDeltas {
scrollRemainder.x += dx
scrollRemainder.y += dy
dx = (scrollRemainder.x / scrollStep).rounded(.towardZero)
dy = (scrollRemainder.y / scrollStep).rounded(.towardZero)
scrollRemainder.x -= dx * scrollStep
scrollRemainder.y -= dy * scrollStep
} else {
dx = dx == 0 ? 0 : (dx > 0 ? max(1, dx.rounded()) : min(-1, dx.rounded()))
dy = dy == 0 ? 0 : (dy > 0 ? max(1, dy.rounded()) : min(-1, dy.rounded()))
}
guard dx != 0 || dy != 0, let point = videoPoint(event) else { return }
connection?.send(.scroll(ticksX: Int16(clamping: Int(dx)), ticksY: Int16(clamping: Int(dy)), x: point.x, y: point.y))
}
// MARK: - Keyboard
private static let specialKeys: [UInt16: String] = [
36: "Return", 76: "KP_Enter", 48: "Tab", 51: "BackSpace", 117: "Delete", 53: "Escape",
49: "space", 123: "Left", 124: "Right", 125: "Down", 126: "Up",
115: "Home", 119: "End", 116: "Page_Up", 121: "Page_Down",
122: "F1", 120: "F2", 99: "F3", 118: "F4", 96: "F5", 97: "F6",
98: "F7", 100: "F8", 101: "F9", 109: "F10", 103: "F11", 111: "F12",
]
private func keyName(_ event: NSEvent) -> String? {
if let name = Self.specialKeys[event.keyCode] { return name }
let flags = event.modifierFlags
let characters = flags.contains(.command) || flags.contains(.control)
? event.charactersIgnoringModifiers?.lowercased()
: event.characters
guard let scalars = characters?.unicodeScalars, scalars.count == 1, let scalar = scalars.first,
scalar.value >= 0x20, scalar.value != 0x7F, !(0xF700...0xF8FF).contains(scalar.value)
else { return nil }
return String(scalar)
}
private func sendKey(_ event: NSEvent, pressed: Bool) {
guard let name = keyName(event) else { return }
connection?.send(.key(pressed: pressed, mods: mods(event), name: name))
}
override func keyDown(with event: NSEvent) { sendKey(event, pressed: true) }
override func keyUp(with event: NSEvent) { sendKey(event, pressed: false) }
override func performKeyEquivalent(with event: NSEvent) -> Bool {
guard event.type == .keyDown, event.modifierFlags.contains(.command), let controller else {
return super.performKeyEquivalent(with: event)
}
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
let shift = event.modifierFlags.contains(.shift)
switch (key, shift) {
case ("w", false), (".", false): controller.destroy()
case ("e", true): controller.state == .live(.expanded) ? controller.shrink() : controller.expand()
case ("[", false): controller.goBack()
case ("]", false): controller.goForward()
case ("r", false): controller.reload()
case ("q", false): NSApp.terminate(nil)
default:
sendKey(event, pressed: true)
sendKey(event, pressed: false)
}
return true
}
}
+115
View File
@@ -0,0 +1,115 @@
import Foundation
import Synchronization
public struct CLIRunner: Sendable {
public struct Output: Sendable {
public var status: Int32
public var text: String
public var lastLine: String {
text.split(whereSeparator: \.isNewline).last.map(String.init) ?? "exit status \(status)"
}
}
public enum Failure: Error, Equatable {
case notInstalled
case timedOut([String])
case launchFailed(String)
}
// Fixed paths: apps launched from the Services menu get a minimal PATH.
public static let searchPaths = ["/usr/local/bin/container", "/opt/homebrew/bin/container"]
public let searchPaths: [String]
public init(searchPaths: [String] = CLIRunner.searchPaths) {
self.searchPaths = searchPaths
}
/// Looked up on every call, so a runtime installed while the app runs is found.
public var executable: String? {
searchPaths.first { FileManager.default.isExecutableFile(atPath: $0) }
}
public func run(_ args: [String], timeout: TimeInterval,
line: (@Sendable (String) -> Void)? = nil) async throws -> Output {
try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(with: Result { try self.runBlocking(args, timeout: timeout, line: line) })
}
}
}
static let outputLimit = 1024 * 1024
private static let builds = Mutex(Set<Int32>())
/// Builds would outlive the app. Other commands are left to the quit path, which waits for them.
public static func stopBuilds() {
for pid in builds.withLock({ $0 }) { kill(pid, SIGTERM) }
}
public func runBlocking(_ args: [String], timeout: TimeInterval,
line: (@Sendable (String) -> Void)? = nil) throws -> Output {
guard let executable else { throw Failure.notInstalled }
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
// Only the tail is kept: a noisy build can print for minutes.
let limit = Self.outputLimit
let collected = Mutex(Data())
let pending = Mutex(Data())
pipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
guard !chunk.isEmpty else { return }
collected.withLock { buffer in
buffer.append(chunk)
if buffer.count > limit { buffer.removeFirst(buffer.count - limit) }
}
guard let line else { return }
let lines: [String] = pending.withLock { buffer in
buffer.append(chunk)
var out: [String] = []
while let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) {
out.append(String(decoding: buffer[buffer.startIndex..<newline], as: UTF8.self))
buffer.removeSubrange(buffer.startIndex...newline)
}
if buffer.count > 16 * 1024 { // A line that never ends.
out.append(String(decoding: buffer.prefix(16 * 1024), as: UTF8.self))
buffer.removeAll()
}
return out
}
for text in lines where !text.trimmingCharacters(in: .whitespaces).isEmpty { line(text) }
}
let finished = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in finished.signal() }
do {
try process.run()
} catch {
pipe.fileHandleForReading.readabilityHandler = nil
throw Failure.launchFailed(error.localizedDescription)
}
let pid = process.processIdentifier
let isBuild = args.first == "build"
if isBuild { Self.builds.withLock { _ = $0.insert(pid) } }
defer { if isBuild { Self.builds.withLock { _ = $0.remove(pid) } } }
if finished.wait(timeout: .now() + timeout) == .timedOut {
process.terminate()
if finished.wait(timeout: .now() + 1) == .timedOut {
kill(process.processIdentifier, SIGKILL)
finished.wait()
}
pipe.fileHandleForReading.readabilityHandler = nil
throw Failure.timedOut(args)
}
pipe.fileHandleForReading.readabilityHandler = nil
let rest = pipe.fileHandleForReading.readDataToEndOfFile()
let data = collected.withLock { ($0 + rest).suffix(limit) }
return Output(status: process.terminationStatus, text: String(decoding: data, as: UTF8.self))
}
}
@@ -0,0 +1,311 @@
import Foundation
import Synchronization
public final class ContainerCLIBackend: VMBackend {
public static let image = "local/knotchy-vm:latest"
public static let staleAfterDays = 14
private let cli: CLIRunner
private let vmSources: URL?
private let runDirectory: URL
private let startsInFlight = Mutex(0)
private let route = Mutex(NetworkRoute.direct)
private let startedRoute = Mutex(NetworkRoute.direct)
public var lastStartedRoute: NetworkRoute { startedRoute.withLock { $0 } }
public func setNetworkRoute(_ newRoute: NetworkRoute) {
route.withLock { $0 = newRoute }
}
/// Test-only (knotchyctl --allow-local-proxy); the app never sets it.
private let localProxyAllowed = Mutex(false)
public func allowLocalProxy() {
localProxyAllowed.withLock { $0 = true }
}
public init(vmSources: URL?, cli: CLIRunner = CLIRunner()) {
self.vmSources = vmSources
self.cli = cli
runDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("knotchy", isDirectory: true)
}
// MARK: Preflight
public func preflight() async -> PreflightResult {
guard cli.executable != nil else { return .issue(.cliMissing) }
if case .outdated(let found) = await runtimeStatus() { return .issue(.cliOutdated(found: found)) }
guard let status = try? await cli.run(["system", "status"], timeout: 15), status.status == 0,
field("status", in: status.text) == "running"
else { return .issue(.serviceStopped) }
let appRoot = field("paths.appRoot", in: status.text)
?? NSHomeDirectory() + "/Library/Application Support/com.apple.container/"
let kernel = URL(fileURLWithPath: appRoot).appendingPathComponent("kernels/default.kernel-arm64")
guard FileManager.default.fileExists(atPath: kernel.path) else { return .issue(.kernelMissing) }
guard let inspect = try? await cli.run(["image", "inspect", Self.image], timeout: 15), inspect.status == 0
else { return .issue(.imageMissing) }
let labels = Self.labels(in: inspect.text)
let found = labels["aculix.knotchy.protocol"].flatMap(Int.init) ?? 0
guard found == WireProtocol.version else {
return .issue(.imageOutdated(found: found, need: WireProtocol.version))
}
prepareRunDirectory()
return .ready(appRoot: appRoot, staleImage: Self.staleness(built: labels["aculix.knotchy.built"]))
}
/// Resolved on the Mac: a proxy route gives the VM no DNS, and a VPN's DNS needs the tunnel up.
static func resolveIPv4(_ host: String) throws -> String {
var address = host
if !WireGuardConfig.isIPv4(host) {
var hints = addrinfo()
hints.ai_family = AF_INET
hints.ai_socktype = SOCK_STREAM
var result: UnsafeMutablePointer<addrinfo>?
guard getaddrinfo(host, nil, &hints, &result) == 0, let first = result, let sockaddr = first.pointee.ai_addr else {
throw VMBackendError.failed("Couldn't find the server “\(host)”. Check Settings → Network.")
}
defer { freeaddrinfo(result) }
var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
var inAddr = sockaddr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee.sin_addr }
guard inet_ntop(AF_INET, &inAddr, &buffer, socklen_t(buffer.count)) != nil else {
throw VMBackendError.failed("Couldn't find the server “\(host)”.")
}
address = String(cString: buffer)
}
if address.hasPrefix("127.") || address == "0.0.0.0" {
throw VMBackendError.failed("The VM can't reach this Mac's localhost (“\(host)”). A proxy on this Mac must listen on the Mac's network address instead.")
}
return address
}
public func imageStatus() async -> ImageStatus {
guard cli.executable != nil else { return .noContainerTool }
guard let inspect = try? await cli.run(["image", "inspect", Self.image], timeout: 15), inspect.status == 0
else { return .missing }
let built = Self.labels(in: inspect.text)["aculix.knotchy.built"].flatMap { ISO8601DateFormatter().date(from: $0) }
return .built(built)
}
public enum ImageStatus: Equatable, Sendable {
case noContainerTool
case missing
case built(Date?)
}
public func runtimeStatus() async -> RuntimeStatus {
guard cli.executable != nil else { return .missing }
let output = try? await cli.run(["--version"], timeout: 10)
return ContainerRuntime.status(versionOutput: output?.text ?? "")
}
public func stopService() async throws {
let result = try await cli.run(["system", "stop"], timeout: 60)
guard result.status == 0 else { throw VMBackendError.failed("Couldn't stop the container service: \(result.lastLine)") }
}
private func field(_ name: String, in text: String) -> String? {
for line in text.split(whereSeparator: \.isNewline) {
let parts = line.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
if parts.count == 2, parts[0] == name {
return parts[1].trimmingCharacters(in: .whitespaces)
}
}
return nil
}
static func labels(in json: String) -> [String: String] {
var found: [String: String] = [:]
func walk(_ value: Any) {
if let dict = value as? [String: Any] {
for (key, child) in dict {
if key.hasPrefix("aculix.knotchy."), let text = child as? String { found[key] = text }
walk(child)
}
} else if let array = value as? [Any] {
array.forEach(walk)
}
}
if let object = try? JSONSerialization.jsonObject(with: Data(json.utf8)) { walk(object) }
return found
}
static func staleness(built: String?, now: Date = Date()) -> StaleImage? {
let formatter = ISO8601DateFormatter()
guard let built, let date = formatter.date(from: built) else { return StaleImage(builtDaysAgo: nil) }
let days = Int(now.timeIntervalSince(date) / 86_400)
return days > staleAfterDays ? StaleImage(builtDaysAgo: days) : nil
}
private func prepareRunDirectory() {
let fm = FileManager.default
let path = runDirectory.path
var isDirectory: ObjCBool = false
let attributes = try? fm.attributesOfItem(atPath: path)
let isSymlink = (attributes?[.type] as? FileAttributeType) == .typeSymbolicLink
let owner = (attributes?[.ownerAccountID] as? NSNumber)?.uint32Value
let mode = (attributes?[.posixPermissions] as? NSNumber)?.intValue
if isSymlink || !fm.fileExists(atPath: path, isDirectory: &isDirectory) || !isDirectory.boolValue
|| owner != getuid() || mode != 0o700 {
try? fm.removeItem(atPath: path)
try? fm.createDirectory(atPath: path, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
}
// Secrets left by a crash; a VM's init deletes its own within a second of booting.
let stale = Date().addingTimeInterval(-120)
for name in (try? fm.contentsOfDirectory(atPath: path)) ?? [] where name.hasSuffix(".secrets") || name.hasSuffix(".env") {
let item = runDirectory.appendingPathComponent(name)
if let modified = (try? fm.attributesOfItem(atPath: item.path))?[.modificationDate] as? Date, modified < stale {
try? fm.removeItem(at: item)
}
}
}
// MARK: Start and destroy
public func start(id: String, url: URL, renderSize: PixelSize, blockCIDRs: [String]) async throws -> VMSession {
startsInFlight.withLock { $0 += 1 }
defer { startsInFlight.withLock { $0 -= 1 } }
prepareRunDirectory()
let socket = runDirectory.appendingPathComponent("\(id).sock").path
unlink(socket)
let chosenRoute = route.withLock { $0 }
let launch = try chosenRoute.launch(resolve: Self.resolveIPv4, hostNetworks: blockCIDRs,
allowLocalProxy: localProxyAllowed.withLock { $0 })
var args = [
"run", "-d", "--rm", "--name", id,
"--cpus", "4", "--memory", "2g", "--shm-size", "512m",
"--dns", "1.1.1.1",
"--cap-add", "NET_ADMIN",
// WebKit's bwrap can't mount procfs over a masked /proc, and init writes /proc/sys.
"--masked-path", "NONE", "--read-only-path", "NONE",
"--publish-socket", "\(socket):/tmp/knotchy/knotchy.sock",
"-e", "KNOTCHY_URL=\(url.absoluteString)",
"-e", "KNOTCHY_SIZE=\(renderSize)",
"-e", "KNOTCHY_BLOCK_CIDRS=\(blockCIDRs.joined(separator: " "))",
]
for setting in launch.environment { args += ["-e", setting] }
if !launch.secrets.isEmpty {
// Init reads, deletes and unmounts this (needs SYS_ADMIN), then drops all capabilities.
let share = secretsDirectory(id)
try? FileManager.default.removeItem(at: share)
guard (try? FileManager.default.createDirectory(at: share, withIntermediateDirectories: false,
attributes: [.posixPermissions: 0o700])) != nil,
FileManager.default.createFile(atPath: share.appendingPathComponent("route").path,
contents: Data((launch.secrets.joined(separator: "\n") + "\n").utf8),
attributes: [.posixPermissions: 0o600])
else {
try? FileManager.default.removeItem(at: share)
throw VMBackendError.failed("Couldn't pass the network settings to the VM.")
}
args += ["--cap-add", "SYS_ADMIN", "--mount", "type=bind,source=\(share.path),target=/run/knotchy-secrets"]
}
args.append(Self.image)
let output: CLIRunner.Output
do {
output = try await cli.run(args, timeout: 60)
} catch {
throw VMBackendError.failed("Starting the VM timed out.")
}
guard output.status == 0 else {
cleanUp(id)
throw VMBackendError.failed("Couldn't start the VM: \(output.lastLine)")
}
startedRoute.withLock { $0 = chosenRoute }
return VMSession(id: id, socketPath: socket)
}
public func destroy(id: String) async throws {
_ = try? await cli.run(["stop", "-t", "2", id], timeout: 10)
if await waitUntilGone(id, for: 5) { return cleanUp(id) }
_ = try? await cli.run(["delete", "--force", id], timeout: 10)
if await waitUntilGone(id, for: 5) { return cleanUp(id) }
throw VMBackendError.destroyStuck(id: id)
}
public func forceDestroy(id: String) async throws {
_ = try? await cli.run(["delete", "--force", id], timeout: 10)
if await waitUntilGone(id, for: 5) { return cleanUp(id) }
throw VMBackendError.destroyStuck(id: id)
}
public func sweepOrphans(keeping: Set<String>) async {
guard let listed = try? await cli.run(["ls", "-a", "-q"], timeout: 10) else { return }
for id in Self.ids(in: listed.text) where Self.isSessionID(id) && !keeping.contains(id) {
_ = try? await cli.run(["delete", "--force", id], timeout: 10)
cleanUp(id)
}
}
static func isSessionID(_ id: String) -> Bool {
id.count == 16 && id.hasPrefix("knotchy-") && id.dropFirst(8).allSatisfy(\.isHexDigit)
}
static func ids(in text: String) -> [String] {
text.split(whereSeparator: \.isNewline).map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
}
private func isListed(_ id: String) async -> Bool? {
guard let listed = try? await cli.run(["ls", "-a", "-q"], timeout: 10), listed.status == 0 else { return nil }
return Self.ids(in: listed.text).contains(id)
}
private func waitUntilGone(_ id: String, for seconds: Double) async -> Bool {
let deadline = Date().addingTimeInterval(seconds)
repeat {
if await isListed(id) == false { return true }
try? await Task.sleep(for: .milliseconds(100))
} while Date() < deadline
return false
}
private func cleanUp(_ id: String) {
unlink(runDirectory.appendingPathComponent("\(id).sock").path)
try? FileManager.default.removeItem(at: secretsDirectory(id))
}
private func secretsDirectory(_ id: String) -> URL {
runDirectory.appendingPathComponent("\(id).secrets", isDirectory: true)
}
// MARK: Setup actions
public func fix(_ issue: SetupIssue, output: @escaping @Sendable (String) -> Void) async throws {
switch issue {
case .serviceStopped, .kernelMissing:
let result = try await cli.run(["system", "start", "--enable-kernel-install"], timeout: 120, line: output)
guard result.status == 0 else { throw VMBackendError.failed("Couldn't start the service: \(result.lastLine)") }
case .imageMissing, .imageOutdated, .imageStale:
guard let vmSources else { throw VMBackendError.failed("The VM sources are missing from the app.") }
let now = Date()
let day = DateFormatter()
day.dateFormat = "yyyy-MM-dd"
let args = [
"build", "--cpus", "4", "--memory", "4g", "--dns", "1.1.1.1", "--pull",
"--build-arg", "APT_REFRESH=\(day.string(from: now))",
"--build-arg", "BUILD_DATE=\(ISO8601DateFormatter().string(from: now))",
"-f", vmSources.appendingPathComponent("Containerfile").path,
"-t", Self.image, vmSources.path,
]
let result = try await cli.run(args, timeout: 15 * 60, line: output)
guard result.status == 0 else { throw VMBackendError.failed("The image build failed: \(result.lastLine)") }
case .cliMissing, .cliOutdated, .lowDisk:
throw VMBackendError.failed("There's no automatic fix for this.")
}
}
// MARK: Quit path
public func destroyBlocking(ids: [String], deadline: Duration) {
let end = Date().addingTimeInterval(TimeInterval(deadline.components.seconds))
// Wait for in-flight starts so a VM created at the last moment is destroyed too.
while startsInFlight.withLock({ $0 }) > 0, Date() < end { usleep(50_000) }
for id in ids {
let left = end.timeIntervalSinceNow
guard left > 0.5 else { return }
_ = try? cli.runBlocking(["delete", "--force", id], timeout: min(10, left))
cleanUp(id)
}
}
}
@@ -0,0 +1,35 @@
import Darwin
import Foundation
public enum HostNetworks {
/// Blocked in the VM on top of the private ranges, for LANs and VPNs on public addresses.
public static func current() -> [String] {
var head: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&head) == 0, let first = head else { return [] }
defer { freeifaddrs(head) }
var networks = Set<String>()
for pointer in sequence(first: first, next: { $0.pointee.ifa_next }) {
let interface = pointer.pointee
guard let address = interface.ifa_addr, let mask = interface.ifa_netmask,
address.pointee.sa_family == UInt8(AF_INET),
interface.ifa_flags & UInt32(IFF_LOOPBACK) == 0 else { continue }
let ip = address.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { UInt32(bigEndian: $0.pointee.sin_addr.s_addr) }
let bits = mask.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { UInt32(bigEndian: $0.pointee.sin_addr.s_addr) }
let prefix = bits.nonzeroBitCount
guard prefix >= 8 else { continue } // Never let a bogus /0 block the whole internet.
let network = ip & bits
networks.insert("\(network >> 24).\((network >> 16) & 0xFF).\((network >> 8) & 0xFF).\(network & 0xFF)/\(prefix)")
}
return networks.sorted()
}
}
public struct SystemDiskSpace: DiskSpaceChecking {
public init() {}
@concurrent
public func bytesFree(atPath path: String) async -> Int64? {
let values = try? URL(fileURLWithPath: path).resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
return values?.volumeAvailableCapacityForImportantUsage
}
}
@@ -0,0 +1,245 @@
import Foundation
/// Proxy and VPN routes fail closed: the VM's firewall lets nothing else out.
public enum NetworkRoute: Equatable, Sendable {
case direct
case proxy(ProxySettings)
case wireGuard(WireGuardConfig)
/// Sessions fail to start rather than quietly browsing directly.
case unusable(reason: String)
}
public struct ProxySettings: Equatable, Sendable {
public enum Kind: String, CaseIterable, Sendable {
case http
case socks5
}
public var kind: Kind
public var host: String
public var port: Int
public var username: String
public var password: String
public init(kind: Kind, host: String, port: Int, username: String = "", password: String = "") {
self.kind = kind
self.host = host
self.port = port
self.username = username
self.password = password
}
public func url(address: String) -> String {
var userinfo = ""
if !username.isEmpty {
userinfo = Self.encode(username) + (password.isEmpty ? "" : ":" + Self.encode(password)) + "@"
}
return "\(kind.rawValue)://\(userinfo)\(address):\(port)"
}
/// Must match the VM firewall's list.
static let localRanges = ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
"172.16.0.0/12", "192.168.0.0/16", "198.18.0.0/15"]
/// A local proxy is refused: pages could reach the Mac and its network through it.
public static func isLocal(_ address: String, hostNetworks: [String]) -> Bool {
guard let ip = ipv4Value(address) else { return true }
return (localRanges + hostNetworks).contains { cidr in
let parts = cidr.split(separator: "/")
guard parts.count == 2, let network = ipv4Value(String(parts[0])), let bits = Int(parts[1]), (0...32).contains(bits)
else { return false }
let mask: UInt32 = bits == 0 ? 0 : ~UInt32(0) << (32 - bits)
return ip & mask == network & mask
}
}
static func ipv4Value(_ text: String) -> UInt32? {
guard WireGuardConfig.isIPv4(text) else { return nil }
return text.split(separator: ".").reduce(UInt32(0)) { $0 << 8 | UInt32($1)! }
}
static func encode(_ text: String) -> String {
let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
return text.addingPercentEncoding(withAllowedCharacters: unreserved) ?? ""
}
}
/// IPv4 only (the VM has IPv6 off). All traffic uses the tunnel, whatever AllowedIPs says.
public struct WireGuardConfig: Equatable, Sendable {
public var privateKey: String
public var addresses: [String]
/// Empty means 1.1.1.1 through the tunnel.
public var dns: [String]
public var mtu: Int?
public var peerPublicKey: String
public var presharedKey: String?
public var endpointHost: String
public var endpointPort: Int
public var persistentKeepalive: Int?
public var name: String?
public enum ParseError: Error, Equatable, Sendable {
case missing(String)
case invalid(String)
case multiplePeers
}
public static func parse(_ text: String) throws(ParseError) -> WireGuardConfig {
var section = ""
var interface: [String: String] = [:]
var peers: [[String: String]] = []
for rawLine in text.split(whereSeparator: \.isNewline) {
let line = rawLine.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)[0]
.trimmingCharacters(in: .whitespaces)
if line.isEmpty { continue }
if line.hasPrefix("[") {
section = line.lowercased()
if section == "[peer]" { peers.append([:]) }
continue
}
guard let equals = line.firstIndex(of: "=") else { throw .invalid(line) }
let key = line[..<equals].trimmingCharacters(in: .whitespaces).lowercased()
let value = line[line.index(after: equals)...].trimmingCharacters(in: .whitespaces)
switch section {
case "[interface]":
interface[key] = interface[key].map { $0 + "," + value } ?? value
case "[peer]":
peers[peers.count - 1][key] = value
default:
throw .invalid(line)
}
}
guard peers.count <= 1 else { throw .multiplePeers }
guard let peer = peers.first else { throw .missing("[Peer]") }
guard let privateKey = interface["privatekey"] else { throw .missing("PrivateKey") }
guard isKey(privateKey) else { throw .invalid("PrivateKey") }
guard let publicKey = peer["publickey"] else { throw .missing("PublicKey") }
guard isKey(publicKey) else { throw .invalid("PublicKey") }
let presharedKey = peer["presharedkey"]
if let presharedKey, !isKey(presharedKey) { throw .invalid("PresharedKey") }
let addresses = list(interface["address"]).compactMap(ipv4CIDR)
guard !addresses.isEmpty else { throw .missing("an IPv4 Address") }
let dns = list(interface["dns"]).filter(isIPv4)
let mtu = try interface["mtu"].map { (text: String) throws(ParseError) -> Int in
guard let mtu = Int(text), (576...9000).contains(mtu) else { throw .invalid("MTU") }
return mtu
}
let keepalive = try peer["persistentkeepalive"].flatMap { (text: String) throws(ParseError) -> Int? in
if text.lowercased() == "off" { return nil }
guard let seconds = Int(text), (0...65535).contains(seconds) else { throw .invalid("PersistentKeepalive") }
return seconds == 0 ? nil : seconds
}
guard let endpoint = peer["endpoint"] else { throw .missing("Endpoint") }
guard !endpoint.hasPrefix("["), let colon = endpoint.lastIndex(of: ":"),
let port = Int(endpoint[endpoint.index(after: colon)...]), (1...65535).contains(port)
else { throw .invalid("Endpoint (an IPv4 address or host name, and a port)") }
let host = String(endpoint[..<colon])
guard !host.isEmpty, host.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "." || $0 == "-" }) else {
throw .invalid("Endpoint")
}
return WireGuardConfig(privateKey: privateKey, addresses: addresses, dns: dns, mtu: mtu,
peerPublicKey: publicKey, presharedKey: presharedKey,
endpointHost: host, endpointPort: port, persistentKeepalive: keepalive)
}
public func setconf(endpointAddress address: String) -> String {
var lines = ["[Interface]", "PrivateKey = \(privateKey)", "", "[Peer]", "PublicKey = \(peerPublicKey)"]
if let presharedKey { lines.append("PresharedKey = \(presharedKey)") }
lines.append("Endpoint = \(address):\(endpointPort)")
lines.append("AllowedIPs = 0.0.0.0/0")
lines.append("PersistentKeepalive = \(persistentKeepalive ?? 25)")
return lines.joined(separator: "\n") + "\n"
}
// MARK: Validation
/// Curve25519: 32 bytes in base64.
static func isKey(_ text: String) -> Bool {
text.count == 44 && Data(base64Encoded: text)?.count == 32
}
static func list(_ text: String?) -> [String] {
(text ?? "").split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
}
static func isIPv4(_ text: String) -> Bool {
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
return parts.count == 4 && parts.allSatisfy { part in
!part.isEmpty && part.count <= 3 && part.allSatisfy(\.isASCII) && part.allSatisfy(\.isNumber)
&& Int(part).map { $0 <= 255 } == true
}
}
static func ipv4CIDR(_ text: String) -> String? {
let parts = text.split(separator: "/", omittingEmptySubsequences: false)
guard parts.count <= 2, isIPv4(String(parts[0])) else { return nil }
guard parts.count == 2 else { return text + "/32" }
guard let prefix = Int(parts[1]), (0...32).contains(prefix), parts[1].allSatisfy(\.isNumber) else { return nil }
return text
}
}
extension NetworkRoute {
public var label: String? {
switch self {
case .direct, .unusable: nil
case .proxy(let proxy): proxy.kind == .socks5 ? "SOCKS5 proxy" : "HTTP proxy"
case .wireGuard(let config): config.name ?? "VPN"
}
}
}
extension WireGuardConfig.ParseError {
/// Finishes "The WireGuard config …".
public var message: String {
switch self {
case .missing(let what): "has no \(what)"
case .invalid(let what): "has an invalid \(what)"
case .multiplePeers: "has more than one peer; Knotchy needs one server per config"
}
}
}
public struct RouteLaunch: Equatable, Sendable {
/// Always includes KNOTCHY_ROUTE, so a VM missing its proxy or VPN settings stops.
public var environment: [String]
/// Never passed with `-e`: `container` saves the environment to disk and shows it in `inspect`.
public var secrets: [String]
}
extension NetworkRoute {
public func launch(resolve: (String) throws -> String, hostNetworks: [String] = [],
allowLocalProxy: Bool = false) throws -> RouteLaunch {
switch self {
case .direct:
return RouteLaunch(environment: ["KNOTCHY_ROUTE=direct"], secrets: [])
case .unusable(let reason):
throw VMBackendError.failed(reason)
case .proxy(let proxy):
let address = try resolve(proxy.host)
if !allowLocalProxy, ProxySettings.isLocal(address, hostNetworks: hostNetworks) {
let which = proxy.host == address ? address : "\(proxy.host) is \(address)"
throw VMBackendError.failed(
"Knotchy won't use a proxy on this Mac or your local network (\(which)): pages could reach your network through it.")
}
return RouteLaunch(environment: ["KNOTCHY_ROUTE=proxy", "KNOTCHY_PROXY_ADDR=\(address):\(proxy.port)"],
secrets: ["KNOTCHY_PROXY_URL=\(proxy.url(address: address))"])
case .wireGuard(let config):
let address = try resolve(config.endpointHost)
return RouteLaunch(
environment: [
"KNOTCHY_ROUTE=wireguard",
"KNOTCHY_WG_ENDPOINT=\(address):\(config.endpointPort)",
"KNOTCHY_WG_ADDRESS=\(config.addresses.joined(separator: " "))",
"KNOTCHY_WG_DNS=\(config.dns.joined(separator: " "))",
"KNOTCHY_WG_MTU=\(config.mtu ?? 1420)",
],
secrets: ["KNOTCHY_WG_SETCONF=\(Data(config.setconf(endpointAddress: address).utf8).base64EncodedString())"]
)
}
}
}
@@ -0,0 +1,65 @@
import Foundation
/// The controller serializes all calls except `destroyBlocking`, which the quit path may make
/// from any thread while an async call is still running.
public protocol VMBackend: Sendable {
func preflight() async -> PreflightResult
/// Must register as in flight before its first suspension so `destroyBlocking` waits for it.
func start(id: String, url: URL, renderSize: PixelSize, blockCIDRs: [String]) async throws -> VMSession
/// Returns only once the VM is confirmed gone; otherwise throws `.destroyStuck`.
func destroy(id: String) async throws
func forceDestroy(id: String) async throws
func sweepOrphans(keeping: Set<String>) async
func fix(_ issue: SetupIssue, output: @escaping @Sendable (String) -> Void) async throws
/// Not serialized by the controller: `container --version` doesn't use the service.
func runtimeStatus() async -> RuntimeStatus
func stopService() async throws
/// Blocking. Waits for in-flight starts first so a VM created at the last moment is destroyed too.
func destroyBlocking(ids: [String], deadline: Duration)
}
public struct VMSession: Equatable, Sendable {
public let id: String
public let socketPath: String
public init(id: String, socketPath: String) {
self.id = id
self.socketPath = socketPath
}
}
public enum SetupIssue: Equatable, Sendable {
case cliMissing
case cliOutdated(found: String)
case serviceStopped
case kernelMissing
case imageMissing
case imageOutdated(found: Int, need: Int)
case imageStale(builtDaysAgo: Int?)
case lowDisk(bytesFree: Int64)
}
public enum PreflightResult: Equatable, Sendable {
case ready(appRoot: String, staleImage: StaleImage?)
/// Never `.imageStale` (that rides on `.ready`) or `.lowDisk` (the controller checks disk).
case issue(SetupIssue)
}
public struct StaleImage: Equatable, Sendable {
public var builtDaysAgo: Int?
public init(builtDaysAgo: Int?) {
self.builtDaysAgo = builtDaysAgo
}
}
public enum VMBackendError: Error, Equatable, Sendable {
case destroyStuck(id: String)
/// The message is shown to the user.
case failed(String)
}
public protocol DiskSpaceChecking: Sendable {
/// Async: the query can block for tens of milliseconds and stall the panel's opening animation.
func bytesFree(atPath path: String) async -> Int64?
}
@@ -0,0 +1,117 @@
import CoreGraphics
/// Rects are in global screen coordinates (AppKit's: origin bottom left, y up).
public struct ScreenInfo: Equatable, Sendable {
public var frame: CGRect
public var visibleFrame: CGRect
public var safeAreaTop: CGFloat
public var auxLeft: CGRect
public var auxRight: CGRect
public var menuBarThickness: CGFloat
public init(frame: CGRect, visibleFrame: CGRect, safeAreaTop: CGFloat,
auxLeft: CGRect, auxRight: CGRect, menuBarThickness: CGFloat) {
self.frame = frame
self.visibleFrame = visibleFrame
self.safeAreaTop = safeAreaTop
self.auxLeft = auxLeft
self.auxRight = auxRight
self.menuBarThickness = menuBarThickness
}
public var hasNotch: Bool {
safeAreaTop > 0 && !auxLeft.isEmpty && !auxRight.isEmpty
}
}
public enum PanelMode: Sendable {
case peek
case expanded
}
/// Global screen coordinates, y up; convert them for SwiftUI views and tracking areas.
public struct PanelLayout: Equatable, Sendable {
public var notch: CGRect
public var pill: CGRect
/// Full size for its mode so the shape never clips; a Peek↔Expanded animation needs the larger.
public var panel: CGRect
public var toolbar: CGRect
public var content: CGRect
public var contentScale: CGFloat
}
public enum NotchGeometry {
public static let toolbarHeight: CGFloat = 34
public static let margin: CGFloat = 6
public static let peekContent = CGSize(width: 640, height: 400)
public static let expandedContent = CGSize(width: 1024, height: 640)
public static let renderWidth: CGFloat = 1024
public static let virtualNotchWidth: CGFloat = 200
public static let minimumNotchHeight: CGFloat = 24
public static let notchWidthRange: ClosedRange<CGFloat> = 120...300
public static let pillEarWidth: CGFloat = 72
public static let screenInset: CGFloat = 8
public static let minimumContentHeight: CGFloat = 50
public static func layout(for screen: ScreenInfo, mode: PanelMode) -> PanelLayout {
let notch = notchRect(for: screen)
let height = notch.height
let base = mode == .peek ? peekContent : expandedContent
// Ignores a side Dock: the panel may cover its top.
let maxWidth = screen.frame.width - 2 * screenInset - 2 * margin
let maxHeight = (screen.frame.maxY - screen.visibleFrame.minY) - height - toolbarHeight - margin - screenInset
// Keep exactly 16:10 in whole points: the height is a multiple of 5, the width 8/5 of it.
let fittingHeight = min(base.height, maxHeight, maxWidth * 5 / 8)
let contentHeight = max(minimumContentHeight, (fittingHeight / 5).rounded(.down) * 5)
let contentWidth = contentHeight * 8 / 5
let panelWidth = contentWidth + 2 * margin
let panelHeight = height + toolbarHeight + contentHeight + margin
let centeredX = (notch.midX - panelWidth / 2).rounded()
let panelX = min(max(centeredX, screen.frame.minX), screen.frame.maxX - panelWidth)
let panel = CGRect(x: panelX, y: screen.frame.maxY - panelHeight, width: panelWidth, height: panelHeight)
return PanelLayout(
notch: notch,
pill: notch.insetBy(dx: -pillEarWidth, dy: 0),
panel: panel,
toolbar: CGRect(x: panel.minX + margin, y: panel.maxY - height - toolbarHeight,
width: contentWidth, height: toolbarHeight),
content: CGRect(x: panel.minX + margin, y: panel.minY + margin,
width: contentWidth, height: contentHeight),
contentScale: contentWidth / renderWidth
)
}
/// `point` is in a flipped view. Nil on degenerate input: drop the event rather than click at (0, 0).
public static func videoPoint(_ point: CGPoint, surfaceSize: CGSize, renderSize: PixelSize) -> (x: UInt16, y: UInt16)? {
let pixels = 1...65536
guard point.x.isFinite, point.y.isFinite, surfaceSize.width.isFinite, surfaceSize.height.isFinite,
surfaceSize.width > 0, surfaceSize.height > 0,
pixels.contains(renderSize.width), pixels.contains(renderSize.height)
else { return nil }
let video = CGSize(width: renderSize.width, height: renderSize.height)
let scale = min(surfaceSize.width / video.width, surfaceSize.height / video.height)
guard scale > 0 else { return nil }
let originX = (surfaceSize.width - video.width * scale) / 2
let originY = (surfaceSize.height - video.height * scale) / 2
let x = min(max((point.x - originX) / scale, 0), video.width - 1)
let y = min(max((point.y - originY) / scale, 0), video.height - 1)
return (UInt16(x), UInt16(y))
}
private static func notchRect(for screen: ScreenInfo) -> CGRect {
if screen.hasNotch {
let left = screen.auxLeft.maxX
let right = screen.auxRight.minX
let width = min(max(right - left, notchWidthRange.lowerBound), notchWidthRange.upperBound)
return CGRect(x: (left + right) / 2 - width / 2, y: screen.frame.maxY - screen.safeAreaTop,
width: width, height: screen.safeAreaTop)
}
// The minimum keeps a virtual notch visible when the menu bar auto-hides.
let height = max(screen.frame.maxY - screen.visibleFrame.maxY, screen.menuBarThickness, minimumNotchHeight)
return CGRect(x: (screen.frame.midX - virtualNotchWidth / 2).rounded(), y: screen.frame.maxY - height,
width: virtualNotchWidth, height: height)
}
}
+131
View File
@@ -0,0 +1,131 @@
import Foundation
/// Input is untrusted and scanned inside the synchronous Services call, so work is linear and capped.
public enum LinkIntake {
/// UTF-8 bytes. Links reach the VM in an env var and in NAVIGATE messages (64 KiB limit).
public static let maxURLBytes = 16 * 1024
public static let maxScannedCharacters = 1024 * 1024
/// The first http(s) URL in `text`, without surrounding prose punctuation, quotes or Markdown.
public static func extractURL(from text: String) -> URL? {
let startLimit = text.index(text.startIndex, offsetBy: maxScannedCharacters, limitedBy: text.endIndex)
?? text.endIndex
// One past the limit, so an over-long link is rejected rather than truncated.
let readLimit = text.index(startLimit, offsetBy: maxURLBytes + 1, limitedBy: text.endIndex) ?? text.endIndex
var from = text.startIndex
while from < startLimit,
let match = text.range(of: "http", options: .caseInsensitive, range: from..<startLimit) {
let end = text[match.lowerBound..<readLimit].firstIndex(where: endsURL) ?? readLimit
let token = text[match.lowerBound..<end]
let before = match.lowerBound > text.startIndex ? text[text.index(before: match.lowerBound)] : nil
var parts = [(token, afterParenthesis: before == "(")]
if let markdown = token.range(of: "](") {
parts = [(token[..<markdown.lowerBound], before == "("), (token[markdown.upperBound...], true)]
}
for (whole, afterParenthesis) in parts {
let part = afterParenthesis ? upToUnbalancedClosingParenthesis(whole) : whole
let candidate = trimmingTrailingPunctuation(part, stripping: before == "*" ? ".,;:!?\"'*" : ".,;:!?\"'")
if candidate.utf8.count <= maxURLBytes,
let url = URL(string: String(candidate)),
let valid = validate(url) {
return valid
}
}
from = end
}
return nil
}
/// Tries `href` targets first: a selected hyperlink's visible text rarely contains its address.
public static func extractURL(fromHTML html: String) -> URL? {
let text = String(html.prefix(maxScannedCharacters))
let range = NSRange(text.startIndex..., in: text)
for match in hrefPattern.matches(in: text, range: range) {
for group in 1...3 {
guard let found = Range(match.range(at: group), in: text) else { continue }
let target = decodingEntities(text[found]).trimmingCharacters(in: .whitespacesAndNewlines)
if target.utf8.count <= maxURLBytes, let url = URL(string: target), let valid = validate(url) {
return valid
}
}
}
return extractURL(from: text)
}
private static let hrefPattern = try! NSRegularExpression(
pattern: #"href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))"#, options: [.caseInsensitive])
private static func decodingEntities(_ value: Substring) -> String {
var result = String(value)
for (entity, character) in [("&quot;", "\""), ("&#34;", "\""), ("&#39;", "'"), ("&apos;", "'"),
("&lt;", "<"), ("&gt;", ">"), ("&#38;", "&"), ("&amp;", "&")] {
result = result.replacingOccurrences(of: entity, with: character, options: .caseInsensitive)
}
return result
}
public static func validate(_ url: URL) -> URL? {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https",
let host = url.host(percentEncoded: false), !host.isEmpty,
url.absoluteString.utf8.count <= maxURLBytes
else { return nil }
return url
}
/// Missed by the category check. IDNA would turn these full stops into dots in the host.
private static let cjkPunctuation: Set<Character> = ["。", ".", "。", "、", ",", "…", "!", "?", ":", ";"]
private static func endsURL(_ c: Character) -> Bool {
if c.isWhitespace || c == "<" || c == ">" || c == "\"" || c == "`" { return true }
guard let scalar = c.unicodeScalars.first, !scalar.isASCII else { return false }
switch scalar.properties.generalCategory {
case .initialPunctuation, .finalPunctuation, .openPunctuation, .closePunctuation:
return true
default:
return cjkPunctuation.contains(c)
}
}
private static func upToUnbalancedClosingParenthesis(_ part: Substring) -> Substring {
var depth = 0
for i in part.indices {
switch part[i] {
case "(":
depth += 1
case ")":
if depth == 0 { return part[..<i] }
depth -= 1
default:
break
}
}
return part
}
private static let openerOf: [Character: Character] = [")": "(", "]": "[", "}": "{"]
private static let closerOf: [Character: Character] = ["(": ")", "[": "]", "{": "}"]
private static func trimmingTrailingPunctuation(_ token: Substring, stripping punctuation: String) -> Substring {
// Closing minus opening brackets, keyed by the closing one.
var unbalanced: [Character: Int] = [:]
for c in token {
if openerOf[c] != nil {
unbalanced[c, default: 0] += 1
} else if let closer = closerOf[c] {
unbalanced[closer, default: 0] -= 1
}
}
var s = token
while let last = s.last {
if punctuation.contains(last) {
s.removeLast()
} else if openerOf[last] != nil, unbalanced[last, default: 0] > 0 {
unbalanced[last, default: 0] -= 1
s.removeLast()
} else {
break
}
}
return s
}
}
@@ -0,0 +1,49 @@
import Synchronization
/// Runs async jobs one at a time, in order, so backend operations never overlap.
@MainActor
final class BackendChain {
private var tail: Task<Void, Never>?
private(set) var isClosed = false
func enqueue(_ job: @escaping @MainActor () async -> Void) {
guard !isClosed else { return }
let previous = tail
tail = Task {
await previous?.value
guard !self.isClosed else { return }
await job()
}
}
func close() {
isClosed = true
}
func waitUntilIdle() async {
while let current = tail {
await current.value
if tail == current { return }
}
}
}
/// Every VM ID from its `start` being queued until its destroy is confirmed.
/// Lock-protected: the quit path reads it off the main actor.
public final class LiveIDs: Sendable {
private let ids = Mutex<Set<String>>([])
public init() {}
public var snapshot: Set<String> {
ids.withLock { $0 }
}
func insert(_ id: String) {
_ = ids.withLock { $0.insert(id) }
}
func remove(_ id: String) {
_ = ids.withLock { $0.remove(id) }
}
}
@@ -0,0 +1,956 @@
import Foundation
import Observation
/// The single-session state machine behind the notch UI. Inputs never block; backend calls run serially.
@MainActor
@Observable
public final class SessionController {
// MARK: - Timings and limits
public static let renderSize = PixelSize(width: 1024, height: 640)
public static let foldGrace: Duration = .milliseconds(400)
public static let neverEnteredFoldDelay: Duration = .seconds(30)
public static let defaultIdleDestroyDelay: Duration = .seconds(5 * 60)
public static let idleQuitDelay: Duration = .seconds(60)
public static let destroyedDisplay: Duration = .milliseconds(1500)
public static let endedDisplay: Duration = .seconds(3)
public static let destroyStuckDisplay: Duration = .seconds(30)
public static let noticeDisplay: Duration = .seconds(2)
public static let progressLinger: Duration = .seconds(2)
public static let minimumFreeBytes: Int64 = 2_000_000_000
public static let setupOutputLimit = 200
public static let stillBrokenMessage = "Setup finished, but the runtime still reports a problem."
// MARK: - Outputs
public private(set) var state: SessionState = .idle
// MARK: - Preferences
/// Takes effect the next time a session folds.
public var idleDestroyDelay: Duration = SessionController.defaultIdleDestroyDelay
public var opensExpanded = false
public var quitsWhenIdle = true
/// Opens the setup window; the app sets it.
@ObservationIgnored public var showSetup: @MainActor () -> Void = {}
public private(set) var setupFolded = false
@ObservationIgnored private var setupOpenedByHover = false
public private(set) var notice: Notice?
public private(set) var progress: Int?
public private(set) var openedURL: URL?
/// Reported by the VM, so untrusted: shown, never opened or retried.
public private(set) var pageURL: URL?
public private(set) var setupLog: String?
/// The latest setup action's last lines, for the setup window's details.
public private(set) var setupOutput: [String] = []
public private(set) var connection: (any StreamConnection)?
public private(set) var staleImage: StaleImage?
public private(set) var idleDeadline: ContinuousClock.Instant?
public var countdown: Duration? {
idleDeadline.map { max(.zero, $0 - clock.now) }
}
public nonisolated let liveIDs = LiveIDs()
// MARK: - Dependencies
private let backend: any VMBackend
private let connector: any StreamConnector
private let diskSpace: any DiskSpaceChecking
private let clock: any KnotchyClock
private let hostNetworks: () -> [String]
private let requestQuit: @MainActor () -> Void
private let makeSessionID: () -> String
// MARK: - Internal state
@MainActor
private final class Session {
let id: String
let url: URL
/// `backend.start` has been called, so a VM may exist.
var startBegan = false
var retired = false
var contentReady = false
var connection: (any StreamConnection)?
var tasks: [Task<Void, Never>] = []
init(id: String, url: URL) {
self.id = id
self.url = url
}
}
private enum TimerKey: Hashable, Sendable {
case foldGrace, neverEnteredFold, idleDestroy, transient, notice, progressHide, idleQuit
}
private enum RunMode: Equatable {
/// From the notch or the menu: stops at the next card, as before.
case link
/// From the setup window.
case window(continuing: Bool)
/// The setup window's check: starts the service, never builds.
case check
}
private struct SetupRun {
var mode: RunMode
/// The loop guard: each issue is fixed at most once per run.
var fixed: [SetupIssue]
var waiter: CheckedContinuation<SetupRunOutcome, Never>?
}
private let chain = BackendChain()
private let timers: DeadlineTimers<TimerKey>
// Invariants: at most one session isn't retired, and it is `current`; only `transition(to:)`
// writes `state` (and owns its timers); while `settingUp`, only the setup outcome changes it.
@ObservationIgnored private var current: Session?
@ObservationIgnored private var pendingURL: URL?
/// IDs whose destroy failed; they stay in `liveIDs`.
@ObservationIgnored private var pendingCleanup: [String] = []
@ObservationIgnored private var requestedImageUpdate: StaleImage?
@ObservationIgnored private var lastProgressAt: ContinuousClock.Instant?
@ObservationIgnored private var entered = false
@ObservationIgnored private var pointerInside = false
@ObservationIgnored private var focused = false
@ObservationIgnored private var run: SetupRun?
@ObservationIgnored private var settleWaiters: [CheckedContinuation<Void, Never>] = []
/// The issues started automatically for the link now waiting; each is tried once per request.
@ObservationIgnored private var autoFixed: (url: URL, issues: [SetupIssue])?
public init(
backend: any VMBackend,
connector: any StreamConnector,
diskSpace: any DiskSpaceChecking,
clock: any KnotchyClock = SystemClock(),
hostNetworks: @escaping () -> [String],
requestQuit: @escaping @MainActor () -> Void,
makeSessionID: @escaping () -> String = SessionController.randomSessionID
) {
self.backend = backend
self.connector = connector
self.diskSpace = diskSpace
self.clock = clock
self.hostNetworks = hostNetworks
self.requestQuit = requestQuit
self.makeSessionID = makeSessionID
timers = DeadlineTimers(clock: clock)
timers.onFire = { [weak self] key in self?.timerFired(key) }
enqueue { [backend] in await backend.sweepOrphans(keeping: []) }
timers.set(.idleQuit, after: Self.idleQuitDelay)
}
/// `knotchy-<8 hex>`: the only names the launch sweep deletes.
public nonisolated static func randomSessionID() -> String {
"knotchy-" + String(format: "%08x", UInt32.random(in: .min ... .max))
}
// MARK: - Links
/// A new link always wins. Anything but http/https shows "No web link found".
public func open(_ url: URL) {
guard let url = LinkIntake.validate(url) else { return show(.noWebLink) }
guard !chain.isClosed else { return }
autoFixed = nil
if case .settingUp = state {
pendingURL = url
setupFolded = false
} else {
startSession(url)
}
}
public func open(text: String) {
guard let url = LinkIntake.extractURL(from: text) else { return show(.noWebLink) }
open(url)
}
// MARK: - Toolbar
public func navigate(_ url: URL) {
guard let url = LinkIntake.validate(url) else { return show(.noWebLink) }
guard case .live = state, let connection else { return }
openedURL = url
pageURL = nil
connection.send(.navigate(url.absoluteString))
}
public func goBack() { runScript("history.back()") }
public func goForward() { runScript("history.forward()") }
public func reload() { runScript("location.reload()") }
private func runScript(_ javaScript: String) {
guard case .live = state else { return }
connection?.send(.script(javaScript))
}
// MARK: - Panel
public func expand() {
guard state == .live(.peek) else { return }
entered = true
focused = true
transition(to: .live(.expanded))
}
public func shrink() {
guard state == .live(.expanded) else { return }
transition(to: .live(.peek))
}
public func pointerEntered() {
pointerInside = true
timers.cancel(.foldGrace)
switch state {
case .settingUp:
if setupFolded { setupOpenedByHover = true }
setupFolded = false
case .starting(let step, folded: true):
unfold(to: .starting(step, folded: false))
case .live(.collapsed):
unfold(to: .live(.peek))
case .starting, .live:
entered = true
timers.cancel(.neverEnteredFold)
default:
break
}
}
public func pointerExited() {
pointerInside = false
if isPeekLike, entered, !focused {
timers.set(.foldGrace, after: Self.foldGrace)
}
if case .settingUp = state, setupOpenedByHover {
timers.set(.foldGrace, after: Self.foldGrace)
}
}
public func panelClicked() {
guard isOpenPanel else { return }
entered = true
focused = true
timers.cancel(.foldGrace)
timers.cancel(.neverEnteredFold)
}
public func clickedOutside() {
if isOpenPanel { fold() }
if case .settingUp = state { setupFolded = true }
}
public func foldSetup() {
if case .settingUp = state { setupFolded = true }
setupOpenedByHover = false
}
private var isPeekLike: Bool {
switch state {
case .starting(_, folded: false), .live(.peek): true
default: false
}
}
private var isOpenPanel: Bool {
isPeekLike || state == .live(.expanded)
}
private var isFolded: Bool {
if case .starting(_, folded: true) = state { true } else { false }
}
private func fold() {
focused = false
pointerInside = false
switch state {
case .starting(let step, folded: false):
transition(to: .starting(step, folded: true))
case .live(.peek), .live(.expanded):
transition(to: .live(.collapsed))
default:
break
}
}
private func unfold(to newState: SessionState) {
entered = true
focused = false
transition(to: newState)
}
// MARK: - Destroy, retry, dismiss
public func destroy() {
guard let session = current else { return }
switch state {
case .starting, .live:
transition(to: .destroying)
retire(session) { [weak self] gone in self?.destroyFinished(session, gone: gone) }
default:
break
}
}
public func retry() {
switch state {
case .failed(.start):
autoFixed = nil // A retry is a new request: it gets its own automatic start.
if let url = current?.url { startSession(url, pointerStays: true) }
case .failed(.destroyStuck):
guard let session = current else { return }
pendingCleanup.removeAll { $0 == session.id }
transition(to: .destroying)
enqueue { [weak self] in
await self?.runDestroy(session) { gone in self?.destroyFinished(session, gone: gone) }
}
default:
break
}
}
public func dismiss() {
switch state {
case .failed(.start):
enterIdle()
case .setupRequired:
pendingURL = nil
enterIdle()
default:
break
}
}
// MARK: - Setup
public func performSetupAction() {
guard case .setupRequired(let issue, _) = state else { return }
switch issue {
case .cliMissing, .cliOutdated:
showSetup()
case .lowDisk:
break
default:
startRun(issue, mode: .link, folded: false)
}
}
/// Rebuilds the image once this session ends.
public func requestImageUpdate() {
guard let stale = staleImage else { return }
staleImage = nil
requestedImageUpdate = stale
}
public func rebuildImage() {
switch state {
case .idle:
startRun(.imageStale(builtDaysAgo: nil), mode: .link, folded: false)
case .settingUp:
break
default:
staleImage = nil
requestedImageUpdate = StaleImage(builtDaysAgo: nil)
}
}
public func showPanel() {
switch state {
case .settingUp:
setupFolded = false
setupOpenedByHover = false
case .starting(let step, folded: true): unfold(to: .starting(step, folded: false))
case .live(.collapsed): unfold(to: .live(.peek))
default: break
}
}
public func dismissStaleImageWarning() {
staleImage = nil
}
/// Quit path: queued backend jobs never start after this. One already running may still call
/// the backend, which is safe because `destroyBlocking` then blocks the main thread until exit.
public func closeBackendChain() {
chain.close()
}
// MARK: - Setup window
/// Runs `issue`'s fix folded into the notch; when `continuing`, also each fixable issue preflight
/// finds next, each at most once. A waiting link opens only once everything is ready. nil when busy.
public func runSetup(_ issue: SetupIssue, continuing: Bool) async -> SetupRunOutcome? {
guard acceptsSetupWindow else { return nil }
return await withCheckedContinuation { continuation in
startRun(issue, mode: .window(continuing: continuing), folded: true, waiter: continuation)
}
}
/// Preflight for the setup window: starts a stopped service or missing kernel, never builds. nil when busy.
public func checkSetup() async -> SetupRunOutcome? {
guard acceptsSetupWindow else { return nil }
let preflight = await onChain { [backend] in await backend.preflight() }
guard acceptsSetupWindow else { return nil }
switch preflight {
case .ready:
// Fixed outside Knotchy while a card held a link: open it.
if case .setupRequired = state, let url = pendingURL { startSession(url, pointerStays: true) }
return .ready
case .issue(let issue) where Self.fixes(issue, in: .check):
return await withCheckedContinuation { continuation in
startRun(issue, mode: .check, folded: true, waiter: continuation)
}
case .issue(let issue):
return .needs(issue)
}
}
/// Around a runtime install: links wait, and an update stops the service first.
public func beginRuntimeInstall(replacing found: String?) async -> Result<Void, RuntimeHoldRefusal> {
guard acceptsSetupWindow else {
if case .settingUp = state { return .failure(.setupRunning) }
return .failure(.sessionOpen)
}
setupLog = nil
setupOutput = []
transition(to: .settingUp(found.map { .cliOutdated(found: $0) } ?? .cliMissing))
setupFolded = true
guard found != nil else { return .success(()) }
let stopped: String? = await onChain { [backend] in
do {
try await backend.stopService()
return nil
} catch {
return Self.message(for: error)
}
}
guard let stopped else { return .success(()) }
_ = await finishRuntimeInstall(.failed(stopped))
return .failure(.stopFailed(stopped))
}
/// After an install, starts the service and builds, like `runSetup(continuing: true)`.
public func finishRuntimeInstall(_ result: RuntimeInstallResult) async -> SetupRunOutcome {
guard case .settingUp(let issue) = state, Self.isRuntimeInstall(issue), run == nil else {
return .failed(.cliMissing, "No runtime install was in progress.")
}
switch result {
case .installed:
return await withCheckedContinuation { continuation in
startRun(.serviceStopped, mode: .window(continuing: true), folded: false, waiter: continuation)
}
case .cancelled:
endHold(issue, error: nil)
return .needs(issue)
case .failed(let message):
endHold(issue, error: message)
return .failed(issue, message)
}
}
private func endHold(_ issue: SetupIssue, error: String?) {
if pendingURL != nil { transition(to: .setupRequired(issue, error: error)) } else { enterIdle() }
}
private static func isRuntimeInstall(_ issue: SetupIssue) -> Bool {
switch issue {
case .cliMissing, .cliOutdated: true
default: false
}
}
public func waitWhileSettingUp() async {
guard case .settingUp = state else { return }
await withCheckedContinuation { settleWaiters.append($0) }
}
public func runtimeStatus() async -> RuntimeStatus {
await backend.runtimeStatus()
}
/// The states where no VM exists. A stuck destroy may still have one.
private var acceptsSetupWindow: Bool {
switch state {
case .idle, .setupRequired, .failed(.start), .failed(.ended), .destroyed: true
default: false
}
}
/// After `closeBackendChain()` the job never runs and this never returns; that only happens while quitting.
private func onChain<T: Sendable>(_ job: @escaping @MainActor () async -> T) async -> T {
await withCheckedContinuation { continuation in
enqueue { continuation.resume(returning: await job()) }
}
}
// MARK: - Session lifecycle
private func startSession(_ url: URL, pointerStays: Bool = false) {
let pointerOnPanel = pointerStays && pointerInside
let startsFolded: Bool
if case .settingUp = state { startsFolded = setupFolded } else { startsFolded = false }
if let old = current { retire(old) }
let session = Session(id: makeSessionID(), url: url)
current = session
pendingURL = nil
setupLog = nil
openedURL = url
pageURL = nil
connection = nil
staleImage = nil
progress = nil
lastProgressAt = nil
entered = pointerOnPanel
pointerInside = pointerOnPanel
focused = false
for key in [TimerKey.foldGrace, .neverEnteredFold, .progressHide] { timers.cancel(key) }
liveIDs.insert(session.id)
transition(to: .starting(.booting, folded: startsFolded))
enqueue { [weak self] in await self?.runStart(session) }
}
private func retire(_ session: Session, onGone: (@MainActor (Bool) -> Void)? = nil) {
guard !session.retired else { return }
session.retired = true
for task in session.tasks { task.cancel() }
session.connection?.close()
session.connection = nil
if current === session {
connection = nil
progress = nil
timers.cancel(.progressHide)
}
enqueue { [weak self] in await self?.runDestroy(session, onGone: onGone) }
}
private func runStart(_ session: Session) async {
guard !session.retired else { return }
let preflight = await backend.preflight()
guard !session.retired else { return }
switch preflight {
case .issue(let issue):
return needsSetup(issue, for: session)
case .ready(let appRoot, let stale):
let free = await diskSpace.bytesFree(atPath: appRoot)
guard !session.retired else { return }
if let free, free < Self.minimumFreeBytes {
return needsSetup(.lowDisk(bytesFree: free), for: session)
}
if requestedImageUpdate == nil { staleImage = stale }
}
session.startBegan = true
do {
let vm = try await backend.start(id: session.id, url: session.url,
renderSize: Self.renderSize, blockCIDRs: hostNetworks())
guard !session.retired else { return } // Superseded mid-start: its destroy job runs next.
transition(to: .starting(.connecting, folded: isFolded))
session.tasks.append(Task { [weak self] in await self?.connect(session, socketPath: vm.socketPath) })
} catch {
guard !session.retired else { return }
retire(session)
transition(to: .failed(.start(Self.message(for: error))))
}
}
/// No VM exists yet, so there is nothing to destroy. Starting the service needs no password, so it just happens.
private func needsSetup(_ issue: SetupIssue, for session: Session) {
session.retired = true
liveIDs.remove(session.id)
pendingURL = session.url
guard issue == .serviceStopped || issue == .kernelMissing else {
return transition(to: .setupRequired(issue, error: nil))
}
var tried = autoFixed?.url == session.url ? autoFixed?.issues ?? [] : []
guard !tried.contains(where: { Self.sameFix($0, issue) }) else {
return transition(to: .setupRequired(issue, error: Self.stillBrokenMessage))
}
tried.append(issue)
autoFixed = (session.url, tried)
startRun(issue, mode: .link, folded: false)
}
private func connect(_ session: Session, socketPath: String) async {
let outcome: Result<any StreamConnection, any Error>
do {
outcome = .success(try await connector.connect(socketPath: socketPath))
} catch {
outcome = .failure(error)
}
guard !session.retired else {
if case .success(let late) = outcome { late.close() }
return
}
switch outcome {
case .success(let stream):
session.connection = stream
connection = stream
transition(to: .starting(.loading, folded: isFolded))
session.tasks.append(Task { [weak self] in
for await event in stream.events { self?.handle(event, from: session) }
// A stream that finishes without `.ended` has still ended.
self?.handle(.ended(.closed), from: session)
})
case .failure(StreamError.versionMismatch(let found, let need)):
retire(session)
pendingURL = session.url
transition(to: .setupRequired(.imageOutdated(found: found, need: need), error: nil))
case .failure(let error):
retire(session)
transition(to: .failed(.start(Self.message(for: error))))
}
}
private func handle(_ event: StreamEvent, from session: Session) {
guard current === session, !session.retired else { return }
switch event {
case .hello:
break
case .progress(let percent):
progressChanged(percent)
case .pageURL(let url):
pageURL = url
case .contentReady:
contentReady(session)
case .ended(let reason):
let wasStarting = if case .starting = state { true } else { false }
retire(session)
transition(to: .failed(wasStarting ? .start(Self.message(for: reason)) : .ended(reason)))
}
}
private func contentReady(_ session: Session) {
guard case .starting(_, let folded) = state, !session.contentReady else { return }
session.contentReady = true
if folded {
transition(to: .live(.collapsed))
} else if opensExpanded {
transition(to: .live(.expanded))
} else {
transition(to: .live(.peek))
if !entered { timers.set(.neverEnteredFold, after: Self.neverEnteredFoldDelay) }
}
scheduleProgressHide()
}
private func runDestroy(_ session: Session, onGone: (@MainActor (Bool) -> Void)?) async {
guard session.startBegan else {
liveIDs.remove(session.id)
onGone?(true)
return
}
do {
try await backend.destroy(id: session.id)
pendingCleanup.removeAll { $0 == session.id }
liveIDs.remove(session.id)
onGone?(true)
} catch {
if !pendingCleanup.contains(session.id) { pendingCleanup.append(session.id) }
onGone?(false)
}
}
private func destroyFinished(_ session: Session, gone: Bool) {
guard current === session, state == .destroying else { return }
transition(to: gone ? .destroyed : .failed(.destroyStuck))
}
private func enterIdle() {
openedURL = nil
pageURL = nil
progress = nil
staleImage = nil
setupLog = nil
connection = nil
entered = false
pointerInside = false
focused = false
if let update = requestedImageUpdate {
requestedImageUpdate = nil
return startRun(.imageStale(builtDaysAgo: update.builtDaysAgo), mode: .link, folded: false)
}
transition(to: .idle)
}
// MARK: - Progress bar
private func progressChanged(_ percent: Int) {
lastProgressAt = clock.now
if percent >= 100 {
progress = nil
timers.cancel(.progressHide)
} else {
progress = percent
scheduleProgressHide()
}
}
/// Hides the bar 2 s after the last PROGRESS, so a load that never reports 100 doesn't leave it up.
private func scheduleProgressHide() {
guard current?.contentReady == true, progress != nil, let last = lastProgressAt else { return }
let hideAt = last + Self.progressLinger
if hideAt <= clock.now {
progress = nil
} else {
timers.set(.progressHide, at: hideAt)
}
}
// MARK: - Setup actions
private func runSetupAction(_ issue: SetupIssue, folded: Bool = false) {
setupLog = nil
setupOutput = []
transition(to: .settingUp(issue))
if folded { setupFolded = true }
let (lines, sink) = AsyncStream.makeStream(of: String.self)
let showLines = Task { [weak self] in
for await line in lines { self?.record(line) }
}
enqueue { [weak self, backend] in
let outcome: Result<Void, any Error>
do {
try await backend.fix(issue) { sink.yield($0) }
outcome = .success(())
} catch {
outcome = .failure(error)
}
sink.finish()
await showLines.value // Every line is shown before the outcome.
await self?.setupFinished(issue, outcome)
}
}
private func record(_ line: String) {
setupLog = line
setupOutput.append(line)
if setupOutput.count > Self.setupOutputLimit {
setupOutput.removeFirst(setupOutput.count - Self.setupOutputLimit)
}
}
private func startRun(_ issue: SetupIssue, mode: RunMode, folded: Bool,
waiter: CheckedContinuation<SetupRunOutcome, Never>? = nil) {
if run != nil { endRun(.needs(issue)) } // Never happens today; it keeps "every waiter resumes once" true.
run = SetupRun(mode: mode, fixed: [issue], waiter: waiter)
runSetupAction(issue, folded: folded)
}
private func endRun(_ outcome: SetupRunOutcome) {
let waiter = run?.waiter
run = nil
waiter?.resume(returning: outcome)
}
/// Link runs leave a card, as before; window runs leave one only for a waiting link.
private func stop(_ outcome: SetupRunOutcome, card: SessionState, mode: RunMode) {
endRun(outcome)
if mode == .link || pendingURL != nil { transition(to: card) } else { enterIdle() }
}
private static func fixes(_ issue: SetupIssue, in mode: RunMode) -> Bool {
switch (mode, issue) {
case (.window(continuing: true), .serviceStopped), (.window(continuing: true), .kernelMissing),
(.window(continuing: true), .imageMissing), (.window(continuing: true), .imageOutdated),
(.check, .serviceStopped), (.check, .kernelMissing):
true
default:
false
}
}
/// The backend runs one command for each group, so another issue from the same group means it didn't help.
private static func sameFix(_ a: SetupIssue, _ b: SetupIssue) -> Bool {
func group(_ issue: SetupIssue) -> Int? {
switch issue {
case .serviceStopped, .kernelMissing: 0
case .imageMissing, .imageOutdated, .imageStale: 1
default: nil
}
}
guard let first = group(a) else { return a == b }
return first == group(b)
}
private func setupFinished(_ issue: SetupIssue, _ outcome: Result<Void, any Error>) async {
guard state == .settingUp(issue) else { return }
let mode = run?.mode ?? .link
if case .failure(let error) = outcome {
let message = Self.message(for: error)
// A failed "Update now" rebuild never blocks a link.
if case .imageStale = issue, let url = pendingURL {
endRun(.failed(issue, message))
return startSession(url, pointerStays: true)
}
return stop(.failed(issue, message), card: .setupRequired(issue, error: message), mode: mode)
}
switch issue {
case .imageMissing, .imageOutdated, .imageStale: requestedImageUpdate = nil
default: break
}
if mode == .link, let url = pendingURL {
endRun(.ready)
return startSession(url, pointerStays: true)
}
let preflight = await backend.preflight()
guard state == .settingUp(issue) else { return }
switch preflight {
case .ready:
endRun(.ready)
if let url = pendingURL { return startSession(url, pointerStays: true) }
enterIdle()
case .issue(let next) where mode == .link:
endRun(.needs(next))
if let url = pendingURL { return startSession(url, pointerStays: true) }
transition(to: .setupRequired(next, error: nil))
case .issue(let next) where run?.fixed.contains(where: { Self.sameFix($0, next) }) == true:
stop(.failed(next, Self.stillBrokenMessage), card: .setupRequired(next, error: Self.stillBrokenMessage), mode: mode)
case .issue(let next) where Self.fixes(next, in: mode):
run?.fixed.append(next)
runSetupAction(next)
case .issue(let next):
stop(.needs(next), card: .setupRequired(next, error: nil), mode: mode)
}
}
// MARK: - Backend chain
/// After each job, one VM left by an earlier failed destroy gets a retry, taking turns.
private func enqueue(_ job: @escaping @MainActor () async -> Void) {
chain.enqueue { [weak self] in
let stuckBefore = self?.pendingCleanup ?? []
await job()
await self?.retryPendingCleanup(among: stuckBefore)
}
}
private func retryPendingCleanup(among candidates: [String]) async {
guard let id = candidates.first(where: pendingCleanup.contains) else { return }
do {
try await backend.forceDestroy(id: id)
pendingCleanup.removeAll { $0 == id }
liveIDs.remove(id)
} catch {
// Move it to the back so a VM stuck for good doesn't starve the others.
pendingCleanup.removeAll { $0 == id }
pendingCleanup.append(id)
}
}
// MARK: - State and timers
private func transition(to newState: SessionState) {
let oldState = state
guard newState != oldState else { return }
state = newState
if case .settingUp = newState {} else {
setupFolded = false
setupOpenedByHover = false
}
if !isPeekLike { timers.cancel(.foldGrace) }
if newState != .live(.peek) { timers.cancel(.neverEnteredFold) }
if newState == .live(.collapsed) {
timers.set(.idleDestroy, after: idleDestroyDelay)
idleDeadline = timers.deadline(of: .idleDestroy)
connection?.decodeEnabled = false
} else if oldState == .live(.collapsed) {
timers.cancel(.idleDestroy)
idleDeadline = nil
if case .live = newState { connection?.decodeEnabled = true }
}
switch newState {
case .destroyed: timers.set(.transient, after: Self.destroyedDisplay)
case .failed(.ended): timers.set(.transient, after: Self.endedDisplay)
case .failed(.destroyStuck): timers.set(.transient, after: Self.destroyStuckDisplay)
default: timers.cancel(.transient)
}
if newState == .idle {
timers.set(.idleQuit, after: Self.idleQuitDelay)
} else {
timers.cancel(.idleQuit)
}
if case .settingUp(let issue) = oldState {
if case .settingUp = newState {} else { settled(after: issue) }
}
}
/// So a waiting setup window never hangs, whatever ended the setup.
private func settled(after issue: SetupIssue) {
if run != nil { endRun(.needs(issue)) }
let waiters = settleWaiters
settleWaiters = []
for waiter in waiters { waiter.resume() }
}
private func show(_ newNotice: Notice) {
notice = newNotice
timers.set(.notice, after: Self.noticeDisplay)
}
private func timerFired(_ key: TimerKey) {
switch key {
case .foldGrace:
if isPeekLike, entered, !pointerInside, !focused { fold() }
if case .settingUp = state, setupOpenedByHover, !pointerInside {
setupOpenedByHover = false
setupFolded = true
}
case .neverEnteredFold:
if state == .live(.peek), !entered { fold() }
case .idleDestroy:
destroy()
case .transient:
enterIdle()
case .notice:
notice = nil
case .progressHide:
progress = nil
case .idleQuit:
if quitsWhenIdle { requestQuit() }
}
}
nonisolated static func message(for error: any Error) -> String {
switch error {
case VMBackendError.failed(let message): message
case StreamError.protocolViolation(let detail): "The VM sent a malformed message: \(detail)."
case StreamError.timedOut: "The VM didn't answer in time."
case StreamError.refused: "Couldn't connect to the VM."
default: String(describing: error)
}
}
nonisolated static func message(for reason: EndReason) -> String {
switch reason {
case .closed: "The VM closed the connection."
case .protocolViolation(let detail): "The VM sent a malformed message: \(detail)."
case .noFirstFrame: "The VM didn't send a first frame in time."
case .decodeErrors: "The video stream couldn't be decoded."
case .socketError(let detail): "The connection to the VM failed: \(detail)."
}
}
// MARK: - Test hooks
func fireDueTimers() {
timers.fireDue()
}
func waitForBackendIdle() async {
await chain.waitUntilIdle()
}
/// Call once the session is retired: a live session's events task runs until its stream ends.
func waitForSessionTasks() async {
for task in current?.tasks ?? [] { await task.value }
}
}
@@ -0,0 +1,36 @@
public enum SessionState: Equatable, Sendable {
case idle
case starting(StartStep, folded: Bool)
case live(LiveMode)
case destroying
case destroyed
case failed(SessionFailure)
case setupRequired(SetupIssue, error: String?)
case settingUp(SetupIssue)
}
public enum StartStep: Sendable {
case booting
/// Waiting for HELLO.
case connecting
/// Waiting for `ContentReadiness`.
case loading
}
public enum LiveMode: Sendable {
case peek
case expanded
case collapsed
}
public enum SessionFailure: Equatable, Sendable {
case start(String)
case ended(EndReason)
/// The VM stays on the pending-cleanup list.
case destroyStuck
}
/// A transient pill, shown whatever the state.
public enum Notice: Equatable, Sendable {
case noWebLink
}
@@ -0,0 +1,20 @@
/// How a setup run started by the setup window ended.
public enum SetupRunOutcome: Equatable, Sendable {
/// Preflight is ready; a waiting link may have started.
case ready
/// Stopped at an issue this kind of run doesn't fix.
case needs(SetupIssue)
case failed(SetupIssue, String)
}
public enum RuntimeHoldRefusal: Error, Equatable, Sendable {
case sessionOpen
case setupRunning
case stopFailed(String)
}
public enum RuntimeInstallResult: Equatable, Sendable {
case installed
case cancelled
case failed(String)
}
@@ -0,0 +1,55 @@
import Foundation
/// Apple's container runtime: the release Knotchy installs, and how an installed one is judged.
public enum ContainerRuntime {
public struct Release: Equatable, Sendable {
public let version: String
public let url: URL
public let sha256: String
public let size: Int64
public init(version: String, url: URL, sha256: String, size: Int64) {
self.version = version
self.url = url
self.sha256 = sha256
self.size = size
}
}
/// The only package Knotchy ever installs. Bump it only after testing a new release.
public static let pinned = Release(
version: "1.4.1",
url: URL(string: "https://github.com/apple/container/releases/download/1.4.1/container-1.4.1-installer-signed.pkg")!,
sha256: "c0d2716afefbb194c93fae662e9cae7cc186bcbcf746816608ec673dd648a6a4",
size: 117_773_865)
public static let minimumVersion = "1.4.0"
public static func version(in output: String) -> String? {
output.firstMatch(of: #/container CLI version (\d+(?:\.\d+)+)/#).map { String($0.1) }
}
/// Output Knotchy can't read counts as current, so a new format can never cause a downgrade.
public static func status(versionOutput: String?) -> RuntimeStatus {
guard let versionOutput else { return .missing }
guard let found = version(in: versionOutput) else { return .current(nil) }
return isOlder(found, than: minimumVersion) ? .outdated(found) : .current(found)
}
public static func isOlder(_ version: String, than other: String) -> Bool {
let lhs = version.split(separator: ".").map { Int($0) ?? 0 }
let rhs = other.split(separator: ".").map { Int($0) ?? 0 }
for index in 0..<max(lhs.count, rhs.count) {
let a = index < lhs.count ? lhs[index] : 0
let b = index < rhs.count ? rhs[index] : 0
if a != b { return a < b }
}
return false
}
}
public enum RuntimeStatus: Equatable, Sendable {
case missing
case outdated(String)
/// nil: installed, but its version couldn't be read.
case current(String?)
}
@@ -0,0 +1,44 @@
import Foundation
import Virtualization
/// Knotchy is built for Apple silicon only; macOS itself refuses to open it on an Intel Mac.
public enum Unsupported: Equatable, Sendable {
case noVirtualization
}
public struct HostFacts: Equatable, Sendable {
public var unsupported: Unsupported?
public var osVersion: String
public var bytesFree: Int64?
public init(unsupported: Unsupported?, osVersion: String, bytesFree: Int64?) {
self.unsupported = unsupported
self.osVersion = osVersion
self.bytesFree = bytesFree
}
}
public protocol HostChecking: Sendable {
func facts() async -> HostFacts
}
public struct SystemHost: HostChecking {
public init() {}
/// Synchronous, so the app can decide on minimal mode before it registers anything.
@MainActor
public static func unsupported() -> Unsupported? {
VZVirtualMachine.isSupported ? nil : .noVirtualization
}
public func facts() async -> HostFacts {
let unsupported = await Self.unsupported()
let version = ProcessInfo.processInfo.operatingSystemVersion
var os = "macOS \(version.majorVersion).\(version.minorVersion)"
if version.patchVersion > 0 { os += ".\(version.patchVersion)" }
// The runtime keeps its data under the home folder.
let values = try? URL(fileURLWithPath: NSHomeDirectory())
.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
return HostFacts(unsupported: unsupported, osVersion: os, bytesFree: values?.volumeAvailableCapacityForImportantUsage)
}
}
@@ -0,0 +1,209 @@
import AppKit
import CryptoKit
import Foundation
public struct DownloadedPackage: Equatable, Sendable {
public let directory: URL
/// Apple's own file name, which Installer shows.
public let name: String
public var file: URL { directory.appendingPathComponent(name) }
public init(directory: URL, name: String) {
self.directory = directory
self.name = name
}
}
public enum DownloadError: Error, Equatable, Sendable {
case failed(String)
/// Size or SHA-256 differ from the pinned release; the download is already deleted.
case mismatch
}
@MainActor
public protocol InstallerSession: AnyObject {
var hasQuit: Bool { get }
}
public enum InstallerOpening {
case opened(any InstallerSession)
/// Size or SHA-256 no longer match; the download is already deleted.
case changed
case failed(String)
}
public protocol RuntimeInstalling: Sendable {
func download(_ release: ContainerRuntime.Release,
progress: @escaping @Sendable (Int64) -> Void) async throws -> DownloadedPackage
/// Re-checks the size and hash off the main thread, then opens the package in Installer.
@MainActor func openInInstaller(_ package: DownloadedPackage, release: ContainerRuntime.Release) async -> InstallerOpening
func discard(_ package: DownloadedPackage)
}
public struct SystemRuntimeInstaller: RuntimeInstalling {
static let installerApp = URL(fileURLWithPath: "/System/Library/CoreServices/Installer.app")
public init() {}
public func download(_ release: ContainerRuntime.Release,
progress: @escaping @Sendable (Int64) -> Void) async throws -> DownloadedPackage {
Self.removeLeftovers()
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("knotchy-runtime-\(UUID().uuidString)", isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false,
attributes: [.posixPermissions: 0o700])
} catch {
throw DownloadError.failed(error.localizedDescription)
}
let package = DownloadedPackage(directory: directory, name: release.url.lastPathComponent)
do {
try await PackageDownload.fetch(release.url, to: package.file, progress: progress)
} catch {
discard(package)
throw DownloadError.failed(error.localizedDescription)
}
guard Self.matches(package.file, release) else {
discard(package)
throw DownloadError.mismatch
}
return package
}
public func discard(_ package: DownloadedPackage) {
try? FileManager.default.removeItem(at: package.directory)
}
/// Folders left by quitting mid-download or a crash.
public static func removeLeftovers() {
let temporary = URL(fileURLWithPath: NSTemporaryDirectory())
let names = (try? FileManager.default.contentsOfDirectory(atPath: temporary.path)) ?? []
for name in names where name.hasPrefix("knotchy-runtime-") {
try? FileManager.default.removeItem(at: temporary.appendingPathComponent(name))
}
}
@MainActor
public func openInInstaller(_ package: DownloadedPackage, release: ContainerRuntime.Release) async -> InstallerOpening {
let file = package.file
guard await Task.detached(operation: { Self.matches(file, release) }).value else {
discard(package)
return .changed
}
let configuration = NSWorkspace.OpenConfiguration()
configuration.activates = true
do {
let app = try await NSWorkspace.shared.open([file], withApplicationAt: Self.installerApp,
configuration: configuration)
return .opened(RunningInstaller(app: app))
} catch {
return .failed(error.localizedDescription)
}
}
static func matches(_ file: URL, _ release: ContainerRuntime.Release) -> Bool {
guard let size = (try? FileManager.default.attributesOfItem(atPath: file.path))?[.size] as? NSNumber,
size.int64Value == release.size,
let handle = try? FileHandle(forReadingFrom: file) else { return false }
defer { try? handle.close() }
var hash = SHA256()
while let chunk = try? handle.read(upToCount: 1 << 20), !chunk.isEmpty { hash.update(data: chunk) }
return hash.finalize().map { String(format: "%02x", $0) }.joined() == release.sha256
}
}
@MainActor
private final class RunningInstaller: InstallerSession {
let app: NSRunningApplication
init(app: NSRunningApplication) {
self.app = app
}
var hasQuit: Bool { app.isTerminated }
}
/// A data task streamed into the package file, so the partial download lives only in its own folder.
private final class PackageDownload: NSObject, URLSessionDataDelegate, @unchecked Sendable {
// Touched only on the session's serial delegate queue.
private var continuation: CheckedContinuation<Void, any Error>?
/// A completion that arrived before anyone waited: a task cancelled before it started.
private var outcome: Result<Void, any Error>?
private var handle: FileHandle?
private var written: Int64 = 0
private var writeError: (any Error)?
private let destination: URL
private let progress: @Sendable (Int64) -> Void
private init(destination: URL, progress: @escaping @Sendable (Int64) -> Void) {
self.destination = destination
self.progress = progress
}
static func fetch(_ url: URL, to destination: URL, progress: @escaping @Sendable (Int64) -> Void) async throws {
let delegate = PackageDownload(destination: destination, progress: progress)
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1
let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: queue)
defer { session.finishTasksAndInvalidate() }
// Created up front: cancelling before it exists would invalidate the session, and creating it then throws.
let task = session.dataTask(with: url)
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
queue.addOperation {
if let outcome = delegate.outcome {
continuation.resume(with: outcome)
} else {
delegate.continuation = continuation
task.resume()
}
}
}
} onCancel: {
task.cancel()
}
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void) {
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
writeError = URLError(.badServerResponse)
return completionHandler(.cancel)
}
do {
guard FileManager.default.createFile(atPath: destination.path, contents: nil) else {
throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: destination.path])
}
handle = try FileHandle(forWritingTo: destination)
} catch {
writeError = error
return completionHandler(.cancel)
}
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
guard let handle, writeError == nil else { return }
do {
try handle.write(contentsOf: data)
written += Int64(data.count)
progress(written)
} catch {
writeError = error
dataTask.cancel()
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) {
try? handle?.close()
handle = nil
// Ours comes first: cancelling after it adds a "cancelled" error.
let result: Result<Void, any Error> = if let failure = writeError ?? error { .failure(failure) } else { .success(()) }
if let waiting = continuation {
continuation = nil
waiting.resume(with: result)
} else {
outcome = result
}
}
}
@@ -0,0 +1,15 @@
/// What the setup flow needs from the session controller.
@MainActor
public protocol SetupDriving: AnyObject {
var state: SessionState { get }
var setupLog: String? { get }
var setupOutput: [String] { get }
func runtimeStatus() async -> RuntimeStatus
func checkSetup() async -> SetupRunOutcome?
func runSetup(_ issue: SetupIssue, continuing: Bool) async -> SetupRunOutcome?
func beginRuntimeInstall(replacing found: String?) async -> Result<Void, RuntimeHoldRefusal>
func finishRuntimeInstall(_ result: RuntimeInstallResult) async -> SetupRunOutcome
func waitWhileSettingUp() async
}
extension SessionController: SetupDriving {}
+360
View File
@@ -0,0 +1,360 @@
import Foundation
import Observation
/// The setup window's rows and the order of its steps. The window binds only to this.
@MainActor
@Observable
public final class SetupFlow {
public enum Status: Equatable, Sendable {
case pending(String)
case running(String, fraction: Double?)
case done(String)
case failed(String, details: [String])
}
public enum RuntimeAction: Equatable, Sendable {
case install
case update
}
public static let requiredFreeBytes: Int64 = 3_000_000_000
static let vmDetail = "About 3 minutes · downloads about 225 MB"
public private(set) var mac: Status = .pending("Checking…")
public private(set) var unsupported: Unsupported?
public private(set) var runtimeAction: RuntimeAction?
public private(set) var isRunning = false
/// Quitting now leaves a partial download behind.
@ObservationIgnored public private(set) var isDownloading = false
private var runtimeBase: Status = .pending("")
private var vmBase: Status = .pending(SetupFlow.vmDetail)
@ObservationIgnored private let host: any HostChecking
@ObservationIgnored private let installer: any RuntimeInstalling
@ObservationIgnored private let driver: any SetupDriving
@ObservationIgnored private let release: ContainerRuntime.Release
@ObservationIgnored private let retryDelay: Duration
@ObservationIgnored private var facts: HostFacts?
@ObservationIgnored private var runtimeStatus: RuntimeStatus?
public init(host: any HostChecking, installer: any RuntimeInstalling, driver: any SetupDriving,
release: ContainerRuntime.Release = ContainerRuntime.pinned, retryDelay: Duration = .milliseconds(500)) {
self.host = host
self.installer = installer
self.driver = driver
self.release = release
self.retryDelay = retryDelay
runtimeBase = .pending(Self.installDetail(release))
}
// MARK: - Rows
/// With the controller's live progress on top, so a setup the notch started shows here too.
public var runtime: Status {
if case .settingUp(let issue) = driver.state, issue == .serviceStopped || issue == .kernelMissing {
return .running("Starting the runtime…", fraction: nil)
}
return runtimeBase
}
public var vm: Status {
if case .settingUp(let issue) = driver.state, Self.isImageIssue(issue) {
return .running(driver.setupLog ?? "Starting the build…", fraction: nil)
}
return vmBase
}
public var allRequiredDone: Bool {
if case .done = mac, case .done = runtime, case .done = vm { return true }
return false
}
public var canSetUp: Bool {
guard !isRunning, unsupported == nil, !allRequiredDone else { return false }
if case .failed = mac { return false }
return true
}
// MARK: - Actions
@ObservationIgnored private var work: Task<Void, Never>?
/// For the Set Up and Try Again buttons: runs `setUp()` where quitting can cancel it.
public func start() {
guard !isRunning else { return }
work = Task { await setUp() }
}
/// Quitting: stops a download. An open Installer is left to finish.
public func cancel() {
work?.cancel()
}
/// Re-checks everything. Starts a stopped service, as a link would, but never installs or builds.
public func refresh() async {
guard !isRunning else { return }
isRunning = true
defer { isRunning = false }
await inspect()
}
/// The Set Up and Try Again buttons: runs whatever is left, in order.
public func setUp() async {
guard !isRunning else { return }
isRunning = true
defer { isRunning = false }
await inspect()
guard unsupported == nil else { return }
// The service didn't start: building would only try again.
if case .failed = runtimeBase { return }
if case .failed = mac { return }
if let action = runtimeAction {
await installRuntime(action)
} else if case .done = vmBase {
return
} else {
await buildVM()
}
updateMac()
}
// MARK: - Steps
private func inspect() async {
let facts = await host.facts()
self.facts = facts
unsupported = facts.unsupported
guard unsupported == nil else { return }
let status = await driver.runtimeStatus()
runtimeStatus = status
switch status {
case .missing:
runtimeAction = .install
runtimeBase = .pending(Self.installDetail(release))
vmBase = .pending(Self.vmDetail)
case .outdated(let found):
runtimeAction = .update
runtimeBase = .pending("container \(found) is too old · updates to \(release.version) with Apple's Installer")
vmBase = .pending(Self.vmDetail)
case .current(let version):
runtimeAction = nil
runtimeBase = .done(Self.installedText(version))
switch await driver.checkSetup() {
case .ready?: vmBase = .done("Ready")
case .needs(let issue)? where Self.isImageIssue(issue): vmBase = .pending(Self.vmDetail)
case .failed(let issue, let message)?: fail(issue, message)
case nil where Self.isBrowserOpen(driver.state): vmBase = .done("Ready") // A running VM has an image.
case .needs?, nil: break // Busy, or the runtime row's business: the live rows show it.
}
}
updateMac()
}
private func installRuntime(_ action: RuntimeAction) async {
switch driver.state {
case .starting, .live:
runtimeBase = .failed("Close the Knotchy browser first.", details: [])
return
case .destroying, .failed(.destroyStuck):
runtimeBase = .failed("Wait for the Knotchy browser to finish closing, then try again.", details: [])
return
default:
break
}
runtimeBase = .running(Self.downloadText(0, of: release.size), fraction: 0)
let package: DownloadedPackage
do {
package = try await download()
} catch DownloadError.mismatch {
runtimeBase = .failed("The download didn't match the expected package, so it wasn't installed.", details: [])
return
} catch {
runtimeBase = .failed("Couldn't download the runtime. Check your connection and try again.",
details: [String(describing: error)])
return
}
let found: String? = if case .outdated(let version) = runtimeStatus { version } else { nil }
let hold = await driver.beginRuntimeInstall(replacing: found)
if case .failure = hold { installer.discard(package) }
switch hold {
case .success:
break
case .failure(.sessionOpen):
runtimeBase = .failed("Close the Knotchy browser first.", details: [])
return
case .failure(.setupRunning):
runtimeBase = .failed("Wait for the current setup step to finish, then try again.", details: [])
return
case .failure(.stopFailed(let message)):
runtimeBase = .failed("Couldn't stop the runtime for the update.", details: [message])
return
}
runtimeBase = .running("Opening Installer…", fraction: nil)
let session: any InstallerSession
switch await installer.openInInstaller(package, release: release) {
case .opened(let opened):
session = opened
case .changed:
return await failInstall("The download changed after it was checked, so it wasn't opened.", details: [])
case .failed(let message):
installer.discard(package)
return await failInstall("Couldn't open Installer.", details: [message])
}
runtimeBase = .running("Finish the install in Installer…", fraction: nil)
let status = await waitForInstall(session)
// Installer reads the package until it quits.
Task { [installer] in
while !session.hasQuit { try? await Task.sleep(for: .seconds(2)) }
installer.discard(package)
}
// Quitting: Installer carries on by itself.
if Task.isCancelled { return }
guard case .current(let version)? = status else {
_ = await driver.finishRuntimeInstall(.cancelled)
runtimeBase = .pending("Installing was cancelled.")
return
}
runtimeStatus = status
runtimeAction = nil
runtimeBase = .done(Self.installedText(version))
apply(await driver.finishRuntimeInstall(.installed))
}
/// The current runtime once it appears, or nil when Installer quits without it.
private func waitForInstall(_ session: any InstallerSession) async -> RuntimeStatus? {
while !Task.isCancelled {
let status = await driver.runtimeStatus()
if case .current = status { return status }
if session.hasQuit {
// Installer may have finished between the two checks.
let last = await driver.runtimeStatus()
if case .current = last { return last }
return nil
}
try? await Task.sleep(for: retryDelay)
}
return nil
}
private func download() async throws -> DownloadedPackage {
isDownloading = true
defer { isDownloading = false }
return try await installer.download(release) { [weak self, release] bytes in
Task { @MainActor in self?.downloaded(bytes, of: release.size) }
}
}
private func failInstall(_ message: String, details: [String]) async {
_ = await driver.finishRuntimeInstall(.failed(message))
runtimeBase = .failed(message, details: details)
}
private func buildVM() async {
// A setup the notch started, or a session winding down, gets up to a minute to finish.
for _ in 0..<120 {
if Task.isCancelled { return }
guard let checked = await driver.checkSetup() else {
if case .settingUp = driver.state {
await driver.waitWhileSettingUp()
} else {
try? await Task.sleep(for: retryDelay)
}
continue
}
switch checked {
case .needs(let issue) where Self.isImageIssue(issue):
guard let result = await driver.runSetup(issue, continuing: true) else { continue }
return apply(result)
case .needs:
return await inspect()
default:
return apply(checked)
}
}
vmBase = .failed("Close the Knotchy browser first, then try again.", details: [])
}
private func apply(_ outcome: SetupRunOutcome) {
switch outcome {
case .ready:
vmBase = .done("Ready")
case .needs(let issue):
if Self.isImageIssue(issue) { vmBase = .pending(Self.vmDetail) }
case .failed(let issue, let message):
fail(issue, message)
}
}
private func fail(_ issue: SetupIssue, _ message: String) {
if Self.isImageIssue(issue) {
vmBase = .failed(message, details: driver.setupOutput)
} else {
runtimeBase = .failed(message, details: driver.setupOutput)
}
}
private func downloaded(_ bytes: Int64, of total: Int64) {
// Late callbacks mustn't overwrite what came after the download.
guard case .running(let text, _) = runtimeBase, text.hasPrefix("Downloading") else { return }
if bytes >= total {
runtimeBase = .running("Verifying…", fraction: nil)
} else {
runtimeBase = .running(Self.downloadText(bytes, of: total), fraction: Double(bytes) / Double(max(total, 1)))
}
}
private func updateMac() {
guard let facts else { return }
let free = facts.bytesFree.map { "\(Self.gigabytes($0)) GB free" }
if needsSpace, let bytes = facts.bytesFree, bytes < Self.requiredFreeBytes {
mac = .failed("Setting up needs about 3 GB free. \(Self.gigabytes(bytes)) GB is free.", details: [])
} else {
mac = .done([facts.osVersion, free].compactMap { $0 }.joined(separator: " · "))
}
}
/// Only the install and the build use space; starting the service doesn't.
private var needsSpace: Bool {
if runtimeAction != nil { return true }
if case .done = vmBase { return false }
return true
}
// MARK: - Text
static func installDetail(_ release: ContainerRuntime.Release) -> String {
"Apple's container \(release.version) · \(megabytes(release.size)) MB · installs with Apple's Installer"
}
static func installedText(_ version: String?) -> String {
version.map { "container \($0) installed" } ?? "container installed"
}
static func downloadText(_ bytes: Int64, of total: Int64) -> String {
"Downloading… \(megabytes(bytes)) of \(megabytes(total)) MB"
}
static func megabytes(_ bytes: Int64) -> Int {
Int((Double(bytes) / 1_000_000).rounded())
}
static func gigabytes(_ bytes: Int64) -> String {
bytes >= 10_000_000_000 ? "\(bytes / 1_000_000_000)" : String(format: "%.1f", Double(bytes) / 1_000_000_000)
}
static func isBrowserOpen(_ state: SessionState) -> Bool {
switch state {
case .starting, .live: true
default: false
}
}
static func isImageIssue(_ issue: SetupIssue) -> Bool {
switch issue {
case .imageMissing, .imageOutdated, .imageStale: true
default: false
}
}
}
@@ -0,0 +1,12 @@
/// When the setup window opens by itself at launch.
public enum SetupLaunchRule {
public static let completedKey = "setupCompleted"
public static func shouldOpen(hostSupported: Bool, setupCompleted: Bool, runtime: RuntimeStatus) -> Bool {
guard hostSupported, setupCompleted else { return true }
switch runtime {
case .missing, .outdated: return true
case .current: return false
}
}
}
@@ -0,0 +1,39 @@
/// Decides when Starting ends. Frames and progress before NAVIGATED come from the agent's
/// about:blank bootstrap and never count; `afterHello` is a backstop if NAVIGATED never arrives.
public struct ContentReadiness: Sendable {
public static let afterNavigated: Duration = .seconds(15)
public static let afterHello: Duration = .seconds(25)
public private(set) var isReady = false
private var helloAt: ContinuousClock.Instant?
private var navigatedAt: ContinuousClock.Instant?
public init() {}
public mutating func hello(at now: ContinuousClock.Instant) {
if helloAt == nil { helloAt = now }
}
public mutating func navigated(at now: ContinuousClock.Instant) {
if navigatedAt == nil { navigatedAt = now }
}
public mutating func frame(isUniform: Bool) {
if navigatedAt != nil, !isUniform { isReady = true }
}
public mutating func progress(_ percent: Int) {
if navigatedAt != nil, percent >= 100 { isReady = true }
}
public mutating func tick(now: ContinuousClock.Instant) {
if let deadline = nextDeadline, now >= deadline { isReady = true }
}
public var nextDeadline: ContinuousClock.Instant? {
guard !isReady else { return nil }
return [navigatedAt.map { $0 + Self.afterNavigated }, helloAt.map { $0 + Self.afterHello }]
.compactMap { $0 }
.min()
}
}
@@ -0,0 +1,22 @@
/// Tells a blank frame from one with page content.
public enum FrameSampler {
public static let grid = 48
public static let maxDimension = 1 << 15
public static let maxBytesPerRow = 1 << 20
/// Malformed input counts as uniform so it can never pose as content; the caps prevent overflow.
public static func isUniform(_ pixels: UnsafeRawBufferPointer, width: Int, height: Int, bytesPerRow: Int) -> Bool {
guard (1...maxDimension).contains(width), (1...maxDimension).contains(height),
bytesPerRow >= width * 4, bytesPerRow <= maxBytesPerRow,
pixels.count >= (height - 1) * bytesPerRow + width * 4
else { return true }
let first = pixels.loadUnaligned(fromByteOffset: 0, as: UInt32.self)
for gy in 0..<grid {
for gx in 0..<grid {
let offset = (gy * height / grid) * bytesPerRow + (gx * width / grid) * 4
if pixels.loadUnaligned(fromByteOffset: offset, as: UInt32.self) != first { return false }
}
}
return true
}
}
+88
View File
@@ -0,0 +1,88 @@
import Foundation
public enum NALUnitType {
public static let slice: UInt8 = 1
public static let idrSlice: UInt8 = 5
public static let sei: UInt8 = 6
public static let sps: UInt8 = 7
public static let pps: UInt8 = 8
public static let accessUnitDelimiter: UInt8 = 9
}
public enum AnnexB {
/// Splits an Annex-B stream into NAL units. Trailing zeros are trimmed: a NAL unit never ends in 0x00.
public static func nalUnits(in stream: Data) -> [Data] {
let bytes = [UInt8](stream)
var starts: [(code: Int, payload: Int)] = []
var i = 0
while i + 2 < bytes.count {
if bytes[i] == 0, bytes[i + 1] == 0, bytes[i + 2] == 1 {
starts.append((code: i, payload: i + 3))
i += 3
} else {
i += 1
}
}
return starts.indices.compactMap { k in
var end = k + 1 < starts.count ? starts[k + 1].code : bytes.count
while end > starts[k].payload, bytes[end - 1] == 0 { end -= 1 }
return end > starts[k].payload ? Data(bytes[starts[k].payload..<end]) : nil
}
}
}
public struct ParameterSets: Equatable, Sendable {
public var sps: Data
public var pps: Data
public init(sps: Data, pps: Data) {
self.sps = sps
self.pps = pps
}
}
public struct ParsedAccessUnit: Equatable, Sendable {
/// AVCC-framed slice NAL units; empty if the access unit has none, so don't decode it.
public var sample = Data()
public var isIDR = false
/// Set only on the unit that changes the SPS/PPS; if that unit is dropped, check `H264Parser.parameterSets`.
public var newParameterSets: ParameterSets?
}
public struct H264Parser: Sendable {
public private(set) var parameterSets: ParameterSets?
public init() {}
public mutating func parse(_ accessUnit: Data) -> ParsedAccessUnit {
var unit = ParsedAccessUnit()
var sps: Data?
var pps: Data?
for nal in AnnexB.nalUnits(in: accessUnit) {
let header = nal[nal.startIndex]
guard header & 0x80 == 0 else { continue } // forbidden_zero_bit
let type = header & 0x1F
switch type {
case NALUnitType.sps:
sps = nal
case NALUnitType.pps:
pps = nal
case NALUnitType.slice, NALUnitType.idrSlice:
if type == NALUnitType.idrSlice { unit.isIDR = true }
unit.sample.appendBE32(UInt32(nal.count))
unit.sample.append(nal)
default:
break
}
}
// SPS and PPS must come from the same access unit; x264 repeats both on every IDR.
if let sps, let pps {
let sets = ParameterSets(sps: sps, pps: pps)
if sets != parameterSets {
parameterSets = sets
unit.newParameterSets = sets
}
}
return unit
}
}
@@ -0,0 +1,78 @@
import Foundation
public struct Modifiers: OptionSet, Hashable, Sendable {
public let rawValue: UInt8
public init(rawValue: UInt8) {
self.rawValue = rawValue
}
public static let shift = Modifiers(rawValue: 1 << 0)
public static let control = Modifiers(rawValue: 1 << 1)
public static let alt = Modifiers(rawValue: 1 << 2)
public static let meta = Modifiers(rawValue: 1 << 3)
public static let leftButton = Modifiers(rawValue: 1 << 4)
public static let rightButton = Modifiers(rawValue: 1 << 5)
public static let middleButton = Modifiers(rawValue: 1 << 6)
}
public enum PointerButton: UInt8, Sendable {
case left = 1
case right = 2
case middle = 3
}
/// A viewer → agent message. Coordinates are render pixels from the top left; negative scroll is down/right.
public enum InputEvent: Equatable, Sendable {
case pointerMove(x: UInt16, y: UInt16, mods: Modifiers)
case pointerButton(PointerButton, pressed: Bool, x: UInt16, y: UInt16, mods: Modifiers)
case scroll(ticksX: Int16, ticksY: Int16, x: UInt16, y: UInt16)
case key(pressed: Bool, mods: Modifiers, name: String)
case navigate(String)
case script(String)
case keyframe
/// Nil if the agent can't use it: a payload over 64 KiB, an empty KEY name, or a NUL in a string.
public func encoded() -> Data? {
var payload = Data()
let type: UInt8
switch self {
case .pointerMove(let x, let y, let mods):
type = MessageType.pointerMove
payload.appendBE16(x)
payload.appendBE16(y)
payload.append(mods.rawValue)
case .pointerButton(let button, let pressed, let x, let y, let mods):
type = MessageType.pointerButton
payload.append(button.rawValue)
payload.append(pressed ? 1 : 0)
payload.appendBE16(x)
payload.appendBE16(y)
payload.append(mods.rawValue)
case .scroll(let ticksX, let ticksY, let x, let y):
type = MessageType.scroll
payload.appendBE16(UInt16(bitPattern: ticksX))
payload.appendBE16(UInt16(bitPattern: ticksY))
payload.appendBE16(x)
payload.appendBE16(y)
case .key(let pressed, let mods, let name):
guard !name.isEmpty, !name.utf8.contains(0) else { return nil }
type = MessageType.key
payload.append(pressed ? 1 : 0)
payload.append(mods.rawValue)
payload.append(contentsOf: Array(name.utf8))
case .navigate(let url):
guard !url.utf8.contains(0) else { return nil }
type = MessageType.navigate
payload.append(contentsOf: Array(url.utf8))
case .script(let javaScript):
guard !javaScript.utf8.contains(0) else { return nil }
type = MessageType.script
payload.append(contentsOf: Array(javaScript.utf8))
case .keyframe:
type = MessageType.keyframe
}
guard payload.count <= WireProtocol.maxOtherPayload else { return nil }
return framed(type, payload)
}
}
@@ -0,0 +1,348 @@
import CoreVideo
import Darwin
import Foundation
public struct StreamClient: StreamConnector {
public let renderSize: PixelSize
public init(renderSize: PixelSize) {
self.renderSize = renderSize
}
public func connect(socketPath: String) async throws -> any StreamConnection {
let renderSize = renderSize
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(with: Result { try Self.connectBlocking(path: socketPath, renderSize: renderSize) })
}
}
}
static func connectBlocking(path: String, renderSize: PixelSize) throws -> StreamSession {
let deadline = Date().addingTimeInterval(30)
var connectedOnce = false
while Date() < deadline {
if let fd = openSocket(path) {
connectedOnce = true
setReadTimeout(fd, seconds: 2)
var decoder = AgentMessageDecoder()
switch readHello(fd, into: &decoder) {
case .hello(let version, let size):
guard version == WireProtocol.version else {
Darwin.close(fd)
throw StreamError.versionMismatch(found: version, need: WireProtocol.version)
}
guard size == renderSize else {
Darwin.close(fd)
throw StreamError.protocolViolation("HELLO is \(size), asked for \(renderSize)")
}
setReadTimeout(fd, seconds: 0)
// A VM that stops reading input must not wedge the session's queue.
setSendTimeout(fd, seconds: 2)
return StreamSession(fd: fd, decoder: decoder, size: size)
case .violation(let detail):
Darwin.close(fd)
throw StreamError.protocolViolation(detail)
case .none:
Darwin.close(fd)
}
}
usleep(100_000)
}
throw connectedOnce ? StreamError.timedOut : StreamError.refused
}
private enum HelloOutcome {
case hello(version: Int, size: PixelSize)
case violation(String)
case none
}
private static func readHello(_ fd: Int32, into decoder: inout AgentMessageDecoder) -> HelloOutcome {
var buffer = [UInt8](repeating: 0, count: 64 * 1024)
while true {
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, $0.count) }
if n < 0, errno == EINTR { continue }
guard n > 0 else { return .none }
decoder.append(Data(buffer[0..<n]))
do {
if case .hello(let version, let size)? = try decoder.next() { return .hello(version: version, size: size) }
} catch {
return .violation(error.description)
}
}
}
private static func openSocket(_ path: String) -> Int32? {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return nil }
var address = sockaddr_un()
address.sun_family = sa_family_t(AF_UNIX)
let bytes = Array(path.utf8)
guard bytes.count < MemoryLayout.size(ofValue: address.sun_path) else {
Darwin.close(fd)
return nil
}
withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: bytes) }
let size = socklen_t(MemoryLayout<sockaddr_un>.size)
let result = withUnsafePointer(to: &address) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.connect(fd, $0, size) }
}
guard result == 0 else {
Darwin.close(fd)
return nil
}
var on: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout<Int32>.size))
return fd
}
static func setReadTimeout(_ fd: Int32, seconds: Int) {
var timeout = timeval.init(tv_sec: seconds, tv_usec: 0)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
}
static func setSendTimeout(_ fd: Int32, seconds: Int) {
var timeout = timeval.init(tv_sec: seconds, tv_usec: 0)
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
}
}
/// Its reader thread must never stop (the VM's watchdog needs a connected viewer); all else runs on `queue`.
final class StreamSession: StreamConnection, @unchecked Sendable {
static let firstFrameTimeout: TimeInterval = 20
static let decodeErrorLimit = 10
let events: AsyncStream<StreamEvent>
private let continuation: AsyncStream<StreamEvent>.Continuation
private let queue = DispatchQueue(label: "aculix.knotchy.stream")
private let fd: Int32
private var decoder: AgentMessageDecoder
private var parser = H264Parser()
private var video: VideoDecoder?
private var readiness = ContentReadiness()
private var announcedReady = false
private var enabled = true
private var waitingForIDR = false
private var frameHandler: (@Sendable (DecodedFrame) -> Void)?
private var readinessTimer: DispatchSourceTimer?
private var firstFrameTimer: DispatchSourceTimer?
private var pendingPageURL: URL?
private var pageURLTimer: DispatchSourceTimer?
private var decodeErrors: [Date] = []
private var ended = false
init(fd: Int32, decoder: AgentMessageDecoder, size: PixelSize) {
self.fd = fd
self.decoder = decoder
let (stream, continuation) = AsyncStream.makeStream(of: StreamEvent.self)
events = stream
self.continuation = continuation
continuation.yield(.hello(version: WireProtocol.version, size: size))
queue.async { self.begin() }
let reader = Thread { [self] in readLoop() }
reader.name = "knotchy stream reader"
reader.start()
}
// MARK: StreamConnection
var decodeEnabled: Bool {
get { queue.sync { enabled } }
set {
queue.async {
if newValue, !self.enabled {
self.waitingForIDR = true
self.write(.keyframe)
}
self.enabled = newValue
}
}
}
func setFrameHandler(_ handler: @escaping @Sendable (DecodedFrame) -> Void) {
queue.async { self.frameHandler = handler }
}
func send(_ input: InputEvent) {
queue.async { self.write(input) }
}
func close() {
queue.async { self.end(nil) }
}
// MARK: Reading
private func readLoop() {
var buffer = [UInt8](repeating: 0, count: 64 * 1024)
while true {
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, $0.count) }
if n < 0, errno == EINTR { continue }
guard n > 0 else { break }
let chunk = Data(buffer[0..<n])
queue.async { self.received(chunk) }
}
queue.async {
self.end(.closed)
Darwin.close(self.fd)
}
}
private func begin() {
readiness.hello(at: .now)
scheduleReadinessTick()
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + Self.firstFrameTimeout)
timer.setEventHandler { [weak self] in self?.end(.noFirstFrame) }
timer.resume()
firstFrameTimer = timer
received(Data()) // Messages that arrived together with HELLO.
}
private func received(_ chunk: Data) {
guard !ended else { return }
decoder.append(chunk)
do {
while let message = try decoder.next() { handle(message) }
} catch {
end(.protocolViolation(error.description))
}
}
private func handle(_ message: AgentMessage) {
switch message {
case .navigated:
readiness.navigated(at: .now)
scheduleReadinessTick()
case .progress(let percent):
readiness.progress(percent)
continuation.yield(.progress(percent))
announceIfReady()
case .video(_, let accessUnit):
firstFrameTimer?.cancel()
firstFrameTimer = nil
let unit = parser.parse(accessUnit)
if unit.newParameterSets != nil || video == nil, let sets = parser.parameterSets {
video = VideoDecoder(parameterSets: sets) { [weak self] frame, status in
guard let self else { return }
self.queue.async { self.decoded(frame, status: status) }
}
}
guard enabled, !unit.sample.isEmpty, let video else { return }
if waitingForIDR {
guard unit.isIDR else { return }
waitingForIDR = false
}
video.decode(unit.sample)
case .pageURL(let text):
guard let url = URL(string: text) else { return }
pendingPageURL = url
passOnPageURL()
case .hello, .unknown:
break
}
}
private func decoded(_ frame: DecodedFrame?, status: OSStatus) {
guard !ended else { return }
guard let frame else { return decodeFailed() }
if !readiness.isReady {
readiness.frame(isUniform: Self.isUniform(frame.pixelBuffer))
announceIfReady()
}
frameHandler?(frame)
}
private func decodeFailed() {
let now = Date()
decodeErrors = decodeErrors.filter { now.timeIntervalSince($0) < 60 } + [now]
if decodeErrors.count > Self.decodeErrorLimit { return end(.decodeErrors) }
waitingForIDR = true
write(.keyframe)
}
private static func isUniform(_ buffer: CVPixelBuffer) -> Bool {
CVPixelBufferLockBaseAddress(buffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) }
guard let base = CVPixelBufferGetBaseAddress(buffer) else { return true }
let bytes = UnsafeRawBufferPointer(start: base, count: CVPixelBufferGetDataSize(buffer))
return FrameSampler.isUniform(bytes, width: CVPixelBufferGetWidth(buffer),
height: CVPixelBufferGetHeight(buffer),
bytesPerRow: CVPixelBufferGetBytesPerRow(buffer))
}
// MARK: Readiness
private func announceIfReady() {
guard readiness.isReady, !announcedReady else { return }
announcedReady = true
readinessTimer?.cancel()
readinessTimer = nil
continuation.yield(.contentReady)
}
private func scheduleReadinessTick() {
readinessTimer?.cancel()
readinessTimer = nil
guard let deadline = readiness.nextDeadline else { return }
let wait = deadline - .now
let seconds = max(0, Double(wait.components.seconds) + Double(wait.components.attoseconds) / 1e18)
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + seconds)
timer.setEventHandler { [weak self] in
guard let self else { return }
self.readiness.tick(now: .now)
self.announceIfReady()
}
timer.resume()
readinessTimer = timer
}
/// At most 10 a second, always including the last: a page can change its address in a loop.
private func passOnPageURL() {
guard pageURLTimer == nil, let url = pendingPageURL else { return }
pendingPageURL = nil
continuation.yield(.pageURL(url))
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 0.1)
timer.setEventHandler { [weak self] in
guard let self, !self.ended else { return }
self.pageURLTimer = nil
self.passOnPageURL()
}
timer.resume()
pageURLTimer = timer
}
// MARK: Writing and ending
private func write(_ input: InputEvent) {
guard !ended, let data = input.encoded() else { return }
let stalled = data.withUnsafeBytes { raw -> Bool in
var offset = 0
while offset < raw.count {
let n = Darwin.write(fd, raw.baseAddress! + offset, raw.count - offset)
if n > 0 { offset += n } else if n < 0, errno == EINTR { continue } else {
return n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)
}
}
return false
}
if stalled { end(.socketError("The VM stopped taking input.")) }
}
private func end(_ reason: EndReason?) {
guard !ended else { return }
ended = true
shutdown(fd, SHUT_RDWR)
readinessTimer?.cancel()
firstFrameTimer?.cancel()
pageURLTimer?.cancel()
video = nil
frameHandler = nil
if let reason { continuation.yield(.ended(reason)) }
continuation.finish()
}
}
@@ -0,0 +1,50 @@
import CoreVideo
public protocol StreamConnector: Sendable {
/// Returns once a valid HELLO arrives; a version mismatch is reported before any other HELLO problem.
func connect(socketPath: String) async throws -> any StreamConnection
}
/// `@unchecked`: a decoded buffer is never written after VideoToolbox returns it.
public struct DecodedFrame: @unchecked Sendable {
public let pixelBuffer: CVPixelBuffer
public init(_ pixelBuffer: CVPixelBuffer) {
self.pixelBuffer = pixelBuffer
}
}
public protocol StreamConnection: AnyObject, Sendable {
/// Unbounded: reading starts only after `connect` returns, and early events must not be lost.
var events: AsyncStream<StreamEvent> { get }
var decodeEnabled: Bool { get set }
/// Called on the decode queue.
func setFrameHandler(_ handler: @escaping @Sendable (DecodedFrame) -> Void)
func send(_ input: InputEvent)
func close()
}
public enum StreamEvent: Equatable, Sendable {
case hello(version: Int, size: PixelSize)
case contentReady
case progress(Int)
/// Untrusted (from the VM): display only.
case pageURL(URL)
case ended(EndReason)
}
public enum EndReason: Equatable, Sendable {
/// EOF: the browser crashed, the firewall failed, or the VM's watchdog fired.
case closed
case protocolViolation(String)
case noFirstFrame
case decodeErrors
case socketError(String)
}
public enum StreamError: Error, Equatable, Sendable {
case versionMismatch(found: Int, need: Int)
case protocolViolation(String)
case timedOut
case refused
}
@@ -0,0 +1,74 @@
import CoreMedia
import CoreVideo
import Foundation
import VideoToolbox
final class VideoDecoder {
typealias Output = @Sendable (DecodedFrame?, OSStatus) -> Void
private let session: VTDecompressionSession
private let format: CMVideoFormatDescription
private let output: Output
init?(parameterSets: ParameterSets, output: @escaping Output) {
var description: CMFormatDescription?
let sps = [UInt8](parameterSets.sps)
let pps = [UInt8](parameterSets.pps)
let status = sps.withUnsafeBufferPointer { s in
pps.withUnsafeBufferPointer { p in
let pointers = [s.baseAddress!, p.baseAddress!]
return CMVideoFormatDescriptionCreateFromH264ParameterSets(
allocator: kCFAllocatorDefault, parameterSetCount: 2, parameterSetPointers: pointers,
parameterSetSizes: [sps.count, pps.count], nalUnitHeaderLength: 4, formatDescriptionOut: &description)
}
}
guard status == noErr, let description else { return nil }
let attributes: [CFString: Any] = [
kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA,
kCVPixelBufferIOSurfacePropertiesKey: [CFString: Any]() as CFDictionary,
]
var created: VTDecompressionSession?
let sessionStatus = VTDecompressionSessionCreate(
allocator: kCFAllocatorDefault, formatDescription: description, decoderSpecification: nil,
imageBufferAttributes: attributes as CFDictionary, outputCallback: nil, decompressionSessionOut: &created)
guard sessionStatus == noErr, let created else { return nil }
session = created
format = description
self.output = output
}
deinit {
VTDecompressionSessionInvalidate(session)
}
func decode(_ sample: Data) {
var block: CMBlockBuffer?
guard CMBlockBufferCreateWithMemoryBlock(
allocator: kCFAllocatorDefault, memoryBlock: nil, blockLength: sample.count,
blockAllocator: kCFAllocatorDefault, customBlockSource: nil, offsetToData: 0,
dataLength: sample.count, flags: 0, blockBufferOut: &block) == noErr, let block
else { return output(nil, -1) }
sample.withUnsafeBytes { bytes in
_ = CMBlockBufferReplaceDataBytes(with: bytes.baseAddress!, blockBuffer: block,
offsetIntoDestination: 0, dataLength: sample.count)
}
var sampleBuffer: CMSampleBuffer?
var size = sample.count
guard CMSampleBufferCreateReady(
allocator: kCFAllocatorDefault, dataBuffer: block, formatDescription: format,
sampleCount: 1, sampleTimingEntryCount: 0, sampleTimingArray: nil,
sampleSizeEntryCount: 1, sampleSizeArray: &size, sampleBufferOut: &sampleBuffer) == noErr,
let sampleBuffer
else { return output(nil, -1) }
let output = self.output
let status = VTDecompressionSessionDecodeFrame(session, sampleBuffer: sampleBuffer, flags: [],
infoFlagsOut: nil) { status, _, image, _, _ in
if status == noErr, let image {
output(DecodedFrame(image), noErr)
} else if status != noErr {
output(nil, status)
}
}
if status != noErr { output(nil, status) }
}
}
@@ -0,0 +1,214 @@
import Foundation
/// knotchy-agent ↔ viewer framing, both directions: `[u8 type][u32 big-endian payload length][payload]`.
public enum WireProtocol {
/// Must equal `ARG KNOTCHY_PROTOCOL` in vm/Containerfile. 2: proxy/VPN routes. 3: PAGE_URL.
public static let version = 3
public static let headerSize = 5
public static let maxVideoPayload = 2 * 1024 * 1024
/// Also the agent's cap on viewer messages; unknown agent messages up to this size are skipped.
public static let maxOtherPayload = 64 * 1024
public static let maxPageURL = 16 * 1024
}
enum MessageType {
static let hello: UInt8 = 0x01
static let video: UInt8 = 0x02
static let progress: UInt8 = 0x03
static let navigated: UInt8 = 0x04
static let pageURL: UInt8 = 0x05
static let pointerMove: UInt8 = 0x10
static let pointerButton: UInt8 = 0x11
static let scroll: UInt8 = 0x12
static let key: UInt8 = 0x13
static let navigate: UInt8 = 0x14
static let script: UInt8 = 0x15
static let keyframe: UInt8 = 0x16
}
public struct PixelSize: Hashable, Sendable, CustomStringConvertible {
public var width: Int
public var height: Int
public init(width: Int, height: Int) {
self.width = width
self.height = height
}
public var description: String { "\(width)x\(height)" }
}
public enum AgentMessage: Equatable, Sendable {
/// Version 0 means a pre-v1 image, whose HELLO has no version byte.
case hello(version: Int, size: PixelSize)
case video(size: PixelSize, accessUnit: Data)
case progress(Int)
case navigated
case pageURL(String)
case unknown(type: UInt8)
}
extension AgentMessage {
/// For tests and fake agents: out-of-range values trap, so build malformed frames by hand.
func encoded() -> Data {
var payload = Data()
let type: UInt8
switch self {
case .hello(let version, let size):
type = MessageType.hello
if version != 0 { payload.append(UInt8(version)) }
payload.appendBE16(UInt16(size.width))
payload.appendBE16(UInt16(size.height))
case .video(let size, let accessUnit):
type = MessageType.video
payload.appendBE16(UInt16(size.width))
payload.appendBE16(UInt16(size.height))
payload.append(accessUnit)
case .progress(let percent):
type = MessageType.progress
payload.append(UInt8(percent))
case .navigated:
type = MessageType.navigated
case .pageURL(let url):
type = MessageType.pageURL
payload.append(Data(url.utf8))
case .unknown(let unknownType):
type = unknownType
}
return framed(type, payload)
}
}
func framed(_ type: UInt8, _ payload: Data) -> Data {
var message = Data([type])
message.appendBE32(UInt32(payload.count))
message.append(payload)
return message
}
extension Data {
mutating func appendBE16(_ value: UInt16) {
append(UInt8(value >> 8))
append(UInt8(truncatingIfNeeded: value))
}
mutating func appendBE32(_ value: UInt32) {
appendBE16(UInt16(value >> 16))
appendBE16(UInt16(truncatingIfNeeded: value))
}
}
/// Strict incremental parser; the VM is untrusted, so any bad message is a final violation.
/// Call `next()` until nil after each `append`: that keeps the buffer to one message plus one read.
public struct AgentMessageDecoder: Sendable {
public struct ProtocolViolation: Error, Equatable, Sendable, CustomStringConvertible {
public let description: String
init(_ description: String) {
self.description = description
}
}
public private(set) var helloSize: PixelSize?
private var buffer: [UInt8] = []
private var start = 0
private var violation: ProtocolViolation?
public init() {}
public mutating func append(_ bytes: Data) {
guard violation == nil else { return }
if start > 0 {
buffer.removeFirst(start)
start = 0
}
buffer.append(contentsOf: bytes)
}
public var bufferedByteCount: Int { buffer.count - start }
/// Includes bytes already returned; for tests of the bound.
var storedByteCount: Int { buffer.count }
public mutating func next() throws(ProtocolViolation) -> AgentMessage? {
if let violation { throw violation }
do {
return try decodeNext()
} catch {
violation = error
buffer = []
start = 0
throw error
}
}
private mutating func decodeNext() throws(ProtocolViolation) -> AgentMessage? {
guard bufferedByteCount >= WireProtocol.headerSize else { return nil }
let type = buffer[start]
let length = Int(be32(at: start + 1))
try checkHeader(type: type, length: length)
guard bufferedByteCount - WireProtocol.headerSize >= length else { return nil }
let p = start + WireProtocol.headerSize
start = p + length
switch type {
case MessageType.hello:
let version = length == 5 ? Int(buffer[p]) : 0
let sizeAt = p + length - 4
let size = PixelSize(width: Int(be16(at: sizeAt)), height: Int(be16(at: sizeAt + 2)))
helloSize = size
return .hello(version: version, size: size)
case MessageType.video:
let size = PixelSize(width: Int(be16(at: p)), height: Int(be16(at: p + 2)))
guard size == helloSize else {
throw ProtocolViolation("VIDEO is \(size) but HELLO said \(helloSize.map(\.description) ?? "nothing")")
}
return .video(size: size, accessUnit: Data(buffer[(p + 4)..<(p + length)]))
case MessageType.progress:
let percent = Int(buffer[p])
guard percent <= 100 else { throw ProtocolViolation("PROGRESS \(percent) is over 100") }
return .progress(percent)
case MessageType.navigated:
return .navigated
case MessageType.pageURL:
guard let url = String(validating: buffer[p..<(p + length)], as: UTF8.self) else {
throw ProtocolViolation("PAGE_URL isn't UTF-8")
}
return .pageURL(url)
default:
return .unknown(type: type)
}
}
private func checkHeader(type: UInt8, length: Int) throws(ProtocolViolation) {
func name() -> String { String(format: "type 0x%02x", type) }
if helloSize == nil, type != MessageType.hello {
throw ProtocolViolation("\(name()) before HELLO")
}
let valid: Bool
switch type {
case MessageType.hello:
guard helloSize == nil else { throw ProtocolViolation("a second HELLO") }
valid = length == 5 || length == 4
case MessageType.video:
valid = (4...WireProtocol.maxVideoPayload).contains(length)
case MessageType.progress:
valid = length == 1
case MessageType.navigated:
valid = length == 0
case MessageType.pageURL:
valid = (1...WireProtocol.maxPageURL).contains(length)
default:
valid = length <= WireProtocol.maxOtherPayload
}
guard valid else { throw ProtocolViolation("\(name()) with a \(length)-byte payload") }
}
private func be16(at i: Int) -> UInt16 {
UInt16(buffer[i]) << 8 | UInt16(buffer[i + 1])
}
private func be32(at i: Int) -> UInt32 {
UInt32(be16(at: i)) << 16 | UInt32(be16(at: i + 2))
}
}
@@ -0,0 +1,67 @@
/// Tests call `fireDue()` after advancing a `TestClock`; firing is idempotent for a given `now`.
@MainActor
final class DeadlineTimers<Key: Hashable & Sendable> {
var onFire: @MainActor (Key) -> Void = { _ in }
private struct Entry {
var deadline: ContinuousClock.Instant
var order: Int
}
private let clock: any KnotchyClock
private var entries: [Key: Entry] = [:]
private var nextOrder = 0
private var driver: Task<Void, Never>?
init(clock: any KnotchyClock) {
self.clock = clock
}
isolated deinit {
driver?.cancel()
}
func set(_ key: Key, after duration: Duration) {
set(key, at: clock.now + duration)
}
func set(_ key: Key, at deadline: ContinuousClock.Instant) {
nextOrder += 1
entries[key] = Entry(deadline: deadline, order: nextOrder)
reschedule()
}
func cancel(_ key: Key) {
guard entries.removeValue(forKey: key) != nil else { return }
reschedule()
}
func deadline(of key: Key) -> ContinuousClock.Instant? {
entries[key]?.deadline
}
/// Keys `onFire` re-arms at or before `now` fire in this same pass; doing that every time loops forever.
func fireDue() {
let now = clock.now
while let (key, _) = entries
.filter({ $0.value.deadline <= now })
.min(by: { ($0.value.deadline, $0.value.order) < ($1.value.deadline, $1.value.order) }) {
entries[key] = nil
onFire(key)
}
reschedule()
}
private func reschedule() {
driver?.cancel()
guard let next = entries.values.map(\.deadline).min() else {
driver = nil
return
}
driver = Task { [weak self, clock] in
do { try await clock.sleep(until: next) } catch { return }
guard !Task.isCancelled else { return } // Replaced while waking up.
self?.fireDue()
}
}
}
@@ -0,0 +1,15 @@
/// `ContinuousClock` keeps counting while the Mac sleeps, so an expired idle countdown fires on wake.
public protocol KnotchyClock: Sendable {
var now: ContinuousClock.Instant { get }
func sleep(until deadline: ContinuousClock.Instant) async throws
}
public struct SystemClock: KnotchyClock {
public init() {}
public var now: ContinuousClock.Instant { .now }
public func sleep(until deadline: ContinuousClock.Instant) async throws {
try await Task.sleep(until: deadline, clock: .continuous)
}
}
+121
View File
@@ -0,0 +1,121 @@
import CoreImage
import Foundation
import ImageIO
import KnotchyCore
import Synchronization
import UniformTypeIdentifiers
// knotchyctl snapshot <url> <out.png> [--after <seconds>] [--click <render x>,<render y>]
// [--proxy <http|socks5>://[user:pass@]host:port] [--wireguard <file.conf>] [--allow-local-proxy]
let arguments = Array(CommandLine.arguments.dropFirst())
func fail(_ message: String) -> Never {
FileHandle.standardError.write(Data("knotchyctl: \(message)\n".utf8))
exit(1)
}
func vmSources() -> URL? {
let repo = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent()
.deletingLastPathComponent().appendingPathComponent("vm")
return FileManager.default.fileExists(atPath: repo.appendingPathComponent("Containerfile").path) ? repo : nil
}
func writePNG(_ buffer: CVPixelBuffer, to path: String) throws {
let image = CIImage(cvPixelBuffer: buffer)
guard let cgImage = CIContext().createCGImage(image, from: image.extent),
let destination = CGImageDestinationCreateWithURL(URL(fileURLWithPath: path) as CFURL,
UTType.png.identifier as CFString, 1, nil)
else { throw CocoaError(.fileWriteUnknown) }
CGImageDestinationAddImage(destination, cgImage, nil)
guard CGImageDestinationFinalize(destination) else { throw CocoaError(.fileWriteUnknown) }
}
// Thrown rather than fail()ed after the VM starts, so the VM still gets destroyed.
struct SnapshotError: Error, CustomStringConvertible {
let description: String
init(_ description: String) { self.description = description }
}
final class LatestFrame: Sendable {
private let frame = Mutex<DecodedFrame?>(nil)
func set(_ new: DecodedFrame) { frame.withLock { $0 = new } }
func get() -> DecodedFrame? { frame.withLock { $0 } }
}
func snapshot(url: URL, out: String, after: Double, click: (x: UInt16, y: UInt16)?, route: NetworkRoute) async throws {
let backend = ContainerCLIBackend(vmSources: vmSources())
backend.setNetworkRoute(route)
if arguments.contains("--allow-local-proxy") { backend.allowLocalProxy() }
// An outdated image would silently ignore the proxy or VPN route.
guard case .ready = await backend.preflight() else { fail("the VM image isn't ready; build it with scripts/build-vm.sh") }
let id = "knotchyctl-" + String(format: "%08x", UInt32.random(in: .min ... .max))
let started = ContinuousClock.now
let vm = try await backend.start(id: id, url: url, renderSize: SessionController.renderSize,
blockCIDRs: HostNetworks.current())
do {
let connection = try await StreamClient(renderSize: SessionController.renderSize).connect(socketPath: vm.socketPath)
let latest = LatestFrame()
connection.setFrameHandler { latest.set($0) }
var ready = false
for await event in connection.events {
if event == .contentReady { ready = true; break }
if case .ended(let reason) = event { throw CocoaError(.featureUnsupported, userInfo: ["reason": "\(reason)"]) }
}
guard ready else { throw SnapshotError("the stream ended before content was ready") }
print("content ready after \(ContinuousClock.now - started)")
let pages = Task {
for await event in connection.events {
if case .pageURL(let page) = event { print("page \(page.absoluteString)") }
}
}
defer { pages.cancel() }
if let click {
try await Task.sleep(for: .seconds(1))
connection.send(.pointerMove(x: click.x, y: click.y, mods: []))
connection.send(.pointerButton(.left, pressed: true, x: click.x, y: click.y, mods: .leftButton))
connection.send(.pointerButton(.left, pressed: false, x: click.x, y: click.y, mods: []))
}
try await Task.sleep(for: .seconds(after))
guard let frame = latest.get() else { throw SnapshotError("no frame decoded") }
try writePNG(frame.pixelBuffer, to: out)
print("wrote \(out)")
connection.close()
} catch {
try? await backend.destroy(id: id)
throw error
}
try await backend.destroy(id: id)
}
guard arguments.first == "snapshot", arguments.count >= 3, let url = URL(string: arguments[1]) else {
fail("usage: knotchyctl snapshot <url> <out.png> [--after <seconds>] [--click <x>,<y>] [--proxy <url>] [--wireguard <file>]")
}
var route = NetworkRoute.direct
if let index = arguments.firstIndex(of: "--proxy") {
guard let proxy = URLComponents(string: arguments[safe: index + 1] ?? ""), let host = proxy.host, let port = proxy.port,
let kind = ProxySettings.Kind(rawValue: proxy.scheme ?? "")
else { fail("--proxy needs http://[user:pass@]host:port or socks5://…") }
route = .proxy(ProxySettings(kind: kind, host: host, port: port,
username: proxy.user ?? "", password: proxy.password ?? ""))
}
if let index = arguments.firstIndex(of: "--wireguard") {
guard let text = try? String(contentsOfFile: arguments[safe: index + 1] ?? "", encoding: .utf8) else {
fail("--wireguard needs a readable .conf file")
}
do { route = .wireGuard(try WireGuardConfig.parse(text)) } catch { fail("the WireGuard config: \(error)") }
}
let after = arguments.firstIndex(of: "--after").flatMap { Double(arguments[safe: $0 + 1] ?? "") } ?? 3
let click: (x: UInt16, y: UInt16)? = arguments.firstIndex(of: "--click").flatMap { index in
let parts = (arguments[safe: index + 1] ?? "").split(separator: ",").compactMap { UInt16($0) }
return parts.count == 2 ? (parts[0], parts[1]) : nil
}
do {
try await snapshot(url: url, out: arguments[2], after: after, click: click, route: route)
} catch {
fail("\(error)")
}
extension Array {
subscript(safe index: Int) -> Element? { indices.contains(index) ? self[index] : nil }
}
@@ -0,0 +1,66 @@
import Foundation
import Testing
@testable import KnotchyCore
@MainActor
struct BackendChainTests {
@Test func runsJobsOneAtATimeInOrder() async {
let chain = BackendChain()
var log: [String] = []
for name in ["a", "b", "c"] {
chain.enqueue {
log.append("\(name) begins")
await Task.yield()
log.append("\(name) ends")
}
}
await chain.waitUntilIdle()
#expect(log == ["a begins", "a ends", "b begins", "b ends", "c begins", "c ends"])
}
@Test func waitsForJobsEnqueuedByJobs() async {
let chain = BackendChain()
var log: [String] = []
chain.enqueue {
log.append("outer")
chain.enqueue {
await Task.yield() // Still running when the outer job's Task finishes.
log.append("inner")
}
}
await chain.waitUntilIdle()
#expect(log == ["outer", "inner"])
}
@Test func closingSkipsJobsThatHaveNotStarted() async {
let chain = BackendChain()
var log: [String] = []
chain.enqueue {
log.append("running")
chain.close()
}
chain.enqueue { log.append("queued") }
await chain.waitUntilIdle()
chain.enqueue { log.append("after close") }
await chain.waitUntilIdle()
#expect(log == ["running"])
}
@Test func liveIDsAreASnapshot() {
let ids = LiveIDs()
ids.insert("knotchy-00000001")
ids.insert("knotchy-00000002")
ids.remove("knotchy-00000001")
#expect(ids.snapshot == ["knotchy-00000002"])
}
@Test nonisolated func liveIDsCanBeUsedFromManyThreadsAtOnce() {
let ids = LiveIDs()
DispatchQueue.concurrentPerform(iterations: 1000) { i in
ids.insert("knotchy-\(i)")
_ = ids.snapshot
if i.isMultiple(of: 2) { ids.remove("knotchy-\(i)") }
}
#expect(ids.snapshot.count == 500)
}
}
@@ -0,0 +1,19 @@
import Foundation
import Testing
@testable import KnotchyCore
struct CLIRunnerTests {
@Test func findsARuntimeInstalledAfterLaunch() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("cli-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let tool = directory.appendingPathComponent("container")
let runner = CLIRunner(searchPaths: [tool.path])
#expect(runner.executable == nil)
try "#!/bin/sh\necho container CLI version 1.4.1\n".write(to: tool, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tool.path)
#expect(runner.executable == tool.path)
#expect(try runner.runBlocking(["--version"], timeout: 5).text.contains("1.4.1"))
}
}
+156
View File
@@ -0,0 +1,156 @@
import Testing
@testable import KnotchyCore
@Suite(.timeLimit(.minutes(1)))
struct TestClockTests {
@Test func sleepReturnsOnlyOnceTimeIsAdvancedPastTheDeadline() async throws {
let clock = TestClock()
let deadline = clock.now + .seconds(10)
let sleeper = Task { try await clock.sleep(until: deadline) }
await waitUntil("the task sleeps") { clock.sleeperCount == 1 }
clock.advance(by: .seconds(9))
#expect(clock.sleeperCount == 1)
clock.advance(by: .seconds(1))
try await sleeper.value
#expect(clock.sleeperCount == 0)
}
@Test func sleepingUntilNowOrThePastReturnsImmediately() async throws {
let clock = TestClock()
try await clock.sleep(until: clock.now)
try await clock.sleep(until: clock.now - .seconds(1))
}
@MainActor
@Test func aSleepInAnAlreadyCancelledTaskThrows() async {
@MainActor final class Outcome {
var error: (any Error)?
var finished = false
}
let clock = TestClock()
let outcome = Outcome()
Task { @MainActor in
withUnsafeCurrentTask { $0?.cancel() }
do { try await clock.sleep(until: clock.now + .seconds(10)) } catch { outcome.error = error }
outcome.finished = true
}
// Bounded: a sleep that wrongly registers would never return.
await waitUntil("the sleep ends") { outcome.finished }
#expect(outcome.error is CancellationError)
#expect(clock.sleeperCount == 0)
}
@Test func cancellingASleeperThrowsAndForgetsIt() async {
let clock = TestClock()
let sleeper = Task { try await clock.sleep(until: clock.now + .seconds(10)) }
await waitUntil("the task sleeps") { clock.sleeperCount == 1 }
sleeper.cancel()
await #expect(throws: CancellationError.self) { try await sleeper.value }
#expect(clock.sleeperCount == 0)
}
}
@MainActor
struct DeadlineTimersTests {
@Test func firesDueDeadlinesInOrder() {
let clock = TestClock()
let timers = DeadlineTimers<String>(clock: clock)
var fired: [String] = []
timers.onFire = { fired.append($0) }
// Seven keys share a deadline, so a lost tie-break shows up nearly every run.
let ties = (1...6).map { "tie\($0)" }
timers.set("late", after: .seconds(2))
timers.set("early", after: .seconds(1))
for key in ties { timers.set(key, after: .seconds(2)) }
clock.advance(by: .milliseconds(999))
timers.fireDue()
#expect(fired == [])
clock.advance(by: .seconds(2))
timers.fireDue()
#expect(fired == ["early", "late"] + ties)
timers.fireDue()
#expect(fired == ["early", "late"] + ties)
}
@Test func settingAKeyAgainReplacesItsDeadline() {
let clock = TestClock()
let timers = DeadlineTimers<String>(clock: clock)
var fired: [String] = []
timers.onFire = { fired.append($0) }
timers.set("a", after: .seconds(1))
timers.set("a", after: .seconds(5))
#expect(timers.deadline(of: "a") == clock.now + .seconds(5))
clock.advance(by: .seconds(4))
timers.fireDue()
#expect(fired == [])
}
@Test func cancelledDeadlinesNeverFire() {
let clock = TestClock()
let timers = DeadlineTimers<String>(clock: clock)
var fired: [String] = []
timers.onFire = { fired.append($0) }
timers.set("a", after: .seconds(1))
timers.cancel("a")
clock.advance(by: .seconds(2))
timers.fireDue()
#expect(fired == [])
#expect(timers.deadline(of: "a") == nil)
}
@Test func onFireMaySetAndCancelTimers() {
let clock = TestClock()
let timers = DeadlineTimers<String>(clock: clock)
var fired: [String] = []
timers.onFire = { [unowned timers] key in
fired.append(key)
if key == "a" {
timers.cancel("b")
timers.set("c", after: .zero)
timers.set("d", after: .seconds(1))
}
}
timers.set("a", after: .seconds(1))
timers.set("b", after: .seconds(1))
clock.advance(by: .seconds(1))
timers.fireDue()
#expect(fired == ["a", "c"])
#expect(timers.deadline(of: "d") == clock.now + .seconds(1))
}
@Test func theDriverFiresEveryTimerInDeadlineOrder() async {
let clock = TestClock()
let timers = DeadlineTimers<String>(clock: clock)
var fired: [String] = []
timers.onFire = { fired.append($0) }
timers.set("b", after: .seconds(2))
timers.set("a", after: .seconds(1))
await waitUntil("the driver sleeps") { clock.sleeperCount == 1 }
clock.advance(by: .seconds(1))
await waitUntil("the driver fires a") { fired == ["a"] }
await waitUntil("the driver sleeps again") { clock.sleeperCount == 1 }
clock.advance(by: .seconds(1))
await waitUntil("the driver fires b") { fired == ["a", "b"] }
}
@Test func theDriverFiresWithoutHelp() async {
let clock = TestClock()
let timers = DeadlineTimers<String>(clock: clock)
var fired: [String] = []
timers.onFire = { fired.append($0) }
timers.set("a", after: .seconds(1))
await waitUntil("the driver sleeps") { clock.sleeperCount == 1 }
clock.advance(by: .seconds(1))
await waitUntil("the driver fires") { fired == ["a"] }
}
}
@@ -0,0 +1,37 @@
import Foundation
import Testing
@testable import KnotchyCore
struct ContainerCLIBackendRuntimeTests {
/// A fake `container` that answers only `--version`.
private func fakeCLI(printing line: String) throws -> (CLIRunner, URL) {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("cli-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let tool = directory.appendingPathComponent("container")
let script = "#!/bin/sh\nif [ \"$1\" = --version ]; then echo '\(line)'; exit 0; fi\nexit 1\n"
try script.write(to: tool, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tool.path)
return (CLIRunner(searchPaths: [tool.path]), directory)
}
@Test func anOldRuntimeIsFlaggedBeforeAnythingElse() async throws {
let (cli, directory) = try fakeCLI(printing: "container CLI version 1.3.0 (build: release)")
defer { try? FileManager.default.removeItem(at: directory) }
let backend = ContainerCLIBackend(vmSources: nil, cli: cli)
#expect(await backend.runtimeStatus() == .outdated("1.3.0"))
#expect(await backend.preflight() == .issue(.cliOutdated(found: "1.3.0")))
}
@Test func aMissingRuntimeIsReported() async {
let backend = ContainerCLIBackend(vmSources: nil, cli: CLIRunner(searchPaths: ["/nonexistent/container"]))
#expect(await backend.runtimeStatus() == .missing)
#expect(await backend.preflight() == .issue(.cliMissing))
}
@Test func anOldRuntimeHasNoAutomaticFix() async throws {
let backend = ContainerCLIBackend(vmSources: nil, cli: CLIRunner(searchPaths: []))
await #expect(throws: VMBackendError.self) {
try await backend.fix(.cliOutdated(found: "1.3.0")) { _ in }
}
}
}
@@ -0,0 +1,34 @@
import Testing
@testable import KnotchyCore
struct ContainerRuntimeTests {
@Test func readsTheVersionFromTheCLIOutput() {
#expect(ContainerRuntime.version(in: "container CLI version 1.4.1 (build: release, commit: 9a8917c)\n") == "1.4.1")
#expect(ContainerRuntime.version(in: "warning: x\ncontainer CLI version 2.0 (build: debug)") == "2.0")
#expect(ContainerRuntime.version(in: "container version 1.4.1") == nil)
}
@Test func comparesVersionsNumerically() {
#expect(ContainerRuntime.isOlder("1.3.9", than: "1.4.0"))
#expect(ContainerRuntime.isOlder("1.4", than: "1.4.1"))
#expect(!ContainerRuntime.isOlder("1.10.0", than: "1.4.0"))
#expect(!ContainerRuntime.isOlder("1.4.0", than: "1.4"))
}
@Test func judgesAnInstalledRuntime() {
#expect(ContainerRuntime.status(versionOutput: nil) == .missing)
#expect(ContainerRuntime.status(versionOutput: "container CLI version 1.3.1 (build: release)") == .outdated("1.3.1"))
#expect(ContainerRuntime.status(versionOutput: "container CLI version 1.4.1 (build: release)") == .current("1.4.1"))
#expect(ContainerRuntime.status(versionOutput: "container CLI version 9.0.0") == .current("9.0.0"))
}
@Test func unreadableOutputNeverTriggersAnUpdate() {
#expect(ContainerRuntime.status(versionOutput: "container 3 (a new format)") == .current(nil))
#expect(ContainerRuntime.status(versionOutput: "") == .current(nil))
}
@Test func thePinnedReleaseIsNewEnough() {
#expect(!ContainerRuntime.isOlder(ContainerRuntime.pinned.version, than: ContainerRuntime.minimumVersion))
#expect(ContainerRuntime.pinned.sha256.count == 64)
}
}
@@ -0,0 +1,150 @@
import Testing
@testable import KnotchyCore
struct ContentReadinessTests {
private let t0 = ContinuousClock.now
@Test func ignoresBootstrapFramesAndProgress() {
// A bootstrap 100 after NAVIGATED never gets here: the agent's progress gate drops it.
var r = ContentReadiness()
r.hello(at: t0)
r.frame(isUniform: false)
r.progress(100)
#expect(!r.isReady)
}
@Test func readyOnTheFirstNonUniformFrameAfterNavigated() {
var r = ContentReadiness()
r.hello(at: t0)
r.navigated(at: t0)
r.frame(isUniform: true)
#expect(!r.isReady)
r.frame(isUniform: false)
#expect(r.isReady)
}
@Test func readyOnProgress100AfterNavigated() {
var r = ContentReadiness()
r.hello(at: t0)
r.navigated(at: t0)
r.progress(40)
#expect(!r.isReady)
r.progress(100)
#expect(r.isReady)
}
@Test func readyFifteenSecondsAfterNavigated() {
var r = ContentReadiness()
r.hello(at: t0)
r.navigated(at: t0 + .seconds(1))
#expect(r.nextDeadline == t0 + .seconds(16))
r.tick(now: t0 + .seconds(16) - .milliseconds(1))
#expect(!r.isReady)
r.tick(now: t0 + .seconds(16))
#expect(r.isReady)
#expect(r.nextDeadline == nil)
}
@Test func readyTwentyFiveSecondsAfterHelloIfNavigatedNeverComes() {
var r = ContentReadiness()
#expect(r.nextDeadline == nil)
r.hello(at: t0)
#expect(r.nextDeadline == t0 + .seconds(25))
r.tick(now: t0 + .seconds(24))
#expect(!r.isReady)
r.tick(now: t0 + .seconds(25))
#expect(r.isReady)
}
@Test func theHelloBackstopStillAppliesAfterALateNavigated() {
var r = ContentReadiness()
r.hello(at: t0)
r.navigated(at: t0 + .seconds(20))
#expect(r.nextDeadline == t0 + .seconds(25))
r.tick(now: t0 + .seconds(25))
#expect(r.isReady)
}
@Test func aLateViewerIsCaughtUpByTheReplay() {
var loaded = ContentReadiness()
loaded.hello(at: t0)
loaded.navigated(at: t0)
loaded.progress(100)
#expect(loaded.isReady)
var loading = ContentReadiness()
loading.hello(at: t0)
loading.navigated(at: t0)
loading.progress(40)
#expect(!loading.isReady)
loading.frame(isUniform: false)
#expect(loading.isReady)
}
@Test func keepsTheFirstHelloAndNavigated() {
// A repeated message can't push the timeouts back.
var r = ContentReadiness()
r.hello(at: t0)
r.hello(at: t0 + .seconds(10))
#expect(r.nextDeadline == t0 + .seconds(25))
r.navigated(at: t0 + .seconds(5))
r.navigated(at: t0 + .seconds(9))
#expect(r.nextDeadline == t0 + .seconds(20))
}
@Test func staysReadyOnceReady() {
var r = ContentReadiness()
r.hello(at: t0)
r.navigated(at: t0)
r.frame(isUniform: false)
r.frame(isUniform: true)
r.progress(10)
#expect(r.isReady)
}
}
struct FrameSamplerTests {
private func frame(width: Int, height: Int, bytesPerRow: Int) -> [UInt8] {
var bytes = [UInt8](repeating: 0, count: height * bytesPerRow)
for y in 0..<height {
for x in 0..<width * 4 { bytes[y * bytesPerRow + x] = 0xFF }
}
return bytes
}
/// Bytes that differ almost everywhere, so an out-of-bounds read can't look uniform by chance.
private func ramp(_ count: Int) -> [UInt8] {
(0..<count).map { UInt8(truncatingIfNeeded: $0 &* 37 &+ 1) }
}
@Test func aFlatFrameIsUniform() {
let bytes = frame(width: 96, height: 60, bytesPerRow: 96 * 4 + 16)
#expect(bytes.withUnsafeBytes { FrameSampler.isUniform($0, width: 96, height: 60, bytesPerRow: 96 * 4 + 16) })
}
@Test func aSampledPixelThatDiffersMakesItContent() {
var bytes = frame(width: 96, height: 60, bytesPerRow: 96 * 4)
// Grid point (gx 24, gy 24) samples pixel (48, 30).
bytes[30 * 96 * 4 + 48 * 4] = 0
#expect(!bytes.withUnsafeBytes { FrameSampler.isUniform($0, width: 96, height: 60, bytesPerRow: 96 * 4) })
}
@Test func aTightBufferIsSampledAndOneByteShortIsRejected() {
let (width, height, bytesPerRow) = (8, 8, 40)
let bytes = ramp((height - 1) * bytesPerRow + width * 4)
#expect(!bytes.withUnsafeBytes { FrameSampler.isUniform($0, width: width, height: height, bytesPerRow: bytesPerRow) })
#expect(Array(bytes.dropLast()).withUnsafeBytes {
FrameSampler.isUniform($0, width: width, height: height, bytesPerRow: bytesPerRow)
})
}
@Test(arguments: [
(8, 8, -32), (8, 8, 31), (8, 0, 32), (0, 8, 32), (8, -1, 32),
(Int.max / 2, 8, Int.max), (8, Int.max / 2, 32), (8, 8, Int.max), (1 << 16, 1, 1 << 18),
])
func malformedGeometryCountsAsUniform(_ geometry: (width: Int, height: Int, bytesPerRow: Int)) {
#expect(ramp(4096).withUnsafeBytes {
FrameSampler.isUniform($0, width: geometry.width, height: geometry.height, bytesPerRow: geometry.bytesPerRow)
})
}
}
+108
View File
@@ -0,0 +1,108 @@
import Foundation
import Testing
@testable import KnotchyCore
struct H264Tests {
private let aud: [UInt8] = [0x09, 0xF0]
private let sei: [UInt8] = [0x06, 0x05, 0x01, 0xAA, 0x80]
private let sps: [UInt8] = [0x67, 0x42, 0xC0, 0x1F, 0x8C]
private let sps2: [UInt8] = [0x67, 0x42, 0xC0, 0x28, 0x8C]
private let pps: [UInt8] = [0x68, 0xCE, 0x3C, 0x80]
private let pps2: [UInt8] = [0x68, 0xCE, 0x3C, 0x81]
private let idr: [UInt8] = [0x65, 0x88, 0x84, 0x21]
private let slice: [UInt8] = [0x41, 0x9A, 0x02]
private let four: [UInt8] = [0, 0, 0, 1]
private let three: [UInt8] = [0, 0, 1]
private func avcc(_ nals: [UInt8]...) -> Data {
nals.reduce(into: Data()) { data, nal in
data.appendBE32(UInt32(nal.count))
data.append(contentsOf: nal)
}
}
@Test func splitsOnThreeAndFourByteStartCodes() {
let stream = Data(four + aud + three + sps + four + pps + three + idr)
#expect(AnnexB.nalUnits(in: stream) == [aud, sps, pps, idr].map { Data($0) })
}
@Test func ignoresLeadingBytesAndTrailingZeros() {
let stream = Data([0xFF, 0x00] + three + slice + [0, 0] + four + slice + [0])
#expect(AnnexB.nalUnits(in: stream) == [Data(slice), Data(slice)])
}
@Test func handlesStreamsWithoutStartCodes() {
#expect(AnnexB.nalUnits(in: Data()) == [])
#expect(AnnexB.nalUnits(in: Data([0x65, 0x88])) == [])
#expect(AnnexB.nalUnits(in: Data(four)) == [])
#expect(AnnexB.nalUnits(in: Data(three + idr + three)) == [Data(idr)])
}
@Test func turnsAnIDRAccessUnitIntoAnAVCCSampleWithNewParameterSets() {
var parser = H264Parser()
let unit = parser.parse(Data(four + aud + four + sps + four + pps + four + sei + four + idr + four + idr))
#expect(unit.sample == avcc(idr, idr))
#expect(unit.isIDR)
#expect(unit.newParameterSets == ParameterSets(sps: Data(sps), pps: Data(pps)))
#expect(parser.parameterSets == unit.newParameterSets)
}
@Test func repeatedParameterSetsAreNotAChange() {
var parser = H264Parser()
_ = parser.parse(Data(four + sps + four + pps + four + idr))
let again = parser.parse(Data(four + aud + four + sps + four + pps + four + idr))
#expect(again.newParameterSets == nil)
let delta = parser.parse(Data(four + aud + four + slice))
#expect(delta.sample == avcc(slice))
#expect(!delta.isIDR)
#expect(delta.newParameterSets == nil)
// Repeated with every IDR: not a change, so the decoder isn't rebuilt.
let nextIDR = parser.parse(Data(four + aud + four + sps + four + pps + four + idr))
#expect(nextIDR.newParameterSets == nil)
}
@Test func aChangedSPSOrPPSIsANewFormat() {
var parser = H264Parser()
_ = parser.parse(Data(four + sps + four + pps + four + idr))
let changedSPS = parser.parse(Data(four + sps2 + four + pps + four + idr))
#expect(changedSPS.newParameterSets == ParameterSets(sps: Data(sps2), pps: Data(pps)))
let changedPPS = parser.parse(Data(four + sps2 + four + pps2 + four + idr))
#expect(changedPPS.newParameterSets == ParameterSets(sps: Data(sps2), pps: Data(pps2)))
}
@Test func aHalfPairIsNeverAFormat() {
var parser = H264Parser()
let spsOnly = parser.parse(Data(four + sps + four + idr))
#expect(spsOnly.newParameterSets == nil)
#expect(parser.parameterSets == nil)
_ = parser.parse(Data(four + sps + four + pps + four + idr))
let lone = parser.parse(Data(four + sps2 + four + slice))
#expect(lone.newParameterSets == nil)
#expect(parser.parameterSets == ParameterSets(sps: Data(sps), pps: Data(pps)))
}
@Test func onlyValidSliceUnitsAreDecoded() {
var parser = H264Parser()
// Type 21, a 3D extension slice.
let extensionSlice = parser.parse(Data(four + [0x75, 0x88]))
#expect(extensionSlice.sample.isEmpty)
#expect(!extensionSlice.isIDR)
// Forbidden bit set.
let forbidden = parser.parse(Data(four + [0xE5, 0x88] + four + slice))
#expect(forbidden.sample == avcc(slice))
#expect(!forbidden.isIDR)
#expect(parser.parse(Data(four + idr + four + slice)).isIDR)
let long = [UInt8(0x41)] + [UInt8](repeating: 0x9A, count: 299)
#expect(parser.parse(Data(four + long)).sample.prefix(4) == Data([0, 0, 0x01, 0x2C]))
}
@Test func slicesBeforeAnyParameterSetsHaveNoFormat() {
var parser = H264Parser()
let unit = parser.parse(Data(four + slice))
#expect(unit.sample == avcc(slice))
#expect(unit.newParameterSets == nil)
#expect(parser.parameterSets == nil)
}
}
@@ -0,0 +1,35 @@
import Testing
@testable import KnotchyCore
struct HostChecksTests {
@Test func unsupportedMacsAlwaysSeeTheWindow() {
#expect(SetupLaunchRule.shouldOpen(hostSupported: false, setupCompleted: true, runtime: .current("1.4.1")))
}
@Test func theFirstLaunchAlwaysSeesTheWindow() {
#expect(SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: false, runtime: .current("1.4.1")))
}
@Test func anUnsupportedMacOrAFirstLaunchOpensItWhateverTheRuntime() {
#expect(SetupLaunchRule.shouldOpen(hostSupported: false, setupCompleted: false, runtime: .missing))
#expect(SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: false, runtime: .missing))
#expect(SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: false, runtime: .outdated("1.3.0")))
}
@Test func laterOnlyAMissingOrOldRuntimeOpensIt() {
#expect(SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: true, runtime: .missing))
#expect(SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: true, runtime: .outdated("1.3.0")))
#expect(!SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: true, runtime: .current("1.4.1")))
#expect(!SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: true, runtime: .current(nil)))
}
@MainActor @Test func thisMacCanRunVirtualMachines() {
#expect(SystemHost.unsupported() == nil)
}
@Test func factsIncludeTheSystemAndFreeSpace() async {
let facts = await SystemHost().facts()
#expect(facts.osVersion.hasPrefix("macOS "))
#expect((facts.bytesFree ?? 0) > 0)
}
}
@@ -0,0 +1,154 @@
import Foundation
import Testing
@testable import KnotchyCore
struct LinkIntakeTests {
private func extract(_ text: String) -> String? {
LinkIntake.extractURL(from: text)?.absoluteString
}
@Test func findsAURLInsideProse() {
#expect(extract("Your parcel is waiting at https://example.com/track?id=42 today") == "https://example.com/track?id=42")
}
@Test func acceptsABareURL() {
#expect(extract("http://example.com") == "http://example.com")
}
@Test func stripsTrailingPunctuation() {
#expect(extract("Go to https://example.com/page.") == "https://example.com/page")
#expect(extract("**https://example.com/page**") == "https://example.com/page")
#expect(extract("https://example.com/search?q=*") == "https://example.com/search?q=*")
#expect(extract("Really? https://example.com/page!?") == "https://example.com/page")
#expect(extract("'https://example.com/a'") == "https://example.com/a")
#expect(extract("\"https://example.com/a\",") == "https://example.com/a")
#expect(extract("see https://example.com/a;") == "https://example.com/a")
}
@Test func keepsBalancedBracketsAndStripsUnbalancedOnes() {
#expect(extract("https://en.wikipedia.org/wiki/Foo_(bar)") == "https://en.wikipedia.org/wiki/Foo_(bar)")
#expect(extract("(see https://en.wikipedia.org/wiki/Foo_(bar)).") == "https://en.wikipedia.org/wiki/Foo_(bar)")
#expect(extract("(see https://example.com/a)") == "https://example.com/a")
#expect(extract("(https://example.com/a)and more") == "https://example.com/a")
#expect(extract("(https://en.wikipedia.org/wiki/Foo_(bar))") == "https://en.wikipedia.org/wiki/Foo_(bar)")
#expect(extract("[link](https://example.com/a)") == "https://example.com/a")
#expect(extract("https://example.com/a]") == "https://example.com/a")
#expect(extract("{https://example.com/a}") == "https://example.com/a")
}
@Test func handlesMarkdownLinks() {
#expect(extract("[https://example.com/a](https://example.com/a)") == "https://example.com/a")
#expect(extract("[https://example.com/a](https://example.org/b)") == "https://example.com/a")
#expect(extract("[httpbin](https://httpbin.org)") == "https://httpbin.org")
#expect(extract("See [XMLHttpRequest](https://developer.mozilla.org/docs/XMLHttpRequest).")
== "https://developer.mozilla.org/docs/XMLHttpRequest")
#expect(extract("**[Example](https://example.com)**") == "https://example.com")
#expect(extract("*[Example](https://example.com/a)*") == "https://example.com/a")
#expect(extract("Get it: **[httpbin](https://httpbin.org)**.") == "https://httpbin.org")
#expect(extract("[x](https://example.com)and more") == "https://example.com")
#expect(extract("[w](https://en.wikipedia.org/wiki/Foo_(bar))**") == "https://en.wikipedia.org/wiki/Foo_(bar)")
}
@Test func smartQuotesAndCJKPunctuationEndALink() {
#expect(extract("\u{201C}https://example.com\u{201D}") == "https://example.com")
#expect(extract("\u{201C}https://example.com/page\u{201D}") == "https://example.com/page")
#expect(extract("\u{00AB}https://example.com/page\u{00BB}") == "https://example.com/page")
#expect(extract("请访问https://example.com。谢谢") == "https://example.com")
#expect(extract("请访问https://example.com.谢谢") == "https://example.com")
#expect(extract("https://example.com。テスト") == "https://example.com")
#expect(extract("见(https://example.com/a)") == "https://example.com/a")
#expect(extract("「https://example.com/a」") == "https://example.com/a")
}
@Test func unwrapsAngleBrackets() {
#expect(extract("Open <https://example.com/a> 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: #"<a href="https://www.theverge.com/transportation/909060/amflows-latest-e-bikes">Amflow's latest e-bikes</a>"#)
== "https://www.theverge.com/transportation/909060/amflows-latest-e-bikes")
#expect(extract(html: #"<a href='https://www.google.com/url?q=https://example.com/&amp;sa=D'>Verify</a>"#)
== "https://www.google.com/url?q=https://example.com/&sa=D")
#expect(extract(html: "<A HREF=https://example.com/a>x</A>") == "https://example.com/a")
#expect(extract(html: #"<a href="mailto:a@example.com">mail</a> <a href="https://example.org/">web</a>"#) == "https://example.org/")
#expect(extract(html: "<p>see https://example.com/page.</p>") == "https://example.com/page")
#expect(extract(html: "<p>no links</p>") == 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)
}
}
@@ -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"))
}
}
@@ -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)
}
}
@@ -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<String> {
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))
}
}
@@ -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])
}
}
@@ -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"])
}
}
@@ -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)
}
}
@@ -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."))
}
}
@@ -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
}
}
@@ -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)
}
}
+242
View File
@@ -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")
}
}
+336
View File
@@ -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<String>)
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<Kind> = []
var nextParkID = 0
var parked: [Kind: [(id: Int, continuation: CheckedContinuation<Void, Never>)]] = [:]
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<String>) 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<Void, Never>) 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<Void, Never>? 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<Void, Never>)] = []
}
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<Void, Never>) 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<Void, Never>? 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<StreamEvent>
private let continuation: AsyncStream<StreamEvent>.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<Int64?>(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 }
}
}
@@ -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
}
}
@@ -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<Void, RuntimeHoldRefusal> = .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<Void, RuntimeHoldRefusal> {
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
}
}
@@ -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<Void, any Error>
}
private struct State {
var now: ContinuousClock.Instant
var nextID = 0
var sleepers: [Sleeper] = []
}
private let state: Mutex<State>
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<Void, any Error>) in
let outcome: Result<Void, any Error>? = 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() }
}
}
@@ -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 }
}
}
@@ -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..<min(chunkStart + 7, stream.count)), into: &decoder)
}
#expect(decoded == messages)
#expect(decoder.bufferedByteCount == 0)
}
@Test func readsTheHelloVersionAndAPreV1HelloIsVersionZero() throws {
#expect(try decodeAll(raw(MessageType.hello, [2, 0x04, 0x00, 0x02, 0x80])) == [.hello(version: 2, size: size)])
let legacy = raw(MessageType.hello, [0x04, 0x00, 0x02, 0x80])
#expect(try decodeAll(legacy) == [.hello(version: 0, size: size)])
#expect(AgentMessage.hello(version: 0, size: size).encoded() == legacy)
}
@Test func waitsForTheRestOfATruncatedMessage() throws {
var decoder = AgentMessageDecoder()
let hello = AgentMessage.hello(version: 1, size: size).encoded()
#expect(try decodeAll(hello.prefix(7), into: &decoder) == [])
#expect(decoder.bufferedByteCount == 7)
#expect(try decodeAll(hello.dropFirst(7), into: &decoder) == [.hello(version: 1, size: size)])
}
@Test func rejectsWrongFixedSizes() {
let hello = AgentMessage.hello(version: 1, size: size).encoded()
#expect(violation(raw(MessageType.hello, [1, 2, 3])) != nil)
#expect(violation(raw(MessageType.hello, [1, 0x04, 0x00, 0x02, 0x80, 0])) != nil)
#expect(violation(hello + raw(MessageType.progress, [])) != nil)
#expect(violation(hello + raw(MessageType.progress, [1, 2])) != nil)
#expect(violation(hello + raw(MessageType.navigated, [0])) != nil)
#expect(violation(hello + raw(MessageType.video, [0x04, 0x00, 0x02])) != nil)
}
@Test func rejectsProgressOver100() {
let hello = AgentMessage.hello(version: 1, size: size).encoded()
#expect(violation(hello + raw(MessageType.progress, [101])) != nil)
}
@Test func rejectsOversizedMessagesAsSoonAsTheHeaderArrives() {
let hello = AgentMessage.hello(version: 1, size: size).encoded()
var video = Data([MessageType.video])
video.appendBE32(UInt32(WireProtocol.maxVideoPayload + 1))
#expect(violation(hello + video) != nil)
var unknown = Data([0x7F])
unknown.appendBE32(UInt32(WireProtocol.maxOtherPayload + 1))
#expect(violation(hello + unknown) != nil)
}
@Test func acceptsMaximumSizeMessages() throws {
let hello = AgentMessage.hello(version: 1, size: size)
let video = AgentMessage.video(size: size, accessUnit: Data(count: WireProtocol.maxVideoPayload - 4))
let unknown = raw(0x7F, [UInt8](repeating: 0, count: WireProtocol.maxOtherPayload))
#expect(try decodeAll(hello.encoded() + video.encoded() + unknown) == [hello, video, .unknown(type: 0x7F)])
}
@Test func rejectsVideoWhoseSizeDiffersFromHello() {
let hello = AgentMessage.hello(version: 1, size: size).encoded()
for other in [PixelSize(width: 800, height: 600), PixelSize(width: 1024, height: 600), PixelSize(width: 800, height: 640)] {
#expect(violation(hello + AgentMessage.video(size: other, accessUnit: Data([1])).encoded()) != nil)
}
}
@Test func aViolationIsFinal() throws {
var decoder = AgentMessageDecoder()
decoder.append(AgentMessage.hello(version: 1, size: size).encoded()
+ raw(MessageType.progress, [101]) + AgentMessage.progress(50).encoded())
#expect(try decoder.next() == .hello(version: 1, size: size))
#expect(throws: AgentMessageDecoder.ProtocolViolation.self) { try decoder.next() }
#expect(throws: AgentMessageDecoder.ProtocolViolation.self) { try decoder.next() }
decoder.append(Data(count: 1000))
#expect(decoder.storedByteCount == 0)
}
@Test func staysBoundedWhenReadsNeverEndOnAMessageBoundary() throws {
let readSize = 4099 // Not a multiple of the 5-byte NAVIGATED, so reads end mid-message.
var decoder = AgentMessageDecoder()
var stream = AgentMessage.hello(version: 1, size: size).encoded()
for _ in 0..<100_000 { stream.append(AgentMessage.navigated.encoded()) }
var decoded = 0
var largest = 0
for chunkStart in stride(from: 0, to: stream.count, by: readSize) {
decoded += try decodeAll(stream.subdata(in: chunkStart..<min(chunkStart + readSize, stream.count)), into: &decoder).count
largest = max(largest, decoder.storedByteCount)
}
#expect(decoded == 100_001)
#expect(largest <= readSize + WireProtocol.headerSize)
}
@Test func rejectsAnythingBeforeHelloAndASecondHello() {
let hello = AgentMessage.hello(version: 1, size: size).encoded()
#expect(violation(AgentMessage.navigated.encoded()) != nil)
#expect(violation(AgentMessage.progress(10).encoded()) != nil)
#expect(violation(raw(MessageType.video, [0x04, 0x00, 0x02, 0x80, 0xAA])) != nil)
#expect(violation(AgentMessage.unknown(type: 0x7F).encoded()) != nil)
#expect(violation(hello + hello) != nil)
}
}
struct InputEventTests {
@Test func encodesPointerMessages() {
#expect(InputEvent.pointerMove(x: 0x0102, y: 0x0304, mods: [.shift, .leftButton]).encoded()
== Data([0x10, 0, 0, 0, 5, 0x01, 0x02, 0x03, 0x04, 0x11]))
#expect(InputEvent.pointerButton(.right, pressed: true, x: 10, y: 20, mods: .control).encoded()
== Data([0x11, 0, 0, 0, 7, 2, 1, 0, 10, 0, 20, 0x02]))
#expect(InputEvent.pointerButton(.left, pressed: false, x: 0, y: 0, mods: []).encoded()
== Data([0x11, 0, 0, 0, 7, 1, 0, 0, 0, 0, 0, 0]))
}
@Test func encodesScrollTicksAsSignedBigEndian() {
#expect(InputEvent.scroll(ticksX: 0, ticksY: -3, x: 1, y: 2).encoded()
== Data([0x12, 0, 0, 0, 8, 0, 0, 0xFF, 0xFD, 0, 1, 0, 2]))
}
@Test func encodesKeysAndStrings() {
#expect(InputEvent.key(pressed: true, mods: .meta, name: "Return").encoded()
== Data([0x13, 0, 0, 0, 8, 1, 0x08]) + Data("Return".utf8))
#expect(InputEvent.navigate("https://a.example").encoded()
== Data([0x14, 0, 0, 0, 17]) + Data("https://a.example".utf8))
#expect(InputEvent.script("history.back()").encoded()
== Data([0x15, 0, 0, 0, 14]) + Data("history.back()".utf8))
#expect(InputEvent.keyframe.encoded() == Data([0x16, 0, 0, 0, 0]))
}
@Test func modifierBitsMatchTheAgent() {
let bits: [Modifiers] = [.shift, .control, .alt, .meta, .leftButton, .rightButton, .middleButton]
#expect(bits.map(\.rawValue) == [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40])
}
@Test func refusesMessagesTheAgentCouldNotUse() {
#expect(InputEvent.script(String(repeating: "a", count: WireProtocol.maxOtherPayload)).encoded() != nil)
#expect(InputEvent.script(String(repeating: "a", count: WireProtocol.maxOtherPayload + 1)).encoded() == nil)
#expect(InputEvent.key(pressed: true, mods: [], name: "").encoded() == nil)
#expect(InputEvent.key(pressed: true, mods: [], name: "\u{0}x").encoded() == nil)
#expect(InputEvent.navigate("https://a.example/\u{0}").encoded() == nil)
#expect(InputEvent.script("history.back()\u{0}").encoded() == nil)
}
}
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="22.5" fill="#132441"/><g fill="none" stroke="#FF8A5B" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round"><path d="M43.15,35.60 L42.62,36.30 L42.10,37.02 L41.60,37.76 L41.11,38.50 L40.64,39.26 L40.19,40.02 L39.75,40.80 L39.33,41.58 L38.93,42.38 L38.55,43.18 L38.18,43.99 L37.83,44.80 L37.51,45.62 L37.20,46.44 L36.91,47.27 L36.64,48.11 L36.39,48.94 L36.16,49.78 L35.95,50.62 L35.76,51.46 L35.59,52.30 L35.44,53.14 L35.31,53.98 L35.20,54.82 L35.11,55.65 L35.04,56.48 L35.00,57.30 L34.97,58.12 L34.96,58.94 L34.97,59.74 L35.01,60.54 L35.06,61.34 L35.13,62.12 L35.23,62.89 L35.34,63.66 L35.47,64.41 L35.62,65.15 L35.79,65.88 L35.97,66.60 L36.18,67.30 L36.40,67.99 L36.64,68.66 L36.90,69.32 L37.17,69.97 L37.46,70.59 L37.77,71.20 L38.09,71.80 L38.43,72.37 L38.78,72.93 L39.14,73.46 L39.52,73.98 L39.91,74.48 L40.32,74.96 L40.73,75.41 L41.16,75.85 L41.60,76.26 L42.05,76.65 L42.51,77.02 L42.98,77.37 L43.46,77.69 L43.94,77.99 L44.44,78.26 L44.94,78.51 L45.44,78.74 L45.96,78.95 L46.47,79.13 L46.99,79.28 L47.52,79.41 L48.05,79.51 L48.58,79.59 L49.11,79.65 L49.64,79.68 L50.18,79.68 L50.71,79.66 L51.24,79.61 L51.78,79.54 L52.30,79.45 L52.83,79.33 L53.35,79.18 L53.87,79.01 L54.39,78.81 L54.89,78.59 L55.40,78.35 L55.89,78.08 L56.38,77.79 L56.86,77.48 L57.33,77.14 L57.80,76.78 L58.25,76.39 L58.69,75.99 L59.12,75.56 L59.55,75.11 L59.95,74.64 L60.35,74.15 L60.73,73.64 L61.10,73.11 L61.46,72.56 L61.80,71.99 L62.13,71.40 L62.44,70.80 L62.73,70.18 L63.01,69.54 L63.28,68.89 L63.52,68.22 L63.75,67.53 L63.96,66.83 L64.15,66.12 L64.33,65.40 L64.48,64.66 L64.58,64.16"/><path d="M62.17,44.80 L61.82,43.99 L61.45,43.18 L61.07,42.38 L60.67,41.58 L60.25,40.80 L59.81,40.02 L59.36,39.26 L58.89,38.50 L58.40,37.76 L57.90,37.02 L57.38,36.30 L56.85,35.60 L56.31,34.90 L55.75,34.22 L55.17,33.56 L54.59,32.91 L53.99,32.27 L53.38,31.65 L52.75,31.05 L52.12,30.47 L51.48,29.90 L50.83,29.35 L50.16,28.82 L49.50,28.31 L48.82,27.81 L48.13,27.34 L47.44,26.89 L46.75,26.45 L46.05,26.04 L45.34,25.65 L44.63,25.28 L43.92,24.93 L43.20,24.60 L42.49,24.29 L41.77,24.01 L41.05,23.74 L40.34,23.50 L39.62,23.28 L38.91,23.09 L38.19,22.91 L37.49,22.76 L36.78,22.63 L36.08,22.52 L35.39,22.44 L34.70,22.38 L34.02,22.34 L33.34,22.32 L32.68,22.32 L32.02,22.35 L31.37,22.40 L30.74,22.47 L30.11,22.56 L29.49,22.67 L28.89,22.80 L28.30,22.96 L27.72,23.13 L27.16,23.32 L26.61,23.54 L26.08,23.77 L25.56,24.02 L25.06,24.29 L24.57,24.58 L24.10,24.89 L23.65,25.22 L23.22,25.56 L22.81,25.92 L22.41,26.29 L22.04,26.68 L21.68,27.09 L21.35,27.50 L21.04,27.94 L20.74,28.39 L20.47,28.85 L20.22,29.32 L20.00,29.80 L19.79,30.30 L19.61,30.81 L19.45,31.32 L19.32,31.85 L19.21,32.38 L19.12,32.92 L19.06,33.48 L19.02,34.03 L19.00,34.60 L19.01,35.16 L19.04,35.74 L19.10,36.32 L19.18,36.90 L19.28,37.48 L19.41,38.07 L19.57,38.66 L19.75,39.24 L19.95,39.83 L20.18,40.42 L20.43,41.01 L20.70,41.59 L21.00,42.18 L21.32,42.76 L21.67,43.33 L22.03,43.90 L22.42,44.47 L22.84,45.03 L23.27,45.59 L23.73,46.13 L24.21,46.67 L24.71,47.21 L25.23,47.73 L25.77,48.24 L26.33,48.75 L26.71,49.08"/><path d="M44.69,56.67 L45.57,56.78 L46.45,56.86 L47.33,56.93 L48.22,56.98 L49.11,57.01 L50.00,57.02 L50.89,57.01 L51.78,56.98 L52.67,56.93 L53.55,56.86 L54.43,56.78 L55.31,56.67 L56.19,56.54 L57.06,56.40 L57.92,56.23 L58.78,56.05 L59.62,55.85 L60.47,55.63 L61.30,55.39 L62.12,55.14 L62.93,54.86 L63.74,54.57 L64.53,54.27 L65.30,53.94 L66.07,53.60 L66.82,53.25 L67.56,52.88 L68.28,52.49 L68.99,52.09 L69.68,51.67 L70.36,51.24 L71.02,50.80 L71.66,50.35 L72.29,49.88 L72.89,49.40 L73.48,48.91 L74.05,48.41 L74.59,47.90 L75.12,47.38 L75.63,46.85 L76.11,46.32 L76.58,45.77 L77.02,45.22 L77.44,44.66 L77.84,44.09 L78.21,43.52 L78.57,42.95 L78.90,42.37 L79.20,41.79 L79.48,41.20 L79.74,40.62 L79.98,40.03 L80.19,39.44 L80.37,38.85 L80.54,38.26 L80.68,37.68 L80.79,37.09 L80.88,36.51 L80.94,35.93 L80.98,35.35 L81.00,34.78 L80.99,34.22 L80.96,33.66 L80.90,33.11 L80.82,32.56 L80.72,32.02 L80.59,31.50 L80.44,30.98 L80.27,30.47 L80.07,29.97 L79.85,29.48 L79.61,29.00 L79.35,28.54 L79.06,28.09 L78.76,27.65 L78.43,27.22 L78.08,26.81 L77.71,26.42 L77.33,26.04 L76.92,25.67 L76.49,25.33 L76.05,25.00 L75.59,24.68 L75.11,24.39 L74.61,24.11 L74.10,23.85 L73.57,23.61 L73.02,23.39 L72.47,23.19 L71.89,23.01 L71.31,22.85 L70.71,22.71 L70.10,22.59 L69.47,22.49 L68.84,22.42 L68.19,22.36 L67.54,22.33 L66.88,22.32 L66.21,22.33 L65.53,22.36 L64.84,22.42 L64.15,22.49 L63.45,22.59 L62.75,22.71 L62.04,22.86 L61.33,23.03 L60.62,23.22 L59.90,23.43 L59.19,23.66 L58.71,23.83"/></g></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="15 18 70 66"><g fill="none" stroke="#FF8A5B" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round"><path d="M43.15,35.60 L42.62,36.30 L42.10,37.02 L41.60,37.76 L41.11,38.50 L40.64,39.26 L40.19,40.02 L39.75,40.80 L39.33,41.58 L38.93,42.38 L38.55,43.18 L38.18,43.99 L37.83,44.80 L37.51,45.62 L37.20,46.44 L36.91,47.27 L36.64,48.11 L36.39,48.94 L36.16,49.78 L35.95,50.62 L35.76,51.46 L35.59,52.30 L35.44,53.14 L35.31,53.98 L35.20,54.82 L35.11,55.65 L35.04,56.48 L35.00,57.30 L34.97,58.12 L34.96,58.94 L34.97,59.74 L35.01,60.54 L35.06,61.34 L35.13,62.12 L35.23,62.89 L35.34,63.66 L35.47,64.41 L35.62,65.15 L35.79,65.88 L35.97,66.60 L36.18,67.30 L36.40,67.99 L36.64,68.66 L36.90,69.32 L37.17,69.97 L37.46,70.59 L37.77,71.20 L38.09,71.80 L38.43,72.37 L38.78,72.93 L39.14,73.46 L39.52,73.98 L39.91,74.48 L40.32,74.96 L40.73,75.41 L41.16,75.85 L41.60,76.26 L42.05,76.65 L42.51,77.02 L42.98,77.37 L43.46,77.69 L43.94,77.99 L44.44,78.26 L44.94,78.51 L45.44,78.74 L45.96,78.95 L46.47,79.13 L46.99,79.28 L47.52,79.41 L48.05,79.51 L48.58,79.59 L49.11,79.65 L49.64,79.68 L50.18,79.68 L50.71,79.66 L51.24,79.61 L51.78,79.54 L52.30,79.45 L52.83,79.33 L53.35,79.18 L53.87,79.01 L54.39,78.81 L54.89,78.59 L55.40,78.35 L55.89,78.08 L56.38,77.79 L56.86,77.48 L57.33,77.14 L57.80,76.78 L58.25,76.39 L58.69,75.99 L59.12,75.56 L59.55,75.11 L59.95,74.64 L60.35,74.15 L60.73,73.64 L61.10,73.11 L61.46,72.56 L61.80,71.99 L62.13,71.40 L62.44,70.80 L62.73,70.18 L63.01,69.54 L63.28,68.89 L63.52,68.22 L63.75,67.53 L63.96,66.83 L64.15,66.12 L64.33,65.40 L64.48,64.66 L64.58,64.16"/><path d="M62.17,44.80 L61.82,43.99 L61.45,43.18 L61.07,42.38 L60.67,41.58 L60.25,40.80 L59.81,40.02 L59.36,39.26 L58.89,38.50 L58.40,37.76 L57.90,37.02 L57.38,36.30 L56.85,35.60 L56.31,34.90 L55.75,34.22 L55.17,33.56 L54.59,32.91 L53.99,32.27 L53.38,31.65 L52.75,31.05 L52.12,30.47 L51.48,29.90 L50.83,29.35 L50.16,28.82 L49.50,28.31 L48.82,27.81 L48.13,27.34 L47.44,26.89 L46.75,26.45 L46.05,26.04 L45.34,25.65 L44.63,25.28 L43.92,24.93 L43.20,24.60 L42.49,24.29 L41.77,24.01 L41.05,23.74 L40.34,23.50 L39.62,23.28 L38.91,23.09 L38.19,22.91 L37.49,22.76 L36.78,22.63 L36.08,22.52 L35.39,22.44 L34.70,22.38 L34.02,22.34 L33.34,22.32 L32.68,22.32 L32.02,22.35 L31.37,22.40 L30.74,22.47 L30.11,22.56 L29.49,22.67 L28.89,22.80 L28.30,22.96 L27.72,23.13 L27.16,23.32 L26.61,23.54 L26.08,23.77 L25.56,24.02 L25.06,24.29 L24.57,24.58 L24.10,24.89 L23.65,25.22 L23.22,25.56 L22.81,25.92 L22.41,26.29 L22.04,26.68 L21.68,27.09 L21.35,27.50 L21.04,27.94 L20.74,28.39 L20.47,28.85 L20.22,29.32 L20.00,29.80 L19.79,30.30 L19.61,30.81 L19.45,31.32 L19.32,31.85 L19.21,32.38 L19.12,32.92 L19.06,33.48 L19.02,34.03 L19.00,34.60 L19.01,35.16 L19.04,35.74 L19.10,36.32 L19.18,36.90 L19.28,37.48 L19.41,38.07 L19.57,38.66 L19.75,39.24 L19.95,39.83 L20.18,40.42 L20.43,41.01 L20.70,41.59 L21.00,42.18 L21.32,42.76 L21.67,43.33 L22.03,43.90 L22.42,44.47 L22.84,45.03 L23.27,45.59 L23.73,46.13 L24.21,46.67 L24.71,47.21 L25.23,47.73 L25.77,48.24 L26.33,48.75 L26.71,49.08"/><path d="M44.69,56.67 L45.57,56.78 L46.45,56.86 L47.33,56.93 L48.22,56.98 L49.11,57.01 L50.00,57.02 L50.89,57.01 L51.78,56.98 L52.67,56.93 L53.55,56.86 L54.43,56.78 L55.31,56.67 L56.19,56.54 L57.06,56.40 L57.92,56.23 L58.78,56.05 L59.62,55.85 L60.47,55.63 L61.30,55.39 L62.12,55.14 L62.93,54.86 L63.74,54.57 L64.53,54.27 L65.30,53.94 L66.07,53.60 L66.82,53.25 L67.56,52.88 L68.28,52.49 L68.99,52.09 L69.68,51.67 L70.36,51.24 L71.02,50.80 L71.66,50.35 L72.29,49.88 L72.89,49.40 L73.48,48.91 L74.05,48.41 L74.59,47.90 L75.12,47.38 L75.63,46.85 L76.11,46.32 L76.58,45.77 L77.02,45.22 L77.44,44.66 L77.84,44.09 L78.21,43.52 L78.57,42.95 L78.90,42.37 L79.20,41.79 L79.48,41.20 L79.74,40.62 L79.98,40.03 L80.19,39.44 L80.37,38.85 L80.54,38.26 L80.68,37.68 L80.79,37.09 L80.88,36.51 L80.94,35.93 L80.98,35.35 L81.00,34.78 L80.99,34.22 L80.96,33.66 L80.90,33.11 L80.82,32.56 L80.72,32.02 L80.59,31.50 L80.44,30.98 L80.27,30.47 L80.07,29.97 L79.85,29.48 L79.61,29.00 L79.35,28.54 L79.06,28.09 L78.76,27.65 L78.43,27.22 L78.08,26.81 L77.71,26.42 L77.33,26.04 L76.92,25.67 L76.49,25.33 L76.05,25.00 L75.59,24.68 L75.11,24.39 L74.61,24.11 L74.10,23.85 L73.57,23.61 L73.02,23.39 L72.47,23.19 L71.89,23.01 L71.31,22.85 L70.71,22.71 L70.10,22.59 L69.47,22.49 L68.84,22.42 L68.19,22.36 L67.54,22.33 L66.88,22.32 L66.21,22.33 L65.53,22.36 L64.84,22.42 L64.15,22.49 L63.45,22.59 L62.75,22.71 L62.04,22.86 L61.33,23.03 L60.62,23.22 L59.90,23.43 L59.19,23.66 L58.71,23.83"/></g></svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

+30
View File
@@ -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
+76
View File
@@ -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-<version>.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.
+13
View File
@@ -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 });
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+14
View File
@@ -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" }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+9
View File
@@ -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"
+47
View File
@@ -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"
+28
View File
@@ -0,0 +1,28 @@
// swift scripts/icons/frame.swift <screenshot> <out.png>: 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 <screenshot> <out.png>")
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])")
+1
View File
@@ -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], [Line truncated
+122
View File
@@ -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'<path d="{path(s)}"/>' for s in icon)
open(os.path.join(ROOT, "assets/knotchy-icon.svg"), "w").write(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
f'<rect width="100" height="100" rx="22.5" fill="{NAVY}"/>'
f'<g fill="none" stroke="{CORAL}" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round">{strokes}</g></svg>\n')
open(os.path.join(ROOT, "assets/knotchy-mark.svg"), "w").write(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="15 18 70 66">'
f'<g fill="none" stroke="{CORAL}" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round">{strokes}</g></svg>\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")
+183
View File
@@ -0,0 +1,183 @@
// swift scripts/icons/render.swift <out-dir>; 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)")
+17
View File
@@ -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/"
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Zips extensions/chrome for the Chrome Web Store: build/open-in-knotchy-<version>.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"
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Builds a signed, notarized build/Knotchy-<version>.dmg for a GitHub release.
# Needs a Developer ID Application certificate and notarization credentials saved once with
# xcrun notarytool store-credentials knotchy --apple-id <email> --team-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"
+8
View File
@@ -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"]
+49
View File
@@ -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 <<CONF
Port 8888
Listen 0.0.0.0
Timeout 60
LogLevel Connect
LogFile "/tmp/tinyproxy.log"
BasicAuth knotchy s3cr3t
MaxClients 20
CONF
tinyproxy -c /tmp/tinyproxy.conf
microsocks -i 0.0.0.0 -p 1080 -u knotchy -P 's3cr3t' > /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 - <<NFT
table ip nat {
chain postrouting {
type nat hook postrouting priority srcnat;
oifname "eth0" masquerade
}
}
NFT
cat > /tmp/client.conf <<CONF
[Interface]
PrivateKey = $(cat /tmp/client.key)
Address = 10.99.0.2/32
DNS = 1.1.1.1
[Peer]
PublicKey = $(cat /tmp/server.pub)
AllowedIPs = 0.0.0.0/0
Endpoint = $me:51820
CONF
echo "route-server ready on $me"
exec sleep infinity
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# scripts/run-vm.sh NAME URL: starts a VM with the app's flags, prints the host socket.
# Env: KNOTCHY_DEBUG, KNOTCHY_FIRST_VIEWER_TIMEOUT, KNOTCHY_ENV_FILE, KNOTCHY_SECRETS_DIR.
# Name VMs knotchyctl-<8 hex> 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"
+247
View File
@@ -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
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more