commit 4b67728cce9180af7f27687fb5666decdfd47ac8 Author: aculix <35836838+aculix@users.noreply.github.com> Date: Mon Sep 28 18:28:32 2026 +0530 Initial release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77bc72e --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.DS_Store + +.build/ +build/ +DerivedData/ +xcuserdata/ +.swiftpm/ +*.app + +snapshots/ + +a.out +*.o +*.dSYM/ +__pycache__/ +*.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b26c32a --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..27f8c31 --- /dev/null +++ b/Package.swift @@ -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"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c34fe0 --- /dev/null +++ b/README.md @@ -0,0 +1,192 @@ + + +# 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). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2bab937 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/Sources/Knotchy/AppDelegate.swift b/Sources/Knotchy/AppDelegate.swift new file mode 100644 index 0000000..5ad9081 --- /dev/null +++ b/Sources/Knotchy/AppDelegate.swift @@ -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) { + open(from: pasteboard, service: "Open in Knotchy") + } + + @objc func openCopiedLink(_ pasteboard: NSPasteboard, userData: String?, error: AutoreleasingUnsafeMutablePointer) { + 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) + } + } +} diff --git a/Sources/Knotchy/AppSettings.swift b/Sources/Knotchy/AppSettings.swift new file mode 100644 index 0000000..865b3db --- /dev/null +++ b/Sources/Knotchy/AppSettings.swift @@ -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? + + // 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.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) } + } +} diff --git a/Sources/Knotchy/ChromeExtension.swift b/Sources/Knotchy/ChromeExtension.swift new file mode 100644 index 0000000..be1ce37 --- /dev/null +++ b/Sources/Knotchy/ChromeExtension.swift @@ -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()) + } +} diff --git a/Sources/Knotchy/DebugTour.swift b/Sources/Knotchy/DebugTour.swift new file mode 100644 index 0000000..ffb54cb --- /dev/null +++ b/Sources/Knotchy/DebugTour.swift @@ -0,0 +1,164 @@ +#if DEBUG +import AppKit +import KnotchyCore +import QuartzCore +import SwiftUI + +// `Knotchy --tour ` saves PNGs of each state; `--tour-timing ` 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 `; 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 diff --git a/Sources/Knotchy/Info.plist b/Sources/Knotchy/Info.plist new file mode 100644 index 0000000..fdc3b94 --- /dev/null +++ b/Sources/Knotchy/Info.plist @@ -0,0 +1,75 @@ + + + + + CFBundleIdentifier + aculix.knotchy.app + CFBundleName + Knotchy + CFBundleDisplayName + Knotchy + CFBundleExecutable + Knotchy + CFBundleIconFile + AppIcon + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 26.0 + LSUIElement + + NSHumanReadableCopyright + © 2026 Aculix Technologies LLP. MIT License. + CFBundleURLTypes + + + CFBundleURLName + aculix.knotchy.open + CFBundleURLSchemes + + knotchy + + + + NSServices + + + NSMenuItem + + default + Open in Knotchy + + NSMessage + openLink + NSPortName + Knotchy + NSSendTypes + + public.url + public.html + public.rtf + public.utf8-plain-text + + NSRequiredContext + + + + NSMenuItem + + default + Open Copied Link in Knotchy + + NSMessage + openCopiedLink + NSPortName + Knotchy + NSRequiredContext + + + + + diff --git a/Sources/Knotchy/Keychain.swift b/Sources/Knotchy/Keychain.swift new file mode 100644 index 0000000..92e112f --- /dev/null +++ b/Sources/Knotchy/Keychain.swift @@ -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) + } + } +} diff --git a/Sources/Knotchy/KnotGlyph.swift b/Sources/Knotchy/KnotGlyph.swift new file mode 100644 index 0000000..998c48f --- /dev/null +++ b/Sources/Knotchy/KnotGlyph.swift @@ -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 + }() +} diff --git a/Sources/Knotchy/KnotchyApp.swift b/Sources/Knotchy/KnotchyApp.swift new file mode 100644 index 0000000..402ccfa --- /dev/null +++ b/Sources/Knotchy/KnotchyApp.swift @@ -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) + } + } +} diff --git a/Sources/Knotchy/MenuBar.swift b/Sources/Knotchy/MenuBar.swift new file mode 100644 index 0000000..b454561 --- /dev/null +++ b/Sources/Knotchy/MenuBar.swift @@ -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 { + 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…" + } + } +} diff --git a/Sources/Knotchy/NotchStyle.swift b/Sources/Knotchy/NotchStyle.swift new file mode 100644 index 0000000..6559df5 --- /dev/null +++ b/Sources/Knotchy/NotchStyle.swift @@ -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 { + 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) + } + } + } +} diff --git a/Sources/Knotchy/NotchViews.swift b/Sources/Knotchy/NotchViews.swift new file mode 100644 index 0000000..5c98876 --- /dev/null +++ b/Sources/Knotchy/NotchViews.swift @@ -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: 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 + } + } +} diff --git a/Sources/Knotchy/NotchWindow.swift b/Sources/Knotchy/NotchWindow.swift new file mode 100644 index 0000000..30a4b55 --- /dev/null +++ b/Sources/Knotchy/NotchWindow.swift @@ -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: NSHostingView { + 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 + 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? + + 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) + } +} diff --git a/Sources/Knotchy/SettingsView.swift b/Sources/Knotchy/SettingsView.swift new file mode 100644 index 0000000..8197665 --- /dev/null +++ b/Sources/Knotchy/SettingsView.swift @@ -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 } + } +} diff --git a/Sources/Knotchy/SetupWindow.swift b/Sources/Knotchy/SetupWindow.swift new file mode 100644 index 0000000..589d149 --- /dev/null +++ b/Sources/Knotchy/SetupWindow.swift @@ -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." + } + } +} diff --git a/Sources/Knotchy/VideoSurface.swift b/Sources/Knotchy/VideoSurface.swift new file mode 100644 index 0000000..1276362 --- /dev/null +++ b/Sources/Knotchy/VideoSurface.swift @@ -0,0 +1,234 @@ +import AVFoundation +import AppKit +import CoreMedia +import KnotchyCore +import SwiftUI +import Synchronization + +final class LatestFrame: Sendable { + private let frame = Mutex(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 + } +} diff --git a/Sources/KnotchyCore/Backend/CLIRunner.swift b/Sources/KnotchyCore/Backend/CLIRunner.swift new file mode 100644 index 0000000..ddf4579 --- /dev/null +++ b/Sources/KnotchyCore/Backend/CLIRunner.swift @@ -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()) + + /// 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.. 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)) + } +} diff --git a/Sources/KnotchyCore/Backend/ContainerCLIBackend.swift b/Sources/KnotchyCore/Backend/ContainerCLIBackend.swift new file mode 100644 index 0000000..464bf70 --- /dev/null +++ b/Sources/KnotchyCore/Backend/ContainerCLIBackend.swift @@ -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? + 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) 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) + } + } +} diff --git a/Sources/KnotchyCore/Backend/HostEnvironment.swift b/Sources/KnotchyCore/Backend/HostEnvironment.swift new file mode 100644 index 0000000..f1c9303 --- /dev/null +++ b/Sources/KnotchyCore/Backend/HostEnvironment.swift @@ -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? + guard getifaddrs(&head) == 0, let first = head else { return [] } + defer { freeifaddrs(head) } + var networks = Set() + 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 + } +} diff --git a/Sources/KnotchyCore/Backend/NetworkRoute.swift b/Sources/KnotchyCore/Backend/NetworkRoute.swift new file mode 100644 index 0000000..d5d5ff2 --- /dev/null +++ b/Sources/KnotchyCore/Backend/NetworkRoute.swift @@ -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[.. 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[.. 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())"] + ) + } + } +} diff --git a/Sources/KnotchyCore/Backend/VMBackend.swift b/Sources/KnotchyCore/Backend/VMBackend.swift new file mode 100644 index 0000000..de71a86 --- /dev/null +++ b/Sources/KnotchyCore/Backend/VMBackend.swift @@ -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) 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? +} diff --git a/Sources/KnotchyCore/Geometry/NotchGeometry.swift b/Sources/KnotchyCore/Geometry/NotchGeometry.swift new file mode 100644 index 0000000..421de3c --- /dev/null +++ b/Sources/KnotchyCore/Geometry/NotchGeometry.swift @@ -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 = 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) + } +} diff --git a/Sources/KnotchyCore/Links/LinkIntake.swift b/Sources/KnotchyCore/Links/LinkIntake.swift new file mode 100644 index 0000000..ff9c450 --- /dev/null +++ b/Sources/KnotchyCore/Links/LinkIntake.swift @@ -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.. text.startIndex ? text[text.index(before: match.lowerBound)] : nil + var parts = [(token, afterParenthesis: before == "(")] + if let markdown = token.range(of: "](") { + parts = [(token[.. URL? { + let text = String(html.prefix(maxScannedCharacters)) + let range = NSRange(text.startIndex..., in: text) + for match in hrefPattern.matches(in: text, range: range) { + for group in 1...3 { + guard let found = Range(match.range(at: group), in: text) else { continue } + let target = decodingEntities(text[found]).trimmingCharacters(in: .whitespacesAndNewlines) + if target.utf8.count <= maxURLBytes, let url = URL(string: target), let valid = validate(url) { + return valid + } + } + } + return extractURL(from: text) + } + + private static let hrefPattern = try! NSRegularExpression( + pattern: #"href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))"#, options: [.caseInsensitive]) + + private static func decodingEntities(_ value: Substring) -> String { + var result = String(value) + for (entity, character) in [(""", "\""), (""", "\""), ("'", "'"), ("'", "'"), + ("<", "<"), (">", ">"), ("&", "&"), ("&", "&")] { + result = result.replacingOccurrences(of: entity, with: character, options: .caseInsensitive) + } + return result + } + + public static func validate(_ url: URL) -> URL? { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https", + let host = url.host(percentEncoded: false), !host.isEmpty, + url.absoluteString.utf8.count <= maxURLBytes + else { return nil } + return url + } + + /// Missed by the category check. IDNA would turn these full stops into dots in the host. + private static let cjkPunctuation: Set = ["。", ".", "。", "、", ",", "…", "!", "?", ":", ";"] + + 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[.. 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 + } +} diff --git a/Sources/KnotchyCore/Session/BackendChain.swift b/Sources/KnotchyCore/Session/BackendChain.swift new file mode 100644 index 0000000..8df6b2c --- /dev/null +++ b/Sources/KnotchyCore/Session/BackendChain.swift @@ -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? + 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>([]) + + public init() {} + + public var snapshot: Set { + ids.withLock { $0 } + } + + func insert(_ id: String) { + _ = ids.withLock { $0.insert(id) } + } + + func remove(_ id: String) { + _ = ids.withLock { $0.remove(id) } + } +} diff --git a/Sources/KnotchyCore/Session/SessionController.swift b/Sources/KnotchyCore/Session/SessionController.swift new file mode 100644 index 0000000..edbb982 --- /dev/null +++ b/Sources/KnotchyCore/Session/SessionController.swift @@ -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] = [] + + 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? + } + + private let chain = BackendChain() + private let timers: DeadlineTimers + // 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] = [] + /// 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 { + 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(_ 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 + 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 + 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? = 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) 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 } + } +} diff --git a/Sources/KnotchyCore/Session/SessionState.swift b/Sources/KnotchyCore/Session/SessionState.swift new file mode 100644 index 0000000..3c3c5a3 --- /dev/null +++ b/Sources/KnotchyCore/Session/SessionState.swift @@ -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 +} diff --git a/Sources/KnotchyCore/Session/SetupRun.swift b/Sources/KnotchyCore/Session/SetupRun.swift new file mode 100644 index 0000000..ce55d85 --- /dev/null +++ b/Sources/KnotchyCore/Session/SetupRun.swift @@ -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) +} diff --git a/Sources/KnotchyCore/Setup/ContainerRuntime.swift b/Sources/KnotchyCore/Setup/ContainerRuntime.swift new file mode 100644 index 0000000..c7d2efd --- /dev/null +++ b/Sources/KnotchyCore/Setup/ContainerRuntime.swift @@ -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.. 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) + } +} diff --git a/Sources/KnotchyCore/Setup/RuntimeInstaller.swift b/Sources/KnotchyCore/Setup/RuntimeInstaller.swift new file mode 100644 index 0000000..f1c893c --- /dev/null +++ b/Sources/KnotchyCore/Setup/RuntimeInstaller.swift @@ -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? + /// A completion that arrived before anyone waited: a task cancelled before it started. + private var outcome: Result? + 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) 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 = if let failure = writeError ?? error { .failure(failure) } else { .success(()) } + if let waiting = continuation { + continuation = nil + waiting.resume(with: result) + } else { + outcome = result + } + } +} diff --git a/Sources/KnotchyCore/Setup/SetupDriving.swift b/Sources/KnotchyCore/Setup/SetupDriving.swift new file mode 100644 index 0000000..87dacae --- /dev/null +++ b/Sources/KnotchyCore/Setup/SetupDriving.swift @@ -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 + func finishRuntimeInstall(_ result: RuntimeInstallResult) async -> SetupRunOutcome + func waitWhileSettingUp() async +} + +extension SessionController: SetupDriving {} diff --git a/Sources/KnotchyCore/Setup/SetupFlow.swift b/Sources/KnotchyCore/Setup/SetupFlow.swift new file mode 100644 index 0000000..6cb7603 --- /dev/null +++ b/Sources/KnotchyCore/Setup/SetupFlow.swift @@ -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? + + /// 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 + } + } +} diff --git a/Sources/KnotchyCore/Setup/SetupLaunchRule.swift b/Sources/KnotchyCore/Setup/SetupLaunchRule.swift new file mode 100644 index 0000000..bb0842b --- /dev/null +++ b/Sources/KnotchyCore/Setup/SetupLaunchRule.swift @@ -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 + } + } +} diff --git a/Sources/KnotchyCore/Stream/ContentReadiness.swift b/Sources/KnotchyCore/Stream/ContentReadiness.swift new file mode 100644 index 0000000..f7fc688 --- /dev/null +++ b/Sources/KnotchyCore/Stream/ContentReadiness.swift @@ -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() + } +} diff --git a/Sources/KnotchyCore/Stream/FrameSampler.swift b/Sources/KnotchyCore/Stream/FrameSampler.swift new file mode 100644 index 0000000..e1466b9 --- /dev/null +++ b/Sources/KnotchyCore/Stream/FrameSampler.swift @@ -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.. [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.. 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 + } +} diff --git a/Sources/KnotchyCore/Stream/InputEvent.swift b/Sources/KnotchyCore/Stream/InputEvent.swift new file mode 100644 index 0000000..2edb7e0 --- /dev/null +++ b/Sources/KnotchyCore/Stream/InputEvent.swift @@ -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) + } +} diff --git a/Sources/KnotchyCore/Stream/StreamClient.swift b/Sources/KnotchyCore/Stream/StreamClient.swift new file mode 100644 index 0000000..e8b4c61 --- /dev/null +++ b/Sources/KnotchyCore/Stream/StreamClient.swift @@ -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.. 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.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.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.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.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 + private let continuation: AsyncStream.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.. 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() + } +} diff --git a/Sources/KnotchyCore/Stream/StreamConnection.swift b/Sources/KnotchyCore/Stream/StreamConnection.swift new file mode 100644 index 0000000..ac5be97 --- /dev/null +++ b/Sources/KnotchyCore/Stream/StreamConnection.swift @@ -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 { 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 +} diff --git a/Sources/KnotchyCore/Stream/VideoDecoder.swift b/Sources/KnotchyCore/Stream/VideoDecoder.swift new file mode 100644 index 0000000..b6a5424 --- /dev/null +++ b/Sources/KnotchyCore/Stream/VideoDecoder.swift @@ -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) } + } +} diff --git a/Sources/KnotchyCore/Stream/WireProtocol.swift b/Sources/KnotchyCore/Stream/WireProtocol.swift new file mode 100644 index 0000000..eced567 --- /dev/null +++ b/Sources/KnotchyCore/Stream/WireProtocol.swift @@ -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)) + } +} diff --git a/Sources/KnotchyCore/Time/DeadlineTimers.swift b/Sources/KnotchyCore/Time/DeadlineTimers.swift new file mode 100644 index 0000000..7aa83bb --- /dev/null +++ b/Sources/KnotchyCore/Time/DeadlineTimers.swift @@ -0,0 +1,67 @@ +/// Tests call `fireDue()` after advancing a `TestClock`; firing is idempotent for a given `now`. +@MainActor +final class DeadlineTimers { + 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? + + 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() + } + } +} diff --git a/Sources/KnotchyCore/Time/KnotchyClock.swift b/Sources/KnotchyCore/Time/KnotchyClock.swift new file mode 100644 index 0000000..4474842 --- /dev/null +++ b/Sources/KnotchyCore/Time/KnotchyClock.swift @@ -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) + } +} diff --git a/Sources/knotchyctl/main.swift b/Sources/knotchyctl/main.swift new file mode 100644 index 0000000..3fa129b --- /dev/null +++ b/Sources/knotchyctl/main.swift @@ -0,0 +1,121 @@ +import CoreImage +import Foundation +import ImageIO +import KnotchyCore +import Synchronization +import UniformTypeIdentifiers + +// knotchyctl snapshot [--after ] [--click ,] +// [--proxy ://[user:pass@]host:port] [--wireguard ] [--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(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 [--after ] [--click ,] [--proxy ] [--wireguard ]") +} +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 } +} diff --git a/Tests/KnotchyCoreTests/BackendChainTests.swift b/Tests/KnotchyCoreTests/BackendChainTests.swift new file mode 100644 index 0000000..914f717 --- /dev/null +++ b/Tests/KnotchyCoreTests/BackendChainTests.swift @@ -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) + } +} diff --git a/Tests/KnotchyCoreTests/CLIRunnerTests.swift b/Tests/KnotchyCoreTests/CLIRunnerTests.swift new file mode 100644 index 0000000..aa08525 --- /dev/null +++ b/Tests/KnotchyCoreTests/CLIRunnerTests.swift @@ -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")) + } +} diff --git a/Tests/KnotchyCoreTests/ClockTests.swift b/Tests/KnotchyCoreTests/ClockTests.swift new file mode 100644 index 0000000..1562ed2 --- /dev/null +++ b/Tests/KnotchyCoreTests/ClockTests.swift @@ -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(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(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(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(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(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(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"] } + } +} diff --git a/Tests/KnotchyCoreTests/ContainerCLIBackendRuntimeTests.swift b/Tests/KnotchyCoreTests/ContainerCLIBackendRuntimeTests.swift new file mode 100644 index 0000000..36d5b69 --- /dev/null +++ b/Tests/KnotchyCoreTests/ContainerCLIBackendRuntimeTests.swift @@ -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 } + } + } +} diff --git a/Tests/KnotchyCoreTests/ContainerRuntimeTests.swift b/Tests/KnotchyCoreTests/ContainerRuntimeTests.swift new file mode 100644 index 0000000..437153e --- /dev/null +++ b/Tests/KnotchyCoreTests/ContainerRuntimeTests.swift @@ -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) + } +} diff --git a/Tests/KnotchyCoreTests/ContentReadinessTests.swift b/Tests/KnotchyCoreTests/ContentReadinessTests.swift new file mode 100644 index 0000000..334abcf --- /dev/null +++ b/Tests/KnotchyCoreTests/ContentReadinessTests.swift @@ -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.. [UInt8] { + (0.. 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) + } +} diff --git a/Tests/KnotchyCoreTests/HostChecksTests.swift b/Tests/KnotchyCoreTests/HostChecksTests.swift new file mode 100644 index 0000000..f3ab10c --- /dev/null +++ b/Tests/KnotchyCoreTests/HostChecksTests.swift @@ -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) + } +} diff --git a/Tests/KnotchyCoreTests/LinkIntakeTests.swift b/Tests/KnotchyCoreTests/LinkIntakeTests.swift new file mode 100644 index 0000000..3bf53d7 --- /dev/null +++ b/Tests/KnotchyCoreTests/LinkIntakeTests.swift @@ -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 now") == "https://example.com/a") + } + + @Test func usesTheFirstWebURL() { + #expect(extract("ftp://files.example.com then https://b.example.com and https://c.example.com") == "https://b.example.com") + } + + @Test func textWithoutAWebLinkHasNoLink() { + #expect(extract("javascript:alert(1)") == nil) + #expect(extract("file:///etc/passwd") == nil) + #expect(extract("mailto:someone@example.com") == nil) + #expect(extract("ftp://files.example.com") == nil) + #expect(extract("httpx://example.com") == nil) + } + + @Test func rejectsEmptyAndHostlessInput() { + #expect(extract("") == nil) + #expect(extract(" \n") == nil) + #expect(extract("no links here, just httpbin and https://") == nil) + } + + @Test func acceptsUppercaseSchemes() { + #expect(extract("HTTPS://EXAMPLE.COM/") == "HTTPS://EXAMPLE.COM/") + } + + @Test func acceptsLinksUpToTheLengthLimit() { + let base = "https://example.com/" + let longest = base + String(repeating: "a", count: LinkIntake.maxURLBytes - base.utf8.count) + #expect(extract(longest) == longest) + #expect(extract("Go to " + longest + ".") == longest) // Measured after trimming. + #expect(extract(longest + "a") == nil) + // Measured as encoded: each `{` becomes `%7B`. + #expect(extract(base + String(repeating: "{", count: 6000)) == nil) + } + + @Test func skipsAnOverlongTokenWholeIncludingLinksNestedInIt() { + let overlong = "https://example.com/" + String(repeating: "a", count: LinkIntake.maxURLBytes) + #expect(extract(overlong + "https://nested.example.com/") == nil) + #expect(extract(overlong + " https://next.example.com/") == "https://next.example.com/") + } + + @Test(arguments: [ + String(repeating: "http", count: 256 * 1024), + String(repeating: "http ", count: 100_000), + String(repeating: "http" + String(repeating: ")", count: 16_000) + " ", count: 16), + String(repeating: "http](", count: 200_000), + ]) + func hostileTextIsScannedInLinearTime(_ hostile: String) { + // Quadratic versions took 10 s to minutes on these; linear takes well under a second. + let start = ContinuousClock.now + #expect(extract(hostile) == nil) + #expect(ContinuousClock.now - start < .seconds(5)) + } + + @Test func onlyLinksStartingNearTheStartOfHugeTextCount() { + let filler = String(repeating: "x", count: LinkIntake.maxScannedCharacters) + #expect(extract(filler + " https://example.com/") == nil) + #expect(extract("https://example.com/ " + filler) == "https://example.com/") + } + + @Test func aLinkThatStartsBeforeTheScanLimitIsReadWhole() { + let link = "https://example.com/very/long/path?token=abcdef" + let filler = String(repeating: "x", count: LinkIntake.maxScannedCharacters - 30) + #expect(extract(filler + " " + link) == link) + } + + @Test func findsTheLinkBehindSelectedHyperlinkText() { + func extract(html: String) -> String? { LinkIntake.extractURL(fromHTML: html)?.absoluteString } + #expect(extract(html: #"Amflow's latest e-bikes"#) + == "https://www.theverge.com/transportation/909060/amflows-latest-e-bikes") + #expect(extract(html: #"Verify"#) + == "https://www.google.com/url?q=https://example.com/&sa=D") + #expect(extract(html: "x") == "https://example.com/a") + #expect(extract(html: #"mail web"#) == "https://example.org/") + #expect(extract(html: "

see https://example.com/page.

") == "https://example.com/page") + #expect(extract(html: "

no links

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

This page couldn't be loaded

%s

", + escaped); + GBytes *bytes = g_bytes_new_take (html, strlen (html)); + + /* WebKit reports the failed load's final 100 after load-failed; resetting + * the gate drops it, so a failed page never reads as loaded. */ + progress_gate_navigated (&a->progress); + g_signal_emit_by_name (a->src, "load-bytes", bytes); + g_bytes_unref (bytes); + g_free (escaped); +} + +static gboolean +on_bus_message (GstBus * bus, GstMessage * msg, gpointer user_data) +{ + Agent *a = user_data; + GError *err = NULL; + gchar *dbg = NULL; + + switch (GST_MESSAGE_TYPE (msg)) { + case GST_MESSAGE_ERROR: + gst_message_parse_error (msg, &err, &dbg); + if (is_load_failure (msg, err, dbg)) { + g_printerr ("knotchy-agent: %s\n", dbg); + show_error_page (a, strstr (dbg, "Failed to load")); + } else { + /* Includes WEBKIT_WEB_PROCESS_CRASHED. Fail closed: the VM goes away. */ + g_printerr ("knotchy-agent: pipeline error: %s (%s)\n", err->message, + dbg ? dbg : ""); + g_main_loop_quit (a->loop); + } + g_clear_error (&err); + g_free (dbg); + break; + case GST_MESSAGE_ELEMENT:{ + const GstStructure *s = gst_message_get_structure (msg); + gdouble value; + if (s && gst_structure_has_name (s, "wpe-stats") + && gst_structure_get_double (s, "estimated-load-progress", &value)) { + int pct = progress_gate_feed (&a->progress, value); + if (a->debug) + g_print ("knotchy-agent: wpe-stats %.1f (%s)\n", value, + pct < 0 ? "dropped" : "sent"); + if (pct >= 0) { + guint8 b = (guint8) pct; + send_message (a, MSG_PROGRESS, &b, 1); + } + } + break; + } + case GST_MESSAGE_EOS: + g_main_loop_quit (a->loop); + break; + default: + break; + } + return G_SOURCE_CONTINUE; +} + +static gboolean +on_terminate (gpointer user_data) +{ + g_main_loop_quit (((Agent *) user_data)->loop); + return G_SOURCE_REMOVE; +} + +int +main (int argc, char **argv) +{ + Agent a = { .client_fd = -1 }; + const char *url = g_getenv ("KNOTCHY_URL"); + const char *size = g_getenv ("KNOTCHY_SIZE"); + const char *sock = g_getenv ("KNOTCHY_SOCKET"); + int fps = env_int ("KNOTCHY_FPS", 30); + int qp = env_int ("KNOTCHY_QP", 21); + GError *err = NULL; + gchar *desc; + GstBus *bus; + + a.started_us = g_get_monotonic_time (); + a.debug = env_int ("KNOTCHY_DEBUG", 0) != 0; + progress_gate_init (&a.progress); + signal (SIGPIPE, SIG_IGN); + gst_init (&argc, &argv); + + if (!url || !*url) + url = "about:blank"; + if (!sock || !*sock) + sock = "/tmp/knotchy/knotchy.sock"; + if (!size || sscanf (size, "%dx%d", &a.width, &a.height) != 2) { + a.width = 1024; + a.height = 640; + } + /* The cap keeps an IDR frame well under the viewer's 2 MiB VIDEO limit. */ + a.width = CLAMP (a.width, 320, 2048) & ~1; + a.height = CLAMP (a.height, 240, 1280) & ~1; + + /* Constant quality: bandwidth is free over a local socket, sharp text isn't. */ + desc = g_strdup_printf ("wpevideosrc name=src draw-background=true " + "! video/x-raw,width=%d,height=%d,framerate=%d/1 " + "! videoconvert ! video/x-raw,format=I420 " + "! x264enc tune=zerolatency speed-preset=ultrafast pass=qual quantizer=%d " + "key-int-max=%d byte-stream=true option-string=repeat-headers=1 " + "! video/x-h264,stream-format=byte-stream,alignment=au " + "! appsink name=sink sync=false max-buffers=8", + a.width, a.height, fps, qp, fps * 4); + a.pipeline = gst_parse_launch (desc, &err); + g_free (desc); + if (!a.pipeline) { + g_printerr ("knotchy-agent: %s\n", err->message); + return 1; + } + + a.src = gst_bin_get_by_name (GST_BIN (a.pipeline), "src"); + a.sink = gst_bin_get_by_name (GST_BIN (a.pipeline), "sink"); + a.src_pad = gst_element_get_static_pad (a.src, "src"); + /* wpevideosrc creates the view only once a page paints, which a failed first + * load never does. The URL is a property, never parsed as pipeline syntax. */ + a.initial_url = g_strdup (url); + g_object_set (a.src, "location", "about:blank", NULL); + g_signal_connect (a.src, "configure-web-view", G_CALLBACK (on_configure_web_view), &a); + + gst_app_sink_set_callbacks (GST_APP_SINK (a.sink), + &(GstAppSinkCallbacks) {.new_sample = on_new_sample}, &a, NULL); + + a.inbuf = g_byte_array_new (); + g_mutex_init (&a.write_lock); + a.loop = g_main_loop_new (NULL, FALSE); + + a.listen_fd = listen_unix (sock); + if (a.listen_fd < 0) { + g_printerr ("knotchy-agent: cannot listen on %s: %s\n", sock, g_strerror (errno)); + return 1; + } + g_unix_fd_add (a.listen_fd, G_IO_IN, on_accept, &a); + + bus = gst_element_get_bus (a.pipeline); + gst_bus_add_watch (bus, on_bus_message, &a); + gst_object_unref (bus); + + /* As PID 1, SIGTERM has no default action. */ + g_unix_signal_add (SIGTERM, on_terminate, &a); + g_unix_signal_add (SIGINT, on_terminate, &a); + + a.orphan_timeout_s = MAX (1, env_int ("KNOTCHY_ORPHAN_TIMEOUT", 10)); + arm_watchdog (&a, MAX (1, env_int ("KNOTCHY_FIRST_VIEWER_TIMEOUT", 60))); + + g_print ("knotchy-agent: %dx%d@%d streaming on %s\n", a.width, a.height, fps, sock); + gst_element_set_state (a.pipeline, GST_STATE_PLAYING); + g_main_loop_run (a.loop); + disarm_watchdog (&a); + + gst_element_set_state (a.pipeline, GST_STATE_NULL); + close_client (&a); + close (a.listen_fd); + unlink (sock); + return 0; +} diff --git a/vm/knotchy-init b/vm/knotchy-init new file mode 100755 index 0000000..d7a301e --- /dev/null +++ b/vm/knotchy-init @@ -0,0 +1,197 @@ +#!/bin/sh +# PID 1 of a session VM: firewalls egress to the public internet (or only the +# proxy, or only the WireGuard server and its tunnel), then runs knotchy-agent as +# the knotchy user with no capabilities, so nothing in the browser can undo it. +# Fails closed: a missing or malformed setting stops the VM. Needs NET_ADMIN. +# Env: KNOTCHY_ROUTE (direct|proxy|wireguard), KNOTCHY_BLOCK_CIDRS, KNOTCHY_PROXY_ADDR, +# KNOTCHY_PROXY_URL*, KNOTCHY_WG_SETCONF*, KNOTCHY_WG_ENDPOINT, KNOTCHY_WG_ADDRESS, +# KNOTCHY_WG_DNS, KNOTCHY_WG_MTU. *Secrets, normally in /run/knotchy-secrets/route. +set -eu + +fail() { + echo "knotchy-init: $*" >&2 + exit 1 +} + +blocked="0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, + 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 192.168.0.0/16, + 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4" + +is_ipv4() { + case "$1" in *[!0-9.]* | "" | *..* | .* | *.) return 1 ;; esac + old_ifs=$IFS; IFS=. + # shellcheck disable=SC2086 # splitting on dots is the point + set -- $1 + IFS=$old_ifs + [ $# -eq 4 ] || return 1 + for octet in "$@"; do + [ "${#octet}" -le 3 ] && [ "$octet" -le 255 ] || return 1 + done +} + +is_port() { + case "$1" in *[!0-9]* | "") return 1 ;; esac + [ "${#1}" -le 5 ] && [ "$1" -ge 1 ] && [ "$1" -le 65535 ] +} + +is_cidr() { + case "$1" in */*) ;; *) return 1 ;; esac + prefix=${1##*/} + case "$prefix" in *[!0-9]* | "") return 1 ;; esac + is_ipv4 "${1%/*}" && [ "${#prefix}" -le 2 ] && [ "$prefix" -le 32 ] +} + +set -f +for cidr in ${KNOTCHY_BLOCK_CIDRS:-}; do + case "$cidr" in + *[!0-9./]* | "") fail "malformed CIDR '$cidr' in KNOTCHY_BLOCK_CIDRS" ;; + *) blocked="$blocked, $cidr" ;; + esac +done + +route=${KNOTCHY_ROUTE:-direct} +case $route in + direct | proxy | wireguard) ;; + *) fail "unknown KNOTCHY_ROUTE '$route'" ;; +esac + +# Secrets come on a share because `container` keeps -e values on disk. Delete +# the file (on the Mac too) and unmount before anything else runs. +proxy_url=${KNOTCHY_PROXY_URL:-} +wg_setconf=${KNOTCHY_WG_SETCONF:-} +secrets=/run/knotchy-secrets +if [ -e "$secrets/route" ]; then + while IFS= read -r line; do + case "$line" in + KNOTCHY_PROXY_URL=*) proxy_url=${line#*=} ;; + KNOTCHY_WG_SETCONF=*) wg_setconf=${line#*=} ;; + esac + done < "$secrets/route" + rm -f "$secrets/route" || fail "could not delete the secrets file" +fi +if grep -q " $secrets " /proc/mounts; then + umount "$secrets" || fail "could not unmount the secrets share" +fi + +case $route in + direct) + [ -z "${KNOTCHY_PROXY_ADDR:-}${KNOTCHY_WG_ENDPOINT:-}$proxy_url$wg_setconf" ] || + fail "proxy or VPN settings given for a direct route" + egress="ip daddr @blocked4 reject with icmpx type admin-prohibited" ;; + proxy) + proxy_addr=${KNOTCHY_PROXY_ADDR:-} + proxy_ip=${proxy_addr%:*} + proxy_port=${proxy_addr##*:} + is_ipv4 "$proxy_ip" && is_port "$proxy_port" || fail "malformed KNOTCHY_PROXY_ADDR" + case "$proxy_url" in + http://*@"$proxy_addr" | http://"$proxy_addr" | socks5://*@"$proxy_addr" | socks5://"$proxy_addr") ;; + *) fail "malformed or missing KNOTCHY_PROXY_URL" ;; + esac + egress="ip daddr $proxy_ip tcp dport $proxy_port accept + reject with icmpx type admin-prohibited" ;; + wireguard) + [ -n "$wg_setconf" ] || fail "KNOTCHY_WG_SETCONF is missing" + wg_endpoint=${KNOTCHY_WG_ENDPOINT:-} + wg_ip=${wg_endpoint%:*} + wg_port=${wg_endpoint##*:} + is_ipv4 "$wg_ip" && is_port "$wg_port" || fail "malformed KNOTCHY_WG_ENDPOINT" + [ -n "${KNOTCHY_WG_ADDRESS:-}" ] || fail "KNOTCHY_WG_ADDRESS is empty" + for cidr in $KNOTCHY_WG_ADDRESS; do is_cidr "$cidr" || fail "malformed KNOTCHY_WG_ADDRESS"; done + wg_dns=${KNOTCHY_WG_DNS:-1.1.1.1} + [ -n "$wg_dns" ] || wg_dns=1.1.1.1 + for ns in $wg_dns; do is_ipv4 "$ns" || fail "malformed KNOTCHY_WG_DNS"; done + wg_mtu=${KNOTCHY_WG_MTU:-1420} + case "$wg_mtu" in *[!0-9]* | "") fail "malformed KNOTCHY_WG_MTU" ;; esac + [ "${#wg_mtu}" -le 4 ] && [ "$wg_mtu" -ge 576 ] || fail "malformed KNOTCHY_WG_MTU" + wg_dns_set=$(echo $wg_dns | sed 's/ /, /g') + # DNS only to the VPN's (often private) resolvers; outside the tunnel, only wireguard-go. + egress="oifname \"wg0\" ip daddr { $wg_dns_set } meta l4proto { tcp, udp } th dport 53 accept + oifname \"wg0\" ip daddr @blocked4 reject with icmpx type admin-prohibited + oifname \"wg0\" accept + ip daddr $wg_ip udp dport $wg_port meta skuid 0 accept + reject with icmpx type admin-prohibited" ;; +esac + +# No DNS exception in the firewall: a --dns resolver in a blocked range can't answer. +dns=$(awk '$1 == "nameserver" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ { + printf "%s%s", sep, $2; sep = ", " }' /etc/resolv.conf) +bad_ns=$(awk '$1 == "nameserver" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ { + split($2, o, "."); a = o[1] + 0; b = o[2] + 0 + if (a == 10 || a == 127 || (a == 169 && b == 254) || (a == 172 && b >= 16 && b <= 31) || + (a == 192 && b == 168) || (a == 100 && b >= 64 && b <= 127)) { print $2; exit } +}' /etc/resolv.conf) +[ -z "$bad_ns" ] || fail "resolver $bad_ns is in a blocked range; it cannot be reached through the egress firewall" + +nft -f - < /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null || true + +nft list table inet knotchy >/dev/null 2>&1 || fail "egress firewall is not in place" + +case $route in + direct) + echo "knotchy-init: egress locked to the public internet${dns:+ (DNS via $dns)}" ;; + proxy) + echo "knotchy-init: egress locked to the proxy at $KNOTCHY_PROXY_ADDR" + export http_proxy="$proxy_url" https_proxy="$proxy_url" \ + HTTP_PROXY="$proxy_url" HTTPS_PROXY="$proxy_url" no_proxy="" ;; + wireguard) + # The firewall is already in place, so a half-built tunnel leaks nothing. + old_umask=$(umask) + umask 077 + printf '%s' "$wg_setconf" | base64 -d > /run/knotchy-wg.conf 2>/dev/null || + fail "malformed KNOTCHY_WG_SETCONF" + # Without SYS_ADMIN, which init only needed to unmount the secrets share. + WG_I_PREFER_BUGGY_USERSPACE_TO_POLISHED_KMOD=1 setpriv --bounding-set -sys_admin \ + wireguard-go wg0 >/dev/null 2>&1 || + fail "could not start wireguard-go" + wg setconf wg0 /run/knotchy-wg.conf || fail "the WireGuard settings were rejected" + rm -f /run/knotchy-wg.conf + umask "$old_umask" + for cidr in $KNOTCHY_WG_ADDRESS; do + ip -4 address add "$cidr" dev wg0 || fail "could not give the tunnel its address" + done + ip link set dev wg0 mtu "$wg_mtu" up || fail "could not bring the tunnel up" + via=$(ip -4 route get "$wg_ip" | awk '{ for (i = 1; i < NF; i++) if ($i == "via") { print $(i + 1); exit } }') + dev=$(ip -4 route get "$wg_ip" | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i + 1); exit } }') + [ -n "$dev" ] || fail "no route to the VPN server" + ip -4 route add "$wg_ip/32" ${via:+via "$via"} dev "$dev" || fail "could not route to the VPN server" + ip -4 route replace default dev wg0 || fail "could not route through the tunnel" + printf 'nameserver %s\n' $wg_dns > /etc/resolv.conf || fail "could not set the VPN's DNS" + echo "knotchy-init: egress locked to the WireGuard tunnel via $KNOTCHY_WG_ENDPOINT" ;; +esac + +# The browser has no business knowing the Mac's networks or the VPN's keys. +unset KNOTCHY_ROUTE KNOTCHY_BLOCK_CIDRS KNOTCHY_PROXY_ADDR KNOTCHY_PROXY_URL KNOTCHY_WG_SETCONF \ + KNOTCHY_WG_ENDPOINT KNOTCHY_WG_ADDRESS KNOTCHY_WG_DNS KNOTCHY_WG_MTU +export HOME=/home/knotchy USER=knotchy LOGNAME=knotchy +exec setpriv --reuid=knotchy --regid=knotchy --init-groups \ + --inh-caps=-all --bounding-set=-all --no-new-privs \ + /usr/local/bin/knotchy-agent diff --git a/vm/progress-gate.c b/vm/progress-gate.c new file mode 100644 index 0000000..77e1e3f --- /dev/null +++ b/vm/progress-gate.c @@ -0,0 +1,53 @@ +#include + +#include "progress-gate.h" + +void +progress_gate_init (ProgressGate * g) +{ + g->has_navigated = false; + g->saw_below_100 = false; + g->last_sent = -1; +} + +void +progress_gate_navigated (ProgressGate * g) +{ + g->has_navigated = true; + g->saw_below_100 = false; + g->last_sent = -1; +} + +int +progress_gate_feed (ProgressGate * g, double value) +{ + if (!isfinite (value)) + return -1; + + /* Floor, not round: 99.6 % is not loaded. */ + int v = value <= 0 ? 0 : value >= 100 ? 100 : (int) floor (value); + + if (!g->has_navigated) + return -1; + + /* A drop means a new load started. */ + if (g->last_sent >= 0 && v < g->last_sent) { + g->saw_below_100 = true; + g->last_sent = v; + return v; + } + + /* A 100 before any lower value is the previous page finishing. */ + if (v < 100) + g->saw_below_100 = true; + + if (v == 100) { + if (!g->saw_below_100 || g->last_sent == 100) + return -1; + } else if (g->last_sent >= 0 && v < g->last_sent + 5) { + return -1; + } + + g->last_sent = v; + return v; +} diff --git a/vm/progress-gate.h b/vm/progress-gate.h new file mode 100644 index 0000000..473d0f4 --- /dev/null +++ b/vm/progress-gate.h @@ -0,0 +1,21 @@ +/* Decides which WebKit load-progress values the agent forwards as PROGRESS. */ +#ifndef KNOTCHY_PROGRESS_GATE_H +#define KNOTCHY_PROGRESS_GATE_H + +#include + +typedef struct { + bool has_navigated; /* false while the about:blank bootstrap loads */ + bool saw_below_100; + int last_sent; +} ProgressGate; + +void progress_gate_init (ProgressGate * g); + +/* On every new top-level load, the error page included. */ +void progress_gate_navigated (ProgressGate * g); + +/* Returns the percent to send, or -1 to drop the value. */ +int progress_gate_feed (ProgressGate * g, double value); + +#endif diff --git a/vm/tests/test-progress-gate.c b/vm/tests/test-progress-gate.c new file mode 100644 index 0000000..7dc3173 --- /dev/null +++ b/vm/tests/test-progress-gate.c @@ -0,0 +1,155 @@ +/* cc -Wall -Werror -I vm -o /tmp/test-progress-gate \ + * vm/tests/test-progress-gate.c vm/progress-gate.c -lm && /tmp/test-progress-gate */ +#include +#include + +#include "progress-gate.h" + +static int failures; + +#define EXPECT(got, want) \ + do { \ + int g_ = (got), w_ = (want); \ + if (g_ != w_) { \ + printf ("FAIL %s:%d: %s = %d, want %d\n", __FILE__, __LINE__, #got, g_, \ + w_); \ + failures++; \ + } \ + } while (0) + +static ProgressGate +navigated_gate (void) +{ + ProgressGate g; + progress_gate_init (&g); + progress_gate_navigated (&g); + return g; +} + +static void +drops_everything_before_navigation (void) +{ + ProgressGate g; + progress_gate_init (&g); + EXPECT (progress_gate_feed (&g, 10), -1); + EXPECT (progress_gate_feed (&g, 100), -1); + EXPECT (g.last_sent, -1); +} + +static void +late_bootstrap_100_is_not_page_loaded (void) +{ + ProgressGate g = navigated_gate (); + EXPECT (progress_gate_feed (&g, 100), -1); + EXPECT (progress_gate_feed (&g, 10), 10); + EXPECT (progress_gate_feed (&g, 100), 100); +} + +static void +rate_limits_increases_but_always_sends_100_once (void) +{ + ProgressGate g = navigated_gate (); + EXPECT (progress_gate_feed (&g, 10), 10); + EXPECT (progress_gate_feed (&g, 12), -1); + EXPECT (progress_gate_feed (&g, 15), 15); + EXPECT (progress_gate_feed (&g, 98), 98); + EXPECT (progress_gate_feed (&g, 100), 100); + EXPECT (progress_gate_feed (&g, 100), -1); +} + +static void +decrease_is_a_new_navigation_and_sent_immediately (void) +{ + ProgressGate g = navigated_gate (); + EXPECT (progress_gate_feed (&g, 10), 10); + EXPECT (progress_gate_feed (&g, 100), 100); + EXPECT (progress_gate_feed (&g, 30), 30); + EXPECT (progress_gate_feed (&g, 32), -1); + EXPECT (progress_gate_feed (&g, 100), 100); +} + +static void +first_value_after_navigation_is_always_sent (void) +{ + ProgressGate g = navigated_gate (); + EXPECT (progress_gate_feed (&g, 0), 0); + EXPECT (progress_gate_feed (&g, 3), -1); +} + +static void +floors_and_clamps_values (void) +{ + ProgressGate g = navigated_gate (); + EXPECT (progress_gate_feed (&g, -5), 0); + EXPECT (progress_gate_feed (&g, 99.9), 99); + EXPECT (progress_gate_feed (&g, 150), 100); +} + +static void +navigating_again_rearms_the_bootstrap_guard (void) +{ + ProgressGate g = navigated_gate (); + EXPECT (progress_gate_feed (&g, 50), 50); + progress_gate_navigated (&g); + EXPECT (progress_gate_feed (&g, 100), -1); +} + +static void +observed_wikipedia_sequence (void) +{ + ProgressGate g; + progress_gate_init (&g); + EXPECT (progress_gate_feed (&g, 10.0), -1); + EXPECT (progress_gate_feed (&g, 100.0), -1); + progress_gate_navigated (&g); + EXPECT (progress_gate_feed (&g, 10.0), 10); + EXPECT (progress_gate_feed (&g, 10.3), -1); + EXPECT (progress_gate_feed (&g, 48.7), 48); + EXPECT (progress_gate_feed (&g, 63.7), 63); + EXPECT (progress_gate_feed (&g, 84.4), 84); + EXPECT (progress_gate_feed (&g, 86.0), -1); + EXPECT (progress_gate_feed (&g, 86.4), -1); + EXPECT (progress_gate_feed (&g, 100.0), 100); +} + +static void +non_finite_values_are_dropped (void) +{ + ProgressGate g = navigated_gate (); + EXPECT (progress_gate_feed (&g, 10), 10); + EXPECT (progress_gate_feed (&g, NAN), -1); + EXPECT (progress_gate_feed (&g, INFINITY), -1); + EXPECT (progress_gate_feed (&g, -INFINITY), -1); + EXPECT (g.last_sent, 10); +} + +static void +navigation_starts_a_fresh_load (void) +{ + ProgressGate g = navigated_gate (); + EXPECT (progress_gate_feed (&g, 50), 50); + progress_gate_navigated (&g); + EXPECT (g.last_sent, -1); + EXPECT (progress_gate_feed (&g, 52), 52); +} + +int +main (void) +{ + drops_everything_before_navigation (); + late_bootstrap_100_is_not_page_loaded (); + rate_limits_increases_but_always_sends_100_once (); + decrease_is_a_new_navigation_and_sent_immediately (); + first_value_after_navigation_is_always_sent (); + floors_and_clamps_values (); + navigating_again_rearms_the_bootstrap_guard (); + observed_wikipedia_sequence (); + non_finite_values_are_dropped (); + navigation_starts_a_fresh_load (); + if (failures) { + printf ("progress-gate: %d failure(s)\n", failures); + return 1; + } + printf ("progress-gate: all tests passed\n"); + return 0; +}