Initial release
No files matched your search
@@ -0,0 +1,16 @@
|
||||
.DS_Store
|
||||
|
||||
.build/
|
||||
build/
|
||||
DerivedData/
|
||||
xcuserdata/
|
||||
.swiftpm/
|
||||
*.app
|
||||
|
||||
snapshots/
|
||||
|
||||
a.out
|
||||
*.o
|
||||
*.dSYM/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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.
|
||||
@@ -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"]),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
<img src="assets/knotchy-icon.svg" width="96" alt="">
|
||||
|
||||
# Knotchy
|
||||
|
||||
Open suspicious links in a throwaway browser that lives in your Mac's notch.
|
||||
|
||||
> **Status:** 0.1.0, the first release. It works, but it's young. If something breaks, please open an issue.
|
||||
|
||||
Some links you'd rather not open. A shortened link in a text from a number you don't know. An "invoice" from a
|
||||
company you've never dealt with. Knotchy opens them somewhere else: a small Linux virtual machine that exists for
|
||||
that one link. The page appears in a panel under the notch, you look at it, and when you're done the whole machine
|
||||
is deleted, along with anything the page did inside it.
|
||||
|
||||
The VM can reach the internet. It can't reach your Mac or anything else on your network.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A Mac with Apple silicon
|
||||
- macOS 26 or later
|
||||
- About 3 GB of free space for the first setup
|
||||
- An administrator password, once, for Apple's installer
|
||||
|
||||
## Install
|
||||
|
||||
1. Download `Knotchy-0.1.0.dmg` from [Releases](https://github.com/aculix/knotchy/releases/latest). It's signed
|
||||
and notarized, so macOS opens it without warnings.
|
||||
2. Open the disk image and drag Knotchy to Applications.
|
||||
3. Open Knotchy. A knot appears in the menu bar and the setup window opens.
|
||||
4. Click **Set Up**, and Knotchy works through the list:
|
||||
- It checks that your Mac can run virtual machines and has enough space.
|
||||
- It downloads Apple's [`container`](https://github.com/apple/container) runtime (version 1.4.1, 118 MB),
|
||||
checks it against the SHA-256 built into Knotchy, and opens it in Apple's Installer. Click through Installer
|
||||
and enter your password there. Knotchy never asks for administrator rights itself.
|
||||
- It starts the runtime, which downloads a Linux kernel the first time.
|
||||
- It builds the browser VM image on your Mac. That takes about 3 minutes and downloads about 225 MB.
|
||||
5. When every row has a green tick, click **Done**.
|
||||
|
||||
If `container` 1.4 or newer is already installed, Knotchy uses it and goes straight to the build. You can close the
|
||||
window at any point and setup carries on; **Set Up Knotchy…** in the menu bar brings it back.
|
||||
|
||||
### The browser extension
|
||||
|
||||
Safari and Mail need nothing extra. Chrome, Edge, Brave and Arc do, because they pass the Services menu a link's
|
||||
text instead of its address. Until the extension is on the Chrome Web Store, install it by hand:
|
||||
|
||||
1. Download `open-in-knotchy-1.0.0.zip` from [Releases](https://github.com/aculix/knotchy/releases/latest) and unzip
|
||||
it somewhere you'll keep it. (The same files are in [`extensions/chrome`](extensions/chrome).)
|
||||
2. Open `chrome://extensions` and turn on **Developer mode** in the top-right corner.
|
||||
3. Click **Load unpacked** and choose the unzipped folder.
|
||||
4. Right-click any link and choose **Open in Knotchy**. The first time, the browser asks whether to open Knotchy.
|
||||
Allow it.
|
||||
|
||||
The extension has one permission, adding that menu item, and only passes `http` and `https` links to the app.
|
||||
It won't update itself, and deleting the folder removes it from the browser.
|
||||
|
||||
## Opening links
|
||||
|
||||
- In Safari, Mail and most other apps, right-click a link and choose **Services → Open in Knotchy**. It also works
|
||||
on selected text that contains a link.
|
||||
- In Chrome and other Chromium browsers, right-click a link and choose **Open in Knotchy**.
|
||||
- Anywhere else, copy the link and press **⌃⌥⌘L**, or choose **Open Copied Link** from the menu bar.
|
||||
|
||||
Only web links open. Anything else gets a brief "No web link found" in the notch.
|
||||
|
||||
## Using the panel
|
||||
|
||||
The page opens in a panel that drops down from the notch. Scroll, click and type as you would in any browser. The
|
||||
toolbar has back, forward and reload, an address field, and a button that makes the panel bigger.
|
||||
|
||||
Click anywhere outside the panel and it folds back into the notch, with a countdown beside it. Hover over the notch
|
||||
to bring the page back. Leave it folded and the VM is destroyed when the countdown runs out, after 5 minutes unless
|
||||
you change it. **Destroy** in the toolbar, or ⌘W, ends it right away.
|
||||
|
||||
Knotchy shows one page at a time. Opening a new link replaces the current VM with a fresh one.
|
||||
|
||||
## Settings
|
||||
|
||||
The knot in the menu bar opens a menu with the open page (show it or destroy it), **Open Copied Link**, the
|
||||
connection picker and **Rebuild VM**. **Settings…** has three sections.
|
||||
|
||||
General sets how long a folded page lives, whether pages open in the small or large panel, the ⌃⌥⌘L shortcut, and
|
||||
whether Knotchy opens at login.
|
||||
|
||||
Network sends pages through an HTTP or SOCKS5 proxy, with a username and password if it needs them, or through a
|
||||
WireGuard VPN. For a VPN, import the `.conf` files or the `.zip` your provider gives you; Mullvad, Proton VPN and IVPN
|
||||
all offer them. If the proxy or VPN is down, pages don't load. They never fall back to your normal connection.
|
||||
Proxies on your Mac or your local network are refused, because a page could reach your network through them.
|
||||
Passwords and VPN keys are kept in your keychain.
|
||||
|
||||
VM shows the runtime version and when the image was built, and has **Rebuild Now**. Rebuilding picks up WebKit
|
||||
security fixes, and Knotchy suggests it once the image is two weeks old.
|
||||
|
||||
## How it works
|
||||
|
||||
There are two programs: the Mac app, and a small agent inside each VM.
|
||||
|
||||
When a link comes in, the app checks that it's `http` or `https` and asks Apple's `container` tool to start a new VM
|
||||
from the `local/knotchy-vm` image. That's Debian with WPE WebKit and GStreamer, given 4 CPU cores and 2 GB of memory.
|
||||
Each VM gets a random name (`knotchy-` and 8 hex digits) and is deleted when it stops.
|
||||
|
||||
The VM's first process is [`vm/knotchy-init`](vm/knotchy-init), a shell script. Before anything else runs, it sets up
|
||||
an nftables firewall. On a direct connection the firewall allows the public internet and blocks private and
|
||||
link-local addresses, carrier-grade NAT, IPv6, and the networks your Mac is on. With a proxy, the proxy is the only
|
||||
thing the VM can reach. With WireGuard, it's the VPN server and the tunnel. If the firewall can't be put in place,
|
||||
the VM stops instead of starting without it. Init then drops all its capabilities and starts the agent as an
|
||||
unprivileged user, so nothing the browser runs can change the firewall.
|
||||
|
||||
The agent, [`vm/knotchy-agent.c`](vm/knotchy-agent.c), loads the page in WPE WebKit at 1024 × 640, encodes what it
|
||||
draws as H.264 with x264, and writes the frames to a Unix socket. `container` exposes that socket to the Mac as a
|
||||
file, so there's no network connection between the two. The app decodes the video with VideoToolbox and draws it
|
||||
in the panel. Clicks, scrolls, keys and address changes go back over the same socket. The message format is at the
|
||||
top of the agent's source.
|
||||
|
||||
Destroying a page stops and deletes its VM. If Knotchy quits with a VM running, it deletes it on the way out, and
|
||||
any VM left behind by a crash is deleted at the next launch.
|
||||
|
||||
The image is built on your Mac from the [`vm`](vm) folder that ships inside the app, so what runs in the VM is what's
|
||||
in this repository. Proxy passwords and VPN keys reach the VM through a shared folder that init reads, deletes and
|
||||
unmounts before the browser starts. They're never passed as environment variables, which `container` saves to disk.
|
||||
|
||||
### What's where
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `Sources/Knotchy` | The Mac app: menu bar, notch panel, setup window and Settings |
|
||||
| `Sources/KnotchyCore` | The session state machine, the VM backend, the stream protocol and decoder, setup logic |
|
||||
| `Sources/knotchyctl` | A command-line tool that opens a link in a VM and saves a screenshot, for testing without the UI |
|
||||
| `Tests` | Unit tests for KnotchyCore; none of them need a VM |
|
||||
| `vm` | The VM image: `Containerfile`, `knotchy-init` and `knotchy-agent.c` |
|
||||
| `extensions/chrome` | The Open in Knotchy extension |
|
||||
| `scripts` | Build, release, test and icon scripts |
|
||||
|
||||
## Known limitations
|
||||
|
||||
- The "Isolated" badge means isolated, not safe. A page can still try to trick you inside the VM.
|
||||
- There's no sound. Clipboard, downloads, popups and links that open a new window stay inside the VM or don't work.
|
||||
- The picture is streamed at 1×, so it's slightly soft on Retina screens.
|
||||
- Proxy and VPN routes are IPv4 only, a WireGuard config can have only one peer, and OpenVPN isn't supported.
|
||||
- The firewall runs in the VM's own kernel, so a kernel exploit inside the VM could get around it.
|
||||
- A site can still reach your router through your network's public address (hairpin NAT), and routes that a VPN
|
||||
on your Mac adds for public addresses aren't blocked. Knotchy reads your Mac's networks when each page starts.
|
||||
- Unprivileged user namespaces are on in the VM, because WebKit's sandbox needs them.
|
||||
|
||||
## Building from source
|
||||
|
||||
You need a Mac with Apple silicon, macOS 26 or later, Xcode 26 or later, and Apple's `container` installed.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/aculix/knotchy.git
|
||||
cd knotchy
|
||||
scripts/bundle-app.sh
|
||||
```
|
||||
|
||||
`bundle-app.sh` builds Knotchy.app and installs it in `~/Applications`. It signs with your Developer ID certificate
|
||||
if you have one and ad hoc if you don't, which is fine for your own Mac. The app builds its VM image the first time
|
||||
it opens, the same as a downloaded copy.
|
||||
|
||||
The other scripts:
|
||||
|
||||
- `swift test` runs the unit tests.
|
||||
- `scripts/build-vm.sh` builds the VM image from the command line.
|
||||
- `scripts/test-egress.sh` starts a VM and checks what it can and can't reach. Run it after any change in `vm`.
|
||||
- `scripts/test-routes.sh` checks the proxy and VPN routes, kill switch included. Build its test server first with
|
||||
`container build --dns 1.1.1.1 -t local/knotchy-route-server scripts/route-server`.
|
||||
- `scripts/release.sh` builds a signed, notarized disk image. It needs a Developer ID certificate and notarization
|
||||
credentials saved with `xcrun notarytool store-credentials`.
|
||||
- `scripts/package-extension.sh` zips the browser extension.
|
||||
- `scripts/make-icons.sh` redraws every icon from the knot in `scripts/icons/knot.py`.
|
||||
|
||||
A VM image you build yourself is exactly as trustworthy as the checkout you built it from.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
Quit Knotchy and move it to the Trash. Then remove its VM image in Terminal:
|
||||
|
||||
```bash
|
||||
container image rm local/knotchy-vm
|
||||
```
|
||||
|
||||
If nothing else on your Mac uses Apple's `container`, you can remove it as well. This deletes all of its data:
|
||||
|
||||
```bash
|
||||
/usr/local/bin/uninstall-container.sh -d
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
To report a security problem, please use GitHub's private reporting. [SECURITY.md](SECURITY.md) has the details.
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,249 @@
|
||||
import AppKit
|
||||
import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let backend: ContainerCLIBackend
|
||||
let controller: SessionController
|
||||
let services: ServicesProvider
|
||||
let settings = AppSettings()
|
||||
let vmStatus: VMStatus
|
||||
let unsupported: Unsupported?
|
||||
let setupFlow: SetupFlow
|
||||
var isSupportedMac: Bool { unsupported == nil }
|
||||
private(set) lazy var setupWindow = SetupWindowController(
|
||||
unsupported.map(SetupWindowController.Content.unsupported) ?? .setup(setupFlow))
|
||||
private(set) lazy var settingsWindow = SettingsWindowController(app: self)
|
||||
private var notch: NotchWindowController?
|
||||
private var copiedLinkHotKey: GlobalHotKey?
|
||||
private var wantsCopiedLinkHotKey = false
|
||||
private var signalSources: [DispatchSourceSignal] = []
|
||||
private var quitting = false
|
||||
|
||||
override init() {
|
||||
unsupported = Self.detectUnsupported()
|
||||
let vmSources = Bundle.main.resourceURL?.appendingPathComponent("vm")
|
||||
let hasSources = vmSources.map { FileManager.default.fileExists(atPath: $0.appendingPathComponent("Containerfile").path) } ?? false
|
||||
backend = ContainerCLIBackend(vmSources: hasSources ? vmSources : nil)
|
||||
controller = SessionController(
|
||||
backend: backend,
|
||||
connector: StreamClient(renderSize: SessionController.renderSize),
|
||||
diskSpace: SystemDiskSpace(),
|
||||
hostNetworks: { HostNetworks.current() },
|
||||
requestQuit: { NSApp.terminate(nil) }
|
||||
)
|
||||
services = ServicesProvider(controller: controller)
|
||||
vmStatus = VMStatus(backend: backend, controller: controller)
|
||||
setupFlow = SetupFlow(host: SystemHost(), installer: SystemRuntimeInstaller(), driver: controller)
|
||||
super.init()
|
||||
controller.quitsWhenIdle = false
|
||||
controller.showSetup = { [weak self] in self?.setupWindow.show() }
|
||||
if isSupportedMac {
|
||||
settings.onChange = { [weak self] in self?.applySettings() }
|
||||
applySettings()
|
||||
} else {
|
||||
services.redirect = { [weak self] in self?.setupWindow.show() }
|
||||
}
|
||||
}
|
||||
|
||||
private static func detectUnsupported() -> Unsupported? {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.environment["KNOTCHY_PRETEND"] == "no-virtualization" { return .noVirtualization }
|
||||
#endif
|
||||
return SystemHost.unsupported()
|
||||
}
|
||||
|
||||
private func applySettings() {
|
||||
settings.apply(to: controller)
|
||||
backend.setNetworkRoute(settings.route)
|
||||
guard settings.copiedLinkShortcut != wantsCopiedLinkHotKey else { return }
|
||||
wantsCopiedLinkHotKey = settings.copiedLinkShortcut
|
||||
if wantsCopiedLinkHotKey {
|
||||
copiedLinkHotKey = GlobalHotKey.openCopiedLink { [services] in services.openCopiedLink() }
|
||||
if copiedLinkHotKey == nil { ServicesLog.write("\(GlobalHotKey.openCopiedLinkLabel) is taken by another app") }
|
||||
} else {
|
||||
copiedLinkHotKey = nil
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillFinishLaunching(_ notification: Notification) {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
// Registered before launch finishes, so a Services request that launched the app isn't lost.
|
||||
NSApp.servicesProvider = services
|
||||
NSUpdateDynamicServices()
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
#if DEBUG
|
||||
if let directory = DebugTour.argument(after: "--setup-snapshot") {
|
||||
Task {
|
||||
await DebugTour.snapshotSetup(app: self, to: URL(fileURLWithPath: directory))
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
#endif
|
||||
guard isSupportedMac else { return setupWindow.show() }
|
||||
notch = NotchWindowController(controller: controller, routeLabel: { [backend] in backend.lastStartedRoute.label })
|
||||
vmStatus.refresh()
|
||||
|
||||
// Route Ctrl-C, kill and logout through applicationShouldTerminate so the VMs get destroyed.
|
||||
for sig in [SIGINT, SIGTERM, SIGHUP] {
|
||||
signal(sig, SIG_IGN)
|
||||
let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
|
||||
source.setEventHandler { NSApp.terminate(nil) }
|
||||
source.resume()
|
||||
signalSources.append(source)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let all = CommandLine.arguments
|
||||
if let index = all.firstIndex(of: "--tour"), index + 2 < all.count, let url = URL(string: all[index + 2]),
|
||||
let notch {
|
||||
let directory = URL(fileURLWithPath: all[index + 1])
|
||||
Task { await DebugTour.run(controller: controller, snapshot: { notch.snapshot(to: directory, name: $0) }, url: url) }
|
||||
return
|
||||
}
|
||||
if all.contains("--settings-timing") {
|
||||
Task {
|
||||
await DebugTour.timeNetworkSwitching(app: self)
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
if let index = all.firstIndex(of: "--settings-snapshot"), index + 1 < all.count {
|
||||
let directory = URL(fileURLWithPath: all[index + 1])
|
||||
Task {
|
||||
await DebugTour.snapshotSettings(app: self, to: directory)
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
if let index = all.firstIndex(of: "--tour-timing"), index + 1 < all.count, let url = URL(string: all[index + 1]) {
|
||||
Task { await DebugTour.run(controller: controller, snapshot: nil, url: url) }
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
Task { await openSetupIfNeeded() }
|
||||
let arguments = CommandLine.arguments.dropFirst().filter { !$0.hasPrefix("-") }
|
||||
if let first = arguments.first { controller.open(text: first) }
|
||||
}
|
||||
|
||||
private func openSetupIfNeeded() async {
|
||||
let runtime = await backend.runtimeStatus()
|
||||
let completed = UserDefaults.standard.bool(forKey: SetupLaunchRule.completedKey)
|
||||
if SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: completed, runtime: runtime) {
|
||||
setupWindow.show()
|
||||
}
|
||||
}
|
||||
|
||||
// From the browser extensions: Chrome's Services menu never sees a link's address.
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
guard isSupportedMac else { return setupWindow.show() }
|
||||
for url in urls where url.scheme == "knotchy" && url.host() == "open" {
|
||||
let link = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?.first { $0.name == "url" }?.value
|
||||
ServicesLog.write("knotchy:// request, link \(link == nil ? "missing" : "present")")
|
||||
if let link, let target = URL(string: link) {
|
||||
controller.open(target)
|
||||
} else {
|
||||
controller.open(text: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
guard !quitting else { return .terminateNow }
|
||||
quitting = true
|
||||
setupFlow.cancel()
|
||||
if setupFlow.isDownloading { SystemRuntimeInstaller.removeLeftovers() }
|
||||
// Not Now mid-build closes the window before the build ends.
|
||||
if setupFlow.allRequiredDone { UserDefaults.standard.set(true, forKey: SetupLaunchRule.completedKey) }
|
||||
// Synchronous on purpose: terminate: often runs inside a non-reentrant main-queue block; awaiting here deadlocked.
|
||||
notch?.hide()
|
||||
CATransaction.flush()
|
||||
CLIRunner.stopBuilds()
|
||||
controller.closeBackendChain()
|
||||
let ids = Array(controller.liveIDs.snapshot)
|
||||
if !ids.isEmpty { backend.destroyBlocking(ids: ids, deadline: .seconds(20)) }
|
||||
return .terminateNow
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ServicesProvider: NSObject {
|
||||
private let controller: SessionController
|
||||
/// In minimal mode, requests only bring the explanation window forward, so the calling app gets a normal reply.
|
||||
var redirect: (@MainActor () -> Void)?
|
||||
|
||||
init(controller: SessionController) {
|
||||
self.controller = controller
|
||||
}
|
||||
|
||||
@objc func openLink(_ pasteboard: NSPasteboard, userData: String?, error: AutoreleasingUnsafeMutablePointer<NSString?>) {
|
||||
open(from: pasteboard, service: "Open in Knotchy")
|
||||
}
|
||||
|
||||
@objc func openCopiedLink(_ pasteboard: NSPasteboard, userData: String?, error: AutoreleasingUnsafeMutablePointer<NSString?>) {
|
||||
open(from: .general, service: "Open Copied Link in Knotchy")
|
||||
}
|
||||
|
||||
func openCopiedLink() {
|
||||
open(from: .general, service: "Shortcut")
|
||||
}
|
||||
|
||||
private func open(from pasteboard: NSPasteboard, service: String) {
|
||||
if let redirect { return redirect() }
|
||||
let types = pasteboard.types?.map(\.rawValue) ?? []
|
||||
let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] ?? []
|
||||
let url: URL?
|
||||
let source: String
|
||||
if let found = urls.first(where: { LinkIntake.validate($0) != nil }) {
|
||||
(url, source) = (found, "url")
|
||||
} else if let html = pasteboard.string(forType: .html), let found = LinkIntake.extractURL(fromHTML: html) {
|
||||
// A selected hyperlink's text rarely holds the address; its HTML does.
|
||||
(url, source) = (found, "html")
|
||||
} else if let found = Self.firstLink(inRTF: pasteboard.data(forType: .rtf)) {
|
||||
(url, source) = (found, "rtf")
|
||||
} else if let text = pasteboard.string(forType: .string), let found = LinkIntake.extractURL(from: text) {
|
||||
(url, source) = (found, "text")
|
||||
} else {
|
||||
(url, source) = (nil, "none")
|
||||
}
|
||||
ServicesLog.write("\(service): types \(types), link from \(source)")
|
||||
if let url {
|
||||
controller.open(url)
|
||||
} else {
|
||||
controller.open(text: "")
|
||||
}
|
||||
}
|
||||
|
||||
private static func firstLink(inRTF data: Data?) -> URL? {
|
||||
guard let data, let text = NSAttributedString(rtf: data, documentAttributes: nil) else { return nil }
|
||||
var found: URL?
|
||||
text.enumerateAttribute(.link, in: NSRange(location: 0, length: text.length)) { value, _, stop in
|
||||
let url = (value as? URL) ?? (value as? String).flatMap(URL.init(string:))
|
||||
if let url, let valid = LinkIntake.validate(url) {
|
||||
found = valid
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
// Pasteboard types and sources only; never log the links themselves.
|
||||
enum ServicesLog {
|
||||
static func write(_ line: String) {
|
||||
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Logs/Knotchy.log")
|
||||
let entry = "\(ISO8601DateFormatter().string(from: Date())) \(line)\n"
|
||||
if let handle = try? FileHandle(forWritingTo: url) {
|
||||
handle.seekToEndOfFile()
|
||||
handle.write(Data(entry.utf8))
|
||||
try? handle.close()
|
||||
} else {
|
||||
try? Data(entry.utf8).write(to: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import AppKit
|
||||
import Carbon.HIToolbox
|
||||
import KnotchyCore
|
||||
import Observation
|
||||
import ServiceManagement
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppSettings {
|
||||
static let autoDestroyChoices = [1, 2, 5, 10, 15, 30]
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
@ObservationIgnored var onChange: () -> Void = {}
|
||||
|
||||
var autoDestroyMinutes: Int {
|
||||
didSet { save(autoDestroyMinutes, "autoDestroyMinutes") }
|
||||
}
|
||||
|
||||
var opensLarge: Bool {
|
||||
didSet { save(opensLarge, "opensLarge") }
|
||||
}
|
||||
|
||||
var copiedLinkShortcut: Bool {
|
||||
didSet { save(copiedLinkShortcut, "copiedLinkShortcut") }
|
||||
}
|
||||
|
||||
// MARK: Network
|
||||
|
||||
enum NetworkMode: String, CaseIterable {
|
||||
case direct, proxy, vpn
|
||||
}
|
||||
|
||||
var networkMode: NetworkMode {
|
||||
didSet { save(networkMode.rawValue, "networkMode") }
|
||||
}
|
||||
|
||||
var proxyKind: ProxySettings.Kind {
|
||||
didSet { save(proxyKind.rawValue, "proxyKind") }
|
||||
}
|
||||
|
||||
var proxyHost: String {
|
||||
didSet { save(proxyHost, "proxyHost") }
|
||||
}
|
||||
|
||||
var proxyPort: Int {
|
||||
didSet { save(proxyPort, "proxyPort") }
|
||||
}
|
||||
|
||||
var proxyUsername: String {
|
||||
didSet { save(proxyUsername, "proxyUsername") }
|
||||
}
|
||||
|
||||
// Debounced: after an app update, every keychain write can raise a keychain prompt.
|
||||
var proxyPassword: String {
|
||||
didSet {
|
||||
passwordSave?.cancel()
|
||||
let password = proxyPassword
|
||||
passwordSave = Task { [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
Keychain.write(password, for: "proxy-password")
|
||||
self.defaults.set(!password.isEmpty, forKey: "proxyHasPassword")
|
||||
}
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
@ObservationIgnored private var passwordSave: Task<Void, Never>?
|
||||
|
||||
// In the keychain, not UserDefaults: WireGuard configs hold private keys.
|
||||
private(set) var wireGuardConfigs: [String: String] {
|
||||
didSet {
|
||||
let json = (try? JSONEncoder().encode(wireGuardConfigs)).flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||
Keychain.write(wireGuardConfigs.isEmpty ? "" : json, for: "wireguard-configs")
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
|
||||
var wireGuardServer: String {
|
||||
didSet { save(wireGuardServer, "wireGuardServer") }
|
||||
}
|
||||
|
||||
var selectedWireGuard: WireGuardConfig? {
|
||||
wireGuardConfigs[wireGuardServer].flatMap { try? WireGuardConfig.parse($0) }
|
||||
}
|
||||
|
||||
var wireGuardNames: [String] {
|
||||
wireGuardConfigs.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
// An unusable proxy or VPN must fail the start, never fall back to a direct connection.
|
||||
var route: NetworkRoute {
|
||||
switch networkMode {
|
||||
case .direct:
|
||||
return .direct
|
||||
case .proxy:
|
||||
let host = proxyHost.trimmingCharacters(in: .whitespaces)
|
||||
guard !host.isEmpty, (1...65535).contains(proxyPort) else {
|
||||
return .unusable(reason: "The proxy isn't set up. Add its server and port in Settings → Network, or switch to Direct.")
|
||||
}
|
||||
return .proxy(ProxySettings(kind: proxyKind, host: host, port: proxyPort,
|
||||
username: proxyUsername, password: proxyPassword))
|
||||
case .vpn:
|
||||
guard let text = wireGuardConfigs[wireGuardServer] else {
|
||||
return .unusable(reason: "No VPN server is chosen. Import a WireGuard config in Settings → Network, or switch to Direct.")
|
||||
}
|
||||
do {
|
||||
var config = try WireGuardConfig.parse(text)
|
||||
config.name = wireGuardServer
|
||||
return .wireGuard(config)
|
||||
} catch {
|
||||
return .unusable(reason: "The WireGuard config “\(wireGuardServer)” \(error.message).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A hint for Settings only; sessions enforce `ProxySettings.isLocal` on the resolved address.
|
||||
var proxyIsLocal: Bool {
|
||||
let host = proxyHost.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
if host == "localhost" || [".local", ".lan", ".home", ".internal"].contains(where: host.hasSuffix) { return true }
|
||||
let octets = host.split(separator: ".", omittingEmptySubsequences: false).compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let (a, b) = (octets[0], octets[1])
|
||||
return a == 10 || a == 127 || (a == 172 && (16...31).contains(b)) || (a == 192 && b == 168)
|
||||
|| (a == 100 && (64...127).contains(b)) || (a == 169 && b == 254)
|
||||
}
|
||||
|
||||
func importWireGuard(from urls: [URL]) async -> String? {
|
||||
var found: [(String, String)] = []
|
||||
var problems: [String] = []
|
||||
for url in urls {
|
||||
let files: [(String, String)]
|
||||
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
|
||||
if url.pathExtension.lowercased() == "zip" {
|
||||
files = await Task.detached { Self.confFiles(inZip: url) }.value
|
||||
if files.isEmpty { problems.append("\(url.lastPathComponent) has no .conf files") }
|
||||
} else if size <= 64 * 1024, let text = try? String(contentsOf: url, encoding: .utf8) {
|
||||
files = [(url.deletingPathExtension().lastPathComponent, text)]
|
||||
} else {
|
||||
problems.append("couldn't read \(url.lastPathComponent)")
|
||||
continue
|
||||
}
|
||||
for (name, text) in files {
|
||||
do {
|
||||
_ = try WireGuardConfig.parse(text)
|
||||
found.append((name, text))
|
||||
} catch {
|
||||
problems.append("“\(name)” \(error.message)")
|
||||
}
|
||||
}
|
||||
}
|
||||
guard !found.isEmpty else { return problems.first.map { "Nothing imported: \($0)." } ?? "Nothing imported." }
|
||||
var configs = wireGuardConfigs
|
||||
for (name, text) in found { configs[name] = text }
|
||||
wireGuardConfigs = configs
|
||||
wireGuardServer = found.map(\.0).sorted { $0.localizedStandardCompare($1) == .orderedAscending }[0]
|
||||
networkMode = .vpn
|
||||
return problems.isEmpty ? nil : "Imported \(found.count); skipped: \(problems.joined(separator: "; "))."
|
||||
}
|
||||
|
||||
func removeWireGuardConfigs() {
|
||||
wireGuardConfigs = [:]
|
||||
wireGuardServer = ""
|
||||
}
|
||||
|
||||
private nonisolated static func confFiles(inZip url: URL) -> [(String, String)] {
|
||||
// Output past `limit` fails the read, so a zip bomb can't exhaust memory.
|
||||
func unzip(_ arguments: [String], limit: Int) -> Data? {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
|
||||
process.arguments = arguments
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = FileHandle.nullDevice
|
||||
guard (try? process.run()) != nil else { return nil }
|
||||
let pid = process.processIdentifier
|
||||
let watchdog = DispatchWorkItem { kill(pid, SIGKILL) }
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 10, execute: watchdog)
|
||||
defer { watchdog.cancel() }
|
||||
var data = Data()
|
||||
while let chunk = try? pipe.fileHandleForReading.read(upToCount: 16 * 1024), !chunk.isEmpty {
|
||||
data.append(chunk)
|
||||
if data.count > limit {
|
||||
process.terminate()
|
||||
process.waitUntilExit()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0 ? data : nil
|
||||
}
|
||||
guard let listing = unzip(["-Z1", url.path], limit: 1024 * 1024).flatMap({ String(data: $0, encoding: .utf8) })
|
||||
else { return [] }
|
||||
let entries = listing.split(whereSeparator: \.isNewline).map(String.init)
|
||||
.filter { $0.lowercased().hasSuffix(".conf") && !$0.hasPrefix("__MACOSX") }
|
||||
.prefix(500)
|
||||
return entries.compactMap { entry in
|
||||
guard let data = unzip(["-p", url.path, entry], limit: 64 * 1024),
|
||||
let text = String(data: data, encoding: .utf8) else { return nil }
|
||||
return (URL(fileURLWithPath: entry).deletingPathExtension().lastPathComponent, text)
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
defaults.register(defaults: ["autoDestroyMinutes": 5, "opensLarge": false, "copiedLinkShortcut": true,
|
||||
"networkMode": "direct", "proxyKind": "http", "proxyPort": 8080])
|
||||
autoDestroyMinutes = defaults.integer(forKey: "autoDestroyMinutes")
|
||||
opensLarge = defaults.bool(forKey: "opensLarge")
|
||||
copiedLinkShortcut = defaults.bool(forKey: "copiedLinkShortcut")
|
||||
networkMode = NetworkMode(rawValue: defaults.string(forKey: "networkMode") ?? "") ?? .direct
|
||||
proxyKind = ProxySettings.Kind(rawValue: defaults.string(forKey: "proxyKind") ?? "") ?? .http
|
||||
proxyHost = defaults.string(forKey: "proxyHost") ?? ""
|
||||
proxyPort = defaults.integer(forKey: "proxyPort")
|
||||
let username = defaults.string(forKey: "proxyUsername") ?? ""
|
||||
let server = defaults.string(forKey: "wireGuardServer") ?? ""
|
||||
proxyUsername = username
|
||||
wireGuardServer = server
|
||||
// Skip the keychain (and its possible prompt) unless a secret was saved.
|
||||
proxyPassword = defaults.bool(forKey: "proxyHasPassword") || !username.isEmpty
|
||||
? Keychain.read("proxy-password") ?? "" : ""
|
||||
wireGuardConfigs = server.isEmpty ? [:] :
|
||||
(Keychain.read("wireguard-configs")?.data(using: .utf8))
|
||||
.flatMap { try? JSONDecoder().decode([String: String].self, from: $0) } ?? [:]
|
||||
}
|
||||
|
||||
private func save(_ value: Any, _ key: String) {
|
||||
defaults.set(value, forKey: key)
|
||||
onChange()
|
||||
}
|
||||
|
||||
func apply(to controller: SessionController) {
|
||||
controller.idleDestroyDelay = .seconds(autoDestroyMinutes * 60)
|
||||
controller.opensExpanded = opensLarge
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum LoginItem {
|
||||
static var isEnabled: Bool {
|
||||
SMAppService.mainApp.status == .enabled
|
||||
}
|
||||
|
||||
static func set(_ enabled: Bool) -> String? {
|
||||
do {
|
||||
if enabled {
|
||||
try SMAppService.mainApp.register()
|
||||
} else {
|
||||
try SMAppService.mainApp.unregister()
|
||||
}
|
||||
return nil
|
||||
} catch {
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Carbon, because its hot keys need no Accessibility permission.
|
||||
@MainActor
|
||||
final class GlobalHotKey {
|
||||
static let openCopiedLinkLabel = "⌃⌥⌘L"
|
||||
|
||||
private var hotKey: EventHotKeyRef?
|
||||
private var handler: EventHandlerRef?
|
||||
private let action: () -> Void
|
||||
|
||||
static func openCopiedLink(_ action: @escaping () -> Void) -> GlobalHotKey? {
|
||||
GlobalHotKey(keyCode: UInt32(kVK_ANSI_L), modifiers: UInt32(controlKey | optionKey | cmdKey), action: action)
|
||||
}
|
||||
|
||||
private init?(keyCode: UInt32, modifiers: UInt32, action: @escaping () -> Void) {
|
||||
self.action = action
|
||||
var type = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
|
||||
let installed = InstallEventHandler(GetApplicationEventTarget(), { _, _, userData in
|
||||
guard let userData else { return OSStatus(eventNotHandledErr) }
|
||||
let hotKey = Unmanaged<GlobalHotKey>.fromOpaque(userData).takeUnretainedValue()
|
||||
MainActor.assumeIsolated { hotKey.action() }
|
||||
return noErr
|
||||
}, 1, &type, Unmanaged.passUnretained(self).toOpaque(), &handler)
|
||||
guard installed == noErr else { return nil }
|
||||
let id = EventHotKeyID(signature: OSType(0x4B4E_4F54), id: 1)
|
||||
// Exclusive, so a shortcut another app owns fails instead of silently sharing.
|
||||
guard RegisterEventHotKey(keyCode, modifiers, id, GetApplicationEventTarget(),
|
||||
OptionBits(kEventHotKeyExclusive), &hotKey) == noErr else {
|
||||
RemoveEventHandler(handler)
|
||||
handler = nil // deinit also runs after a failed init; don't remove it twice.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
isolated deinit {
|
||||
if let hotKey { UnregisterEventHotKey(hotKey) }
|
||||
if let handler { RemoveEventHandler(handler) }
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
#if DEBUG
|
||||
import AppKit
|
||||
import KnotchyCore
|
||||
import QuartzCore
|
||||
import SwiftUI
|
||||
|
||||
// `Knotchy --tour <dir> <url>` saves PNGs of each state; `--tour-timing <url>` reports dropped frames.
|
||||
// Checks the UI without screen recording permission; video doesn't appear in the snapshots.
|
||||
@MainActor
|
||||
enum DebugTour {
|
||||
static func run(controller: SessionController, snapshot: ((String) -> Void)?, url: URL) async {
|
||||
let meter = FrameMeter()
|
||||
func wait(_ seconds: Double) async { try? await Task.sleep(for: .seconds(seconds)) }
|
||||
func step(_ name: String, _ times: [Double], _ action: () -> Void) async {
|
||||
if snapshot == nil { meter.start() }
|
||||
action()
|
||||
var elapsed = 0.0
|
||||
for time in times {
|
||||
await wait(time - elapsed)
|
||||
elapsed = time
|
||||
snapshot?("\(name)-\(Int(time * 1000))ms")
|
||||
}
|
||||
if snapshot == nil { print("\(name): \(meter.stop())") }
|
||||
}
|
||||
|
||||
await step("01-no-link", [0.08, 0.2, 0.6, 1.0]) { controller.open(text: "no link here") }
|
||||
await wait(2.5)
|
||||
await step("02-opening", [0.06, 0.12, 0.2, 0.3, 0.45, 0.7, 1.2]) { controller.open(url) }
|
||||
for _ in 0..<300 where controller.state != .live(.peek) { await wait(0.1) }
|
||||
await wait(1)
|
||||
await step("04-expand", [0.08, 0.16, 0.25, 0.4, 1.0]) { controller.expand() }
|
||||
await step("05-shrink", [0.08, 0.16, 0.3, 1.0]) { controller.shrink() }
|
||||
await step("06-fold", [0.06, 0.12, 0.2, 0.35, 1.0]) { controller.clickedOutside() }
|
||||
await wait(0.5)
|
||||
await step("07-unfold", [0.06, 0.12, 0.2, 0.35, 1.0]) { controller.pointerEntered() }
|
||||
await step("08-destroy", [0.08, 0.2, 0.4, 0.8, 1.6, 2.2, 2.6]) { controller.destroy() }
|
||||
await wait(1)
|
||||
await step("09-reopen", [0.06, 0.12, 0.2, 0.3, 0.45, 0.7, 1.2]) { controller.open(url) }
|
||||
for _ in 0..<300 where controller.state != .live(.peek) { await wait(0.1) }
|
||||
await step("10-destroy", [0.3, 2.6]) { controller.destroy() }
|
||||
await wait(1)
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
|
||||
static func argument(after flag: String) -> String? {
|
||||
let all = CommandLine.arguments
|
||||
guard let index = all.firstIndex(of: flag), index + 1 < all.count else { return nil }
|
||||
return all[index + 1]
|
||||
}
|
||||
|
||||
/// `Knotchy --setup-snapshot <dir>`; with `KNOTCHY_PRETEND=no-virtualization` it shows minimal mode.
|
||||
static func snapshotSetup(app: AppDelegate, to directory: URL) async {
|
||||
app.setupWindow.show()
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
guard let window = app.setupWindow.window, let view = window.contentView else { return }
|
||||
print("setup: window \(window.frame.size)")
|
||||
DebugSnapshot.write(view, to: directory.appendingPathComponent("setup.png"))
|
||||
}
|
||||
}
|
||||
|
||||
extension DebugTour {
|
||||
static func snapshotSettings(app: AppDelegate, to directory: URL) async {
|
||||
let settings = app.settings
|
||||
let saved = (settings.networkMode, settings.proxyHost)
|
||||
let cases: [(String, SettingsTab, AppSettings.NetworkMode)] = [
|
||||
("general", .general, .direct), ("network-direct", .network, .direct),
|
||||
("network-proxy", .network, .proxy), ("network-vpn", .network, .vpn), ("vm", .vm, .direct),
|
||||
]
|
||||
for (name, section, mode) in cases {
|
||||
settings.networkMode = mode
|
||||
if mode == .proxy { settings.proxyHost = "192.168.1.20" }
|
||||
app.settingsWindow.show(section)
|
||||
try? await Task.sleep(for: .seconds(0.8))
|
||||
guard let window = app.settingsWindow.window, let view = window.contentView else { continue }
|
||||
print("\(name): window \(window.frame.size)")
|
||||
DebugSnapshot.write(view, to: directory.appendingPathComponent("settings-\(name).png"))
|
||||
}
|
||||
app.settingsWindow.window?.orderOut(nil)
|
||||
(settings.networkMode, settings.proxyHost) = saved
|
||||
}
|
||||
|
||||
static func timeNetworkSwitching(app: AppDelegate) async {
|
||||
let settings = app.settings
|
||||
let saved = settings.networkMode
|
||||
settings.networkMode = .direct
|
||||
app.settingsWindow.show(.general)
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
let meter = FrameMeter()
|
||||
let steps: [(String, () -> Void)] = [
|
||||
("section network", { app.settingsWindow.show(.network) }),
|
||||
("mode proxy", { settings.networkMode = .proxy }),
|
||||
("mode vpn", { settings.networkMode = .vpn }),
|
||||
("mode direct", { settings.networkMode = .direct }),
|
||||
("section vm", { app.settingsWindow.show(.vm) }),
|
||||
("section general", { app.settingsWindow.show(.general) }),
|
||||
("section network", { app.settingsWindow.show(.network) }),
|
||||
]
|
||||
for (name, step) in steps {
|
||||
meter.start()
|
||||
step()
|
||||
try? await Task.sleep(for: .seconds(0.8))
|
||||
let size = app.settingsWindow.window?.frame.size ?? .zero
|
||||
print("\(name) (window \(Int(size.width))x\(Int(size.height))): \(meter.stop())")
|
||||
}
|
||||
app.settingsWindow.window?.orderOut(nil)
|
||||
settings.networkMode = saved
|
||||
}
|
||||
}
|
||||
|
||||
enum DebugSnapshot {
|
||||
@MainActor
|
||||
static func write(_ view: NSView, to url: URL) {
|
||||
guard let layer = view.layer, let window = view.window else { return }
|
||||
let scale = window.backingScaleFactor
|
||||
let size = view.bounds.size
|
||||
guard let context = CGContext(data: nil, width: Int(size.width * scale), height: Int(size.height * scale),
|
||||
bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpace(name: CGColorSpace.sRGB)!,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return }
|
||||
context.setFillColor(CGColor(gray: 0.55, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: size.width * scale, height: size.height * scale))
|
||||
context.scaleBy(x: scale, y: scale)
|
||||
if view.isFlipped || !layer.isGeometryFlipped {
|
||||
context.translateBy(x: 0, y: size.height)
|
||||
context.scaleBy(x: 1, y: -1)
|
||||
}
|
||||
(layer.presentation() ?? layer).render(in: context)
|
||||
guard let image = context.makeImage(),
|
||||
let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else { return }
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
CGImageDestinationFinalize(destination)
|
||||
}
|
||||
}
|
||||
|
||||
// SwiftUI animates on the main thread, so a late display-link callback is a dropped frame.
|
||||
@MainActor
|
||||
final class FrameMeter: NSObject {
|
||||
private var link: CADisplayLink?
|
||||
private var stamps: [CFTimeInterval] = []
|
||||
private var nominal: CFTimeInterval = 1.0 / 60
|
||||
|
||||
func start() {
|
||||
stamps = []
|
||||
link = (NSScreen.main ?? NSScreen.screens[0]).displayLink(target: self, selector: #selector(tick(_:)))
|
||||
link?.add(to: .main, forMode: .common)
|
||||
}
|
||||
|
||||
@objc private func tick(_ link: CADisplayLink) {
|
||||
stamps.append(CACurrentMediaTime())
|
||||
nominal = link.targetTimestamp - link.timestamp
|
||||
}
|
||||
|
||||
func stop() -> String {
|
||||
link?.invalidate()
|
||||
link = nil
|
||||
let intervals = zip(stamps.dropFirst(), stamps).map { $0 - $1 }
|
||||
func ms(_ t: CFTimeInterval) -> String { String(format: "%.0f", t * 1000) }
|
||||
// "duration@end", both in ms, end measured from the start.
|
||||
let late = zip(intervals, stamps.dropFirst()).filter { $0.0 > nominal * 1.5 }
|
||||
.map { "\(ms($0.0))@\(ms($0.1 - (stamps.first ?? 0)))" }
|
||||
return "\(stamps.count) frames at \(ms(nominal)) ms, worst \(ms(intervals.max() ?? 0)) ms, "
|
||||
+ "\(late.count) late \(late)"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>aculix.knotchy.app</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Knotchy</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Knotchy</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Knotchy</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>26.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2026 Aculix Technologies LLP. MIT License.</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>aculix.knotchy.open</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>knotchy</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSServices</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSMenuItem</key>
|
||||
<dict>
|
||||
<key>default</key>
|
||||
<string>Open in Knotchy</string>
|
||||
</dict>
|
||||
<key>NSMessage</key>
|
||||
<string>openLink</string>
|
||||
<key>NSPortName</key>
|
||||
<string>Knotchy</string>
|
||||
<key>NSSendTypes</key>
|
||||
<array>
|
||||
<string>public.url</string>
|
||||
<string>public.html</string>
|
||||
<string>public.rtf</string>
|
||||
<string>public.utf8-plain-text</string>
|
||||
</array>
|
||||
<key>NSRequiredContext</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSMenuItem</key>
|
||||
<dict>
|
||||
<key>default</key>
|
||||
<string>Open Copied Link in Knotchy</string>
|
||||
</dict>
|
||||
<key>NSMessage</key>
|
||||
<string>openCopiedLink</string>
|
||||
<key>NSPortName</key>
|
||||
<string>Knotchy</string>
|
||||
<key>NSRequiredContext</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import AppKit
|
||||
import KnotchyCore
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct KnotchyMenu: View {
|
||||
let app: AppDelegate
|
||||
@Bindable var settings: AppSettings
|
||||
|
||||
private var controller: SessionController { app.controller }
|
||||
|
||||
var body: some View {
|
||||
if app.vmStatus.needsSetup {
|
||||
Button("Set Up Knotchy…") { app.setupWindow.show() }
|
||||
Divider()
|
||||
}
|
||||
|
||||
if let session = SessionSummary(controller.state, url: controller.pageURL ?? controller.openedURL,
|
||||
setupFolded: controller.setupFolded) {
|
||||
Section(session.title) {
|
||||
if session.canShow {
|
||||
Button("Show Panel") { controller.showPanel() }
|
||||
}
|
||||
if session.canDestroy {
|
||||
Button("Destroy VM") { controller.destroy() }
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
|
||||
Button("Open Copied Link") { app.services.openCopiedLink() }
|
||||
.keyboardShortcut("l", modifiers: [.control, .option, .command])
|
||||
|
||||
Divider()
|
||||
|
||||
Picker("Auto-Destroy Folded Pages", selection: $settings.autoDestroyMinutes) {
|
||||
ForEach(AppSettings.autoDestroyChoices, id: \.self) { minutes in
|
||||
Text(minutes == 1 ? "After 1 Minute" : "After \(minutes) Minutes").tag(minutes)
|
||||
}
|
||||
}
|
||||
Picker("Open Pages In", selection: $settings.opensLarge) {
|
||||
Text("Small Panel").tag(false)
|
||||
Text("Large Panel").tag(true)
|
||||
}
|
||||
Menu("Connection") {
|
||||
Picker("Connection", selection: connection) {
|
||||
Text("Direct").tag(Connection.direct)
|
||||
if !settings.proxyHost.isEmpty {
|
||||
Text("Proxy (\(settings.proxyHost):\(String(settings.proxyPort)))").tag(Connection.proxy)
|
||||
}
|
||||
if !settings.wireGuardNames.isEmpty {
|
||||
Section("WireGuard VPN") {
|
||||
ForEach(settings.wireGuardNames, id: \.self) { Text($0).tag(Connection.vpn($0)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
.labelsHidden()
|
||||
Divider()
|
||||
Button("Network Settings…") { showSettings(.network) }
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
controller.rebuildImage()
|
||||
} label: {
|
||||
Text("Rebuild VM")
|
||||
Text(app.vmStatus.summary)
|
||||
}
|
||||
.disabled(isSettingUp || app.vmStatus.image == .noContainerTool || app.vmStatus.needsSetup)
|
||||
|
||||
Divider()
|
||||
|
||||
if !app.vmStatus.needsSetup {
|
||||
Button("Set Up Knotchy…") { app.setupWindow.show() }
|
||||
}
|
||||
Button("Settings…") { showSettings(nil) }
|
||||
.keyboardShortcut(",")
|
||||
Button("Quit Knotchy") { NSApp.terminate(nil) }
|
||||
.keyboardShortcut("q")
|
||||
}
|
||||
|
||||
private enum Connection: Hashable {
|
||||
case direct, proxy, vpn(String)
|
||||
}
|
||||
|
||||
private var connection: Binding<Connection> {
|
||||
Binding {
|
||||
switch settings.networkMode {
|
||||
case .direct: .direct
|
||||
case .proxy: .proxy
|
||||
case .vpn: .vpn(settings.wireGuardServer)
|
||||
}
|
||||
} set: { choice in
|
||||
switch choice {
|
||||
case .direct:
|
||||
settings.networkMode = .direct
|
||||
case .proxy:
|
||||
settings.networkMode = .proxy
|
||||
case .vpn(let server):
|
||||
settings.wireGuardServer = server
|
||||
settings.networkMode = .vpn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isSettingUp: Bool {
|
||||
if case .settingUp = controller.state { true } else { false }
|
||||
}
|
||||
|
||||
private func showSettings(_ section: SettingsTab?) {
|
||||
app.settingsWindow.show(section)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class VMStatus {
|
||||
private(set) var image: ContainerCLIBackend.ImageStatus?
|
||||
private(set) var runtime: RuntimeStatus?
|
||||
@ObservationIgnored private let backend: ContainerCLIBackend
|
||||
@ObservationIgnored private let controller: SessionController
|
||||
|
||||
init(backend: ContainerCLIBackend, controller: SessionController) {
|
||||
self.backend = backend
|
||||
self.controller = controller
|
||||
watchSetup()
|
||||
}
|
||||
|
||||
var needsSetup: Bool {
|
||||
switch runtime {
|
||||
case .missing?, .outdated?: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
var runtimeSummary: String {
|
||||
switch runtime {
|
||||
case nil: "Checking…"
|
||||
case .missing: "Not installed"
|
||||
case .outdated(let version): "Apple container \(version) (too old)"
|
||||
case .current(let version?): "Apple container \(version)"
|
||||
case .current(nil): "Apple container (version unknown)"
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
Task {
|
||||
let runtime = await backend.runtimeStatus()
|
||||
if runtime != self.runtime { self.runtime = runtime }
|
||||
let status = await backend.imageStatus()
|
||||
if status != image { image = status }
|
||||
}
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
if needsSetup { return "Setup isn't finished" }
|
||||
return switch image {
|
||||
case nil: "Checking…"
|
||||
case .noContainerTool: "Setup isn't finished"
|
||||
case .missing: "Not built yet"
|
||||
case .built(nil): "Built"
|
||||
case .built(let date?): "Built " + RelativeDateTimeFormatter().localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
private func watchSetup() {
|
||||
let wasSettingUp: Bool
|
||||
if case .settingUp = controller.state { wasSettingUp = true } else { wasSettingUp = false }
|
||||
withObservationTracking {
|
||||
_ = controller.state
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
if wasSettingUp { self.refresh() }
|
||||
self.watchSetup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionSummary {
|
||||
var title: String
|
||||
var canShow: Bool
|
||||
var canDestroy: Bool
|
||||
|
||||
init?(_ state: SessionState, url: URL?, setupFolded: Bool) {
|
||||
let host = url?.host().map { " — \($0)" } ?? ""
|
||||
switch state {
|
||||
case .starting(_, let folded):
|
||||
(title, canShow, canDestroy) = ("Opening\(host)", folded, true)
|
||||
case .live(let mode):
|
||||
(title, canShow, canDestroy) = ("Open\(host)", mode == .collapsed, true)
|
||||
case .settingUp(let issue):
|
||||
(title, canShow, canDestroy) = (Self.setupTitle(issue), setupFolded, false)
|
||||
case .destroying:
|
||||
(title, canShow, canDestroy) = ("Destroying the VM…", false, false)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func setupTitle(_ issue: SetupIssue) -> String {
|
||||
switch issue {
|
||||
case .cliMissing: "Installing the container runtime…"
|
||||
case .cliOutdated: "Updating the container runtime…"
|
||||
case .serviceStopped, .kernelMissing: "Starting the container runtime…"
|
||||
default: "Building the VM…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Flared top "shoulders" like the notch's own; `rect` includes them.
|
||||
struct NotchShape: Shape {
|
||||
var topRadius: CGFloat
|
||||
var bottomRadius: CGFloat
|
||||
|
||||
var animatableData: AnimatablePair<CGFloat, CGFloat> {
|
||||
get { AnimatablePair(topRadius, bottomRadius) }
|
||||
set { (topRadius, bottomRadius) = (newValue.first, newValue.second) }
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let top = max(0, min(topRadius, rect.width / 4, rect.height / 2))
|
||||
let bottom = max(0, min(bottomRadius, (rect.width - 2 * top) / 2, rect.height - top))
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
path.addQuadCurve(to: CGPoint(x: rect.minX + top, y: rect.minY + top),
|
||||
control: CGPoint(x: rect.minX + top, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.minX + top, y: rect.maxY - bottom))
|
||||
path.addQuadCurve(to: CGPoint(x: rect.minX + top + bottom, y: rect.maxY),
|
||||
control: CGPoint(x: rect.minX + top, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX - top - bottom, y: rect.maxY))
|
||||
path.addQuadCurve(to: CGPoint(x: rect.maxX - top, y: rect.maxY - bottom),
|
||||
control: CGPoint(x: rect.maxX - top, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX - top, y: rect.minY + top))
|
||||
path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.minY),
|
||||
control: CGPoint(x: rect.maxX - top, y: rect.minY))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
struct Morph: ViewModifier, Animatable {
|
||||
var progress: CGFloat
|
||||
var smallest: CGFloat = 0.55
|
||||
var blur: CGFloat = 10
|
||||
var clearUntil: CGFloat = 0.3
|
||||
var opaqueFrom: CGFloat = 0.85
|
||||
|
||||
nonisolated var animatableData: CGFloat {
|
||||
get { progress }
|
||||
set { progress = newValue }
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.scaleEffect(smallest + (1 - smallest) * progress, anchor: .top)
|
||||
.blur(radius: max(0, 1 - progress) * blur)
|
||||
.opacity(Double(min(1, max(0, (progress - clearUntil) / (opaqueFrom - clearUntil)))))
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyTransition {
|
||||
/// Old content is gone early so it barely overlaps the new.
|
||||
static var island: AnyTransition {
|
||||
.asymmetric(
|
||||
insertion: .modifier(active: Morph(progress: 0), identity: Morph(progress: 1)),
|
||||
removal: .modifier(active: Morph(progress: 0, smallest: 0.8, blur: 8, clearUntil: 0.6, opaqueFrom: 0.95),
|
||||
identity: Morph(progress: 1, smallest: 0.8, blur: 8, clearUntil: 0.6, opaqueFrom: 0.95))
|
||||
.animation(.spring(response: 0.2, dampingFraction: 1))
|
||||
)
|
||||
}
|
||||
|
||||
static var swap: AnyTransition {
|
||||
.modifier(active: Morph(progress: 0, smallest: 0.92, blur: 6, clearUntil: 0.2, opaqueFrom: 0.8),
|
||||
identity: Morph(progress: 1, smallest: 0.92, blur: 6, clearUntil: 0.2, opaqueFrom: 0.8))
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.9))
|
||||
}
|
||||
}
|
||||
|
||||
enum Motion {
|
||||
static let open = Animation.spring(response: 0.46, dampingFraction: 0.74)
|
||||
static let resize = Animation.spring(response: 0.42, dampingFraction: 0.8)
|
||||
static let fold = Animation.spring(response: 0.4, dampingFraction: 0.84)
|
||||
static let small = Animation.spring(response: 0.4, dampingFraction: 0.7)
|
||||
static let hide = Animation.spring(response: 0.36, dampingFraction: 0.9)
|
||||
}
|
||||
|
||||
struct KnotMark: View {
|
||||
var size: CGFloat
|
||||
var color: Color = Palette.coral
|
||||
|
||||
var body: some View {
|
||||
KnotShape()
|
||||
.stroke(color, style: StrokeStyle(lineWidth: size * 1.9 / 18, lineCap: .round, lineJoin: .round))
|
||||
.frame(width: size, height: size)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// `KnotGlyph.strands` are in an 18-point box.
|
||||
struct KnotShape: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let scale = min(rect.width, rect.height) / 18
|
||||
var path = Path()
|
||||
for strand in KnotGlyph.strands {
|
||||
path.move(to: CGPoint(x: rect.minX + strand[0].x * scale, y: rect.minY + strand[0].y * scale))
|
||||
for point in strand.dropFirst() {
|
||||
path.addLine(to: CGPoint(x: rect.minX + point.x * scale, y: rect.minY + point.y * scale))
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
struct Breathing: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content.phaseAnimator([1.0, 0.55]) { view, opacity in
|
||||
view.opacity(opacity)
|
||||
} animation: { _ in .easeInOut(duration: 1.6) }
|
||||
}
|
||||
}
|
||||
|
||||
enum Palette {
|
||||
static let coral = Color(red: 1, green: 0.54, blue: 0.36)
|
||||
static let green = Color(red: 0.25, green: 0.86, blue: 0.47)
|
||||
static let red = Color(red: 1, green: 0.38, blue: 0.36)
|
||||
static let amber = Color(red: 1, green: 0.72, blue: 0.2)
|
||||
static let surface = LinearGradient(colors: [Color(white: 0.1), Color(white: 0.055)],
|
||||
startPoint: .top, endPoint: .bottom)
|
||||
}
|
||||
|
||||
struct IconButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
Styled(configuration: configuration)
|
||||
}
|
||||
|
||||
struct Styled: View {
|
||||
let configuration: Configuration
|
||||
@State private var hovering = false
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
var body: some View {
|
||||
configuration.label
|
||||
.foregroundStyle(.white.opacity(isEnabled ? (hovering ? 1 : 0.72) : 0.22))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||
.fill(.white.opacity(configuration.isPressed ? 0.18 : (hovering && isEnabled ? 0.1 : 0)))
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onHover { hovering = $0 }
|
||||
.animation(.easeOut(duration: 0.12), value: hovering)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CapsuleButtonStyle: ButtonStyle {
|
||||
enum Kind { case primary, secondary, destructive }
|
||||
|
||||
var kind: Kind = .secondary
|
||||
var small = false
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
Styled(configuration: configuration, kind: kind, small: small)
|
||||
}
|
||||
|
||||
struct Styled: View {
|
||||
let configuration: Configuration
|
||||
let kind: Kind
|
||||
let small: Bool
|
||||
@State private var hovering = false
|
||||
|
||||
var body: some View {
|
||||
configuration.label
|
||||
.font(.system(size: small ? 11 : 12, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, small ? 9 : 14)
|
||||
.frame(height: small ? 20 : 28)
|
||||
.foregroundStyle(foreground)
|
||||
.background(Capsule().fill(fill))
|
||||
.contentShape(Capsule())
|
||||
.scaleEffect(configuration.isPressed ? 0.96 : 1)
|
||||
.onHover { hovering = $0 }
|
||||
.animation(.easeOut(duration: 0.12), value: hovering)
|
||||
.animation(.easeOut(duration: 0.08), value: configuration.isPressed)
|
||||
}
|
||||
|
||||
private var foreground: Color {
|
||||
switch kind {
|
||||
case .primary: .black
|
||||
case .secondary: .white
|
||||
case .destructive: Palette.red
|
||||
}
|
||||
}
|
||||
|
||||
private var fill: Color {
|
||||
let boost = configuration.isPressed ? 0.1 : (hovering ? 0.06 : 0)
|
||||
switch kind {
|
||||
case .primary: return .white.opacity(0.92 + boost)
|
||||
case .secondary: return .white.opacity(0.12 + boost)
|
||||
case .destructive: return Palette.red.opacity(0.16 + boost * 1.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
import KnotchyCore
|
||||
import SwiftUI
|
||||
|
||||
struct NotchRootView: View {
|
||||
let controller: SessionController
|
||||
let chrome: NotchChrome
|
||||
|
||||
var body: some View {
|
||||
let size = chrome.bodySize
|
||||
ZStack(alignment: .top) {
|
||||
switch chrome.content {
|
||||
case .hidden:
|
||||
Color.clear.frame(width: 0, height: 0)
|
||||
case .compact:
|
||||
CompactView(controller: controller, state: chrome.state, notchWidth: chrome.notchSize.width)
|
||||
.frame(width: NotchChrome.bodySize(.compact, notch: chrome.notchSize, panel: chrome.panelSize,
|
||||
hasNotch: chrome.hasNotch).width,
|
||||
height: chrome.notchSize.height)
|
||||
.transition(.island)
|
||||
case .banner:
|
||||
BannerView(controller: controller, state: chrome.state, notice: chrome.notice,
|
||||
notchHeight: chrome.notchSize.height)
|
||||
.frame(width: NotchChrome.bodySize(.banner, notch: chrome.notchSize, panel: chrome.panelSize,
|
||||
hasNotch: chrome.hasNotch).width)
|
||||
.transition(.island)
|
||||
case .panel:
|
||||
PanelView(controller: controller, chrome: chrome)
|
||||
.frame(width: chrome.panelSize.width, height: chrome.panelSize.height)
|
||||
.transition(.island)
|
||||
}
|
||||
}
|
||||
.modifier(Morph(progress: chrome.reveal))
|
||||
.frame(width: size.width, height: size.height, alignment: .top)
|
||||
.padding(.horizontal, chrome.topRadius)
|
||||
.clipShape(NotchShape(topRadius: chrome.topRadius, bottomRadius: chrome.bottomRadius))
|
||||
.background {
|
||||
// Shadow from the outline alone, not from everything inside it.
|
||||
NotchShape(topRadius: chrome.topRadius, bottomRadius: chrome.bottomRadius)
|
||||
.fill(.black)
|
||||
.shadow(color: .black.opacity(chrome.presentation == .panel ? 0.5 : 0), radius: 14, y: 6)
|
||||
}
|
||||
.offset(x: chrome.offsetX)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compact
|
||||
|
||||
private struct CompactView: View {
|
||||
let controller: SessionController
|
||||
let state: SessionState
|
||||
let notchWidth: CGFloat
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
leading.frame(maxWidth: .infinity)
|
||||
Color.clear.frame(width: notchWidth)
|
||||
trailing.frame(maxWidth: .infinity)
|
||||
}
|
||||
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.contentShape(Rectangle())
|
||||
.contextMenu { PillMenu(controller: controller, state: state) }
|
||||
}
|
||||
|
||||
@ViewBuilder private var leading: some View {
|
||||
switch state {
|
||||
case .starting, .settingUp:
|
||||
ProgressView().controlSize(.small).scaleEffect(0.75)
|
||||
default:
|
||||
KnotMark(size: 15)
|
||||
.modifier(Breathing())
|
||||
.help("A page is open in a disposable VM")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var trailing: some View {
|
||||
switch state {
|
||||
case .starting:
|
||||
Text("Opening").foregroundStyle(.white.opacity(0.7))
|
||||
case .settingUp(let issue):
|
||||
Text(Self.setupWord(issue)).foregroundStyle(.white.opacity(0.7))
|
||||
.help("Setting up the browser VM. Hover to see progress.")
|
||||
default:
|
||||
TimelineView(.periodic(from: .now, by: 1)) { _ in
|
||||
let text = Self.format(controller.countdown)
|
||||
Text(text)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
.animation(.snappy, value: text)
|
||||
.help("Destroyed automatically when this reaches 0:00")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func setupWord(_ issue: SetupIssue) -> String {
|
||||
switch issue {
|
||||
case .imageMissing, .imageOutdated: "Building"
|
||||
case .imageStale, .cliOutdated: "Updating"
|
||||
case .cliMissing: "Installing"
|
||||
default: "Starting"
|
||||
}
|
||||
}
|
||||
|
||||
private static func format(_ remaining: Duration?) -> String {
|
||||
guard let remaining else { return "" }
|
||||
let seconds = Int(remaining.components.seconds)
|
||||
return String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Banner
|
||||
|
||||
private struct BannerView: View {
|
||||
let controller: SessionController
|
||||
let state: SessionState
|
||||
let notice: Notice?
|
||||
let notchHeight: CGFloat
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Color.clear.frame(height: notchHeight)
|
||||
ZStack {
|
||||
row.id(kind).transition(.swap)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: NotchChrome.bannerRowHeight - 4)
|
||||
}
|
||||
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.contentShape(Rectangle())
|
||||
.contextMenu { PillMenu(controller: controller, state: state) }
|
||||
}
|
||||
|
||||
private enum Kind: Hashable { case noWebLink, destroying, destroyed, ended, stuck, other }
|
||||
|
||||
private var kind: Kind {
|
||||
if notice == .noWebLink, state == .idle { return .noWebLink }
|
||||
switch state {
|
||||
case .destroying: return .destroying
|
||||
case .destroyed: return .destroyed
|
||||
case .failed(.ended): return .ended
|
||||
case .failed(.destroyStuck): return .stuck
|
||||
default: return .other
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var row: some View {
|
||||
HStack(spacing: 7) {
|
||||
switch kind {
|
||||
case .noWebLink:
|
||||
Image(systemName: "link").foregroundStyle(Palette.amber)
|
||||
Text("No web link found")
|
||||
case .destroying:
|
||||
ProgressView().controlSize(.small).scaleEffect(0.7).frame(width: 16, height: 16)
|
||||
Text("Destroying the VM…").foregroundStyle(.white.opacity(0.8))
|
||||
case .destroyed:
|
||||
BouncingSymbol(name: "checkmark.circle.fill", color: Palette.green)
|
||||
Text("VM destroyed")
|
||||
case .ended:
|
||||
Image(systemName: "xmark.octagon.fill").foregroundStyle(Palette.red)
|
||||
Text("Session ended")
|
||||
case .stuck:
|
||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Palette.amber)
|
||||
Text("VM may still be running")
|
||||
Button("Retry") { controller.retry() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .secondary, small: true))
|
||||
.padding(.leading, 4)
|
||||
case .other:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PillMenu: View {
|
||||
let controller: SessionController
|
||||
let state: SessionState
|
||||
|
||||
var body: some View {
|
||||
if case .live = state { Button("Destroy Now") { controller.destroy() } }
|
||||
Button("Quit Knotchy") { NSApp.terminate(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct BouncingSymbol: View {
|
||||
let name: String
|
||||
let color: Color
|
||||
@State private var appeared = false
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: name)
|
||||
.foregroundStyle(color)
|
||||
.symbolEffect(.bounce, value: appeared)
|
||||
.onAppear { appeared = true }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Panel
|
||||
|
||||
private struct PanelView: View {
|
||||
let controller: SessionController
|
||||
let chrome: NotchChrome
|
||||
|
||||
private var innerRadius: CGFloat { NotchChrome.bottomRadius(.panel) - NotchGeometry.margin }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
TopStrip(controller: controller, state: chrome.state, notchWidth: chrome.notchSize.width,
|
||||
routeLabel: chrome.routeLabel)
|
||||
.frame(height: chrome.notchSize.height)
|
||||
Toolbar(controller: controller, state: chrome.state)
|
||||
.frame(height: NotchGeometry.toolbarHeight)
|
||||
ZStack(alignment: .bottom) {
|
||||
content
|
||||
.id(ContentKind(chrome.state))
|
||||
.transition(.swap)
|
||||
if let stale = controller.staleImage, case .live = chrome.state {
|
||||
StaleBanner(controller: controller, stale: stale)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if case .live = chrome.state { ProgressLine(progress: controller.progress) }
|
||||
}
|
||||
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 8, bottomLeadingRadius: innerRadius,
|
||||
bottomTrailingRadius: innerRadius, topTrailingRadius: 8,
|
||||
style: .continuous))
|
||||
.padding([.horizontal, .bottom], NotchGeometry.margin)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
private enum ContentKind: Hashable {
|
||||
case starting, live, failed, setup, settingUp, other
|
||||
|
||||
init(_ state: SessionState) {
|
||||
switch state {
|
||||
case .starting: self = .starting
|
||||
case .live: self = .live
|
||||
case .failed(.start): self = .failed
|
||||
case .setupRequired: self = .setup
|
||||
case .settingUp: self = .settingUp
|
||||
default: self = .other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
switch chrome.state {
|
||||
case .live:
|
||||
VideoSurface(controller: controller)
|
||||
case .starting(let step, _):
|
||||
StartingView(step: step, host: controller.openedURL?.host())
|
||||
case .failed(.start(let message)):
|
||||
MessageCard(symbol: "exclamationmark.triangle.fill", tint: Palette.amber,
|
||||
title: "Couldn't open the link", message: message) {
|
||||
Button("Dismiss") { controller.dismiss() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .secondary))
|
||||
Button("Try Again") { controller.retry() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .primary))
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
case .setupRequired(let issue, let error):
|
||||
MessageCard(symbol: Setup.symbol(issue), tint: error == nil ? .white : Palette.amber,
|
||||
title: Setup.title(issue), message: error ?? Setup.text(issue)) {
|
||||
Button("Not Now") { controller.dismiss() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .secondary))
|
||||
if let action = Setup.action(issue) {
|
||||
Button(action) { controller.performSetupAction() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .primary))
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
case .settingUp(let issue):
|
||||
MessageCard(symbol: Setup.symbol(issue), tint: .white, title: Setup.progressTitle(issue),
|
||||
message: Self.lastLine(controller.setupLog) ?? "Working…", monospaced: true) {
|
||||
ProgressView().controlSize(.small)
|
||||
Button("Hide") { controller.foldSetup() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .secondary))
|
||||
.help("Keep going in the background; hover over the notch to check on it")
|
||||
}
|
||||
default:
|
||||
Color.black
|
||||
}
|
||||
}
|
||||
|
||||
private static func lastLine(_ log: String?) -> String? {
|
||||
log?.split(whereSeparator: \.isNewline).last.map(String.init)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TopStrip: View {
|
||||
let controller: SessionController
|
||||
let state: SessionState
|
||||
let notchWidth: CGFloat
|
||||
let routeLabel: String?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
HStack(spacing: 5) {
|
||||
KnotMark(size: 13)
|
||||
Text("Isolated").foregroundStyle(.white.opacity(0.75))
|
||||
if let routeLabel {
|
||||
Text("· via \(routeLabel)").foregroundStyle(.white.opacity(0.45)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
.help("This page runs in a disposable Linux VM, cut off from your Mac and your network")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 14)
|
||||
|
||||
Color.clear.frame(width: notchWidth)
|
||||
|
||||
Group {
|
||||
if Self.canDestroy(state) {
|
||||
Button { controller.destroy() } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "power").font(.system(size: 9, weight: .bold))
|
||||
Text("Destroy")
|
||||
}
|
||||
}
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .destructive, small: true))
|
||||
.help("Destroy the VM and everything in it (⌘W)")
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.padding(.trailing, 10)
|
||||
}
|
||||
}
|
||||
|
||||
static func canDestroy(_ state: SessionState) -> Bool {
|
||||
switch state {
|
||||
case .starting, .live: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Toolbar: View {
|
||||
let controller: SessionController
|
||||
let state: SessionState
|
||||
|
||||
private var isLive: Bool {
|
||||
if case .live = state { true } else { false }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
IconButton(symbol: "chevron.left", help: "Back (⌘[)") { controller.goBack() }
|
||||
IconButton(symbol: "chevron.right", help: "Forward (⌘])") { controller.goForward() }
|
||||
IconButton(symbol: "arrow.clockwise", help: "Reload (⌘R)") { controller.reload() }
|
||||
AddressField(url: controller.pageURL ?? controller.openedURL)
|
||||
.padding(.horizontal, 6)
|
||||
if state == .live(.expanded) {
|
||||
IconButton(symbol: "arrow.down.right.and.arrow.up.left", help: "Make smaller (⇧⌘E)") { controller.shrink() }
|
||||
} else {
|
||||
IconButton(symbol: "arrow.up.left.and.arrow.down.right", help: "Make larger (⇧⌘E)") { controller.expand() }
|
||||
}
|
||||
}
|
||||
.disabled(!isLive)
|
||||
.padding(.horizontal, NotchGeometry.margin)
|
||||
}
|
||||
}
|
||||
|
||||
private struct IconButton: View {
|
||||
let symbol: String
|
||||
let help: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.frame(width: 28, height: 24)
|
||||
}
|
||||
.buttonStyle(IconButtonStyle())
|
||||
.help(help)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AddressField: View {
|
||||
let url: URL?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: url?.scheme == "https" ? "lock.fill" : "globe")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
Text(Self.display(url))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.padding(.horizontal, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: 24)
|
||||
.background(RoundedRectangle(cornerRadius: 7, style: .continuous).fill(.white.opacity(0.08)))
|
||||
.help(url?.absoluteString ?? "")
|
||||
}
|
||||
|
||||
static func display(_ url: URL?) -> AttributedString {
|
||||
guard let url else { return AttributedString("") }
|
||||
var host = AttributedString(url.host() ?? url.absoluteString)
|
||||
host.foregroundColor = .white
|
||||
var rest = url.path()
|
||||
if let query = url.query() { rest += "?" + query }
|
||||
if rest == "/" { rest = "" }
|
||||
var tail = AttributedString(rest)
|
||||
tail.foregroundColor = .white.opacity(0.4)
|
||||
return host + tail
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProgressLine: View {
|
||||
let progress: Int?
|
||||
@State private var shown: Int = 0
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
Capsule()
|
||||
.fill(LinearGradient(colors: [Color(red: 0.35, green: 0.7, blue: 1), Color(red: 0.2, green: 0.5, blue: 1)],
|
||||
startPoint: .leading, endPoint: .trailing))
|
||||
.frame(width: max(4, proxy.size.width * CGFloat(shown) / 100), height: 3)
|
||||
}
|
||||
.frame(height: 3)
|
||||
.opacity(progress == nil || progress == 100 ? 0 : 1)
|
||||
.animation(.easeOut(duration: 0.35), value: progress == nil || progress == 100)
|
||||
.onChange(of: progress, initial: true) { _, value in
|
||||
guard let value else { return }
|
||||
withAnimation(value < shown ? nil : .easeOut(duration: 0.25)) { shown = value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StartingView: View {
|
||||
let step: StartStep
|
||||
let host: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
ZStack {
|
||||
Circle().fill(Palette.coral.opacity(0.14)).frame(width: 64, height: 64)
|
||||
KnotMark(size: 32)
|
||||
.modifier(Breathing())
|
||||
}
|
||||
VStack(spacing: 4) {
|
||||
Text("Opening in a disposable VM").font(.system(size: 15, weight: .semibold))
|
||||
if let host {
|
||||
Text(host).font(.system(size: 12)).foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
StepRow(title: "Starting the VM", done: step != .booting, active: step == .booting)
|
||||
StepRow(title: "Connecting to its browser", done: step == .loading, active: step == .connecting)
|
||||
StepRow(title: "Loading the page", done: false, active: step == .loading)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Palette.surface)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StepRow: View {
|
||||
let title: String
|
||||
let done: Bool
|
||||
let active: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 9) {
|
||||
ZStack {
|
||||
if done {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Palette.green)
|
||||
.transition(.scale(scale: 0.4).combined(with: .opacity))
|
||||
} else if active {
|
||||
ProgressView().controlSize(.small).scaleEffect(0.75)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
Image(systemName: "circle").foregroundStyle(.white.opacity(0.22))
|
||||
}
|
||||
}
|
||||
.frame(width: 16, height: 16)
|
||||
Text(title)
|
||||
.foregroundStyle(.white.opacity(done || active ? 0.9 : 0.4))
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
.animation(.spring(duration: 0.35, bounce: 0.4), value: done)
|
||||
.animation(.easeOut(duration: 0.2), value: active)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessageCard<Actions: View>: View {
|
||||
let symbol: String
|
||||
let tint: Color
|
||||
let title: String
|
||||
let message: String
|
||||
var monospaced = false
|
||||
@ViewBuilder let actions: Actions
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle().fill(tint.opacity(0.14)).frame(width: 52, height: 52)
|
||||
Image(systemName: symbol).font(.system(size: 21, weight: .semibold)).foregroundStyle(tint)
|
||||
}
|
||||
VStack(spacing: 6) {
|
||||
Text(title).font(.system(size: 15, weight: .semibold))
|
||||
Text(message)
|
||||
.font(monospaced ? .system(size: 11, design: .monospaced) : .system(size: 12))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(5)
|
||||
.frame(maxWidth: 400)
|
||||
}
|
||||
HStack(spacing: 8) { actions }
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Palette.surface)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StaleBanner: View {
|
||||
let controller: SessionController
|
||||
let stale: StaleImage
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Palette.amber)
|
||||
Text(stale.builtDaysAgo.map { "The browser VM is \($0) days old." } ?? "The browser VM's age is unknown.")
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
Spacer(minLength: 8)
|
||||
Button("Later") { controller.dismissStaleImageWarning() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .secondary, small: true))
|
||||
Button("Update Now") { controller.requestImageUpdate() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .primary, small: true))
|
||||
}
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color(white: 0.1).opacity(0.94)))
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
private enum Setup {
|
||||
static func symbol(_ issue: SetupIssue) -> String {
|
||||
switch issue {
|
||||
case .cliMissing, .cliOutdated: "shippingbox"
|
||||
case .serviceStopped, .kernelMissing: "power"
|
||||
case .imageMissing, .imageOutdated: "square.and.arrow.down"
|
||||
case .imageStale: "arrow.triangle.2.circlepath"
|
||||
case .lowDisk: "internaldrive"
|
||||
}
|
||||
}
|
||||
|
||||
static func title(_ issue: SetupIssue) -> String {
|
||||
switch issue {
|
||||
case .cliMissing: "Knotchy needs Apple's container runtime"
|
||||
case .cliOutdated: "Knotchy needs a newer container runtime"
|
||||
case .serviceStopped, .kernelMissing: "The container service isn't running"
|
||||
case .imageMissing, .imageOutdated: "The browser VM image needs to be built"
|
||||
case .imageStale: "Updating the browser VM"
|
||||
case .lowDisk: "Not enough disk space"
|
||||
}
|
||||
}
|
||||
|
||||
static func progressTitle(_ issue: SetupIssue) -> String {
|
||||
switch issue {
|
||||
case .cliMissing: "Installing the container runtime…"
|
||||
case .cliOutdated: "Updating the container runtime…"
|
||||
case .serviceStopped, .kernelMissing: "Starting the container runtime…"
|
||||
default: title(issue)
|
||||
}
|
||||
}
|
||||
|
||||
static func text(_ issue: SetupIssue) -> String {
|
||||
switch issue {
|
||||
case .cliMissing: "Set it up in one step; the link opens when it's done."
|
||||
case .cliOutdated: "Update it in one step; the link opens when it's done."
|
||||
case .serviceStopped, .kernelMissing: "Start it to run the isolated browser."
|
||||
case .imageMissing, .imageOutdated: "This takes about 3 minutes and downloads about 225 MB."
|
||||
case .imageStale: "Rebuilding gets the latest WebKit security fixes."
|
||||
case .lowDisk(let free): "Only \(free / 1_000_000_000) GB free; a session needs about 2 GB."
|
||||
}
|
||||
}
|
||||
|
||||
static func action(_ issue: SetupIssue) -> String? {
|
||||
switch issue {
|
||||
case .cliMissing, .cliOutdated: "Set Up…"
|
||||
case .serviceStopped, .kernelMissing: "Start Service"
|
||||
case .imageMissing, .imageOutdated: "Build Image"
|
||||
case .imageStale: "Update Now"
|
||||
case .lowDisk: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import AppKit
|
||||
import KnotchyCore
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
final class NotchPanel: NSPanel {
|
||||
init() {
|
||||
super.init(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
|
||||
level = NSWindow.Level(rawValue: NSWindow.Level.mainMenu.rawValue + 3)
|
||||
collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
|
||||
isOpaque = false
|
||||
backgroundColor = .clear
|
||||
hasShadow = false
|
||||
becomesKeyOnlyIfNeeded = true
|
||||
isMovable = false
|
||||
isReleasedWhenClosed = false
|
||||
hidesOnDeactivate = false
|
||||
}
|
||||
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { false }
|
||||
}
|
||||
|
||||
enum Presentation: Equatable {
|
||||
case hidden
|
||||
case compact
|
||||
case banner
|
||||
case panel
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class NotchChrome {
|
||||
static let earWidth: CGFloat = 64
|
||||
static let bannerWidth: CGFloat = 320
|
||||
static let bannerRowHeight: CGFloat = 34
|
||||
|
||||
var presentation: Presentation = .hidden
|
||||
/// Lags `presentation` while retracting so the content shrinks into the notch with the shape.
|
||||
var content: Presentation = .hidden
|
||||
var reveal: CGFloat = 0
|
||||
var state: SessionState = .idle
|
||||
var notice: Notice?
|
||||
var notchSize = CGSize(width: 200, height: 32)
|
||||
var hasNotch = true
|
||||
var panelSize = CGSize(width: 652, height: 472)
|
||||
var panelOffsetX: CGFloat = 0
|
||||
var routeLabel: String?
|
||||
|
||||
var bodySize: CGSize { Self.bodySize(presentation, notch: notchSize, panel: panelSize, hasNotch: hasNotch) }
|
||||
var topRadius: CGFloat { Self.topRadius(presentation) }
|
||||
var bottomRadius: CGFloat { Self.bottomRadius(presentation) }
|
||||
var offsetX: CGFloat { presentation == .panel ? panelOffsetX : 0 }
|
||||
|
||||
static func bodySize(_ presentation: Presentation, notch: CGSize, panel: CGSize, hasNotch: Bool) -> CGSize {
|
||||
switch presentation {
|
||||
case .hidden: CGSize(width: notch.width, height: hasNotch ? notch.height : 0)
|
||||
case .compact: CGSize(width: notch.width + 2 * earWidth, height: notch.height)
|
||||
case .banner: CGSize(width: max(notch.width + 2 * earWidth, bannerWidth), height: notch.height + bannerRowHeight)
|
||||
case .panel: panel
|
||||
}
|
||||
}
|
||||
|
||||
static func topRadius(_ presentation: Presentation) -> CGFloat {
|
||||
switch presentation {
|
||||
case .hidden: 0
|
||||
case .compact: 6
|
||||
case .banner: 8
|
||||
case .panel: 10
|
||||
}
|
||||
}
|
||||
|
||||
static func bottomRadius(_ presentation: Presentation) -> CGFloat {
|
||||
switch presentation {
|
||||
case .hidden: 10
|
||||
case .compact: 12
|
||||
case .banner: 18
|
||||
case .panel: 22
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks the shape, not the window, which is larger while the shape animates.
|
||||
final class TrackingHostingView<Content: View>: NSHostingView<Content> {
|
||||
var onEnter: () -> Void = {}
|
||||
var onExit: () -> Void = {}
|
||||
var trackingRect: CGRect = .zero {
|
||||
didSet { if trackingRect != oldValue { updateTrackingAreas() } }
|
||||
}
|
||||
/// Remove only this one; NSHostingView's own areas drive SwiftUI hover effects.
|
||||
private var shapeArea: NSTrackingArea?
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
if let shapeArea { removeTrackingArea(shapeArea) }
|
||||
shapeArea = nil
|
||||
guard !trackingRect.isEmpty else { return }
|
||||
let area = NSTrackingArea(rect: trackingRect, options: [.mouseEnteredAndExited, .activeAlways], owner: self)
|
||||
addTrackingArea(area)
|
||||
shapeArea = area
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
if event.trackingArea === shapeArea { onEnter() } else { super.mouseEntered(with: event) }
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
if event.trackingArea === shapeArea { onExit() } else { super.mouseExited(with: event) }
|
||||
}
|
||||
|
||||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class NotchWindowController {
|
||||
private static let shadowPadding: CGFloat = 36
|
||||
|
||||
private let controller: SessionController
|
||||
private let chrome = NotchChrome()
|
||||
private let panel = NotchPanel()
|
||||
private let host: TrackingHostingView<NotchRootView>
|
||||
private var screen: NSScreen?
|
||||
private var anchor = CGPoint.zero
|
||||
private var clickMonitor: Any?
|
||||
private var lastState: SessionState = .idle
|
||||
/// A finished animation only tidies up if no newer one has started.
|
||||
private var generation = 0
|
||||
private var trackingExtent: Extent?
|
||||
private let currentRouteLabel: () -> String?
|
||||
private var sessionRouteLabel: String?
|
||||
|
||||
init(controller: SessionController, routeLabel: @escaping () -> String? = { nil }) {
|
||||
self.controller = controller
|
||||
currentRouteLabel = routeLabel
|
||||
host = TrackingHostingView(rootView: NotchRootView(controller: controller, chrome: chrome))
|
||||
host.sizingOptions = []
|
||||
host.onEnter = { [weak self] in self?.pointerEntered() }
|
||||
host.onExit = { [weak self] in self?.pointerExited() }
|
||||
panel.contentView = host
|
||||
// Global mouse monitors need no Accessibility permission.
|
||||
clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { _ in
|
||||
MainActor.assumeIsolated { controller.clickedOutside() }
|
||||
}
|
||||
NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification,
|
||||
object: nil, queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self else { return }
|
||||
self.screen = nil
|
||||
self.panel.orderOut(nil)
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
observe()
|
||||
update()
|
||||
}
|
||||
|
||||
func hide() {
|
||||
panel.orderOut(nil)
|
||||
}
|
||||
|
||||
/// So crossing the notch on the way to the menu bar doesn't open the folded pill.
|
||||
private static let hoverIntent: Duration = .milliseconds(150)
|
||||
private var pendingEnter: Task<Void, Never>?
|
||||
|
||||
private func pointerEntered() {
|
||||
pendingEnter?.cancel()
|
||||
guard chrome.presentation == .compact else {
|
||||
controller.pointerEntered()
|
||||
return
|
||||
}
|
||||
pendingEnter = Task { [weak self] in
|
||||
try? await Task.sleep(for: Self.hoverIntent)
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.controller.pointerEntered()
|
||||
}
|
||||
}
|
||||
|
||||
private func pointerExited() {
|
||||
pendingEnter?.cancel()
|
||||
pendingEnter = nil
|
||||
controller.pointerExited()
|
||||
}
|
||||
|
||||
/// A key non-activating panel keeps the keyboard until it leaves the screen; `resignKey()` only notifies.
|
||||
private func relinquishKey() {
|
||||
guard panel.isKeyWindow else { return }
|
||||
panel.orderOut(nil)
|
||||
panel.orderFrontRegardless()
|
||||
}
|
||||
|
||||
private func observe() {
|
||||
withObservationTracking {
|
||||
_ = controller.state
|
||||
_ = controller.notice
|
||||
_ = controller.setupFolded
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.update()
|
||||
self?.observe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func presentation(for state: SessionState, notice: Notice?, setupFolded: Bool) -> Presentation {
|
||||
switch state {
|
||||
case .settingUp where setupFolded:
|
||||
.compact
|
||||
case .idle:
|
||||
notice == nil ? .hidden : .banner
|
||||
case .starting(_, folded: true), .live(.collapsed):
|
||||
.compact
|
||||
case .destroying, .destroyed, .failed(.ended), .failed(.destroyStuck):
|
||||
.banner
|
||||
case .starting(_, folded: false), .live(.peek), .live(.expanded), .failed(.start), .setupRequired, .settingUp:
|
||||
.panel
|
||||
}
|
||||
}
|
||||
|
||||
private func update() {
|
||||
let state = controller.state
|
||||
let notice = controller.notice
|
||||
defer { lastState = state }
|
||||
let target = Self.presentation(for: state, notice: notice, setupFolded: controller.setupFolded)
|
||||
guard target != .hidden else {
|
||||
retract()
|
||||
return
|
||||
}
|
||||
|
||||
if screen == nil || (state == .starting(.booting, folded: false) && lastState != state) {
|
||||
let next = Self.screenUnderPointer()
|
||||
if next != screen { panel.orderOut(nil) }
|
||||
screen = next
|
||||
}
|
||||
guard let screen else { return }
|
||||
// The route is chosen as the VM boots, so read it once connecting.
|
||||
if case .starting(.booting, _) = state, lastState != state { sessionRouteLabel = nil }
|
||||
if case .starting(.connecting, _) = state, lastState != state { sessionRouteLabel = currentRouteLabel() }
|
||||
let routeLabel = sessionRouteLabel
|
||||
let info = ScreenInfo(screen)
|
||||
let large: Bool
|
||||
switch state {
|
||||
case .live(.expanded): large = true
|
||||
case .starting(_, folded: false): large = controller.opensExpanded
|
||||
default: large = false
|
||||
}
|
||||
let layout = NotchGeometry.layout(for: info, mode: large ? .expanded : .peek)
|
||||
let notchSize = layout.notch.size
|
||||
let panelSize = layout.panel.size
|
||||
let panelOffsetX = layout.panel.midX - layout.notch.midX
|
||||
anchor = CGPoint(x: layout.notch.midX, y: info.frame.maxY)
|
||||
|
||||
generation += 1
|
||||
let current = generation
|
||||
let apply = { [chrome] in
|
||||
chrome.presentation = target
|
||||
chrome.content = target
|
||||
chrome.reveal = 1
|
||||
chrome.state = state
|
||||
chrome.notice = notice
|
||||
chrome.notchSize = notchSize
|
||||
chrome.panelSize = panelSize
|
||||
chrome.panelOffsetX = panelOffsetX
|
||||
chrome.hasNotch = info.hasNotch
|
||||
chrome.routeLabel = routeLabel
|
||||
}
|
||||
let targetExtent = Self.extent(target, notch: notchSize, panel: panelSize, offset: panelOffsetX, hasNotch: info.hasNotch)
|
||||
setTracking(targetExtent)
|
||||
|
||||
if !panel.isVisible {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
apply()
|
||||
chrome.presentation = .hidden
|
||||
chrome.reveal = 0
|
||||
}
|
||||
let hiddenExtent = Self.extent(.hidden, notch: notchSize, panel: panelSize, offset: 0, hasNotch: info.hasNotch)
|
||||
fitWindow(hiddenExtent, targetExtent)
|
||||
panel.orderFrontRegardless()
|
||||
// Draw while invisible and start ~2 frames later, or the first draw (fonts, symbols) stalls the animation.
|
||||
withTransaction(transaction) { host.layoutSubtreeIfNeeded() }
|
||||
panel.displayIfNeeded()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(35)) { [weak self] in
|
||||
guard let self, self.generation == current else { return }
|
||||
self.animate(from: .hidden, to: target, apply: apply, generation: current, extent: targetExtent)
|
||||
}
|
||||
} else {
|
||||
let from = chrome.presentation
|
||||
let currentExtent = Self.extent(from, notch: chrome.notchSize, panel: chrome.panelSize,
|
||||
offset: chrome.offsetX, hasNotch: chrome.hasNotch)
|
||||
fitWindow(currentExtent, targetExtent)
|
||||
animate(from: from, to: target, apply: apply, generation: current, extent: targetExtent)
|
||||
}
|
||||
|
||||
// Only a click into the page (VideoView) makes the panel key; opening never does.
|
||||
if target != .panel { relinquishKey() }
|
||||
}
|
||||
|
||||
private func animate(from: Presentation, to target: Presentation, apply: @escaping () -> Void,
|
||||
generation current: Int, extent: Extent) {
|
||||
withAnimation(Self.animation(from: from, to: target), completionCriteria: .removed) {
|
||||
apply()
|
||||
} completion: { [weak self] in
|
||||
guard let self, self.generation == current else { return }
|
||||
self.fitWindow(extent)
|
||||
}
|
||||
}
|
||||
|
||||
private func retract() {
|
||||
guard panel.isVisible else {
|
||||
screen = nil
|
||||
return
|
||||
}
|
||||
generation += 1
|
||||
let current = generation
|
||||
setTracking(nil)
|
||||
relinquishKey()
|
||||
withAnimation(Motion.hide, completionCriteria: .removed) {
|
||||
chrome.presentation = .hidden
|
||||
chrome.reveal = 0
|
||||
} completion: { [weak self] in
|
||||
guard let self, self.generation == current else { return }
|
||||
self.panel.orderOut(nil)
|
||||
self.screen = nil
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) { self.chrome.content = .hidden }
|
||||
}
|
||||
}
|
||||
|
||||
private static func animation(from: Presentation, to target: Presentation) -> Animation {
|
||||
switch (from, target) {
|
||||
case (.panel, .panel): Motion.resize
|
||||
case (_, .panel): Motion.open
|
||||
case (.panel, _): Motion.fold
|
||||
default: Motion.small
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geometry
|
||||
|
||||
struct Extent {
|
||||
/// Reach on the wider side of the notch's centre line.
|
||||
var halfWidth: CGFloat
|
||||
var height: CGFloat
|
||||
var offset: CGFloat = 0
|
||||
var width: CGFloat = 0
|
||||
}
|
||||
|
||||
static func extent(_ presentation: Presentation, notch: CGSize, panel: CGSize, offset: CGFloat, hasNotch: Bool) -> Extent {
|
||||
let body = NotchChrome.bodySize(presentation, notch: notch, panel: panel, hasNotch: hasNotch)
|
||||
let width = body.width + 2 * NotchChrome.topRadius(presentation)
|
||||
let shift = presentation == .panel ? offset : 0
|
||||
return Extent(halfWidth: width / 2 + abs(shift), height: body.height, offset: shift, width: width)
|
||||
}
|
||||
|
||||
private func fitWindow(_ extents: Extent...) {
|
||||
let half = (extents.map(\.halfWidth).max() ?? 0) + Self.shadowPadding
|
||||
let height = (extents.map(\.height).max() ?? 0) + Self.shadowPadding
|
||||
let frame = CGRect(x: (anchor.x - half).rounded(), y: anchor.y - height.rounded(.up),
|
||||
width: (2 * half).rounded(.up), height: height.rounded(.up))
|
||||
if panel.frame != frame {
|
||||
panel.setFrame(frame, display: false)
|
||||
// Lay out outside any animation, or the shape animates across from the old window position.
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) { host.layoutSubtreeIfNeeded() }
|
||||
}
|
||||
refreshTracking()
|
||||
}
|
||||
|
||||
private func setTracking(_ extent: Extent?) {
|
||||
trackingExtent = extent
|
||||
refreshTracking()
|
||||
}
|
||||
|
||||
private func refreshTracking() {
|
||||
guard let extent = trackingExtent else {
|
||||
host.trackingRect = .zero
|
||||
return
|
||||
}
|
||||
let bounds = host.bounds
|
||||
let y = host.isFlipped ? bounds.minY : bounds.maxY - extent.height
|
||||
host.trackingRect = CGRect(x: bounds.midX + extent.offset - extent.width / 2, y: y,
|
||||
width: extent.width, height: extent.height)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func snapshot(to directory: URL, name: String) {
|
||||
guard panel.isVisible else {
|
||||
FileHandle.standardError.write(Data("snapshot \(name): window hidden\n".utf8))
|
||||
return
|
||||
}
|
||||
DebugSnapshot.write(host, to: directory.appendingPathComponent("\(name).png"))
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func screenUnderPointer() -> NSScreen? {
|
||||
let pointer = NSEvent.mouseLocation
|
||||
return NSScreen.screens.first { NSMouseInRect(pointer, $0.frame, false) } ?? NSScreen.main
|
||||
}
|
||||
}
|
||||
|
||||
extension ScreenInfo {
|
||||
/// The only code that reads `NSScreen`.
|
||||
@MainActor
|
||||
init(_ screen: NSScreen) {
|
||||
func global(_ rect: CGRect?) -> CGRect {
|
||||
guard let rect, !rect.isEmpty else { return .zero }
|
||||
// The auxiliary areas are documented in screen coordinates; make them global.
|
||||
return screen.frame.contains(rect.origin) ? rect : rect.offsetBy(dx: screen.frame.minX, dy: screen.frame.minY)
|
||||
}
|
||||
self.init(frame: screen.frame, visibleFrame: screen.visibleFrame,
|
||||
safeAreaTop: screen.safeAreaInsets.top,
|
||||
auxLeft: global(screen.auxiliaryTopLeftArea), auxRight: global(screen.auxiliaryTopRightArea),
|
||||
menuBarThickness: NSStatusBar.system.thickness)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import CoreMedia
|
||||
import KnotchyCore
|
||||
import SwiftUI
|
||||
import Synchronization
|
||||
|
||||
final class LatestFrame: Sendable {
|
||||
private let frame = Mutex<DecodedFrame?>(nil)
|
||||
|
||||
func put(_ new: DecodedFrame) -> Bool {
|
||||
frame.withLock { slot in
|
||||
defer { slot = new }
|
||||
return slot == nil
|
||||
}
|
||||
}
|
||||
|
||||
func take() -> DecodedFrame? {
|
||||
frame.withLock { slot in
|
||||
defer { slot = nil }
|
||||
return slot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VideoSurface: NSViewRepresentable {
|
||||
let controller: SessionController
|
||||
|
||||
func makeNSView(context: Context) -> VideoView {
|
||||
let view = VideoView()
|
||||
view.controller = controller
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: VideoView, context: Context) {
|
||||
view.attach(controller.connection)
|
||||
}
|
||||
}
|
||||
|
||||
final class VideoView: NSView {
|
||||
weak var controller: SessionController?
|
||||
private var connection: (any StreamConnection)?
|
||||
private let displayLayer = AVSampleBufferDisplayLayer()
|
||||
private var scrollRemainder = CGPoint.zero
|
||||
/// WebKit scrolls about 40 px per wheel tick.
|
||||
private let scrollStep: CGFloat = 40
|
||||
|
||||
override init(frame: NSRect) {
|
||||
super.init(frame: frame)
|
||||
wantsLayer = true
|
||||
layer?.backgroundColor = NSColor.black.cgColor
|
||||
displayLayer.videoGravity = .resizeAspect
|
||||
displayLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
|
||||
layer?.addSublayer(displayLayer)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("not used") }
|
||||
|
||||
override var isFlipped: Bool { true }
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
displayLayer.frame = bounds
|
||||
CATransaction.commit()
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.environment["KNOTCHY_LOG_LAYOUT"] != nil {
|
||||
print(String(format: "%.3f video %.0fx%.0f", CACurrentMediaTime(), bounds.width, bounds.height))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func attach(_ connection: (any StreamConnection)?) {
|
||||
guard self.connection !== connection else { return }
|
||||
self.connection = connection
|
||||
displayLayer.sampleBufferRenderer.flush()
|
||||
let slot = LatestFrame()
|
||||
connection?.setFrameHandler { [weak self] frame in
|
||||
// Keep only the newest frame so a busy main thread drops frames instead of queueing multi-MB buffers.
|
||||
guard slot.put(frame) else { return }
|
||||
DispatchQueue.main.async {
|
||||
guard let frame = slot.take() else { return }
|
||||
MainActor.assumeIsolated { self?.show(frame.pixelBuffer) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func show(_ pixelBuffer: CVPixelBuffer) {
|
||||
var format: CMVideoFormatDescription?
|
||||
CMVideoFormatDescriptionCreateForImageBuffer(allocator: nil, imageBuffer: pixelBuffer, formatDescriptionOut: &format)
|
||||
guard let format else { return }
|
||||
var timing = CMSampleTimingInfo(duration: .invalid, presentationTimeStamp: .zero, decodeTimeStamp: .invalid)
|
||||
var sample: CMSampleBuffer?
|
||||
CMSampleBufferCreateReadyWithImageBuffer(allocator: nil, imageBuffer: pixelBuffer, formatDescription: format,
|
||||
sampleTiming: &timing, sampleBufferOut: &sample)
|
||||
guard let sample else { return }
|
||||
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: true),
|
||||
CFArrayGetCount(attachments) > 0 {
|
||||
let dictionary = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self)
|
||||
CFDictionarySetValue(dictionary,
|
||||
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
|
||||
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
|
||||
}
|
||||
let renderer = displayLayer.sampleBufferRenderer
|
||||
if renderer.status == .failed { renderer.flush() }
|
||||
renderer.enqueue(sample)
|
||||
}
|
||||
|
||||
// MARK: - Mouse
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
for area in trackingAreas where area.owner === self { removeTrackingArea(area) }
|
||||
addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseMoved, .activeAlways, .inVisibleRect], owner: self))
|
||||
}
|
||||
|
||||
private func videoPoint(_ event: NSEvent) -> (x: UInt16, y: UInt16)? {
|
||||
NotchGeometry.videoPoint(convert(event.locationInWindow, from: nil), surfaceSize: bounds.size,
|
||||
renderSize: SessionController.renderSize)
|
||||
}
|
||||
|
||||
private func mods(_ event: NSEvent) -> Modifiers {
|
||||
var mods: Modifiers = []
|
||||
let flags = event.modifierFlags
|
||||
if flags.contains(.shift) { mods.insert(.shift) }
|
||||
// ⌘ maps to Ctrl so ⌘A/C/V/X/Z work like Linux shortcuts in the VM.
|
||||
if flags.contains(.control) || flags.contains(.command) { mods.insert(.control) }
|
||||
if flags.contains(.option) { mods.insert(.alt) }
|
||||
let buttons = NSEvent.pressedMouseButtons
|
||||
if buttons & 1 != 0 { mods.insert(.leftButton) }
|
||||
if buttons & 2 != 0 { mods.insert(.rightButton) }
|
||||
if buttons & 4 != 0 { mods.insert(.middleButton) }
|
||||
return mods
|
||||
}
|
||||
|
||||
private func sendMove(_ event: NSEvent) {
|
||||
guard let point = videoPoint(event) else { return }
|
||||
connection?.send(.pointerMove(x: point.x, y: point.y, mods: mods(event)))
|
||||
}
|
||||
|
||||
private func sendButton(_ event: NSEvent, _ button: PointerButton, pressed: Bool) {
|
||||
guard let point = videoPoint(event) else { return }
|
||||
connection?.send(.pointerButton(button, pressed: pressed, x: point.x, y: point.y, mods: mods(event)))
|
||||
}
|
||||
|
||||
override func mouseMoved(with event: NSEvent) { sendMove(event) }
|
||||
override func mouseDragged(with event: NSEvent) { sendMove(event) }
|
||||
override func rightMouseDragged(with event: NSEvent) { sendMove(event) }
|
||||
override func otherMouseDragged(with event: NSEvent) { sendMove(event) }
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
controller?.panelClicked()
|
||||
window?.makeKey()
|
||||
window?.makeFirstResponder(self)
|
||||
sendButton(event, .left, pressed: true)
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) { sendButton(event, .left, pressed: false) }
|
||||
override func rightMouseDown(with event: NSEvent) { sendButton(event, .right, pressed: true) }
|
||||
override func rightMouseUp(with event: NSEvent) { sendButton(event, .right, pressed: false) }
|
||||
override func otherMouseDown(with event: NSEvent) { sendButton(event, .middle, pressed: true) }
|
||||
override func otherMouseUp(with event: NSEvent) { sendButton(event, .middle, pressed: false) }
|
||||
|
||||
override func scrollWheel(with event: NSEvent) {
|
||||
var dx = event.scrollingDeltaX
|
||||
var dy = event.scrollingDeltaY
|
||||
if event.hasPreciseScrollingDeltas {
|
||||
scrollRemainder.x += dx
|
||||
scrollRemainder.y += dy
|
||||
dx = (scrollRemainder.x / scrollStep).rounded(.towardZero)
|
||||
dy = (scrollRemainder.y / scrollStep).rounded(.towardZero)
|
||||
scrollRemainder.x -= dx * scrollStep
|
||||
scrollRemainder.y -= dy * scrollStep
|
||||
} else {
|
||||
dx = dx == 0 ? 0 : (dx > 0 ? max(1, dx.rounded()) : min(-1, dx.rounded()))
|
||||
dy = dy == 0 ? 0 : (dy > 0 ? max(1, dy.rounded()) : min(-1, dy.rounded()))
|
||||
}
|
||||
guard dx != 0 || dy != 0, let point = videoPoint(event) else { return }
|
||||
connection?.send(.scroll(ticksX: Int16(clamping: Int(dx)), ticksY: Int16(clamping: Int(dy)), x: point.x, y: point.y))
|
||||
}
|
||||
|
||||
// MARK: - Keyboard
|
||||
|
||||
private static let specialKeys: [UInt16: String] = [
|
||||
36: "Return", 76: "KP_Enter", 48: "Tab", 51: "BackSpace", 117: "Delete", 53: "Escape",
|
||||
49: "space", 123: "Left", 124: "Right", 125: "Down", 126: "Up",
|
||||
115: "Home", 119: "End", 116: "Page_Up", 121: "Page_Down",
|
||||
122: "F1", 120: "F2", 99: "F3", 118: "F4", 96: "F5", 97: "F6",
|
||||
98: "F7", 100: "F8", 101: "F9", 109: "F10", 103: "F11", 111: "F12",
|
||||
]
|
||||
|
||||
private func keyName(_ event: NSEvent) -> String? {
|
||||
if let name = Self.specialKeys[event.keyCode] { return name }
|
||||
let flags = event.modifierFlags
|
||||
let characters = flags.contains(.command) || flags.contains(.control)
|
||||
? event.charactersIgnoringModifiers?.lowercased()
|
||||
: event.characters
|
||||
guard let scalars = characters?.unicodeScalars, scalars.count == 1, let scalar = scalars.first,
|
||||
scalar.value >= 0x20, scalar.value != 0x7F, !(0xF700...0xF8FF).contains(scalar.value)
|
||||
else { return nil }
|
||||
return String(scalar)
|
||||
}
|
||||
|
||||
private func sendKey(_ event: NSEvent, pressed: Bool) {
|
||||
guard let name = keyName(event) else { return }
|
||||
connection?.send(.key(pressed: pressed, mods: mods(event), name: name))
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) { sendKey(event, pressed: true) }
|
||||
override func keyUp(with event: NSEvent) { sendKey(event, pressed: false) }
|
||||
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
guard event.type == .keyDown, event.modifierFlags.contains(.command), let controller else {
|
||||
return super.performKeyEquivalent(with: event)
|
||||
}
|
||||
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
|
||||
let shift = event.modifierFlags.contains(.shift)
|
||||
switch (key, shift) {
|
||||
case ("w", false), (".", false): controller.destroy()
|
||||
case ("e", true): controller.state == .live(.expanded) ? controller.shrink() : controller.expand()
|
||||
case ("[", false): controller.goBack()
|
||||
case ("]", false): controller.goForward()
|
||||
case ("r", false): controller.reload()
|
||||
case ("q", false): NSApp.terminate(nil)
|
||||
default:
|
||||
sendKey(event, pressed: true)
|
||||
sendKey(event, pressed: false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
import Synchronization
|
||||
|
||||
public struct CLIRunner: Sendable {
|
||||
public struct Output: Sendable {
|
||||
public var status: Int32
|
||||
public var text: String
|
||||
|
||||
public var lastLine: String {
|
||||
text.split(whereSeparator: \.isNewline).last.map(String.init) ?? "exit status \(status)"
|
||||
}
|
||||
}
|
||||
|
||||
public enum Failure: Error, Equatable {
|
||||
case notInstalled
|
||||
case timedOut([String])
|
||||
case launchFailed(String)
|
||||
}
|
||||
|
||||
// Fixed paths: apps launched from the Services menu get a minimal PATH.
|
||||
public static let searchPaths = ["/usr/local/bin/container", "/opt/homebrew/bin/container"]
|
||||
public let searchPaths: [String]
|
||||
|
||||
public init(searchPaths: [String] = CLIRunner.searchPaths) {
|
||||
self.searchPaths = searchPaths
|
||||
}
|
||||
|
||||
/// Looked up on every call, so a runtime installed while the app runs is found.
|
||||
public var executable: String? {
|
||||
searchPaths.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
}
|
||||
|
||||
public func run(_ args: [String], timeout: TimeInterval,
|
||||
line: (@Sendable (String) -> Void)? = nil) async throws -> Output {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
continuation.resume(with: Result { try self.runBlocking(args, timeout: timeout, line: line) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static let outputLimit = 1024 * 1024
|
||||
|
||||
private static let builds = Mutex(Set<Int32>())
|
||||
|
||||
/// Builds would outlive the app. Other commands are left to the quit path, which waits for them.
|
||||
public static func stopBuilds() {
|
||||
for pid in builds.withLock({ $0 }) { kill(pid, SIGTERM) }
|
||||
}
|
||||
|
||||
public func runBlocking(_ args: [String], timeout: TimeInterval,
|
||||
line: (@Sendable (String) -> Void)? = nil) throws -> Output {
|
||||
guard let executable else { throw Failure.notInstalled }
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = args
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
// Only the tail is kept: a noisy build can print for minutes.
|
||||
let limit = Self.outputLimit
|
||||
let collected = Mutex(Data())
|
||||
let pending = Mutex(Data())
|
||||
pipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let chunk = handle.availableData
|
||||
guard !chunk.isEmpty else { return }
|
||||
collected.withLock { buffer in
|
||||
buffer.append(chunk)
|
||||
if buffer.count > limit { buffer.removeFirst(buffer.count - limit) }
|
||||
}
|
||||
guard let line else { return }
|
||||
let lines: [String] = pending.withLock { buffer in
|
||||
buffer.append(chunk)
|
||||
var out: [String] = []
|
||||
while let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) {
|
||||
out.append(String(decoding: buffer[buffer.startIndex..<newline], as: UTF8.self))
|
||||
buffer.removeSubrange(buffer.startIndex...newline)
|
||||
}
|
||||
if buffer.count > 16 * 1024 { // A line that never ends.
|
||||
out.append(String(decoding: buffer.prefix(16 * 1024), as: UTF8.self))
|
||||
buffer.removeAll()
|
||||
}
|
||||
return out
|
||||
}
|
||||
for text in lines where !text.trimmingCharacters(in: .whitespaces).isEmpty { line(text) }
|
||||
}
|
||||
|
||||
let finished = DispatchSemaphore(value: 0)
|
||||
process.terminationHandler = { _ in finished.signal() }
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
pipe.fileHandleForReading.readabilityHandler = nil
|
||||
throw Failure.launchFailed(error.localizedDescription)
|
||||
}
|
||||
let pid = process.processIdentifier
|
||||
let isBuild = args.first == "build"
|
||||
if isBuild { Self.builds.withLock { _ = $0.insert(pid) } }
|
||||
defer { if isBuild { Self.builds.withLock { _ = $0.remove(pid) } } }
|
||||
if finished.wait(timeout: .now() + timeout) == .timedOut {
|
||||
process.terminate()
|
||||
if finished.wait(timeout: .now() + 1) == .timedOut {
|
||||
kill(process.processIdentifier, SIGKILL)
|
||||
finished.wait()
|
||||
}
|
||||
pipe.fileHandleForReading.readabilityHandler = nil
|
||||
throw Failure.timedOut(args)
|
||||
}
|
||||
pipe.fileHandleForReading.readabilityHandler = nil
|
||||
let rest = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let data = collected.withLock { ($0 + rest).suffix(limit) }
|
||||
return Output(status: process.terminationStatus, text: String(decoding: data, as: UTF8.self))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import Foundation
|
||||
import Synchronization
|
||||
|
||||
public final class ContainerCLIBackend: VMBackend {
|
||||
public static let image = "local/knotchy-vm:latest"
|
||||
public static let staleAfterDays = 14
|
||||
|
||||
private let cli: CLIRunner
|
||||
private let vmSources: URL?
|
||||
private let runDirectory: URL
|
||||
private let startsInFlight = Mutex(0)
|
||||
private let route = Mutex(NetworkRoute.direct)
|
||||
private let startedRoute = Mutex(NetworkRoute.direct)
|
||||
|
||||
public var lastStartedRoute: NetworkRoute { startedRoute.withLock { $0 } }
|
||||
|
||||
public func setNetworkRoute(_ newRoute: NetworkRoute) {
|
||||
route.withLock { $0 = newRoute }
|
||||
}
|
||||
|
||||
/// Test-only (knotchyctl --allow-local-proxy); the app never sets it.
|
||||
private let localProxyAllowed = Mutex(false)
|
||||
|
||||
public func allowLocalProxy() {
|
||||
localProxyAllowed.withLock { $0 = true }
|
||||
}
|
||||
|
||||
public init(vmSources: URL?, cli: CLIRunner = CLIRunner()) {
|
||||
self.vmSources = vmSources
|
||||
self.cli = cli
|
||||
runDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("knotchy", isDirectory: true)
|
||||
}
|
||||
|
||||
// MARK: Preflight
|
||||
|
||||
public func preflight() async -> PreflightResult {
|
||||
guard cli.executable != nil else { return .issue(.cliMissing) }
|
||||
if case .outdated(let found) = await runtimeStatus() { return .issue(.cliOutdated(found: found)) }
|
||||
guard let status = try? await cli.run(["system", "status"], timeout: 15), status.status == 0,
|
||||
field("status", in: status.text) == "running"
|
||||
else { return .issue(.serviceStopped) }
|
||||
let appRoot = field("paths.appRoot", in: status.text)
|
||||
?? NSHomeDirectory() + "/Library/Application Support/com.apple.container/"
|
||||
let kernel = URL(fileURLWithPath: appRoot).appendingPathComponent("kernels/default.kernel-arm64")
|
||||
guard FileManager.default.fileExists(atPath: kernel.path) else { return .issue(.kernelMissing) }
|
||||
|
||||
guard let inspect = try? await cli.run(["image", "inspect", Self.image], timeout: 15), inspect.status == 0
|
||||
else { return .issue(.imageMissing) }
|
||||
let labels = Self.labels(in: inspect.text)
|
||||
let found = labels["aculix.knotchy.protocol"].flatMap(Int.init) ?? 0
|
||||
guard found == WireProtocol.version else {
|
||||
return .issue(.imageOutdated(found: found, need: WireProtocol.version))
|
||||
}
|
||||
prepareRunDirectory()
|
||||
return .ready(appRoot: appRoot, staleImage: Self.staleness(built: labels["aculix.knotchy.built"]))
|
||||
}
|
||||
|
||||
/// Resolved on the Mac: a proxy route gives the VM no DNS, and a VPN's DNS needs the tunnel up.
|
||||
static func resolveIPv4(_ host: String) throws -> String {
|
||||
var address = host
|
||||
if !WireGuardConfig.isIPv4(host) {
|
||||
var hints = addrinfo()
|
||||
hints.ai_family = AF_INET
|
||||
hints.ai_socktype = SOCK_STREAM
|
||||
var result: UnsafeMutablePointer<addrinfo>?
|
||||
guard getaddrinfo(host, nil, &hints, &result) == 0, let first = result, let sockaddr = first.pointee.ai_addr else {
|
||||
throw VMBackendError.failed("Couldn't find the server “\(host)”. Check Settings → Network.")
|
||||
}
|
||||
defer { freeaddrinfo(result) }
|
||||
var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
|
||||
var inAddr = sockaddr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee.sin_addr }
|
||||
guard inet_ntop(AF_INET, &inAddr, &buffer, socklen_t(buffer.count)) != nil else {
|
||||
throw VMBackendError.failed("Couldn't find the server “\(host)”.")
|
||||
}
|
||||
address = String(cString: buffer)
|
||||
}
|
||||
if address.hasPrefix("127.") || address == "0.0.0.0" {
|
||||
throw VMBackendError.failed("The VM can't reach this Mac's localhost (“\(host)”). A proxy on this Mac must listen on the Mac's network address instead.")
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
public func imageStatus() async -> ImageStatus {
|
||||
guard cli.executable != nil else { return .noContainerTool }
|
||||
guard let inspect = try? await cli.run(["image", "inspect", Self.image], timeout: 15), inspect.status == 0
|
||||
else { return .missing }
|
||||
let built = Self.labels(in: inspect.text)["aculix.knotchy.built"].flatMap { ISO8601DateFormatter().date(from: $0) }
|
||||
return .built(built)
|
||||
}
|
||||
|
||||
public enum ImageStatus: Equatable, Sendable {
|
||||
case noContainerTool
|
||||
case missing
|
||||
case built(Date?)
|
||||
}
|
||||
|
||||
public func runtimeStatus() async -> RuntimeStatus {
|
||||
guard cli.executable != nil else { return .missing }
|
||||
let output = try? await cli.run(["--version"], timeout: 10)
|
||||
return ContainerRuntime.status(versionOutput: output?.text ?? "")
|
||||
}
|
||||
|
||||
public func stopService() async throws {
|
||||
let result = try await cli.run(["system", "stop"], timeout: 60)
|
||||
guard result.status == 0 else { throw VMBackendError.failed("Couldn't stop the container service: \(result.lastLine)") }
|
||||
}
|
||||
|
||||
private func field(_ name: String, in text: String) -> String? {
|
||||
for line in text.split(whereSeparator: \.isNewline) {
|
||||
let parts = line.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
|
||||
if parts.count == 2, parts[0] == name {
|
||||
return parts[1].trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func labels(in json: String) -> [String: String] {
|
||||
var found: [String: String] = [:]
|
||||
func walk(_ value: Any) {
|
||||
if let dict = value as? [String: Any] {
|
||||
for (key, child) in dict {
|
||||
if key.hasPrefix("aculix.knotchy."), let text = child as? String { found[key] = text }
|
||||
walk(child)
|
||||
}
|
||||
} else if let array = value as? [Any] {
|
||||
array.forEach(walk)
|
||||
}
|
||||
}
|
||||
if let object = try? JSONSerialization.jsonObject(with: Data(json.utf8)) { walk(object) }
|
||||
return found
|
||||
}
|
||||
|
||||
static func staleness(built: String?, now: Date = Date()) -> StaleImage? {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
guard let built, let date = formatter.date(from: built) else { return StaleImage(builtDaysAgo: nil) }
|
||||
let days = Int(now.timeIntervalSince(date) / 86_400)
|
||||
return days > staleAfterDays ? StaleImage(builtDaysAgo: days) : nil
|
||||
}
|
||||
|
||||
private func prepareRunDirectory() {
|
||||
let fm = FileManager.default
|
||||
let path = runDirectory.path
|
||||
var isDirectory: ObjCBool = false
|
||||
let attributes = try? fm.attributesOfItem(atPath: path)
|
||||
let isSymlink = (attributes?[.type] as? FileAttributeType) == .typeSymbolicLink
|
||||
let owner = (attributes?[.ownerAccountID] as? NSNumber)?.uint32Value
|
||||
let mode = (attributes?[.posixPermissions] as? NSNumber)?.intValue
|
||||
if isSymlink || !fm.fileExists(atPath: path, isDirectory: &isDirectory) || !isDirectory.boolValue
|
||||
|| owner != getuid() || mode != 0o700 {
|
||||
try? fm.removeItem(atPath: path)
|
||||
try? fm.createDirectory(atPath: path, withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700])
|
||||
}
|
||||
// Secrets left by a crash; a VM's init deletes its own within a second of booting.
|
||||
let stale = Date().addingTimeInterval(-120)
|
||||
for name in (try? fm.contentsOfDirectory(atPath: path)) ?? [] where name.hasSuffix(".secrets") || name.hasSuffix(".env") {
|
||||
let item = runDirectory.appendingPathComponent(name)
|
||||
if let modified = (try? fm.attributesOfItem(atPath: item.path))?[.modificationDate] as? Date, modified < stale {
|
||||
try? fm.removeItem(at: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Start and destroy
|
||||
|
||||
public func start(id: String, url: URL, renderSize: PixelSize, blockCIDRs: [String]) async throws -> VMSession {
|
||||
startsInFlight.withLock { $0 += 1 }
|
||||
defer { startsInFlight.withLock { $0 -= 1 } }
|
||||
prepareRunDirectory()
|
||||
let socket = runDirectory.appendingPathComponent("\(id).sock").path
|
||||
unlink(socket)
|
||||
let chosenRoute = route.withLock { $0 }
|
||||
let launch = try chosenRoute.launch(resolve: Self.resolveIPv4, hostNetworks: blockCIDRs,
|
||||
allowLocalProxy: localProxyAllowed.withLock { $0 })
|
||||
var args = [
|
||||
"run", "-d", "--rm", "--name", id,
|
||||
"--cpus", "4", "--memory", "2g", "--shm-size", "512m",
|
||||
"--dns", "1.1.1.1",
|
||||
"--cap-add", "NET_ADMIN",
|
||||
// WebKit's bwrap can't mount procfs over a masked /proc, and init writes /proc/sys.
|
||||
"--masked-path", "NONE", "--read-only-path", "NONE",
|
||||
"--publish-socket", "\(socket):/tmp/knotchy/knotchy.sock",
|
||||
"-e", "KNOTCHY_URL=\(url.absoluteString)",
|
||||
"-e", "KNOTCHY_SIZE=\(renderSize)",
|
||||
"-e", "KNOTCHY_BLOCK_CIDRS=\(blockCIDRs.joined(separator: " "))",
|
||||
]
|
||||
for setting in launch.environment { args += ["-e", setting] }
|
||||
if !launch.secrets.isEmpty {
|
||||
// Init reads, deletes and unmounts this (needs SYS_ADMIN), then drops all capabilities.
|
||||
let share = secretsDirectory(id)
|
||||
try? FileManager.default.removeItem(at: share)
|
||||
guard (try? FileManager.default.createDirectory(at: share, withIntermediateDirectories: false,
|
||||
attributes: [.posixPermissions: 0o700])) != nil,
|
||||
FileManager.default.createFile(atPath: share.appendingPathComponent("route").path,
|
||||
contents: Data((launch.secrets.joined(separator: "\n") + "\n").utf8),
|
||||
attributes: [.posixPermissions: 0o600])
|
||||
else {
|
||||
try? FileManager.default.removeItem(at: share)
|
||||
throw VMBackendError.failed("Couldn't pass the network settings to the VM.")
|
||||
}
|
||||
args += ["--cap-add", "SYS_ADMIN", "--mount", "type=bind,source=\(share.path),target=/run/knotchy-secrets"]
|
||||
}
|
||||
args.append(Self.image)
|
||||
let output: CLIRunner.Output
|
||||
do {
|
||||
output = try await cli.run(args, timeout: 60)
|
||||
} catch {
|
||||
throw VMBackendError.failed("Starting the VM timed out.")
|
||||
}
|
||||
guard output.status == 0 else {
|
||||
cleanUp(id)
|
||||
throw VMBackendError.failed("Couldn't start the VM: \(output.lastLine)")
|
||||
}
|
||||
startedRoute.withLock { $0 = chosenRoute }
|
||||
return VMSession(id: id, socketPath: socket)
|
||||
}
|
||||
|
||||
public func destroy(id: String) async throws {
|
||||
_ = try? await cli.run(["stop", "-t", "2", id], timeout: 10)
|
||||
if await waitUntilGone(id, for: 5) { return cleanUp(id) }
|
||||
_ = try? await cli.run(["delete", "--force", id], timeout: 10)
|
||||
if await waitUntilGone(id, for: 5) { return cleanUp(id) }
|
||||
throw VMBackendError.destroyStuck(id: id)
|
||||
}
|
||||
|
||||
public func forceDestroy(id: String) async throws {
|
||||
_ = try? await cli.run(["delete", "--force", id], timeout: 10)
|
||||
if await waitUntilGone(id, for: 5) { return cleanUp(id) }
|
||||
throw VMBackendError.destroyStuck(id: id)
|
||||
}
|
||||
|
||||
public func sweepOrphans(keeping: Set<String>) async {
|
||||
guard let listed = try? await cli.run(["ls", "-a", "-q"], timeout: 10) else { return }
|
||||
for id in Self.ids(in: listed.text) where Self.isSessionID(id) && !keeping.contains(id) {
|
||||
_ = try? await cli.run(["delete", "--force", id], timeout: 10)
|
||||
cleanUp(id)
|
||||
}
|
||||
}
|
||||
|
||||
static func isSessionID(_ id: String) -> Bool {
|
||||
id.count == 16 && id.hasPrefix("knotchy-") && id.dropFirst(8).allSatisfy(\.isHexDigit)
|
||||
}
|
||||
|
||||
static func ids(in text: String) -> [String] {
|
||||
text.split(whereSeparator: \.isNewline).map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private func isListed(_ id: String) async -> Bool? {
|
||||
guard let listed = try? await cli.run(["ls", "-a", "-q"], timeout: 10), listed.status == 0 else { return nil }
|
||||
return Self.ids(in: listed.text).contains(id)
|
||||
}
|
||||
|
||||
private func waitUntilGone(_ id: String, for seconds: Double) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(seconds)
|
||||
repeat {
|
||||
if await isListed(id) == false { return true }
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
} while Date() < deadline
|
||||
return false
|
||||
}
|
||||
|
||||
private func cleanUp(_ id: String) {
|
||||
unlink(runDirectory.appendingPathComponent("\(id).sock").path)
|
||||
try? FileManager.default.removeItem(at: secretsDirectory(id))
|
||||
}
|
||||
|
||||
private func secretsDirectory(_ id: String) -> URL {
|
||||
runDirectory.appendingPathComponent("\(id).secrets", isDirectory: true)
|
||||
}
|
||||
|
||||
// MARK: Setup actions
|
||||
|
||||
public func fix(_ issue: SetupIssue, output: @escaping @Sendable (String) -> Void) async throws {
|
||||
switch issue {
|
||||
case .serviceStopped, .kernelMissing:
|
||||
let result = try await cli.run(["system", "start", "--enable-kernel-install"], timeout: 120, line: output)
|
||||
guard result.status == 0 else { throw VMBackendError.failed("Couldn't start the service: \(result.lastLine)") }
|
||||
case .imageMissing, .imageOutdated, .imageStale:
|
||||
guard let vmSources else { throw VMBackendError.failed("The VM sources are missing from the app.") }
|
||||
let now = Date()
|
||||
let day = DateFormatter()
|
||||
day.dateFormat = "yyyy-MM-dd"
|
||||
let args = [
|
||||
"build", "--cpus", "4", "--memory", "4g", "--dns", "1.1.1.1", "--pull",
|
||||
"--build-arg", "APT_REFRESH=\(day.string(from: now))",
|
||||
"--build-arg", "BUILD_DATE=\(ISO8601DateFormatter().string(from: now))",
|
||||
"-f", vmSources.appendingPathComponent("Containerfile").path,
|
||||
"-t", Self.image, vmSources.path,
|
||||
]
|
||||
let result = try await cli.run(args, timeout: 15 * 60, line: output)
|
||||
guard result.status == 0 else { throw VMBackendError.failed("The image build failed: \(result.lastLine)") }
|
||||
case .cliMissing, .cliOutdated, .lowDisk:
|
||||
throw VMBackendError.failed("There's no automatic fix for this.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Quit path
|
||||
|
||||
public func destroyBlocking(ids: [String], deadline: Duration) {
|
||||
let end = Date().addingTimeInterval(TimeInterval(deadline.components.seconds))
|
||||
// Wait for in-flight starts so a VM created at the last moment is destroyed too.
|
||||
while startsInFlight.withLock({ $0 }) > 0, Date() < end { usleep(50_000) }
|
||||
for id in ids {
|
||||
let left = end.timeIntervalSinceNow
|
||||
guard left > 0.5 else { return }
|
||||
_ = try? cli.runBlocking(["delete", "--force", id], timeout: min(10, left))
|
||||
cleanUp(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
public enum HostNetworks {
|
||||
/// Blocked in the VM on top of the private ranges, for LANs and VPNs on public addresses.
|
||||
public static func current() -> [String] {
|
||||
var head: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&head) == 0, let first = head else { return [] }
|
||||
defer { freeifaddrs(head) }
|
||||
var networks = Set<String>()
|
||||
for pointer in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let interface = pointer.pointee
|
||||
guard let address = interface.ifa_addr, let mask = interface.ifa_netmask,
|
||||
address.pointee.sa_family == UInt8(AF_INET),
|
||||
interface.ifa_flags & UInt32(IFF_LOOPBACK) == 0 else { continue }
|
||||
let ip = address.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { UInt32(bigEndian: $0.pointee.sin_addr.s_addr) }
|
||||
let bits = mask.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { UInt32(bigEndian: $0.pointee.sin_addr.s_addr) }
|
||||
let prefix = bits.nonzeroBitCount
|
||||
guard prefix >= 8 else { continue } // Never let a bogus /0 block the whole internet.
|
||||
let network = ip & bits
|
||||
networks.insert("\(network >> 24).\((network >> 16) & 0xFF).\((network >> 8) & 0xFF).\(network & 0xFF)/\(prefix)")
|
||||
}
|
||||
return networks.sorted()
|
||||
}
|
||||
}
|
||||
|
||||
public struct SystemDiskSpace: DiskSpaceChecking {
|
||||
public init() {}
|
||||
|
||||
@concurrent
|
||||
public func bytesFree(atPath path: String) async -> Int64? {
|
||||
let values = try? URL(fileURLWithPath: path).resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
|
||||
return values?.volumeAvailableCapacityForImportantUsage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import Foundation
|
||||
|
||||
/// Proxy and VPN routes fail closed: the VM's firewall lets nothing else out.
|
||||
public enum NetworkRoute: Equatable, Sendable {
|
||||
case direct
|
||||
case proxy(ProxySettings)
|
||||
case wireGuard(WireGuardConfig)
|
||||
/// Sessions fail to start rather than quietly browsing directly.
|
||||
case unusable(reason: String)
|
||||
}
|
||||
|
||||
public struct ProxySettings: Equatable, Sendable {
|
||||
public enum Kind: String, CaseIterable, Sendable {
|
||||
case http
|
||||
case socks5
|
||||
}
|
||||
|
||||
public var kind: Kind
|
||||
public var host: String
|
||||
public var port: Int
|
||||
public var username: String
|
||||
public var password: String
|
||||
|
||||
public init(kind: Kind, host: String, port: Int, username: String = "", password: String = "") {
|
||||
self.kind = kind
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.password = password
|
||||
}
|
||||
|
||||
public func url(address: String) -> String {
|
||||
var userinfo = ""
|
||||
if !username.isEmpty {
|
||||
userinfo = Self.encode(username) + (password.isEmpty ? "" : ":" + Self.encode(password)) + "@"
|
||||
}
|
||||
return "\(kind.rawValue)://\(userinfo)\(address):\(port)"
|
||||
}
|
||||
|
||||
/// Must match the VM firewall's list.
|
||||
static let localRanges = ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
|
||||
"172.16.0.0/12", "192.168.0.0/16", "198.18.0.0/15"]
|
||||
|
||||
/// A local proxy is refused: pages could reach the Mac and its network through it.
|
||||
public static func isLocal(_ address: String, hostNetworks: [String]) -> Bool {
|
||||
guard let ip = ipv4Value(address) else { return true }
|
||||
return (localRanges + hostNetworks).contains { cidr in
|
||||
let parts = cidr.split(separator: "/")
|
||||
guard parts.count == 2, let network = ipv4Value(String(parts[0])), let bits = Int(parts[1]), (0...32).contains(bits)
|
||||
else { return false }
|
||||
let mask: UInt32 = bits == 0 ? 0 : ~UInt32(0) << (32 - bits)
|
||||
return ip & mask == network & mask
|
||||
}
|
||||
}
|
||||
|
||||
static func ipv4Value(_ text: String) -> UInt32? {
|
||||
guard WireGuardConfig.isIPv4(text) else { return nil }
|
||||
return text.split(separator: ".").reduce(UInt32(0)) { $0 << 8 | UInt32($1)! }
|
||||
}
|
||||
|
||||
static func encode(_ text: String) -> String {
|
||||
let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
|
||||
return text.addingPercentEncoding(withAllowedCharacters: unreserved) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
/// IPv4 only (the VM has IPv6 off). All traffic uses the tunnel, whatever AllowedIPs says.
|
||||
public struct WireGuardConfig: Equatable, Sendable {
|
||||
public var privateKey: String
|
||||
public var addresses: [String]
|
||||
/// Empty means 1.1.1.1 through the tunnel.
|
||||
public var dns: [String]
|
||||
public var mtu: Int?
|
||||
public var peerPublicKey: String
|
||||
public var presharedKey: String?
|
||||
public var endpointHost: String
|
||||
public var endpointPort: Int
|
||||
public var persistentKeepalive: Int?
|
||||
public var name: String?
|
||||
|
||||
public enum ParseError: Error, Equatable, Sendable {
|
||||
case missing(String)
|
||||
case invalid(String)
|
||||
case multiplePeers
|
||||
}
|
||||
|
||||
public static func parse(_ text: String) throws(ParseError) -> WireGuardConfig {
|
||||
var section = ""
|
||||
var interface: [String: String] = [:]
|
||||
var peers: [[String: String]] = []
|
||||
for rawLine in text.split(whereSeparator: \.isNewline) {
|
||||
let line = rawLine.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)[0]
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
if line.isEmpty { continue }
|
||||
if line.hasPrefix("[") {
|
||||
section = line.lowercased()
|
||||
if section == "[peer]" { peers.append([:]) }
|
||||
continue
|
||||
}
|
||||
guard let equals = line.firstIndex(of: "=") else { throw .invalid(line) }
|
||||
let key = line[..<equals].trimmingCharacters(in: .whitespaces).lowercased()
|
||||
let value = line[line.index(after: equals)...].trimmingCharacters(in: .whitespaces)
|
||||
switch section {
|
||||
case "[interface]":
|
||||
interface[key] = interface[key].map { $0 + "," + value } ?? value
|
||||
case "[peer]":
|
||||
peers[peers.count - 1][key] = value
|
||||
default:
|
||||
throw .invalid(line)
|
||||
}
|
||||
}
|
||||
guard peers.count <= 1 else { throw .multiplePeers }
|
||||
guard let peer = peers.first else { throw .missing("[Peer]") }
|
||||
|
||||
guard let privateKey = interface["privatekey"] else { throw .missing("PrivateKey") }
|
||||
guard isKey(privateKey) else { throw .invalid("PrivateKey") }
|
||||
guard let publicKey = peer["publickey"] else { throw .missing("PublicKey") }
|
||||
guard isKey(publicKey) else { throw .invalid("PublicKey") }
|
||||
let presharedKey = peer["presharedkey"]
|
||||
if let presharedKey, !isKey(presharedKey) { throw .invalid("PresharedKey") }
|
||||
|
||||
let addresses = list(interface["address"]).compactMap(ipv4CIDR)
|
||||
guard !addresses.isEmpty else { throw .missing("an IPv4 Address") }
|
||||
let dns = list(interface["dns"]).filter(isIPv4)
|
||||
let mtu = try interface["mtu"].map { (text: String) throws(ParseError) -> Int in
|
||||
guard let mtu = Int(text), (576...9000).contains(mtu) else { throw .invalid("MTU") }
|
||||
return mtu
|
||||
}
|
||||
let keepalive = try peer["persistentkeepalive"].flatMap { (text: String) throws(ParseError) -> Int? in
|
||||
if text.lowercased() == "off" { return nil }
|
||||
guard let seconds = Int(text), (0...65535).contains(seconds) else { throw .invalid("PersistentKeepalive") }
|
||||
return seconds == 0 ? nil : seconds
|
||||
}
|
||||
|
||||
guard let endpoint = peer["endpoint"] else { throw .missing("Endpoint") }
|
||||
guard !endpoint.hasPrefix("["), let colon = endpoint.lastIndex(of: ":"),
|
||||
let port = Int(endpoint[endpoint.index(after: colon)...]), (1...65535).contains(port)
|
||||
else { throw .invalid("Endpoint (an IPv4 address or host name, and a port)") }
|
||||
let host = String(endpoint[..<colon])
|
||||
guard !host.isEmpty, host.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "." || $0 == "-" }) else {
|
||||
throw .invalid("Endpoint")
|
||||
}
|
||||
|
||||
return WireGuardConfig(privateKey: privateKey, addresses: addresses, dns: dns, mtu: mtu,
|
||||
peerPublicKey: publicKey, presharedKey: presharedKey,
|
||||
endpointHost: host, endpointPort: port, persistentKeepalive: keepalive)
|
||||
}
|
||||
|
||||
public func setconf(endpointAddress address: String) -> String {
|
||||
var lines = ["[Interface]", "PrivateKey = \(privateKey)", "", "[Peer]", "PublicKey = \(peerPublicKey)"]
|
||||
if let presharedKey { lines.append("PresharedKey = \(presharedKey)") }
|
||||
lines.append("Endpoint = \(address):\(endpointPort)")
|
||||
lines.append("AllowedIPs = 0.0.0.0/0")
|
||||
lines.append("PersistentKeepalive = \(persistentKeepalive ?? 25)")
|
||||
return lines.joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
||||
// MARK: Validation
|
||||
|
||||
/// Curve25519: 32 bytes in base64.
|
||||
static func isKey(_ text: String) -> Bool {
|
||||
text.count == 44 && Data(base64Encoded: text)?.count == 32
|
||||
}
|
||||
|
||||
static func list(_ text: String?) -> [String] {
|
||||
(text ?? "").split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
static func isIPv4(_ text: String) -> Bool {
|
||||
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
|
||||
return parts.count == 4 && parts.allSatisfy { part in
|
||||
!part.isEmpty && part.count <= 3 && part.allSatisfy(\.isASCII) && part.allSatisfy(\.isNumber)
|
||||
&& Int(part).map { $0 <= 255 } == true
|
||||
}
|
||||
}
|
||||
|
||||
static func ipv4CIDR(_ text: String) -> String? {
|
||||
let parts = text.split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard parts.count <= 2, isIPv4(String(parts[0])) else { return nil }
|
||||
guard parts.count == 2 else { return text + "/32" }
|
||||
guard let prefix = Int(parts[1]), (0...32).contains(prefix), parts[1].allSatisfy(\.isNumber) else { return nil }
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
extension NetworkRoute {
|
||||
public var label: String? {
|
||||
switch self {
|
||||
case .direct, .unusable: nil
|
||||
case .proxy(let proxy): proxy.kind == .socks5 ? "SOCKS5 proxy" : "HTTP proxy"
|
||||
case .wireGuard(let config): config.name ?? "VPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WireGuardConfig.ParseError {
|
||||
/// Finishes "The WireGuard config …".
|
||||
public var message: String {
|
||||
switch self {
|
||||
case .missing(let what): "has no \(what)"
|
||||
case .invalid(let what): "has an invalid \(what)"
|
||||
case .multiplePeers: "has more than one peer; Knotchy needs one server per config"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct RouteLaunch: Equatable, Sendable {
|
||||
/// Always includes KNOTCHY_ROUTE, so a VM missing its proxy or VPN settings stops.
|
||||
public var environment: [String]
|
||||
/// Never passed with `-e`: `container` saves the environment to disk and shows it in `inspect`.
|
||||
public var secrets: [String]
|
||||
}
|
||||
|
||||
extension NetworkRoute {
|
||||
public func launch(resolve: (String) throws -> String, hostNetworks: [String] = [],
|
||||
allowLocalProxy: Bool = false) throws -> RouteLaunch {
|
||||
switch self {
|
||||
case .direct:
|
||||
return RouteLaunch(environment: ["KNOTCHY_ROUTE=direct"], secrets: [])
|
||||
case .unusable(let reason):
|
||||
throw VMBackendError.failed(reason)
|
||||
case .proxy(let proxy):
|
||||
let address = try resolve(proxy.host)
|
||||
if !allowLocalProxy, ProxySettings.isLocal(address, hostNetworks: hostNetworks) {
|
||||
let which = proxy.host == address ? address : "\(proxy.host) is \(address)"
|
||||
throw VMBackendError.failed(
|
||||
"Knotchy won't use a proxy on this Mac or your local network (\(which)): pages could reach your network through it.")
|
||||
}
|
||||
return RouteLaunch(environment: ["KNOTCHY_ROUTE=proxy", "KNOTCHY_PROXY_ADDR=\(address):\(proxy.port)"],
|
||||
secrets: ["KNOTCHY_PROXY_URL=\(proxy.url(address: address))"])
|
||||
case .wireGuard(let config):
|
||||
let address = try resolve(config.endpointHost)
|
||||
return RouteLaunch(
|
||||
environment: [
|
||||
"KNOTCHY_ROUTE=wireguard",
|
||||
"KNOTCHY_WG_ENDPOINT=\(address):\(config.endpointPort)",
|
||||
"KNOTCHY_WG_ADDRESS=\(config.addresses.joined(separator: " "))",
|
||||
"KNOTCHY_WG_DNS=\(config.dns.joined(separator: " "))",
|
||||
"KNOTCHY_WG_MTU=\(config.mtu ?? 1420)",
|
||||
],
|
||||
secrets: ["KNOTCHY_WG_SETCONF=\(Data(config.setconf(endpointAddress: address).utf8).base64EncodedString())"]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
/// The controller serializes all calls except `destroyBlocking`, which the quit path may make
|
||||
/// from any thread while an async call is still running.
|
||||
public protocol VMBackend: Sendable {
|
||||
func preflight() async -> PreflightResult
|
||||
/// Must register as in flight before its first suspension so `destroyBlocking` waits for it.
|
||||
func start(id: String, url: URL, renderSize: PixelSize, blockCIDRs: [String]) async throws -> VMSession
|
||||
/// Returns only once the VM is confirmed gone; otherwise throws `.destroyStuck`.
|
||||
func destroy(id: String) async throws
|
||||
func forceDestroy(id: String) async throws
|
||||
func sweepOrphans(keeping: Set<String>) async
|
||||
func fix(_ issue: SetupIssue, output: @escaping @Sendable (String) -> Void) async throws
|
||||
/// Not serialized by the controller: `container --version` doesn't use the service.
|
||||
func runtimeStatus() async -> RuntimeStatus
|
||||
func stopService() async throws
|
||||
/// Blocking. Waits for in-flight starts first so a VM created at the last moment is destroyed too.
|
||||
func destroyBlocking(ids: [String], deadline: Duration)
|
||||
}
|
||||
|
||||
public struct VMSession: Equatable, Sendable {
|
||||
public let id: String
|
||||
public let socketPath: String
|
||||
|
||||
public init(id: String, socketPath: String) {
|
||||
self.id = id
|
||||
self.socketPath = socketPath
|
||||
}
|
||||
}
|
||||
|
||||
public enum SetupIssue: Equatable, Sendable {
|
||||
case cliMissing
|
||||
case cliOutdated(found: String)
|
||||
case serviceStopped
|
||||
case kernelMissing
|
||||
case imageMissing
|
||||
case imageOutdated(found: Int, need: Int)
|
||||
case imageStale(builtDaysAgo: Int?)
|
||||
case lowDisk(bytesFree: Int64)
|
||||
}
|
||||
|
||||
public enum PreflightResult: Equatable, Sendable {
|
||||
case ready(appRoot: String, staleImage: StaleImage?)
|
||||
/// Never `.imageStale` (that rides on `.ready`) or `.lowDisk` (the controller checks disk).
|
||||
case issue(SetupIssue)
|
||||
}
|
||||
|
||||
public struct StaleImage: Equatable, Sendable {
|
||||
public var builtDaysAgo: Int?
|
||||
|
||||
public init(builtDaysAgo: Int?) {
|
||||
self.builtDaysAgo = builtDaysAgo
|
||||
}
|
||||
}
|
||||
|
||||
public enum VMBackendError: Error, Equatable, Sendable {
|
||||
case destroyStuck(id: String)
|
||||
/// The message is shown to the user.
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
public protocol DiskSpaceChecking: Sendable {
|
||||
/// Async: the query can block for tens of milliseconds and stall the panel's opening animation.
|
||||
func bytesFree(atPath path: String) async -> Int64?
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import CoreGraphics
|
||||
|
||||
/// Rects are in global screen coordinates (AppKit's: origin bottom left, y up).
|
||||
public struct ScreenInfo: Equatable, Sendable {
|
||||
public var frame: CGRect
|
||||
public var visibleFrame: CGRect
|
||||
public var safeAreaTop: CGFloat
|
||||
public var auxLeft: CGRect
|
||||
public var auxRight: CGRect
|
||||
public var menuBarThickness: CGFloat
|
||||
|
||||
public init(frame: CGRect, visibleFrame: CGRect, safeAreaTop: CGFloat,
|
||||
auxLeft: CGRect, auxRight: CGRect, menuBarThickness: CGFloat) {
|
||||
self.frame = frame
|
||||
self.visibleFrame = visibleFrame
|
||||
self.safeAreaTop = safeAreaTop
|
||||
self.auxLeft = auxLeft
|
||||
self.auxRight = auxRight
|
||||
self.menuBarThickness = menuBarThickness
|
||||
}
|
||||
|
||||
public var hasNotch: Bool {
|
||||
safeAreaTop > 0 && !auxLeft.isEmpty && !auxRight.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
public enum PanelMode: Sendable {
|
||||
case peek
|
||||
case expanded
|
||||
}
|
||||
|
||||
/// Global screen coordinates, y up; convert them for SwiftUI views and tracking areas.
|
||||
public struct PanelLayout: Equatable, Sendable {
|
||||
public var notch: CGRect
|
||||
public var pill: CGRect
|
||||
/// Full size for its mode so the shape never clips; a Peek↔Expanded animation needs the larger.
|
||||
public var panel: CGRect
|
||||
public var toolbar: CGRect
|
||||
public var content: CGRect
|
||||
public var contentScale: CGFloat
|
||||
}
|
||||
|
||||
public enum NotchGeometry {
|
||||
public static let toolbarHeight: CGFloat = 34
|
||||
public static let margin: CGFloat = 6
|
||||
public static let peekContent = CGSize(width: 640, height: 400)
|
||||
public static let expandedContent = CGSize(width: 1024, height: 640)
|
||||
public static let renderWidth: CGFloat = 1024
|
||||
public static let virtualNotchWidth: CGFloat = 200
|
||||
public static let minimumNotchHeight: CGFloat = 24
|
||||
public static let notchWidthRange: ClosedRange<CGFloat> = 120...300
|
||||
public static let pillEarWidth: CGFloat = 72
|
||||
public static let screenInset: CGFloat = 8
|
||||
public static let minimumContentHeight: CGFloat = 50
|
||||
|
||||
public static func layout(for screen: ScreenInfo, mode: PanelMode) -> PanelLayout {
|
||||
let notch = notchRect(for: screen)
|
||||
let height = notch.height
|
||||
|
||||
let base = mode == .peek ? peekContent : expandedContent
|
||||
// Ignores a side Dock: the panel may cover its top.
|
||||
let maxWidth = screen.frame.width - 2 * screenInset - 2 * margin
|
||||
let maxHeight = (screen.frame.maxY - screen.visibleFrame.minY) - height - toolbarHeight - margin - screenInset
|
||||
// Keep exactly 16:10 in whole points: the height is a multiple of 5, the width 8/5 of it.
|
||||
let fittingHeight = min(base.height, maxHeight, maxWidth * 5 / 8)
|
||||
let contentHeight = max(minimumContentHeight, (fittingHeight / 5).rounded(.down) * 5)
|
||||
let contentWidth = contentHeight * 8 / 5
|
||||
|
||||
let panelWidth = contentWidth + 2 * margin
|
||||
let panelHeight = height + toolbarHeight + contentHeight + margin
|
||||
let centeredX = (notch.midX - panelWidth / 2).rounded()
|
||||
let panelX = min(max(centeredX, screen.frame.minX), screen.frame.maxX - panelWidth)
|
||||
let panel = CGRect(x: panelX, y: screen.frame.maxY - panelHeight, width: panelWidth, height: panelHeight)
|
||||
|
||||
return PanelLayout(
|
||||
notch: notch,
|
||||
pill: notch.insetBy(dx: -pillEarWidth, dy: 0),
|
||||
panel: panel,
|
||||
toolbar: CGRect(x: panel.minX + margin, y: panel.maxY - height - toolbarHeight,
|
||||
width: contentWidth, height: toolbarHeight),
|
||||
content: CGRect(x: panel.minX + margin, y: panel.minY + margin,
|
||||
width: contentWidth, height: contentHeight),
|
||||
contentScale: contentWidth / renderWidth
|
||||
)
|
||||
}
|
||||
|
||||
/// `point` is in a flipped view. Nil on degenerate input: drop the event rather than click at (0, 0).
|
||||
public static func videoPoint(_ point: CGPoint, surfaceSize: CGSize, renderSize: PixelSize) -> (x: UInt16, y: UInt16)? {
|
||||
let pixels = 1...65536
|
||||
guard point.x.isFinite, point.y.isFinite, surfaceSize.width.isFinite, surfaceSize.height.isFinite,
|
||||
surfaceSize.width > 0, surfaceSize.height > 0,
|
||||
pixels.contains(renderSize.width), pixels.contains(renderSize.height)
|
||||
else { return nil }
|
||||
let video = CGSize(width: renderSize.width, height: renderSize.height)
|
||||
let scale = min(surfaceSize.width / video.width, surfaceSize.height / video.height)
|
||||
guard scale > 0 else { return nil }
|
||||
let originX = (surfaceSize.width - video.width * scale) / 2
|
||||
let originY = (surfaceSize.height - video.height * scale) / 2
|
||||
let x = min(max((point.x - originX) / scale, 0), video.width - 1)
|
||||
let y = min(max((point.y - originY) / scale, 0), video.height - 1)
|
||||
return (UInt16(x), UInt16(y))
|
||||
}
|
||||
|
||||
private static func notchRect(for screen: ScreenInfo) -> CGRect {
|
||||
if screen.hasNotch {
|
||||
let left = screen.auxLeft.maxX
|
||||
let right = screen.auxRight.minX
|
||||
let width = min(max(right - left, notchWidthRange.lowerBound), notchWidthRange.upperBound)
|
||||
return CGRect(x: (left + right) / 2 - width / 2, y: screen.frame.maxY - screen.safeAreaTop,
|
||||
width: width, height: screen.safeAreaTop)
|
||||
}
|
||||
// The minimum keeps a virtual notch visible when the menu bar auto-hides.
|
||||
let height = max(screen.frame.maxY - screen.visibleFrame.maxY, screen.menuBarThickness, minimumNotchHeight)
|
||||
return CGRect(x: (screen.frame.midX - virtualNotchWidth / 2).rounded(), y: screen.frame.maxY - height,
|
||||
width: virtualNotchWidth, height: height)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
|
||||
/// Input is untrusted and scanned inside the synchronous Services call, so work is linear and capped.
|
||||
public enum LinkIntake {
|
||||
/// UTF-8 bytes. Links reach the VM in an env var and in NAVIGATE messages (64 KiB limit).
|
||||
public static let maxURLBytes = 16 * 1024
|
||||
public static let maxScannedCharacters = 1024 * 1024
|
||||
|
||||
/// The first http(s) URL in `text`, without surrounding prose punctuation, quotes or Markdown.
|
||||
public static func extractURL(from text: String) -> URL? {
|
||||
let startLimit = text.index(text.startIndex, offsetBy: maxScannedCharacters, limitedBy: text.endIndex)
|
||||
?? text.endIndex
|
||||
// One past the limit, so an over-long link is rejected rather than truncated.
|
||||
let readLimit = text.index(startLimit, offsetBy: maxURLBytes + 1, limitedBy: text.endIndex) ?? text.endIndex
|
||||
var from = text.startIndex
|
||||
while from < startLimit,
|
||||
let match = text.range(of: "http", options: .caseInsensitive, range: from..<startLimit) {
|
||||
let end = text[match.lowerBound..<readLimit].firstIndex(where: endsURL) ?? readLimit
|
||||
let token = text[match.lowerBound..<end]
|
||||
let before = match.lowerBound > text.startIndex ? text[text.index(before: match.lowerBound)] : nil
|
||||
var parts = [(token, afterParenthesis: before == "(")]
|
||||
if let markdown = token.range(of: "](") {
|
||||
parts = [(token[..<markdown.lowerBound], before == "("), (token[markdown.upperBound...], true)]
|
||||
}
|
||||
for (whole, afterParenthesis) in parts {
|
||||
let part = afterParenthesis ? upToUnbalancedClosingParenthesis(whole) : whole
|
||||
let candidate = trimmingTrailingPunctuation(part, stripping: before == "*" ? ".,;:!?\"'*" : ".,;:!?\"'")
|
||||
if candidate.utf8.count <= maxURLBytes,
|
||||
let url = URL(string: String(candidate)),
|
||||
let valid = validate(url) {
|
||||
return valid
|
||||
}
|
||||
}
|
||||
from = end
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Tries `href` targets first: a selected hyperlink's visible text rarely contains its address.
|
||||
public static func extractURL(fromHTML html: String) -> URL? {
|
||||
let text = String(html.prefix(maxScannedCharacters))
|
||||
let range = NSRange(text.startIndex..., in: text)
|
||||
for match in hrefPattern.matches(in: text, range: range) {
|
||||
for group in 1...3 {
|
||||
guard let found = Range(match.range(at: group), in: text) else { continue }
|
||||
let target = decodingEntities(text[found]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if target.utf8.count <= maxURLBytes, let url = URL(string: target), let valid = validate(url) {
|
||||
return valid
|
||||
}
|
||||
}
|
||||
}
|
||||
return extractURL(from: text)
|
||||
}
|
||||
|
||||
private static let hrefPattern = try! NSRegularExpression(
|
||||
pattern: #"href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))"#, options: [.caseInsensitive])
|
||||
|
||||
private static func decodingEntities(_ value: Substring) -> String {
|
||||
var result = String(value)
|
||||
for (entity, character) in [(""", "\""), (""", "\""), ("'", "'"), ("'", "'"),
|
||||
("<", "<"), (">", ">"), ("&", "&"), ("&", "&")] {
|
||||
result = result.replacingOccurrences(of: entity, with: character, options: .caseInsensitive)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public static func validate(_ url: URL) -> URL? {
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https",
|
||||
let host = url.host(percentEncoded: false), !host.isEmpty,
|
||||
url.absoluteString.utf8.count <= maxURLBytes
|
||||
else { return nil }
|
||||
return url
|
||||
}
|
||||
|
||||
/// Missed by the category check. IDNA would turn these full stops into dots in the host.
|
||||
private static let cjkPunctuation: Set<Character> = ["。", ".", "。", "、", ",", "…", "!", "?", ":", ";"]
|
||||
|
||||
private static func endsURL(_ c: Character) -> Bool {
|
||||
if c.isWhitespace || c == "<" || c == ">" || c == "\"" || c == "`" { return true }
|
||||
guard let scalar = c.unicodeScalars.first, !scalar.isASCII else { return false }
|
||||
switch scalar.properties.generalCategory {
|
||||
case .initialPunctuation, .finalPunctuation, .openPunctuation, .closePunctuation:
|
||||
return true
|
||||
default:
|
||||
return cjkPunctuation.contains(c)
|
||||
}
|
||||
}
|
||||
|
||||
private static func upToUnbalancedClosingParenthesis(_ part: Substring) -> Substring {
|
||||
var depth = 0
|
||||
for i in part.indices {
|
||||
switch part[i] {
|
||||
case "(":
|
||||
depth += 1
|
||||
case ")":
|
||||
if depth == 0 { return part[..<i] }
|
||||
depth -= 1
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return part
|
||||
}
|
||||
|
||||
private static let openerOf: [Character: Character] = [")": "(", "]": "[", "}": "{"]
|
||||
private static let closerOf: [Character: Character] = ["(": ")", "[": "]", "{": "}"]
|
||||
|
||||
private static func trimmingTrailingPunctuation(_ token: Substring, stripping punctuation: String) -> Substring {
|
||||
// Closing minus opening brackets, keyed by the closing one.
|
||||
var unbalanced: [Character: Int] = [:]
|
||||
for c in token {
|
||||
if openerOf[c] != nil {
|
||||
unbalanced[c, default: 0] += 1
|
||||
} else if let closer = closerOf[c] {
|
||||
unbalanced[closer, default: 0] -= 1
|
||||
}
|
||||
}
|
||||
var s = token
|
||||
while let last = s.last {
|
||||
if punctuation.contains(last) {
|
||||
s.removeLast()
|
||||
} else if openerOf[last] != nil, unbalanced[last, default: 0] > 0 {
|
||||
unbalanced[last, default: 0] -= 1
|
||||
s.removeLast()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Synchronization
|
||||
|
||||
/// Runs async jobs one at a time, in order, so backend operations never overlap.
|
||||
@MainActor
|
||||
final class BackendChain {
|
||||
private var tail: Task<Void, Never>?
|
||||
private(set) var isClosed = false
|
||||
|
||||
func enqueue(_ job: @escaping @MainActor () async -> Void) {
|
||||
guard !isClosed else { return }
|
||||
let previous = tail
|
||||
tail = Task {
|
||||
await previous?.value
|
||||
guard !self.isClosed else { return }
|
||||
await job()
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
isClosed = true
|
||||
}
|
||||
|
||||
func waitUntilIdle() async {
|
||||
while let current = tail {
|
||||
await current.value
|
||||
if tail == current { return }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every VM ID from its `start` being queued until its destroy is confirmed.
|
||||
/// Lock-protected: the quit path reads it off the main actor.
|
||||
public final class LiveIDs: Sendable {
|
||||
private let ids = Mutex<Set<String>>([])
|
||||
|
||||
public init() {}
|
||||
|
||||
public var snapshot: Set<String> {
|
||||
ids.withLock { $0 }
|
||||
}
|
||||
|
||||
func insert(_ id: String) {
|
||||
_ = ids.withLock { $0.insert(id) }
|
||||
}
|
||||
|
||||
func remove(_ id: String) {
|
||||
_ = ids.withLock { $0.remove(id) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,956 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// The single-session state machine behind the notch UI. Inputs never block; backend calls run serially.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class SessionController {
|
||||
// MARK: - Timings and limits
|
||||
|
||||
public static let renderSize = PixelSize(width: 1024, height: 640)
|
||||
public static let foldGrace: Duration = .milliseconds(400)
|
||||
public static let neverEnteredFoldDelay: Duration = .seconds(30)
|
||||
public static let defaultIdleDestroyDelay: Duration = .seconds(5 * 60)
|
||||
public static let idleQuitDelay: Duration = .seconds(60)
|
||||
public static let destroyedDisplay: Duration = .milliseconds(1500)
|
||||
public static let endedDisplay: Duration = .seconds(3)
|
||||
public static let destroyStuckDisplay: Duration = .seconds(30)
|
||||
public static let noticeDisplay: Duration = .seconds(2)
|
||||
public static let progressLinger: Duration = .seconds(2)
|
||||
public static let minimumFreeBytes: Int64 = 2_000_000_000
|
||||
public static let setupOutputLimit = 200
|
||||
public static let stillBrokenMessage = "Setup finished, but the runtime still reports a problem."
|
||||
|
||||
// MARK: - Outputs
|
||||
|
||||
public private(set) var state: SessionState = .idle
|
||||
|
||||
// MARK: - Preferences
|
||||
|
||||
/// Takes effect the next time a session folds.
|
||||
public var idleDestroyDelay: Duration = SessionController.defaultIdleDestroyDelay
|
||||
public var opensExpanded = false
|
||||
public var quitsWhenIdle = true
|
||||
/// Opens the setup window; the app sets it.
|
||||
@ObservationIgnored public var showSetup: @MainActor () -> Void = {}
|
||||
|
||||
public private(set) var setupFolded = false
|
||||
@ObservationIgnored private var setupOpenedByHover = false
|
||||
|
||||
public private(set) var notice: Notice?
|
||||
public private(set) var progress: Int?
|
||||
public private(set) var openedURL: URL?
|
||||
/// Reported by the VM, so untrusted: shown, never opened or retried.
|
||||
public private(set) var pageURL: URL?
|
||||
public private(set) var setupLog: String?
|
||||
/// The latest setup action's last lines, for the setup window's details.
|
||||
public private(set) var setupOutput: [String] = []
|
||||
public private(set) var connection: (any StreamConnection)?
|
||||
public private(set) var staleImage: StaleImage?
|
||||
public private(set) var idleDeadline: ContinuousClock.Instant?
|
||||
|
||||
public var countdown: Duration? {
|
||||
idleDeadline.map { max(.zero, $0 - clock.now) }
|
||||
}
|
||||
|
||||
public nonisolated let liveIDs = LiveIDs()
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let backend: any VMBackend
|
||||
private let connector: any StreamConnector
|
||||
private let diskSpace: any DiskSpaceChecking
|
||||
private let clock: any KnotchyClock
|
||||
private let hostNetworks: () -> [String]
|
||||
private let requestQuit: @MainActor () -> Void
|
||||
private let makeSessionID: () -> String
|
||||
|
||||
// MARK: - Internal state
|
||||
|
||||
@MainActor
|
||||
private final class Session {
|
||||
let id: String
|
||||
let url: URL
|
||||
/// `backend.start` has been called, so a VM may exist.
|
||||
var startBegan = false
|
||||
var retired = false
|
||||
var contentReady = false
|
||||
var connection: (any StreamConnection)?
|
||||
var tasks: [Task<Void, Never>] = []
|
||||
|
||||
init(id: String, url: URL) {
|
||||
self.id = id
|
||||
self.url = url
|
||||
}
|
||||
}
|
||||
|
||||
private enum TimerKey: Hashable, Sendable {
|
||||
case foldGrace, neverEnteredFold, idleDestroy, transient, notice, progressHide, idleQuit
|
||||
}
|
||||
|
||||
private enum RunMode: Equatable {
|
||||
/// From the notch or the menu: stops at the next card, as before.
|
||||
case link
|
||||
/// From the setup window.
|
||||
case window(continuing: Bool)
|
||||
/// The setup window's check: starts the service, never builds.
|
||||
case check
|
||||
}
|
||||
|
||||
private struct SetupRun {
|
||||
var mode: RunMode
|
||||
/// The loop guard: each issue is fixed at most once per run.
|
||||
var fixed: [SetupIssue]
|
||||
var waiter: CheckedContinuation<SetupRunOutcome, Never>?
|
||||
}
|
||||
|
||||
private let chain = BackendChain()
|
||||
private let timers: DeadlineTimers<TimerKey>
|
||||
// Invariants: at most one session isn't retired, and it is `current`; only `transition(to:)`
|
||||
// writes `state` (and owns its timers); while `settingUp`, only the setup outcome changes it.
|
||||
@ObservationIgnored private var current: Session?
|
||||
@ObservationIgnored private var pendingURL: URL?
|
||||
/// IDs whose destroy failed; they stay in `liveIDs`.
|
||||
@ObservationIgnored private var pendingCleanup: [String] = []
|
||||
@ObservationIgnored private var requestedImageUpdate: StaleImage?
|
||||
@ObservationIgnored private var lastProgressAt: ContinuousClock.Instant?
|
||||
@ObservationIgnored private var entered = false
|
||||
@ObservationIgnored private var pointerInside = false
|
||||
@ObservationIgnored private var focused = false
|
||||
@ObservationIgnored private var run: SetupRun?
|
||||
@ObservationIgnored private var settleWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
/// The issues started automatically for the link now waiting; each is tried once per request.
|
||||
@ObservationIgnored private var autoFixed: (url: URL, issues: [SetupIssue])?
|
||||
|
||||
public init(
|
||||
backend: any VMBackend,
|
||||
connector: any StreamConnector,
|
||||
diskSpace: any DiskSpaceChecking,
|
||||
clock: any KnotchyClock = SystemClock(),
|
||||
hostNetworks: @escaping () -> [String],
|
||||
requestQuit: @escaping @MainActor () -> Void,
|
||||
makeSessionID: @escaping () -> String = SessionController.randomSessionID
|
||||
) {
|
||||
self.backend = backend
|
||||
self.connector = connector
|
||||
self.diskSpace = diskSpace
|
||||
self.clock = clock
|
||||
self.hostNetworks = hostNetworks
|
||||
self.requestQuit = requestQuit
|
||||
self.makeSessionID = makeSessionID
|
||||
timers = DeadlineTimers(clock: clock)
|
||||
timers.onFire = { [weak self] key in self?.timerFired(key) }
|
||||
enqueue { [backend] in await backend.sweepOrphans(keeping: []) }
|
||||
timers.set(.idleQuit, after: Self.idleQuitDelay)
|
||||
}
|
||||
|
||||
/// `knotchy-<8 hex>`: the only names the launch sweep deletes.
|
||||
public nonisolated static func randomSessionID() -> String {
|
||||
"knotchy-" + String(format: "%08x", UInt32.random(in: .min ... .max))
|
||||
}
|
||||
|
||||
// MARK: - Links
|
||||
|
||||
/// A new link always wins. Anything but http/https shows "No web link found".
|
||||
public func open(_ url: URL) {
|
||||
guard let url = LinkIntake.validate(url) else { return show(.noWebLink) }
|
||||
guard !chain.isClosed else { return }
|
||||
autoFixed = nil
|
||||
if case .settingUp = state {
|
||||
pendingURL = url
|
||||
setupFolded = false
|
||||
} else {
|
||||
startSession(url)
|
||||
}
|
||||
}
|
||||
|
||||
public func open(text: String) {
|
||||
guard let url = LinkIntake.extractURL(from: text) else { return show(.noWebLink) }
|
||||
open(url)
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
public func navigate(_ url: URL) {
|
||||
guard let url = LinkIntake.validate(url) else { return show(.noWebLink) }
|
||||
guard case .live = state, let connection else { return }
|
||||
openedURL = url
|
||||
pageURL = nil
|
||||
connection.send(.navigate(url.absoluteString))
|
||||
}
|
||||
|
||||
public func goBack() { runScript("history.back()") }
|
||||
public func goForward() { runScript("history.forward()") }
|
||||
public func reload() { runScript("location.reload()") }
|
||||
|
||||
private func runScript(_ javaScript: String) {
|
||||
guard case .live = state else { return }
|
||||
connection?.send(.script(javaScript))
|
||||
}
|
||||
|
||||
// MARK: - Panel
|
||||
|
||||
public func expand() {
|
||||
guard state == .live(.peek) else { return }
|
||||
entered = true
|
||||
focused = true
|
||||
transition(to: .live(.expanded))
|
||||
}
|
||||
|
||||
public func shrink() {
|
||||
guard state == .live(.expanded) else { return }
|
||||
transition(to: .live(.peek))
|
||||
}
|
||||
|
||||
public func pointerEntered() {
|
||||
pointerInside = true
|
||||
timers.cancel(.foldGrace)
|
||||
switch state {
|
||||
case .settingUp:
|
||||
if setupFolded { setupOpenedByHover = true }
|
||||
setupFolded = false
|
||||
case .starting(let step, folded: true):
|
||||
unfold(to: .starting(step, folded: false))
|
||||
case .live(.collapsed):
|
||||
unfold(to: .live(.peek))
|
||||
case .starting, .live:
|
||||
entered = true
|
||||
timers.cancel(.neverEnteredFold)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
public func pointerExited() {
|
||||
pointerInside = false
|
||||
if isPeekLike, entered, !focused {
|
||||
timers.set(.foldGrace, after: Self.foldGrace)
|
||||
}
|
||||
if case .settingUp = state, setupOpenedByHover {
|
||||
timers.set(.foldGrace, after: Self.foldGrace)
|
||||
}
|
||||
}
|
||||
|
||||
public func panelClicked() {
|
||||
guard isOpenPanel else { return }
|
||||
entered = true
|
||||
focused = true
|
||||
timers.cancel(.foldGrace)
|
||||
timers.cancel(.neverEnteredFold)
|
||||
}
|
||||
|
||||
public func clickedOutside() {
|
||||
if isOpenPanel { fold() }
|
||||
if case .settingUp = state { setupFolded = true }
|
||||
}
|
||||
|
||||
public func foldSetup() {
|
||||
if case .settingUp = state { setupFolded = true }
|
||||
setupOpenedByHover = false
|
||||
}
|
||||
|
||||
private var isPeekLike: Bool {
|
||||
switch state {
|
||||
case .starting(_, folded: false), .live(.peek): true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
private var isOpenPanel: Bool {
|
||||
isPeekLike || state == .live(.expanded)
|
||||
}
|
||||
|
||||
private var isFolded: Bool {
|
||||
if case .starting(_, folded: true) = state { true } else { false }
|
||||
}
|
||||
|
||||
private func fold() {
|
||||
focused = false
|
||||
pointerInside = false
|
||||
switch state {
|
||||
case .starting(let step, folded: false):
|
||||
transition(to: .starting(step, folded: true))
|
||||
case .live(.peek), .live(.expanded):
|
||||
transition(to: .live(.collapsed))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func unfold(to newState: SessionState) {
|
||||
entered = true
|
||||
focused = false
|
||||
transition(to: newState)
|
||||
}
|
||||
|
||||
// MARK: - Destroy, retry, dismiss
|
||||
|
||||
public func destroy() {
|
||||
guard let session = current else { return }
|
||||
switch state {
|
||||
case .starting, .live:
|
||||
transition(to: .destroying)
|
||||
retire(session) { [weak self] gone in self?.destroyFinished(session, gone: gone) }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
public func retry() {
|
||||
switch state {
|
||||
case .failed(.start):
|
||||
autoFixed = nil // A retry is a new request: it gets its own automatic start.
|
||||
if let url = current?.url { startSession(url, pointerStays: true) }
|
||||
case .failed(.destroyStuck):
|
||||
guard let session = current else { return }
|
||||
pendingCleanup.removeAll { $0 == session.id }
|
||||
transition(to: .destroying)
|
||||
enqueue { [weak self] in
|
||||
await self?.runDestroy(session) { gone in self?.destroyFinished(session, gone: gone) }
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
public func dismiss() {
|
||||
switch state {
|
||||
case .failed(.start):
|
||||
enterIdle()
|
||||
case .setupRequired:
|
||||
pendingURL = nil
|
||||
enterIdle()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
public func performSetupAction() {
|
||||
guard case .setupRequired(let issue, _) = state else { return }
|
||||
switch issue {
|
||||
case .cliMissing, .cliOutdated:
|
||||
showSetup()
|
||||
case .lowDisk:
|
||||
break
|
||||
default:
|
||||
startRun(issue, mode: .link, folded: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuilds the image once this session ends.
|
||||
public func requestImageUpdate() {
|
||||
guard let stale = staleImage else { return }
|
||||
staleImage = nil
|
||||
requestedImageUpdate = stale
|
||||
}
|
||||
|
||||
public func rebuildImage() {
|
||||
switch state {
|
||||
case .idle:
|
||||
startRun(.imageStale(builtDaysAgo: nil), mode: .link, folded: false)
|
||||
case .settingUp:
|
||||
break
|
||||
default:
|
||||
staleImage = nil
|
||||
requestedImageUpdate = StaleImage(builtDaysAgo: nil)
|
||||
}
|
||||
}
|
||||
|
||||
public func showPanel() {
|
||||
switch state {
|
||||
case .settingUp:
|
||||
setupFolded = false
|
||||
setupOpenedByHover = false
|
||||
case .starting(let step, folded: true): unfold(to: .starting(step, folded: false))
|
||||
case .live(.collapsed): unfold(to: .live(.peek))
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
public func dismissStaleImageWarning() {
|
||||
staleImage = nil
|
||||
}
|
||||
|
||||
/// Quit path: queued backend jobs never start after this. One already running may still call
|
||||
/// the backend, which is safe because `destroyBlocking` then blocks the main thread until exit.
|
||||
public func closeBackendChain() {
|
||||
chain.close()
|
||||
}
|
||||
|
||||
// MARK: - Setup window
|
||||
|
||||
/// Runs `issue`'s fix folded into the notch; when `continuing`, also each fixable issue preflight
|
||||
/// finds next, each at most once. A waiting link opens only once everything is ready. nil when busy.
|
||||
public func runSetup(_ issue: SetupIssue, continuing: Bool) async -> SetupRunOutcome? {
|
||||
guard acceptsSetupWindow else { return nil }
|
||||
return await withCheckedContinuation { continuation in
|
||||
startRun(issue, mode: .window(continuing: continuing), folded: true, waiter: continuation)
|
||||
}
|
||||
}
|
||||
|
||||
/// Preflight for the setup window: starts a stopped service or missing kernel, never builds. nil when busy.
|
||||
public func checkSetup() async -> SetupRunOutcome? {
|
||||
guard acceptsSetupWindow else { return nil }
|
||||
let preflight = await onChain { [backend] in await backend.preflight() }
|
||||
guard acceptsSetupWindow else { return nil }
|
||||
switch preflight {
|
||||
case .ready:
|
||||
// Fixed outside Knotchy while a card held a link: open it.
|
||||
if case .setupRequired = state, let url = pendingURL { startSession(url, pointerStays: true) }
|
||||
return .ready
|
||||
case .issue(let issue) where Self.fixes(issue, in: .check):
|
||||
return await withCheckedContinuation { continuation in
|
||||
startRun(issue, mode: .check, folded: true, waiter: continuation)
|
||||
}
|
||||
case .issue(let issue):
|
||||
return .needs(issue)
|
||||
}
|
||||
}
|
||||
|
||||
/// Around a runtime install: links wait, and an update stops the service first.
|
||||
public func beginRuntimeInstall(replacing found: String?) async -> Result<Void, RuntimeHoldRefusal> {
|
||||
guard acceptsSetupWindow else {
|
||||
if case .settingUp = state { return .failure(.setupRunning) }
|
||||
return .failure(.sessionOpen)
|
||||
}
|
||||
setupLog = nil
|
||||
setupOutput = []
|
||||
transition(to: .settingUp(found.map { .cliOutdated(found: $0) } ?? .cliMissing))
|
||||
setupFolded = true
|
||||
guard found != nil else { return .success(()) }
|
||||
let stopped: String? = await onChain { [backend] in
|
||||
do {
|
||||
try await backend.stopService()
|
||||
return nil
|
||||
} catch {
|
||||
return Self.message(for: error)
|
||||
}
|
||||
}
|
||||
guard let stopped else { return .success(()) }
|
||||
_ = await finishRuntimeInstall(.failed(stopped))
|
||||
return .failure(.stopFailed(stopped))
|
||||
}
|
||||
|
||||
/// After an install, starts the service and builds, like `runSetup(continuing: true)`.
|
||||
public func finishRuntimeInstall(_ result: RuntimeInstallResult) async -> SetupRunOutcome {
|
||||
guard case .settingUp(let issue) = state, Self.isRuntimeInstall(issue), run == nil else {
|
||||
return .failed(.cliMissing, "No runtime install was in progress.")
|
||||
}
|
||||
switch result {
|
||||
case .installed:
|
||||
return await withCheckedContinuation { continuation in
|
||||
startRun(.serviceStopped, mode: .window(continuing: true), folded: false, waiter: continuation)
|
||||
}
|
||||
case .cancelled:
|
||||
endHold(issue, error: nil)
|
||||
return .needs(issue)
|
||||
case .failed(let message):
|
||||
endHold(issue, error: message)
|
||||
return .failed(issue, message)
|
||||
}
|
||||
}
|
||||
|
||||
private func endHold(_ issue: SetupIssue, error: String?) {
|
||||
if pendingURL != nil { transition(to: .setupRequired(issue, error: error)) } else { enterIdle() }
|
||||
}
|
||||
|
||||
private static func isRuntimeInstall(_ issue: SetupIssue) -> Bool {
|
||||
switch issue {
|
||||
case .cliMissing, .cliOutdated: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
public func waitWhileSettingUp() async {
|
||||
guard case .settingUp = state else { return }
|
||||
await withCheckedContinuation { settleWaiters.append($0) }
|
||||
}
|
||||
|
||||
public func runtimeStatus() async -> RuntimeStatus {
|
||||
await backend.runtimeStatus()
|
||||
}
|
||||
|
||||
/// The states where no VM exists. A stuck destroy may still have one.
|
||||
private var acceptsSetupWindow: Bool {
|
||||
switch state {
|
||||
case .idle, .setupRequired, .failed(.start), .failed(.ended), .destroyed: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
/// After `closeBackendChain()` the job never runs and this never returns; that only happens while quitting.
|
||||
private func onChain<T: Sendable>(_ job: @escaping @MainActor () async -> T) async -> T {
|
||||
await withCheckedContinuation { continuation in
|
||||
enqueue { continuation.resume(returning: await job()) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session lifecycle
|
||||
|
||||
private func startSession(_ url: URL, pointerStays: Bool = false) {
|
||||
let pointerOnPanel = pointerStays && pointerInside
|
||||
let startsFolded: Bool
|
||||
if case .settingUp = state { startsFolded = setupFolded } else { startsFolded = false }
|
||||
if let old = current { retire(old) }
|
||||
let session = Session(id: makeSessionID(), url: url)
|
||||
current = session
|
||||
pendingURL = nil
|
||||
setupLog = nil
|
||||
openedURL = url
|
||||
pageURL = nil
|
||||
connection = nil
|
||||
staleImage = nil
|
||||
progress = nil
|
||||
lastProgressAt = nil
|
||||
entered = pointerOnPanel
|
||||
pointerInside = pointerOnPanel
|
||||
focused = false
|
||||
for key in [TimerKey.foldGrace, .neverEnteredFold, .progressHide] { timers.cancel(key) }
|
||||
liveIDs.insert(session.id)
|
||||
transition(to: .starting(.booting, folded: startsFolded))
|
||||
enqueue { [weak self] in await self?.runStart(session) }
|
||||
}
|
||||
|
||||
private func retire(_ session: Session, onGone: (@MainActor (Bool) -> Void)? = nil) {
|
||||
guard !session.retired else { return }
|
||||
session.retired = true
|
||||
for task in session.tasks { task.cancel() }
|
||||
session.connection?.close()
|
||||
session.connection = nil
|
||||
if current === session {
|
||||
connection = nil
|
||||
progress = nil
|
||||
timers.cancel(.progressHide)
|
||||
}
|
||||
enqueue { [weak self] in await self?.runDestroy(session, onGone: onGone) }
|
||||
}
|
||||
|
||||
private func runStart(_ session: Session) async {
|
||||
guard !session.retired else { return }
|
||||
let preflight = await backend.preflight()
|
||||
guard !session.retired else { return }
|
||||
switch preflight {
|
||||
case .issue(let issue):
|
||||
return needsSetup(issue, for: session)
|
||||
case .ready(let appRoot, let stale):
|
||||
let free = await diskSpace.bytesFree(atPath: appRoot)
|
||||
guard !session.retired else { return }
|
||||
if let free, free < Self.minimumFreeBytes {
|
||||
return needsSetup(.lowDisk(bytesFree: free), for: session)
|
||||
}
|
||||
if requestedImageUpdate == nil { staleImage = stale }
|
||||
}
|
||||
|
||||
session.startBegan = true
|
||||
do {
|
||||
let vm = try await backend.start(id: session.id, url: session.url,
|
||||
renderSize: Self.renderSize, blockCIDRs: hostNetworks())
|
||||
guard !session.retired else { return } // Superseded mid-start: its destroy job runs next.
|
||||
transition(to: .starting(.connecting, folded: isFolded))
|
||||
session.tasks.append(Task { [weak self] in await self?.connect(session, socketPath: vm.socketPath) })
|
||||
} catch {
|
||||
guard !session.retired else { return }
|
||||
retire(session)
|
||||
transition(to: .failed(.start(Self.message(for: error))))
|
||||
}
|
||||
}
|
||||
|
||||
/// No VM exists yet, so there is nothing to destroy. Starting the service needs no password, so it just happens.
|
||||
private func needsSetup(_ issue: SetupIssue, for session: Session) {
|
||||
session.retired = true
|
||||
liveIDs.remove(session.id)
|
||||
pendingURL = session.url
|
||||
guard issue == .serviceStopped || issue == .kernelMissing else {
|
||||
return transition(to: .setupRequired(issue, error: nil))
|
||||
}
|
||||
var tried = autoFixed?.url == session.url ? autoFixed?.issues ?? [] : []
|
||||
guard !tried.contains(where: { Self.sameFix($0, issue) }) else {
|
||||
return transition(to: .setupRequired(issue, error: Self.stillBrokenMessage))
|
||||
}
|
||||
tried.append(issue)
|
||||
autoFixed = (session.url, tried)
|
||||
startRun(issue, mode: .link, folded: false)
|
||||
}
|
||||
|
||||
private func connect(_ session: Session, socketPath: String) async {
|
||||
let outcome: Result<any StreamConnection, any Error>
|
||||
do {
|
||||
outcome = .success(try await connector.connect(socketPath: socketPath))
|
||||
} catch {
|
||||
outcome = .failure(error)
|
||||
}
|
||||
guard !session.retired else {
|
||||
if case .success(let late) = outcome { late.close() }
|
||||
return
|
||||
}
|
||||
switch outcome {
|
||||
case .success(let stream):
|
||||
session.connection = stream
|
||||
connection = stream
|
||||
transition(to: .starting(.loading, folded: isFolded))
|
||||
session.tasks.append(Task { [weak self] in
|
||||
for await event in stream.events { self?.handle(event, from: session) }
|
||||
// A stream that finishes without `.ended` has still ended.
|
||||
self?.handle(.ended(.closed), from: session)
|
||||
})
|
||||
case .failure(StreamError.versionMismatch(let found, let need)):
|
||||
retire(session)
|
||||
pendingURL = session.url
|
||||
transition(to: .setupRequired(.imageOutdated(found: found, need: need), error: nil))
|
||||
case .failure(let error):
|
||||
retire(session)
|
||||
transition(to: .failed(.start(Self.message(for: error))))
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ event: StreamEvent, from session: Session) {
|
||||
guard current === session, !session.retired else { return }
|
||||
switch event {
|
||||
case .hello:
|
||||
break
|
||||
case .progress(let percent):
|
||||
progressChanged(percent)
|
||||
case .pageURL(let url):
|
||||
pageURL = url
|
||||
case .contentReady:
|
||||
contentReady(session)
|
||||
case .ended(let reason):
|
||||
let wasStarting = if case .starting = state { true } else { false }
|
||||
retire(session)
|
||||
transition(to: .failed(wasStarting ? .start(Self.message(for: reason)) : .ended(reason)))
|
||||
}
|
||||
}
|
||||
|
||||
private func contentReady(_ session: Session) {
|
||||
guard case .starting(_, let folded) = state, !session.contentReady else { return }
|
||||
session.contentReady = true
|
||||
if folded {
|
||||
transition(to: .live(.collapsed))
|
||||
} else if opensExpanded {
|
||||
transition(to: .live(.expanded))
|
||||
} else {
|
||||
transition(to: .live(.peek))
|
||||
if !entered { timers.set(.neverEnteredFold, after: Self.neverEnteredFoldDelay) }
|
||||
}
|
||||
scheduleProgressHide()
|
||||
}
|
||||
|
||||
private func runDestroy(_ session: Session, onGone: (@MainActor (Bool) -> Void)?) async {
|
||||
guard session.startBegan else {
|
||||
liveIDs.remove(session.id)
|
||||
onGone?(true)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await backend.destroy(id: session.id)
|
||||
pendingCleanup.removeAll { $0 == session.id }
|
||||
liveIDs.remove(session.id)
|
||||
onGone?(true)
|
||||
} catch {
|
||||
if !pendingCleanup.contains(session.id) { pendingCleanup.append(session.id) }
|
||||
onGone?(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func destroyFinished(_ session: Session, gone: Bool) {
|
||||
guard current === session, state == .destroying else { return }
|
||||
transition(to: gone ? .destroyed : .failed(.destroyStuck))
|
||||
}
|
||||
|
||||
private func enterIdle() {
|
||||
openedURL = nil
|
||||
pageURL = nil
|
||||
progress = nil
|
||||
staleImage = nil
|
||||
setupLog = nil
|
||||
connection = nil
|
||||
entered = false
|
||||
pointerInside = false
|
||||
focused = false
|
||||
if let update = requestedImageUpdate {
|
||||
requestedImageUpdate = nil
|
||||
return startRun(.imageStale(builtDaysAgo: update.builtDaysAgo), mode: .link, folded: false)
|
||||
}
|
||||
transition(to: .idle)
|
||||
}
|
||||
|
||||
// MARK: - Progress bar
|
||||
|
||||
private func progressChanged(_ percent: Int) {
|
||||
lastProgressAt = clock.now
|
||||
if percent >= 100 {
|
||||
progress = nil
|
||||
timers.cancel(.progressHide)
|
||||
} else {
|
||||
progress = percent
|
||||
scheduleProgressHide()
|
||||
}
|
||||
}
|
||||
|
||||
/// Hides the bar 2 s after the last PROGRESS, so a load that never reports 100 doesn't leave it up.
|
||||
private func scheduleProgressHide() {
|
||||
guard current?.contentReady == true, progress != nil, let last = lastProgressAt else { return }
|
||||
let hideAt = last + Self.progressLinger
|
||||
if hideAt <= clock.now {
|
||||
progress = nil
|
||||
} else {
|
||||
timers.set(.progressHide, at: hideAt)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Setup actions
|
||||
|
||||
private func runSetupAction(_ issue: SetupIssue, folded: Bool = false) {
|
||||
setupLog = nil
|
||||
setupOutput = []
|
||||
transition(to: .settingUp(issue))
|
||||
if folded { setupFolded = true }
|
||||
let (lines, sink) = AsyncStream.makeStream(of: String.self)
|
||||
let showLines = Task { [weak self] in
|
||||
for await line in lines { self?.record(line) }
|
||||
}
|
||||
enqueue { [weak self, backend] in
|
||||
let outcome: Result<Void, any Error>
|
||||
do {
|
||||
try await backend.fix(issue) { sink.yield($0) }
|
||||
outcome = .success(())
|
||||
} catch {
|
||||
outcome = .failure(error)
|
||||
}
|
||||
sink.finish()
|
||||
await showLines.value // Every line is shown before the outcome.
|
||||
await self?.setupFinished(issue, outcome)
|
||||
}
|
||||
}
|
||||
|
||||
private func record(_ line: String) {
|
||||
setupLog = line
|
||||
setupOutput.append(line)
|
||||
if setupOutput.count > Self.setupOutputLimit {
|
||||
setupOutput.removeFirst(setupOutput.count - Self.setupOutputLimit)
|
||||
}
|
||||
}
|
||||
|
||||
private func startRun(_ issue: SetupIssue, mode: RunMode, folded: Bool,
|
||||
waiter: CheckedContinuation<SetupRunOutcome, Never>? = nil) {
|
||||
if run != nil { endRun(.needs(issue)) } // Never happens today; it keeps "every waiter resumes once" true.
|
||||
run = SetupRun(mode: mode, fixed: [issue], waiter: waiter)
|
||||
runSetupAction(issue, folded: folded)
|
||||
}
|
||||
|
||||
private func endRun(_ outcome: SetupRunOutcome) {
|
||||
let waiter = run?.waiter
|
||||
run = nil
|
||||
waiter?.resume(returning: outcome)
|
||||
}
|
||||
|
||||
/// Link runs leave a card, as before; window runs leave one only for a waiting link.
|
||||
private func stop(_ outcome: SetupRunOutcome, card: SessionState, mode: RunMode) {
|
||||
endRun(outcome)
|
||||
if mode == .link || pendingURL != nil { transition(to: card) } else { enterIdle() }
|
||||
}
|
||||
|
||||
private static func fixes(_ issue: SetupIssue, in mode: RunMode) -> Bool {
|
||||
switch (mode, issue) {
|
||||
case (.window(continuing: true), .serviceStopped), (.window(continuing: true), .kernelMissing),
|
||||
(.window(continuing: true), .imageMissing), (.window(continuing: true), .imageOutdated),
|
||||
(.check, .serviceStopped), (.check, .kernelMissing):
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend runs one command for each group, so another issue from the same group means it didn't help.
|
||||
private static func sameFix(_ a: SetupIssue, _ b: SetupIssue) -> Bool {
|
||||
func group(_ issue: SetupIssue) -> Int? {
|
||||
switch issue {
|
||||
case .serviceStopped, .kernelMissing: 0
|
||||
case .imageMissing, .imageOutdated, .imageStale: 1
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
guard let first = group(a) else { return a == b }
|
||||
return first == group(b)
|
||||
}
|
||||
|
||||
private func setupFinished(_ issue: SetupIssue, _ outcome: Result<Void, any Error>) async {
|
||||
guard state == .settingUp(issue) else { return }
|
||||
let mode = run?.mode ?? .link
|
||||
if case .failure(let error) = outcome {
|
||||
let message = Self.message(for: error)
|
||||
// A failed "Update now" rebuild never blocks a link.
|
||||
if case .imageStale = issue, let url = pendingURL {
|
||||
endRun(.failed(issue, message))
|
||||
return startSession(url, pointerStays: true)
|
||||
}
|
||||
return stop(.failed(issue, message), card: .setupRequired(issue, error: message), mode: mode)
|
||||
}
|
||||
switch issue {
|
||||
case .imageMissing, .imageOutdated, .imageStale: requestedImageUpdate = nil
|
||||
default: break
|
||||
}
|
||||
if mode == .link, let url = pendingURL {
|
||||
endRun(.ready)
|
||||
return startSession(url, pointerStays: true)
|
||||
}
|
||||
let preflight = await backend.preflight()
|
||||
guard state == .settingUp(issue) else { return }
|
||||
switch preflight {
|
||||
case .ready:
|
||||
endRun(.ready)
|
||||
if let url = pendingURL { return startSession(url, pointerStays: true) }
|
||||
enterIdle()
|
||||
case .issue(let next) where mode == .link:
|
||||
endRun(.needs(next))
|
||||
if let url = pendingURL { return startSession(url, pointerStays: true) }
|
||||
transition(to: .setupRequired(next, error: nil))
|
||||
case .issue(let next) where run?.fixed.contains(where: { Self.sameFix($0, next) }) == true:
|
||||
stop(.failed(next, Self.stillBrokenMessage), card: .setupRequired(next, error: Self.stillBrokenMessage), mode: mode)
|
||||
case .issue(let next) where Self.fixes(next, in: mode):
|
||||
run?.fixed.append(next)
|
||||
runSetupAction(next)
|
||||
case .issue(let next):
|
||||
stop(.needs(next), card: .setupRequired(next, error: nil), mode: mode)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Backend chain
|
||||
|
||||
/// After each job, one VM left by an earlier failed destroy gets a retry, taking turns.
|
||||
private func enqueue(_ job: @escaping @MainActor () async -> Void) {
|
||||
chain.enqueue { [weak self] in
|
||||
let stuckBefore = self?.pendingCleanup ?? []
|
||||
await job()
|
||||
await self?.retryPendingCleanup(among: stuckBefore)
|
||||
}
|
||||
}
|
||||
|
||||
private func retryPendingCleanup(among candidates: [String]) async {
|
||||
guard let id = candidates.first(where: pendingCleanup.contains) else { return }
|
||||
do {
|
||||
try await backend.forceDestroy(id: id)
|
||||
pendingCleanup.removeAll { $0 == id }
|
||||
liveIDs.remove(id)
|
||||
} catch {
|
||||
// Move it to the back so a VM stuck for good doesn't starve the others.
|
||||
pendingCleanup.removeAll { $0 == id }
|
||||
pendingCleanup.append(id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State and timers
|
||||
|
||||
private func transition(to newState: SessionState) {
|
||||
let oldState = state
|
||||
guard newState != oldState else { return }
|
||||
state = newState
|
||||
if case .settingUp = newState {} else {
|
||||
setupFolded = false
|
||||
setupOpenedByHover = false
|
||||
}
|
||||
|
||||
if !isPeekLike { timers.cancel(.foldGrace) }
|
||||
if newState != .live(.peek) { timers.cancel(.neverEnteredFold) }
|
||||
|
||||
if newState == .live(.collapsed) {
|
||||
timers.set(.idleDestroy, after: idleDestroyDelay)
|
||||
idleDeadline = timers.deadline(of: .idleDestroy)
|
||||
connection?.decodeEnabled = false
|
||||
} else if oldState == .live(.collapsed) {
|
||||
timers.cancel(.idleDestroy)
|
||||
idleDeadline = nil
|
||||
if case .live = newState { connection?.decodeEnabled = true }
|
||||
}
|
||||
|
||||
switch newState {
|
||||
case .destroyed: timers.set(.transient, after: Self.destroyedDisplay)
|
||||
case .failed(.ended): timers.set(.transient, after: Self.endedDisplay)
|
||||
case .failed(.destroyStuck): timers.set(.transient, after: Self.destroyStuckDisplay)
|
||||
default: timers.cancel(.transient)
|
||||
}
|
||||
|
||||
if newState == .idle {
|
||||
timers.set(.idleQuit, after: Self.idleQuitDelay)
|
||||
} else {
|
||||
timers.cancel(.idleQuit)
|
||||
}
|
||||
|
||||
if case .settingUp(let issue) = oldState {
|
||||
if case .settingUp = newState {} else { settled(after: issue) }
|
||||
}
|
||||
}
|
||||
|
||||
/// So a waiting setup window never hangs, whatever ended the setup.
|
||||
private func settled(after issue: SetupIssue) {
|
||||
if run != nil { endRun(.needs(issue)) }
|
||||
let waiters = settleWaiters
|
||||
settleWaiters = []
|
||||
for waiter in waiters { waiter.resume() }
|
||||
}
|
||||
|
||||
private func show(_ newNotice: Notice) {
|
||||
notice = newNotice
|
||||
timers.set(.notice, after: Self.noticeDisplay)
|
||||
}
|
||||
|
||||
private func timerFired(_ key: TimerKey) {
|
||||
switch key {
|
||||
case .foldGrace:
|
||||
if isPeekLike, entered, !pointerInside, !focused { fold() }
|
||||
if case .settingUp = state, setupOpenedByHover, !pointerInside {
|
||||
setupOpenedByHover = false
|
||||
setupFolded = true
|
||||
}
|
||||
case .neverEnteredFold:
|
||||
if state == .live(.peek), !entered { fold() }
|
||||
case .idleDestroy:
|
||||
destroy()
|
||||
case .transient:
|
||||
enterIdle()
|
||||
case .notice:
|
||||
notice = nil
|
||||
case .progressHide:
|
||||
progress = nil
|
||||
case .idleQuit:
|
||||
if quitsWhenIdle { requestQuit() }
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func message(for error: any Error) -> String {
|
||||
switch error {
|
||||
case VMBackendError.failed(let message): message
|
||||
case StreamError.protocolViolation(let detail): "The VM sent a malformed message: \(detail)."
|
||||
case StreamError.timedOut: "The VM didn't answer in time."
|
||||
case StreamError.refused: "Couldn't connect to the VM."
|
||||
default: String(describing: error)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func message(for reason: EndReason) -> String {
|
||||
switch reason {
|
||||
case .closed: "The VM closed the connection."
|
||||
case .protocolViolation(let detail): "The VM sent a malformed message: \(detail)."
|
||||
case .noFirstFrame: "The VM didn't send a first frame in time."
|
||||
case .decodeErrors: "The video stream couldn't be decoded."
|
||||
case .socketError(let detail): "The connection to the VM failed: \(detail)."
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test hooks
|
||||
|
||||
func fireDueTimers() {
|
||||
timers.fireDue()
|
||||
}
|
||||
|
||||
func waitForBackendIdle() async {
|
||||
await chain.waitUntilIdle()
|
||||
}
|
||||
|
||||
/// Call once the session is retired: a live session's events task runs until its stream ends.
|
||||
func waitForSessionTasks() async {
|
||||
for task in current?.tasks ?? [] { await task.value }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
public enum SessionState: Equatable, Sendable {
|
||||
case idle
|
||||
case starting(StartStep, folded: Bool)
|
||||
case live(LiveMode)
|
||||
case destroying
|
||||
case destroyed
|
||||
case failed(SessionFailure)
|
||||
case setupRequired(SetupIssue, error: String?)
|
||||
case settingUp(SetupIssue)
|
||||
}
|
||||
|
||||
public enum StartStep: Sendable {
|
||||
case booting
|
||||
/// Waiting for HELLO.
|
||||
case connecting
|
||||
/// Waiting for `ContentReadiness`.
|
||||
case loading
|
||||
}
|
||||
|
||||
public enum LiveMode: Sendable {
|
||||
case peek
|
||||
case expanded
|
||||
case collapsed
|
||||
}
|
||||
|
||||
public enum SessionFailure: Equatable, Sendable {
|
||||
case start(String)
|
||||
case ended(EndReason)
|
||||
/// The VM stays on the pending-cleanup list.
|
||||
case destroyStuck
|
||||
}
|
||||
|
||||
/// A transient pill, shown whatever the state.
|
||||
public enum Notice: Equatable, Sendable {
|
||||
case noWebLink
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/// How a setup run started by the setup window ended.
|
||||
public enum SetupRunOutcome: Equatable, Sendable {
|
||||
/// Preflight is ready; a waiting link may have started.
|
||||
case ready
|
||||
/// Stopped at an issue this kind of run doesn't fix.
|
||||
case needs(SetupIssue)
|
||||
case failed(SetupIssue, String)
|
||||
}
|
||||
|
||||
public enum RuntimeHoldRefusal: Error, Equatable, Sendable {
|
||||
case sessionOpen
|
||||
case setupRunning
|
||||
case stopFailed(String)
|
||||
}
|
||||
|
||||
public enum RuntimeInstallResult: Equatable, Sendable {
|
||||
case installed
|
||||
case cancelled
|
||||
case failed(String)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
/// Apple's container runtime: the release Knotchy installs, and how an installed one is judged.
|
||||
public enum ContainerRuntime {
|
||||
public struct Release: Equatable, Sendable {
|
||||
public let version: String
|
||||
public let url: URL
|
||||
public let sha256: String
|
||||
public let size: Int64
|
||||
|
||||
public init(version: String, url: URL, sha256: String, size: Int64) {
|
||||
self.version = version
|
||||
self.url = url
|
||||
self.sha256 = sha256
|
||||
self.size = size
|
||||
}
|
||||
}
|
||||
|
||||
/// The only package Knotchy ever installs. Bump it only after testing a new release.
|
||||
public static let pinned = Release(
|
||||
version: "1.4.1",
|
||||
url: URL(string: "https://github.com/apple/container/releases/download/1.4.1/container-1.4.1-installer-signed.pkg")!,
|
||||
sha256: "c0d2716afefbb194c93fae662e9cae7cc186bcbcf746816608ec673dd648a6a4",
|
||||
size: 117_773_865)
|
||||
public static let minimumVersion = "1.4.0"
|
||||
|
||||
public static func version(in output: String) -> String? {
|
||||
output.firstMatch(of: #/container CLI version (\d+(?:\.\d+)+)/#).map { String($0.1) }
|
||||
}
|
||||
|
||||
/// Output Knotchy can't read counts as current, so a new format can never cause a downgrade.
|
||||
public static func status(versionOutput: String?) -> RuntimeStatus {
|
||||
guard let versionOutput else { return .missing }
|
||||
guard let found = version(in: versionOutput) else { return .current(nil) }
|
||||
return isOlder(found, than: minimumVersion) ? .outdated(found) : .current(found)
|
||||
}
|
||||
|
||||
public static func isOlder(_ version: String, than other: String) -> Bool {
|
||||
let lhs = version.split(separator: ".").map { Int($0) ?? 0 }
|
||||
let rhs = other.split(separator: ".").map { Int($0) ?? 0 }
|
||||
for index in 0..<max(lhs.count, rhs.count) {
|
||||
let a = index < lhs.count ? lhs[index] : 0
|
||||
let b = index < rhs.count ? rhs[index] : 0
|
||||
if a != b { return a < b }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public enum RuntimeStatus: Equatable, Sendable {
|
||||
case missing
|
||||
case outdated(String)
|
||||
/// nil: installed, but its version couldn't be read.
|
||||
case current(String?)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
/// Knotchy is built for Apple silicon only; macOS itself refuses to open it on an Intel Mac.
|
||||
public enum Unsupported: Equatable, Sendable {
|
||||
case noVirtualization
|
||||
}
|
||||
|
||||
public struct HostFacts: Equatable, Sendable {
|
||||
public var unsupported: Unsupported?
|
||||
public var osVersion: String
|
||||
public var bytesFree: Int64?
|
||||
|
||||
public init(unsupported: Unsupported?, osVersion: String, bytesFree: Int64?) {
|
||||
self.unsupported = unsupported
|
||||
self.osVersion = osVersion
|
||||
self.bytesFree = bytesFree
|
||||
}
|
||||
}
|
||||
|
||||
public protocol HostChecking: Sendable {
|
||||
func facts() async -> HostFacts
|
||||
}
|
||||
|
||||
public struct SystemHost: HostChecking {
|
||||
public init() {}
|
||||
|
||||
/// Synchronous, so the app can decide on minimal mode before it registers anything.
|
||||
@MainActor
|
||||
public static func unsupported() -> Unsupported? {
|
||||
VZVirtualMachine.isSupported ? nil : .noVirtualization
|
||||
}
|
||||
|
||||
public func facts() async -> HostFacts {
|
||||
let unsupported = await Self.unsupported()
|
||||
let version = ProcessInfo.processInfo.operatingSystemVersion
|
||||
var os = "macOS \(version.majorVersion).\(version.minorVersion)"
|
||||
if version.patchVersion > 0 { os += ".\(version.patchVersion)" }
|
||||
// The runtime keeps its data under the home folder.
|
||||
let values = try? URL(fileURLWithPath: NSHomeDirectory())
|
||||
.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
|
||||
return HostFacts(unsupported: unsupported, osVersion: os, bytesFree: values?.volumeAvailableCapacityForImportantUsage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import AppKit
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
public struct DownloadedPackage: Equatable, Sendable {
|
||||
public let directory: URL
|
||||
/// Apple's own file name, which Installer shows.
|
||||
public let name: String
|
||||
public var file: URL { directory.appendingPathComponent(name) }
|
||||
|
||||
public init(directory: URL, name: String) {
|
||||
self.directory = directory
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
public enum DownloadError: Error, Equatable, Sendable {
|
||||
case failed(String)
|
||||
/// Size or SHA-256 differ from the pinned release; the download is already deleted.
|
||||
case mismatch
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public protocol InstallerSession: AnyObject {
|
||||
var hasQuit: Bool { get }
|
||||
}
|
||||
|
||||
public enum InstallerOpening {
|
||||
case opened(any InstallerSession)
|
||||
/// Size or SHA-256 no longer match; the download is already deleted.
|
||||
case changed
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
public protocol RuntimeInstalling: Sendable {
|
||||
func download(_ release: ContainerRuntime.Release,
|
||||
progress: @escaping @Sendable (Int64) -> Void) async throws -> DownloadedPackage
|
||||
/// Re-checks the size and hash off the main thread, then opens the package in Installer.
|
||||
@MainActor func openInInstaller(_ package: DownloadedPackage, release: ContainerRuntime.Release) async -> InstallerOpening
|
||||
func discard(_ package: DownloadedPackage)
|
||||
}
|
||||
|
||||
public struct SystemRuntimeInstaller: RuntimeInstalling {
|
||||
static let installerApp = URL(fileURLWithPath: "/System/Library/CoreServices/Installer.app")
|
||||
|
||||
public init() {}
|
||||
|
||||
public func download(_ release: ContainerRuntime.Release,
|
||||
progress: @escaping @Sendable (Int64) -> Void) async throws -> DownloadedPackage {
|
||||
Self.removeLeftovers()
|
||||
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("knotchy-runtime-\(UUID().uuidString)", isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false,
|
||||
attributes: [.posixPermissions: 0o700])
|
||||
} catch {
|
||||
throw DownloadError.failed(error.localizedDescription)
|
||||
}
|
||||
let package = DownloadedPackage(directory: directory, name: release.url.lastPathComponent)
|
||||
do {
|
||||
try await PackageDownload.fetch(release.url, to: package.file, progress: progress)
|
||||
} catch {
|
||||
discard(package)
|
||||
throw DownloadError.failed(error.localizedDescription)
|
||||
}
|
||||
guard Self.matches(package.file, release) else {
|
||||
discard(package)
|
||||
throw DownloadError.mismatch
|
||||
}
|
||||
return package
|
||||
}
|
||||
|
||||
public func discard(_ package: DownloadedPackage) {
|
||||
try? FileManager.default.removeItem(at: package.directory)
|
||||
}
|
||||
|
||||
/// Folders left by quitting mid-download or a crash.
|
||||
public static func removeLeftovers() {
|
||||
let temporary = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
let names = (try? FileManager.default.contentsOfDirectory(atPath: temporary.path)) ?? []
|
||||
for name in names where name.hasPrefix("knotchy-runtime-") {
|
||||
try? FileManager.default.removeItem(at: temporary.appendingPathComponent(name))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func openInInstaller(_ package: DownloadedPackage, release: ContainerRuntime.Release) async -> InstallerOpening {
|
||||
let file = package.file
|
||||
guard await Task.detached(operation: { Self.matches(file, release) }).value else {
|
||||
discard(package)
|
||||
return .changed
|
||||
}
|
||||
let configuration = NSWorkspace.OpenConfiguration()
|
||||
configuration.activates = true
|
||||
do {
|
||||
let app = try await NSWorkspace.shared.open([file], withApplicationAt: Self.installerApp,
|
||||
configuration: configuration)
|
||||
return .opened(RunningInstaller(app: app))
|
||||
} catch {
|
||||
return .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
static func matches(_ file: URL, _ release: ContainerRuntime.Release) -> Bool {
|
||||
guard let size = (try? FileManager.default.attributesOfItem(atPath: file.path))?[.size] as? NSNumber,
|
||||
size.int64Value == release.size,
|
||||
let handle = try? FileHandle(forReadingFrom: file) else { return false }
|
||||
defer { try? handle.close() }
|
||||
var hash = SHA256()
|
||||
while let chunk = try? handle.read(upToCount: 1 << 20), !chunk.isEmpty { hash.update(data: chunk) }
|
||||
return hash.finalize().map { String(format: "%02x", $0) }.joined() == release.sha256
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class RunningInstaller: InstallerSession {
|
||||
let app: NSRunningApplication
|
||||
|
||||
init(app: NSRunningApplication) {
|
||||
self.app = app
|
||||
}
|
||||
|
||||
var hasQuit: Bool { app.isTerminated }
|
||||
}
|
||||
|
||||
/// A data task streamed into the package file, so the partial download lives only in its own folder.
|
||||
private final class PackageDownload: NSObject, URLSessionDataDelegate, @unchecked Sendable {
|
||||
// Touched only on the session's serial delegate queue.
|
||||
private var continuation: CheckedContinuation<Void, any Error>?
|
||||
/// A completion that arrived before anyone waited: a task cancelled before it started.
|
||||
private var outcome: Result<Void, any Error>?
|
||||
private var handle: FileHandle?
|
||||
private var written: Int64 = 0
|
||||
private var writeError: (any Error)?
|
||||
private let destination: URL
|
||||
private let progress: @Sendable (Int64) -> Void
|
||||
|
||||
private init(destination: URL, progress: @escaping @Sendable (Int64) -> Void) {
|
||||
self.destination = destination
|
||||
self.progress = progress
|
||||
}
|
||||
|
||||
static func fetch(_ url: URL, to destination: URL, progress: @escaping @Sendable (Int64) -> Void) async throws {
|
||||
let delegate = PackageDownload(destination: destination, progress: progress)
|
||||
let queue = OperationQueue()
|
||||
queue.maxConcurrentOperationCount = 1
|
||||
let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: queue)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
// Created up front: cancelling before it exists would invalidate the session, and creating it then throws.
|
||||
let task = session.dataTask(with: url)
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
queue.addOperation {
|
||||
if let outcome = delegate.outcome {
|
||||
continuation.resume(with: outcome)
|
||||
} else {
|
||||
delegate.continuation = continuation
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
|
||||
completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void) {
|
||||
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
||||
writeError = URLError(.badServerResponse)
|
||||
return completionHandler(.cancel)
|
||||
}
|
||||
do {
|
||||
guard FileManager.default.createFile(atPath: destination.path, contents: nil) else {
|
||||
throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: destination.path])
|
||||
}
|
||||
handle = try FileHandle(forWritingTo: destination)
|
||||
} catch {
|
||||
writeError = error
|
||||
return completionHandler(.cancel)
|
||||
}
|
||||
completionHandler(.allow)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
guard let handle, writeError == nil else { return }
|
||||
do {
|
||||
try handle.write(contentsOf: data)
|
||||
written += Int64(data.count)
|
||||
progress(written)
|
||||
} catch {
|
||||
writeError = error
|
||||
dataTask.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) {
|
||||
try? handle?.close()
|
||||
handle = nil
|
||||
// Ours comes first: cancelling after it adds a "cancelled" error.
|
||||
let result: Result<Void, any Error> = if let failure = writeError ?? error { .failure(failure) } else { .success(()) }
|
||||
if let waiting = continuation {
|
||||
continuation = nil
|
||||
waiting.resume(with: result)
|
||||
} else {
|
||||
outcome = result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/// What the setup flow needs from the session controller.
|
||||
@MainActor
|
||||
public protocol SetupDriving: AnyObject {
|
||||
var state: SessionState { get }
|
||||
var setupLog: String? { get }
|
||||
var setupOutput: [String] { get }
|
||||
func runtimeStatus() async -> RuntimeStatus
|
||||
func checkSetup() async -> SetupRunOutcome?
|
||||
func runSetup(_ issue: SetupIssue, continuing: Bool) async -> SetupRunOutcome?
|
||||
func beginRuntimeInstall(replacing found: String?) async -> Result<Void, RuntimeHoldRefusal>
|
||||
func finishRuntimeInstall(_ result: RuntimeInstallResult) async -> SetupRunOutcome
|
||||
func waitWhileSettingUp() async
|
||||
}
|
||||
|
||||
extension SessionController: SetupDriving {}
|
||||
@@ -0,0 +1,360 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// The setup window's rows and the order of its steps. The window binds only to this.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class SetupFlow {
|
||||
public enum Status: Equatable, Sendable {
|
||||
case pending(String)
|
||||
case running(String, fraction: Double?)
|
||||
case done(String)
|
||||
case failed(String, details: [String])
|
||||
}
|
||||
|
||||
public enum RuntimeAction: Equatable, Sendable {
|
||||
case install
|
||||
case update
|
||||
}
|
||||
|
||||
public static let requiredFreeBytes: Int64 = 3_000_000_000
|
||||
static let vmDetail = "About 3 minutes · downloads about 225 MB"
|
||||
|
||||
public private(set) var mac: Status = .pending("Checking…")
|
||||
public private(set) var unsupported: Unsupported?
|
||||
public private(set) var runtimeAction: RuntimeAction?
|
||||
public private(set) var isRunning = false
|
||||
/// Quitting now leaves a partial download behind.
|
||||
@ObservationIgnored public private(set) var isDownloading = false
|
||||
private var runtimeBase: Status = .pending("")
|
||||
private var vmBase: Status = .pending(SetupFlow.vmDetail)
|
||||
|
||||
@ObservationIgnored private let host: any HostChecking
|
||||
@ObservationIgnored private let installer: any RuntimeInstalling
|
||||
@ObservationIgnored private let driver: any SetupDriving
|
||||
@ObservationIgnored private let release: ContainerRuntime.Release
|
||||
@ObservationIgnored private let retryDelay: Duration
|
||||
@ObservationIgnored private var facts: HostFacts?
|
||||
@ObservationIgnored private var runtimeStatus: RuntimeStatus?
|
||||
|
||||
public init(host: any HostChecking, installer: any RuntimeInstalling, driver: any SetupDriving,
|
||||
release: ContainerRuntime.Release = ContainerRuntime.pinned, retryDelay: Duration = .milliseconds(500)) {
|
||||
self.host = host
|
||||
self.installer = installer
|
||||
self.driver = driver
|
||||
self.release = release
|
||||
self.retryDelay = retryDelay
|
||||
runtimeBase = .pending(Self.installDetail(release))
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
/// With the controller's live progress on top, so a setup the notch started shows here too.
|
||||
public var runtime: Status {
|
||||
if case .settingUp(let issue) = driver.state, issue == .serviceStopped || issue == .kernelMissing {
|
||||
return .running("Starting the runtime…", fraction: nil)
|
||||
}
|
||||
return runtimeBase
|
||||
}
|
||||
|
||||
public var vm: Status {
|
||||
if case .settingUp(let issue) = driver.state, Self.isImageIssue(issue) {
|
||||
return .running(driver.setupLog ?? "Starting the build…", fraction: nil)
|
||||
}
|
||||
return vmBase
|
||||
}
|
||||
|
||||
public var allRequiredDone: Bool {
|
||||
if case .done = mac, case .done = runtime, case .done = vm { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
public var canSetUp: Bool {
|
||||
guard !isRunning, unsupported == nil, !allRequiredDone else { return false }
|
||||
if case .failed = mac { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@ObservationIgnored private var work: Task<Void, Never>?
|
||||
|
||||
/// For the Set Up and Try Again buttons: runs `setUp()` where quitting can cancel it.
|
||||
public func start() {
|
||||
guard !isRunning else { return }
|
||||
work = Task { await setUp() }
|
||||
}
|
||||
|
||||
/// Quitting: stops a download. An open Installer is left to finish.
|
||||
public func cancel() {
|
||||
work?.cancel()
|
||||
}
|
||||
|
||||
/// Re-checks everything. Starts a stopped service, as a link would, but never installs or builds.
|
||||
public func refresh() async {
|
||||
guard !isRunning else { return }
|
||||
isRunning = true
|
||||
defer { isRunning = false }
|
||||
await inspect()
|
||||
}
|
||||
|
||||
/// The Set Up and Try Again buttons: runs whatever is left, in order.
|
||||
public func setUp() async {
|
||||
guard !isRunning else { return }
|
||||
isRunning = true
|
||||
defer { isRunning = false }
|
||||
await inspect()
|
||||
guard unsupported == nil else { return }
|
||||
// The service didn't start: building would only try again.
|
||||
if case .failed = runtimeBase { return }
|
||||
if case .failed = mac { return }
|
||||
if let action = runtimeAction {
|
||||
await installRuntime(action)
|
||||
} else if case .done = vmBase {
|
||||
return
|
||||
} else {
|
||||
await buildVM()
|
||||
}
|
||||
updateMac()
|
||||
}
|
||||
|
||||
// MARK: - Steps
|
||||
|
||||
private func inspect() async {
|
||||
let facts = await host.facts()
|
||||
self.facts = facts
|
||||
unsupported = facts.unsupported
|
||||
guard unsupported == nil else { return }
|
||||
let status = await driver.runtimeStatus()
|
||||
runtimeStatus = status
|
||||
switch status {
|
||||
case .missing:
|
||||
runtimeAction = .install
|
||||
runtimeBase = .pending(Self.installDetail(release))
|
||||
vmBase = .pending(Self.vmDetail)
|
||||
case .outdated(let found):
|
||||
runtimeAction = .update
|
||||
runtimeBase = .pending("container \(found) is too old · updates to \(release.version) with Apple's Installer")
|
||||
vmBase = .pending(Self.vmDetail)
|
||||
case .current(let version):
|
||||
runtimeAction = nil
|
||||
runtimeBase = .done(Self.installedText(version))
|
||||
switch await driver.checkSetup() {
|
||||
case .ready?: vmBase = .done("Ready")
|
||||
case .needs(let issue)? where Self.isImageIssue(issue): vmBase = .pending(Self.vmDetail)
|
||||
case .failed(let issue, let message)?: fail(issue, message)
|
||||
case nil where Self.isBrowserOpen(driver.state): vmBase = .done("Ready") // A running VM has an image.
|
||||
case .needs?, nil: break // Busy, or the runtime row's business: the live rows show it.
|
||||
}
|
||||
}
|
||||
updateMac()
|
||||
}
|
||||
|
||||
private func installRuntime(_ action: RuntimeAction) async {
|
||||
switch driver.state {
|
||||
case .starting, .live:
|
||||
runtimeBase = .failed("Close the Knotchy browser first.", details: [])
|
||||
return
|
||||
case .destroying, .failed(.destroyStuck):
|
||||
runtimeBase = .failed("Wait for the Knotchy browser to finish closing, then try again.", details: [])
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
runtimeBase = .running(Self.downloadText(0, of: release.size), fraction: 0)
|
||||
let package: DownloadedPackage
|
||||
do {
|
||||
package = try await download()
|
||||
} catch DownloadError.mismatch {
|
||||
runtimeBase = .failed("The download didn't match the expected package, so it wasn't installed.", details: [])
|
||||
return
|
||||
} catch {
|
||||
runtimeBase = .failed("Couldn't download the runtime. Check your connection and try again.",
|
||||
details: [String(describing: error)])
|
||||
return
|
||||
}
|
||||
let found: String? = if case .outdated(let version) = runtimeStatus { version } else { nil }
|
||||
let hold = await driver.beginRuntimeInstall(replacing: found)
|
||||
if case .failure = hold { installer.discard(package) }
|
||||
switch hold {
|
||||
case .success:
|
||||
break
|
||||
case .failure(.sessionOpen):
|
||||
runtimeBase = .failed("Close the Knotchy browser first.", details: [])
|
||||
return
|
||||
case .failure(.setupRunning):
|
||||
runtimeBase = .failed("Wait for the current setup step to finish, then try again.", details: [])
|
||||
return
|
||||
case .failure(.stopFailed(let message)):
|
||||
runtimeBase = .failed("Couldn't stop the runtime for the update.", details: [message])
|
||||
return
|
||||
}
|
||||
|
||||
runtimeBase = .running("Opening Installer…", fraction: nil)
|
||||
let session: any InstallerSession
|
||||
switch await installer.openInInstaller(package, release: release) {
|
||||
case .opened(let opened):
|
||||
session = opened
|
||||
case .changed:
|
||||
return await failInstall("The download changed after it was checked, so it wasn't opened.", details: [])
|
||||
case .failed(let message):
|
||||
installer.discard(package)
|
||||
return await failInstall("Couldn't open Installer.", details: [message])
|
||||
}
|
||||
|
||||
runtimeBase = .running("Finish the install in Installer…", fraction: nil)
|
||||
let status = await waitForInstall(session)
|
||||
// Installer reads the package until it quits.
|
||||
Task { [installer] in
|
||||
while !session.hasQuit { try? await Task.sleep(for: .seconds(2)) }
|
||||
installer.discard(package)
|
||||
}
|
||||
// Quitting: Installer carries on by itself.
|
||||
if Task.isCancelled { return }
|
||||
guard case .current(let version)? = status else {
|
||||
_ = await driver.finishRuntimeInstall(.cancelled)
|
||||
runtimeBase = .pending("Installing was cancelled.")
|
||||
return
|
||||
}
|
||||
runtimeStatus = status
|
||||
runtimeAction = nil
|
||||
runtimeBase = .done(Self.installedText(version))
|
||||
apply(await driver.finishRuntimeInstall(.installed))
|
||||
}
|
||||
|
||||
/// The current runtime once it appears, or nil when Installer quits without it.
|
||||
private func waitForInstall(_ session: any InstallerSession) async -> RuntimeStatus? {
|
||||
while !Task.isCancelled {
|
||||
let status = await driver.runtimeStatus()
|
||||
if case .current = status { return status }
|
||||
if session.hasQuit {
|
||||
// Installer may have finished between the two checks.
|
||||
let last = await driver.runtimeStatus()
|
||||
if case .current = last { return last }
|
||||
return nil
|
||||
}
|
||||
try? await Task.sleep(for: retryDelay)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func download() async throws -> DownloadedPackage {
|
||||
isDownloading = true
|
||||
defer { isDownloading = false }
|
||||
return try await installer.download(release) { [weak self, release] bytes in
|
||||
Task { @MainActor in self?.downloaded(bytes, of: release.size) }
|
||||
}
|
||||
}
|
||||
|
||||
private func failInstall(_ message: String, details: [String]) async {
|
||||
_ = await driver.finishRuntimeInstall(.failed(message))
|
||||
runtimeBase = .failed(message, details: details)
|
||||
}
|
||||
|
||||
private func buildVM() async {
|
||||
// A setup the notch started, or a session winding down, gets up to a minute to finish.
|
||||
for _ in 0..<120 {
|
||||
if Task.isCancelled { return }
|
||||
guard let checked = await driver.checkSetup() else {
|
||||
if case .settingUp = driver.state {
|
||||
await driver.waitWhileSettingUp()
|
||||
} else {
|
||||
try? await Task.sleep(for: retryDelay)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch checked {
|
||||
case .needs(let issue) where Self.isImageIssue(issue):
|
||||
guard let result = await driver.runSetup(issue, continuing: true) else { continue }
|
||||
return apply(result)
|
||||
case .needs:
|
||||
return await inspect()
|
||||
default:
|
||||
return apply(checked)
|
||||
}
|
||||
}
|
||||
vmBase = .failed("Close the Knotchy browser first, then try again.", details: [])
|
||||
}
|
||||
|
||||
private func apply(_ outcome: SetupRunOutcome) {
|
||||
switch outcome {
|
||||
case .ready:
|
||||
vmBase = .done("Ready")
|
||||
case .needs(let issue):
|
||||
if Self.isImageIssue(issue) { vmBase = .pending(Self.vmDetail) }
|
||||
case .failed(let issue, let message):
|
||||
fail(issue, message)
|
||||
}
|
||||
}
|
||||
|
||||
private func fail(_ issue: SetupIssue, _ message: String) {
|
||||
if Self.isImageIssue(issue) {
|
||||
vmBase = .failed(message, details: driver.setupOutput)
|
||||
} else {
|
||||
runtimeBase = .failed(message, details: driver.setupOutput)
|
||||
}
|
||||
}
|
||||
|
||||
private func downloaded(_ bytes: Int64, of total: Int64) {
|
||||
// Late callbacks mustn't overwrite what came after the download.
|
||||
guard case .running(let text, _) = runtimeBase, text.hasPrefix("Downloading") else { return }
|
||||
if bytes >= total {
|
||||
runtimeBase = .running("Verifying…", fraction: nil)
|
||||
} else {
|
||||
runtimeBase = .running(Self.downloadText(bytes, of: total), fraction: Double(bytes) / Double(max(total, 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMac() {
|
||||
guard let facts else { return }
|
||||
let free = facts.bytesFree.map { "\(Self.gigabytes($0)) GB free" }
|
||||
if needsSpace, let bytes = facts.bytesFree, bytes < Self.requiredFreeBytes {
|
||||
mac = .failed("Setting up needs about 3 GB free. \(Self.gigabytes(bytes)) GB is free.", details: [])
|
||||
} else {
|
||||
mac = .done([facts.osVersion, free].compactMap { $0 }.joined(separator: " · "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the install and the build use space; starting the service doesn't.
|
||||
private var needsSpace: Bool {
|
||||
if runtimeAction != nil { return true }
|
||||
if case .done = vmBase { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Text
|
||||
|
||||
static func installDetail(_ release: ContainerRuntime.Release) -> String {
|
||||
"Apple's container \(release.version) · \(megabytes(release.size)) MB · installs with Apple's Installer"
|
||||
}
|
||||
|
||||
static func installedText(_ version: String?) -> String {
|
||||
version.map { "container \($0) installed" } ?? "container installed"
|
||||
}
|
||||
|
||||
static func downloadText(_ bytes: Int64, of total: Int64) -> String {
|
||||
"Downloading… \(megabytes(bytes)) of \(megabytes(total)) MB"
|
||||
}
|
||||
|
||||
static func megabytes(_ bytes: Int64) -> Int {
|
||||
Int((Double(bytes) / 1_000_000).rounded())
|
||||
}
|
||||
|
||||
static func gigabytes(_ bytes: Int64) -> String {
|
||||
bytes >= 10_000_000_000 ? "\(bytes / 1_000_000_000)" : String(format: "%.1f", Double(bytes) / 1_000_000_000)
|
||||
}
|
||||
|
||||
static func isBrowserOpen(_ state: SessionState) -> Bool {
|
||||
switch state {
|
||||
case .starting, .live: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
static func isImageIssue(_ issue: SetupIssue) -> Bool {
|
||||
switch issue {
|
||||
case .imageMissing, .imageOutdated, .imageStale: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/// When the setup window opens by itself at launch.
|
||||
public enum SetupLaunchRule {
|
||||
public static let completedKey = "setupCompleted"
|
||||
|
||||
public static func shouldOpen(hostSupported: Bool, setupCompleted: Bool, runtime: RuntimeStatus) -> Bool {
|
||||
guard hostSupported, setupCompleted else { return true }
|
||||
switch runtime {
|
||||
case .missing, .outdated: return true
|
||||
case .current: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/// Decides when Starting ends. Frames and progress before NAVIGATED come from the agent's
|
||||
/// about:blank bootstrap and never count; `afterHello` is a backstop if NAVIGATED never arrives.
|
||||
public struct ContentReadiness: Sendable {
|
||||
public static let afterNavigated: Duration = .seconds(15)
|
||||
public static let afterHello: Duration = .seconds(25)
|
||||
|
||||
public private(set) var isReady = false
|
||||
private var helloAt: ContinuousClock.Instant?
|
||||
private var navigatedAt: ContinuousClock.Instant?
|
||||
|
||||
public init() {}
|
||||
|
||||
public mutating func hello(at now: ContinuousClock.Instant) {
|
||||
if helloAt == nil { helloAt = now }
|
||||
}
|
||||
|
||||
public mutating func navigated(at now: ContinuousClock.Instant) {
|
||||
if navigatedAt == nil { navigatedAt = now }
|
||||
}
|
||||
|
||||
public mutating func frame(isUniform: Bool) {
|
||||
if navigatedAt != nil, !isUniform { isReady = true }
|
||||
}
|
||||
|
||||
public mutating func progress(_ percent: Int) {
|
||||
if navigatedAt != nil, percent >= 100 { isReady = true }
|
||||
}
|
||||
|
||||
public mutating func tick(now: ContinuousClock.Instant) {
|
||||
if let deadline = nextDeadline, now >= deadline { isReady = true }
|
||||
}
|
||||
|
||||
public var nextDeadline: ContinuousClock.Instant? {
|
||||
guard !isReady else { return nil }
|
||||
return [navigatedAt.map { $0 + Self.afterNavigated }, helloAt.map { $0 + Self.afterHello }]
|
||||
.compactMap { $0 }
|
||||
.min()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// Tells a blank frame from one with page content.
|
||||
public enum FrameSampler {
|
||||
public static let grid = 48
|
||||
public static let maxDimension = 1 << 15
|
||||
public static let maxBytesPerRow = 1 << 20
|
||||
|
||||
/// Malformed input counts as uniform so it can never pose as content; the caps prevent overflow.
|
||||
public static func isUniform(_ pixels: UnsafeRawBufferPointer, width: Int, height: Int, bytesPerRow: Int) -> Bool {
|
||||
guard (1...maxDimension).contains(width), (1...maxDimension).contains(height),
|
||||
bytesPerRow >= width * 4, bytesPerRow <= maxBytesPerRow,
|
||||
pixels.count >= (height - 1) * bytesPerRow + width * 4
|
||||
else { return true }
|
||||
let first = pixels.loadUnaligned(fromByteOffset: 0, as: UInt32.self)
|
||||
for gy in 0..<grid {
|
||||
for gx in 0..<grid {
|
||||
let offset = (gy * height / grid) * bytesPerRow + (gx * width / grid) * 4
|
||||
if pixels.loadUnaligned(fromByteOffset: offset, as: UInt32.self) != first { return false }
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
public enum NALUnitType {
|
||||
public static let slice: UInt8 = 1
|
||||
public static let idrSlice: UInt8 = 5
|
||||
public static let sei: UInt8 = 6
|
||||
public static let sps: UInt8 = 7
|
||||
public static let pps: UInt8 = 8
|
||||
public static let accessUnitDelimiter: UInt8 = 9
|
||||
}
|
||||
|
||||
public enum AnnexB {
|
||||
/// Splits an Annex-B stream into NAL units. Trailing zeros are trimmed: a NAL unit never ends in 0x00.
|
||||
public static func nalUnits(in stream: Data) -> [Data] {
|
||||
let bytes = [UInt8](stream)
|
||||
var starts: [(code: Int, payload: Int)] = []
|
||||
var i = 0
|
||||
while i + 2 < bytes.count {
|
||||
if bytes[i] == 0, bytes[i + 1] == 0, bytes[i + 2] == 1 {
|
||||
starts.append((code: i, payload: i + 3))
|
||||
i += 3
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return starts.indices.compactMap { k in
|
||||
var end = k + 1 < starts.count ? starts[k + 1].code : bytes.count
|
||||
while end > starts[k].payload, bytes[end - 1] == 0 { end -= 1 }
|
||||
return end > starts[k].payload ? Data(bytes[starts[k].payload..<end]) : nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ParameterSets: Equatable, Sendable {
|
||||
public var sps: Data
|
||||
public var pps: Data
|
||||
|
||||
public init(sps: Data, pps: Data) {
|
||||
self.sps = sps
|
||||
self.pps = pps
|
||||
}
|
||||
}
|
||||
|
||||
public struct ParsedAccessUnit: Equatable, Sendable {
|
||||
/// AVCC-framed slice NAL units; empty if the access unit has none, so don't decode it.
|
||||
public var sample = Data()
|
||||
public var isIDR = false
|
||||
/// Set only on the unit that changes the SPS/PPS; if that unit is dropped, check `H264Parser.parameterSets`.
|
||||
public var newParameterSets: ParameterSets?
|
||||
}
|
||||
|
||||
public struct H264Parser: Sendable {
|
||||
public private(set) var parameterSets: ParameterSets?
|
||||
|
||||
public init() {}
|
||||
|
||||
public mutating func parse(_ accessUnit: Data) -> ParsedAccessUnit {
|
||||
var unit = ParsedAccessUnit()
|
||||
var sps: Data?
|
||||
var pps: Data?
|
||||
for nal in AnnexB.nalUnits(in: accessUnit) {
|
||||
let header = nal[nal.startIndex]
|
||||
guard header & 0x80 == 0 else { continue } // forbidden_zero_bit
|
||||
let type = header & 0x1F
|
||||
switch type {
|
||||
case NALUnitType.sps:
|
||||
sps = nal
|
||||
case NALUnitType.pps:
|
||||
pps = nal
|
||||
case NALUnitType.slice, NALUnitType.idrSlice:
|
||||
if type == NALUnitType.idrSlice { unit.isIDR = true }
|
||||
unit.sample.appendBE32(UInt32(nal.count))
|
||||
unit.sample.append(nal)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
// SPS and PPS must come from the same access unit; x264 repeats both on every IDR.
|
||||
if let sps, let pps {
|
||||
let sets = ParameterSets(sps: sps, pps: pps)
|
||||
if sets != parameterSets {
|
||||
parameterSets = sets
|
||||
unit.newParameterSets = sets
|
||||
}
|
||||
}
|
||||
return unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
|
||||
public struct Modifiers: OptionSet, Hashable, Sendable {
|
||||
public let rawValue: UInt8
|
||||
|
||||
public init(rawValue: UInt8) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
public static let shift = Modifiers(rawValue: 1 << 0)
|
||||
public static let control = Modifiers(rawValue: 1 << 1)
|
||||
public static let alt = Modifiers(rawValue: 1 << 2)
|
||||
public static let meta = Modifiers(rawValue: 1 << 3)
|
||||
public static let leftButton = Modifiers(rawValue: 1 << 4)
|
||||
public static let rightButton = Modifiers(rawValue: 1 << 5)
|
||||
public static let middleButton = Modifiers(rawValue: 1 << 6)
|
||||
}
|
||||
|
||||
public enum PointerButton: UInt8, Sendable {
|
||||
case left = 1
|
||||
case right = 2
|
||||
case middle = 3
|
||||
}
|
||||
|
||||
/// A viewer → agent message. Coordinates are render pixels from the top left; negative scroll is down/right.
|
||||
public enum InputEvent: Equatable, Sendable {
|
||||
case pointerMove(x: UInt16, y: UInt16, mods: Modifiers)
|
||||
case pointerButton(PointerButton, pressed: Bool, x: UInt16, y: UInt16, mods: Modifiers)
|
||||
case scroll(ticksX: Int16, ticksY: Int16, x: UInt16, y: UInt16)
|
||||
case key(pressed: Bool, mods: Modifiers, name: String)
|
||||
case navigate(String)
|
||||
case script(String)
|
||||
case keyframe
|
||||
|
||||
/// Nil if the agent can't use it: a payload over 64 KiB, an empty KEY name, or a NUL in a string.
|
||||
public func encoded() -> Data? {
|
||||
var payload = Data()
|
||||
let type: UInt8
|
||||
switch self {
|
||||
case .pointerMove(let x, let y, let mods):
|
||||
type = MessageType.pointerMove
|
||||
payload.appendBE16(x)
|
||||
payload.appendBE16(y)
|
||||
payload.append(mods.rawValue)
|
||||
case .pointerButton(let button, let pressed, let x, let y, let mods):
|
||||
type = MessageType.pointerButton
|
||||
payload.append(button.rawValue)
|
||||
payload.append(pressed ? 1 : 0)
|
||||
payload.appendBE16(x)
|
||||
payload.appendBE16(y)
|
||||
payload.append(mods.rawValue)
|
||||
case .scroll(let ticksX, let ticksY, let x, let y):
|
||||
type = MessageType.scroll
|
||||
payload.appendBE16(UInt16(bitPattern: ticksX))
|
||||
payload.appendBE16(UInt16(bitPattern: ticksY))
|
||||
payload.appendBE16(x)
|
||||
payload.appendBE16(y)
|
||||
case .key(let pressed, let mods, let name):
|
||||
guard !name.isEmpty, !name.utf8.contains(0) else { return nil }
|
||||
type = MessageType.key
|
||||
payload.append(pressed ? 1 : 0)
|
||||
payload.append(mods.rawValue)
|
||||
payload.append(contentsOf: Array(name.utf8))
|
||||
case .navigate(let url):
|
||||
guard !url.utf8.contains(0) else { return nil }
|
||||
type = MessageType.navigate
|
||||
payload.append(contentsOf: Array(url.utf8))
|
||||
case .script(let javaScript):
|
||||
guard !javaScript.utf8.contains(0) else { return nil }
|
||||
type = MessageType.script
|
||||
payload.append(contentsOf: Array(javaScript.utf8))
|
||||
case .keyframe:
|
||||
type = MessageType.keyframe
|
||||
}
|
||||
guard payload.count <= WireProtocol.maxOtherPayload else { return nil }
|
||||
return framed(type, payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import CoreVideo
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
public struct StreamClient: StreamConnector {
|
||||
public let renderSize: PixelSize
|
||||
|
||||
public init(renderSize: PixelSize) {
|
||||
self.renderSize = renderSize
|
||||
}
|
||||
|
||||
public func connect(socketPath: String) async throws -> any StreamConnection {
|
||||
let renderSize = renderSize
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
continuation.resume(with: Result { try Self.connectBlocking(path: socketPath, renderSize: renderSize) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func connectBlocking(path: String, renderSize: PixelSize) throws -> StreamSession {
|
||||
let deadline = Date().addingTimeInterval(30)
|
||||
var connectedOnce = false
|
||||
while Date() < deadline {
|
||||
if let fd = openSocket(path) {
|
||||
connectedOnce = true
|
||||
setReadTimeout(fd, seconds: 2)
|
||||
var decoder = AgentMessageDecoder()
|
||||
switch readHello(fd, into: &decoder) {
|
||||
case .hello(let version, let size):
|
||||
guard version == WireProtocol.version else {
|
||||
Darwin.close(fd)
|
||||
throw StreamError.versionMismatch(found: version, need: WireProtocol.version)
|
||||
}
|
||||
guard size == renderSize else {
|
||||
Darwin.close(fd)
|
||||
throw StreamError.protocolViolation("HELLO is \(size), asked for \(renderSize)")
|
||||
}
|
||||
setReadTimeout(fd, seconds: 0)
|
||||
// A VM that stops reading input must not wedge the session's queue.
|
||||
setSendTimeout(fd, seconds: 2)
|
||||
return StreamSession(fd: fd, decoder: decoder, size: size)
|
||||
case .violation(let detail):
|
||||
Darwin.close(fd)
|
||||
throw StreamError.protocolViolation(detail)
|
||||
case .none:
|
||||
Darwin.close(fd)
|
||||
}
|
||||
}
|
||||
usleep(100_000)
|
||||
}
|
||||
throw connectedOnce ? StreamError.timedOut : StreamError.refused
|
||||
}
|
||||
|
||||
private enum HelloOutcome {
|
||||
case hello(version: Int, size: PixelSize)
|
||||
case violation(String)
|
||||
case none
|
||||
}
|
||||
|
||||
private static func readHello(_ fd: Int32, into decoder: inout AgentMessageDecoder) -> HelloOutcome {
|
||||
var buffer = [UInt8](repeating: 0, count: 64 * 1024)
|
||||
while true {
|
||||
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, $0.count) }
|
||||
if n < 0, errno == EINTR { continue }
|
||||
guard n > 0 else { return .none }
|
||||
decoder.append(Data(buffer[0..<n]))
|
||||
do {
|
||||
if case .hello(let version, let size)? = try decoder.next() { return .hello(version: version, size: size) }
|
||||
} catch {
|
||||
return .violation(error.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func openSocket(_ path: String) -> Int32? {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { return nil }
|
||||
var address = sockaddr_un()
|
||||
address.sun_family = sa_family_t(AF_UNIX)
|
||||
let bytes = Array(path.utf8)
|
||||
guard bytes.count < MemoryLayout.size(ofValue: address.sun_path) else {
|
||||
Darwin.close(fd)
|
||||
return nil
|
||||
}
|
||||
withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: bytes) }
|
||||
let size = socklen_t(MemoryLayout<sockaddr_un>.size)
|
||||
let result = withUnsafePointer(to: &address) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.connect(fd, $0, size) }
|
||||
}
|
||||
guard result == 0 else {
|
||||
Darwin.close(fd)
|
||||
return nil
|
||||
}
|
||||
var on: Int32 = 1
|
||||
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout<Int32>.size))
|
||||
return fd
|
||||
}
|
||||
|
||||
static func setReadTimeout(_ fd: Int32, seconds: Int) {
|
||||
var timeout = timeval.init(tv_sec: seconds, tv_usec: 0)
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
|
||||
}
|
||||
|
||||
static func setSendTimeout(_ fd: Int32, seconds: Int) {
|
||||
var timeout = timeval.init(tv_sec: seconds, tv_usec: 0)
|
||||
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
|
||||
}
|
||||
}
|
||||
|
||||
/// Its reader thread must never stop (the VM's watchdog needs a connected viewer); all else runs on `queue`.
|
||||
final class StreamSession: StreamConnection, @unchecked Sendable {
|
||||
static let firstFrameTimeout: TimeInterval = 20
|
||||
static let decodeErrorLimit = 10
|
||||
|
||||
let events: AsyncStream<StreamEvent>
|
||||
private let continuation: AsyncStream<StreamEvent>.Continuation
|
||||
private let queue = DispatchQueue(label: "aculix.knotchy.stream")
|
||||
|
||||
private let fd: Int32
|
||||
private var decoder: AgentMessageDecoder
|
||||
private var parser = H264Parser()
|
||||
private var video: VideoDecoder?
|
||||
private var readiness = ContentReadiness()
|
||||
private var announcedReady = false
|
||||
private var enabled = true
|
||||
private var waitingForIDR = false
|
||||
private var frameHandler: (@Sendable (DecodedFrame) -> Void)?
|
||||
private var readinessTimer: DispatchSourceTimer?
|
||||
private var firstFrameTimer: DispatchSourceTimer?
|
||||
private var pendingPageURL: URL?
|
||||
private var pageURLTimer: DispatchSourceTimer?
|
||||
private var decodeErrors: [Date] = []
|
||||
private var ended = false
|
||||
|
||||
init(fd: Int32, decoder: AgentMessageDecoder, size: PixelSize) {
|
||||
self.fd = fd
|
||||
self.decoder = decoder
|
||||
let (stream, continuation) = AsyncStream.makeStream(of: StreamEvent.self)
|
||||
events = stream
|
||||
self.continuation = continuation
|
||||
continuation.yield(.hello(version: WireProtocol.version, size: size))
|
||||
queue.async { self.begin() }
|
||||
let reader = Thread { [self] in readLoop() }
|
||||
reader.name = "knotchy stream reader"
|
||||
reader.start()
|
||||
}
|
||||
|
||||
// MARK: StreamConnection
|
||||
|
||||
var decodeEnabled: Bool {
|
||||
get { queue.sync { enabled } }
|
||||
set {
|
||||
queue.async {
|
||||
if newValue, !self.enabled {
|
||||
self.waitingForIDR = true
|
||||
self.write(.keyframe)
|
||||
}
|
||||
self.enabled = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setFrameHandler(_ handler: @escaping @Sendable (DecodedFrame) -> Void) {
|
||||
queue.async { self.frameHandler = handler }
|
||||
}
|
||||
|
||||
func send(_ input: InputEvent) {
|
||||
queue.async { self.write(input) }
|
||||
}
|
||||
|
||||
func close() {
|
||||
queue.async { self.end(nil) }
|
||||
}
|
||||
|
||||
// MARK: Reading
|
||||
|
||||
private func readLoop() {
|
||||
var buffer = [UInt8](repeating: 0, count: 64 * 1024)
|
||||
while true {
|
||||
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, $0.count) }
|
||||
if n < 0, errno == EINTR { continue }
|
||||
guard n > 0 else { break }
|
||||
let chunk = Data(buffer[0..<n])
|
||||
queue.async { self.received(chunk) }
|
||||
}
|
||||
queue.async {
|
||||
self.end(.closed)
|
||||
Darwin.close(self.fd)
|
||||
}
|
||||
}
|
||||
|
||||
private func begin() {
|
||||
readiness.hello(at: .now)
|
||||
scheduleReadinessTick()
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now() + Self.firstFrameTimeout)
|
||||
timer.setEventHandler { [weak self] in self?.end(.noFirstFrame) }
|
||||
timer.resume()
|
||||
firstFrameTimer = timer
|
||||
received(Data()) // Messages that arrived together with HELLO.
|
||||
}
|
||||
|
||||
private func received(_ chunk: Data) {
|
||||
guard !ended else { return }
|
||||
decoder.append(chunk)
|
||||
do {
|
||||
while let message = try decoder.next() { handle(message) }
|
||||
} catch {
|
||||
end(.protocolViolation(error.description))
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ message: AgentMessage) {
|
||||
switch message {
|
||||
case .navigated:
|
||||
readiness.navigated(at: .now)
|
||||
scheduleReadinessTick()
|
||||
case .progress(let percent):
|
||||
readiness.progress(percent)
|
||||
continuation.yield(.progress(percent))
|
||||
announceIfReady()
|
||||
case .video(_, let accessUnit):
|
||||
firstFrameTimer?.cancel()
|
||||
firstFrameTimer = nil
|
||||
let unit = parser.parse(accessUnit)
|
||||
if unit.newParameterSets != nil || video == nil, let sets = parser.parameterSets {
|
||||
video = VideoDecoder(parameterSets: sets) { [weak self] frame, status in
|
||||
guard let self else { return }
|
||||
self.queue.async { self.decoded(frame, status: status) }
|
||||
}
|
||||
}
|
||||
guard enabled, !unit.sample.isEmpty, let video else { return }
|
||||
if waitingForIDR {
|
||||
guard unit.isIDR else { return }
|
||||
waitingForIDR = false
|
||||
}
|
||||
video.decode(unit.sample)
|
||||
case .pageURL(let text):
|
||||
guard let url = URL(string: text) else { return }
|
||||
pendingPageURL = url
|
||||
passOnPageURL()
|
||||
case .hello, .unknown:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func decoded(_ frame: DecodedFrame?, status: OSStatus) {
|
||||
guard !ended else { return }
|
||||
guard let frame else { return decodeFailed() }
|
||||
if !readiness.isReady {
|
||||
readiness.frame(isUniform: Self.isUniform(frame.pixelBuffer))
|
||||
announceIfReady()
|
||||
}
|
||||
frameHandler?(frame)
|
||||
}
|
||||
|
||||
private func decodeFailed() {
|
||||
let now = Date()
|
||||
decodeErrors = decodeErrors.filter { now.timeIntervalSince($0) < 60 } + [now]
|
||||
if decodeErrors.count > Self.decodeErrorLimit { return end(.decodeErrors) }
|
||||
waitingForIDR = true
|
||||
write(.keyframe)
|
||||
}
|
||||
|
||||
private static func isUniform(_ buffer: CVPixelBuffer) -> Bool {
|
||||
CVPixelBufferLockBaseAddress(buffer, .readOnly)
|
||||
defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) }
|
||||
guard let base = CVPixelBufferGetBaseAddress(buffer) else { return true }
|
||||
let bytes = UnsafeRawBufferPointer(start: base, count: CVPixelBufferGetDataSize(buffer))
|
||||
return FrameSampler.isUniform(bytes, width: CVPixelBufferGetWidth(buffer),
|
||||
height: CVPixelBufferGetHeight(buffer),
|
||||
bytesPerRow: CVPixelBufferGetBytesPerRow(buffer))
|
||||
}
|
||||
|
||||
// MARK: Readiness
|
||||
|
||||
private func announceIfReady() {
|
||||
guard readiness.isReady, !announcedReady else { return }
|
||||
announcedReady = true
|
||||
readinessTimer?.cancel()
|
||||
readinessTimer = nil
|
||||
continuation.yield(.contentReady)
|
||||
}
|
||||
|
||||
private func scheduleReadinessTick() {
|
||||
readinessTimer?.cancel()
|
||||
readinessTimer = nil
|
||||
guard let deadline = readiness.nextDeadline else { return }
|
||||
let wait = deadline - .now
|
||||
let seconds = max(0, Double(wait.components.seconds) + Double(wait.components.attoseconds) / 1e18)
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now() + seconds)
|
||||
timer.setEventHandler { [weak self] in
|
||||
guard let self else { return }
|
||||
self.readiness.tick(now: .now)
|
||||
self.announceIfReady()
|
||||
}
|
||||
timer.resume()
|
||||
readinessTimer = timer
|
||||
}
|
||||
|
||||
/// At most 10 a second, always including the last: a page can change its address in a loop.
|
||||
private func passOnPageURL() {
|
||||
guard pageURLTimer == nil, let url = pendingPageURL else { return }
|
||||
pendingPageURL = nil
|
||||
continuation.yield(.pageURL(url))
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now() + 0.1)
|
||||
timer.setEventHandler { [weak self] in
|
||||
guard let self, !self.ended else { return }
|
||||
self.pageURLTimer = nil
|
||||
self.passOnPageURL()
|
||||
}
|
||||
timer.resume()
|
||||
pageURLTimer = timer
|
||||
}
|
||||
|
||||
// MARK: Writing and ending
|
||||
|
||||
private func write(_ input: InputEvent) {
|
||||
guard !ended, let data = input.encoded() else { return }
|
||||
let stalled = data.withUnsafeBytes { raw -> Bool in
|
||||
var offset = 0
|
||||
while offset < raw.count {
|
||||
let n = Darwin.write(fd, raw.baseAddress! + offset, raw.count - offset)
|
||||
if n > 0 { offset += n } else if n < 0, errno == EINTR { continue } else {
|
||||
return n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if stalled { end(.socketError("The VM stopped taking input.")) }
|
||||
}
|
||||
|
||||
private func end(_ reason: EndReason?) {
|
||||
guard !ended else { return }
|
||||
ended = true
|
||||
shutdown(fd, SHUT_RDWR)
|
||||
readinessTimer?.cancel()
|
||||
firstFrameTimer?.cancel()
|
||||
pageURLTimer?.cancel()
|
||||
video = nil
|
||||
frameHandler = nil
|
||||
if let reason { continuation.yield(.ended(reason)) }
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import CoreVideo
|
||||
|
||||
public protocol StreamConnector: Sendable {
|
||||
/// Returns once a valid HELLO arrives; a version mismatch is reported before any other HELLO problem.
|
||||
func connect(socketPath: String) async throws -> any StreamConnection
|
||||
}
|
||||
|
||||
/// `@unchecked`: a decoded buffer is never written after VideoToolbox returns it.
|
||||
public struct DecodedFrame: @unchecked Sendable {
|
||||
public let pixelBuffer: CVPixelBuffer
|
||||
|
||||
public init(_ pixelBuffer: CVPixelBuffer) {
|
||||
self.pixelBuffer = pixelBuffer
|
||||
}
|
||||
}
|
||||
|
||||
public protocol StreamConnection: AnyObject, Sendable {
|
||||
/// Unbounded: reading starts only after `connect` returns, and early events must not be lost.
|
||||
var events: AsyncStream<StreamEvent> { get }
|
||||
var decodeEnabled: Bool { get set }
|
||||
/// Called on the decode queue.
|
||||
func setFrameHandler(_ handler: @escaping @Sendable (DecodedFrame) -> Void)
|
||||
func send(_ input: InputEvent)
|
||||
func close()
|
||||
}
|
||||
|
||||
public enum StreamEvent: Equatable, Sendable {
|
||||
case hello(version: Int, size: PixelSize)
|
||||
case contentReady
|
||||
case progress(Int)
|
||||
/// Untrusted (from the VM): display only.
|
||||
case pageURL(URL)
|
||||
case ended(EndReason)
|
||||
}
|
||||
|
||||
public enum EndReason: Equatable, Sendable {
|
||||
/// EOF: the browser crashed, the firewall failed, or the VM's watchdog fired.
|
||||
case closed
|
||||
case protocolViolation(String)
|
||||
case noFirstFrame
|
||||
case decodeErrors
|
||||
case socketError(String)
|
||||
}
|
||||
|
||||
public enum StreamError: Error, Equatable, Sendable {
|
||||
case versionMismatch(found: Int, need: Int)
|
||||
case protocolViolation(String)
|
||||
case timedOut
|
||||
case refused
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import CoreMedia
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import VideoToolbox
|
||||
|
||||
final class VideoDecoder {
|
||||
typealias Output = @Sendable (DecodedFrame?, OSStatus) -> Void
|
||||
|
||||
private let session: VTDecompressionSession
|
||||
private let format: CMVideoFormatDescription
|
||||
private let output: Output
|
||||
|
||||
init?(parameterSets: ParameterSets, output: @escaping Output) {
|
||||
var description: CMFormatDescription?
|
||||
let sps = [UInt8](parameterSets.sps)
|
||||
let pps = [UInt8](parameterSets.pps)
|
||||
let status = sps.withUnsafeBufferPointer { s in
|
||||
pps.withUnsafeBufferPointer { p in
|
||||
let pointers = [s.baseAddress!, p.baseAddress!]
|
||||
return CMVideoFormatDescriptionCreateFromH264ParameterSets(
|
||||
allocator: kCFAllocatorDefault, parameterSetCount: 2, parameterSetPointers: pointers,
|
||||
parameterSetSizes: [sps.count, pps.count], nalUnitHeaderLength: 4, formatDescriptionOut: &description)
|
||||
}
|
||||
}
|
||||
guard status == noErr, let description else { return nil }
|
||||
let attributes: [CFString: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA,
|
||||
kCVPixelBufferIOSurfacePropertiesKey: [CFString: Any]() as CFDictionary,
|
||||
]
|
||||
var created: VTDecompressionSession?
|
||||
let sessionStatus = VTDecompressionSessionCreate(
|
||||
allocator: kCFAllocatorDefault, formatDescription: description, decoderSpecification: nil,
|
||||
imageBufferAttributes: attributes as CFDictionary, outputCallback: nil, decompressionSessionOut: &created)
|
||||
guard sessionStatus == noErr, let created else { return nil }
|
||||
session = created
|
||||
format = description
|
||||
self.output = output
|
||||
}
|
||||
|
||||
deinit {
|
||||
VTDecompressionSessionInvalidate(session)
|
||||
}
|
||||
|
||||
func decode(_ sample: Data) {
|
||||
var block: CMBlockBuffer?
|
||||
guard CMBlockBufferCreateWithMemoryBlock(
|
||||
allocator: kCFAllocatorDefault, memoryBlock: nil, blockLength: sample.count,
|
||||
blockAllocator: kCFAllocatorDefault, customBlockSource: nil, offsetToData: 0,
|
||||
dataLength: sample.count, flags: 0, blockBufferOut: &block) == noErr, let block
|
||||
else { return output(nil, -1) }
|
||||
sample.withUnsafeBytes { bytes in
|
||||
_ = CMBlockBufferReplaceDataBytes(with: bytes.baseAddress!, blockBuffer: block,
|
||||
offsetIntoDestination: 0, dataLength: sample.count)
|
||||
}
|
||||
var sampleBuffer: CMSampleBuffer?
|
||||
var size = sample.count
|
||||
guard CMSampleBufferCreateReady(
|
||||
allocator: kCFAllocatorDefault, dataBuffer: block, formatDescription: format,
|
||||
sampleCount: 1, sampleTimingEntryCount: 0, sampleTimingArray: nil,
|
||||
sampleSizeEntryCount: 1, sampleSizeArray: &size, sampleBufferOut: &sampleBuffer) == noErr,
|
||||
let sampleBuffer
|
||||
else { return output(nil, -1) }
|
||||
let output = self.output
|
||||
let status = VTDecompressionSessionDecodeFrame(session, sampleBuffer: sampleBuffer, flags: [],
|
||||
infoFlagsOut: nil) { status, _, image, _, _ in
|
||||
if status == noErr, let image {
|
||||
output(DecodedFrame(image), noErr)
|
||||
} else if status != noErr {
|
||||
output(nil, status)
|
||||
}
|
||||
}
|
||||
if status != noErr { output(nil, status) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import Foundation
|
||||
|
||||
/// knotchy-agent ↔ viewer framing, both directions: `[u8 type][u32 big-endian payload length][payload]`.
|
||||
public enum WireProtocol {
|
||||
/// Must equal `ARG KNOTCHY_PROTOCOL` in vm/Containerfile. 2: proxy/VPN routes. 3: PAGE_URL.
|
||||
public static let version = 3
|
||||
public static let headerSize = 5
|
||||
public static let maxVideoPayload = 2 * 1024 * 1024
|
||||
/// Also the agent's cap on viewer messages; unknown agent messages up to this size are skipped.
|
||||
public static let maxOtherPayload = 64 * 1024
|
||||
public static let maxPageURL = 16 * 1024
|
||||
}
|
||||
|
||||
enum MessageType {
|
||||
static let hello: UInt8 = 0x01
|
||||
static let video: UInt8 = 0x02
|
||||
static let progress: UInt8 = 0x03
|
||||
static let navigated: UInt8 = 0x04
|
||||
static let pageURL: UInt8 = 0x05
|
||||
static let pointerMove: UInt8 = 0x10
|
||||
static let pointerButton: UInt8 = 0x11
|
||||
static let scroll: UInt8 = 0x12
|
||||
static let key: UInt8 = 0x13
|
||||
static let navigate: UInt8 = 0x14
|
||||
static let script: UInt8 = 0x15
|
||||
static let keyframe: UInt8 = 0x16
|
||||
}
|
||||
|
||||
public struct PixelSize: Hashable, Sendable, CustomStringConvertible {
|
||||
public var width: Int
|
||||
public var height: Int
|
||||
|
||||
public init(width: Int, height: Int) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
|
||||
public var description: String { "\(width)x\(height)" }
|
||||
}
|
||||
|
||||
public enum AgentMessage: Equatable, Sendable {
|
||||
/// Version 0 means a pre-v1 image, whose HELLO has no version byte.
|
||||
case hello(version: Int, size: PixelSize)
|
||||
case video(size: PixelSize, accessUnit: Data)
|
||||
case progress(Int)
|
||||
case navigated
|
||||
case pageURL(String)
|
||||
case unknown(type: UInt8)
|
||||
}
|
||||
|
||||
extension AgentMessage {
|
||||
/// For tests and fake agents: out-of-range values trap, so build malformed frames by hand.
|
||||
func encoded() -> Data {
|
||||
var payload = Data()
|
||||
let type: UInt8
|
||||
switch self {
|
||||
case .hello(let version, let size):
|
||||
type = MessageType.hello
|
||||
if version != 0 { payload.append(UInt8(version)) }
|
||||
payload.appendBE16(UInt16(size.width))
|
||||
payload.appendBE16(UInt16(size.height))
|
||||
case .video(let size, let accessUnit):
|
||||
type = MessageType.video
|
||||
payload.appendBE16(UInt16(size.width))
|
||||
payload.appendBE16(UInt16(size.height))
|
||||
payload.append(accessUnit)
|
||||
case .progress(let percent):
|
||||
type = MessageType.progress
|
||||
payload.append(UInt8(percent))
|
||||
case .navigated:
|
||||
type = MessageType.navigated
|
||||
case .pageURL(let url):
|
||||
type = MessageType.pageURL
|
||||
payload.append(Data(url.utf8))
|
||||
case .unknown(let unknownType):
|
||||
type = unknownType
|
||||
}
|
||||
return framed(type, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func framed(_ type: UInt8, _ payload: Data) -> Data {
|
||||
var message = Data([type])
|
||||
message.appendBE32(UInt32(payload.count))
|
||||
message.append(payload)
|
||||
return message
|
||||
}
|
||||
|
||||
extension Data {
|
||||
mutating func appendBE16(_ value: UInt16) {
|
||||
append(UInt8(value >> 8))
|
||||
append(UInt8(truncatingIfNeeded: value))
|
||||
}
|
||||
|
||||
mutating func appendBE32(_ value: UInt32) {
|
||||
appendBE16(UInt16(value >> 16))
|
||||
appendBE16(UInt16(truncatingIfNeeded: value))
|
||||
}
|
||||
}
|
||||
|
||||
/// Strict incremental parser; the VM is untrusted, so any bad message is a final violation.
|
||||
/// Call `next()` until nil after each `append`: that keeps the buffer to one message plus one read.
|
||||
public struct AgentMessageDecoder: Sendable {
|
||||
public struct ProtocolViolation: Error, Equatable, Sendable, CustomStringConvertible {
|
||||
public let description: String
|
||||
|
||||
init(_ description: String) {
|
||||
self.description = description
|
||||
}
|
||||
}
|
||||
|
||||
public private(set) var helloSize: PixelSize?
|
||||
private var buffer: [UInt8] = []
|
||||
private var start = 0
|
||||
private var violation: ProtocolViolation?
|
||||
|
||||
public init() {}
|
||||
|
||||
public mutating func append(_ bytes: Data) {
|
||||
guard violation == nil else { return }
|
||||
if start > 0 {
|
||||
buffer.removeFirst(start)
|
||||
start = 0
|
||||
}
|
||||
buffer.append(contentsOf: bytes)
|
||||
}
|
||||
|
||||
public var bufferedByteCount: Int { buffer.count - start }
|
||||
|
||||
/// Includes bytes already returned; for tests of the bound.
|
||||
var storedByteCount: Int { buffer.count }
|
||||
|
||||
public mutating func next() throws(ProtocolViolation) -> AgentMessage? {
|
||||
if let violation { throw violation }
|
||||
do {
|
||||
return try decodeNext()
|
||||
} catch {
|
||||
violation = error
|
||||
buffer = []
|
||||
start = 0
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func decodeNext() throws(ProtocolViolation) -> AgentMessage? {
|
||||
guard bufferedByteCount >= WireProtocol.headerSize else { return nil }
|
||||
let type = buffer[start]
|
||||
let length = Int(be32(at: start + 1))
|
||||
try checkHeader(type: type, length: length)
|
||||
guard bufferedByteCount - WireProtocol.headerSize >= length else { return nil }
|
||||
|
||||
let p = start + WireProtocol.headerSize
|
||||
start = p + length
|
||||
switch type {
|
||||
case MessageType.hello:
|
||||
let version = length == 5 ? Int(buffer[p]) : 0
|
||||
let sizeAt = p + length - 4
|
||||
let size = PixelSize(width: Int(be16(at: sizeAt)), height: Int(be16(at: sizeAt + 2)))
|
||||
helloSize = size
|
||||
return .hello(version: version, size: size)
|
||||
case MessageType.video:
|
||||
let size = PixelSize(width: Int(be16(at: p)), height: Int(be16(at: p + 2)))
|
||||
guard size == helloSize else {
|
||||
throw ProtocolViolation("VIDEO is \(size) but HELLO said \(helloSize.map(\.description) ?? "nothing")")
|
||||
}
|
||||
return .video(size: size, accessUnit: Data(buffer[(p + 4)..<(p + length)]))
|
||||
case MessageType.progress:
|
||||
let percent = Int(buffer[p])
|
||||
guard percent <= 100 else { throw ProtocolViolation("PROGRESS \(percent) is over 100") }
|
||||
return .progress(percent)
|
||||
case MessageType.navigated:
|
||||
return .navigated
|
||||
case MessageType.pageURL:
|
||||
guard let url = String(validating: buffer[p..<(p + length)], as: UTF8.self) else {
|
||||
throw ProtocolViolation("PAGE_URL isn't UTF-8")
|
||||
}
|
||||
return .pageURL(url)
|
||||
default:
|
||||
return .unknown(type: type)
|
||||
}
|
||||
}
|
||||
|
||||
private func checkHeader(type: UInt8, length: Int) throws(ProtocolViolation) {
|
||||
func name() -> String { String(format: "type 0x%02x", type) }
|
||||
if helloSize == nil, type != MessageType.hello {
|
||||
throw ProtocolViolation("\(name()) before HELLO")
|
||||
}
|
||||
let valid: Bool
|
||||
switch type {
|
||||
case MessageType.hello:
|
||||
guard helloSize == nil else { throw ProtocolViolation("a second HELLO") }
|
||||
valid = length == 5 || length == 4
|
||||
case MessageType.video:
|
||||
valid = (4...WireProtocol.maxVideoPayload).contains(length)
|
||||
case MessageType.progress:
|
||||
valid = length == 1
|
||||
case MessageType.navigated:
|
||||
valid = length == 0
|
||||
case MessageType.pageURL:
|
||||
valid = (1...WireProtocol.maxPageURL).contains(length)
|
||||
default:
|
||||
valid = length <= WireProtocol.maxOtherPayload
|
||||
}
|
||||
guard valid else { throw ProtocolViolation("\(name()) with a \(length)-byte payload") }
|
||||
}
|
||||
|
||||
private func be16(at i: Int) -> UInt16 {
|
||||
UInt16(buffer[i]) << 8 | UInt16(buffer[i + 1])
|
||||
}
|
||||
|
||||
private func be32(at i: Int) -> UInt32 {
|
||||
UInt32(be16(at: i)) << 16 | UInt32(be16(at: i + 2))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/// Tests call `fireDue()` after advancing a `TestClock`; firing is idempotent for a given `now`.
|
||||
@MainActor
|
||||
final class DeadlineTimers<Key: Hashable & Sendable> {
|
||||
var onFire: @MainActor (Key) -> Void = { _ in }
|
||||
|
||||
private struct Entry {
|
||||
var deadline: ContinuousClock.Instant
|
||||
var order: Int
|
||||
}
|
||||
|
||||
private let clock: any KnotchyClock
|
||||
private var entries: [Key: Entry] = [:]
|
||||
private var nextOrder = 0
|
||||
private var driver: Task<Void, Never>?
|
||||
|
||||
init(clock: any KnotchyClock) {
|
||||
self.clock = clock
|
||||
}
|
||||
|
||||
isolated deinit {
|
||||
driver?.cancel()
|
||||
}
|
||||
|
||||
func set(_ key: Key, after duration: Duration) {
|
||||
set(key, at: clock.now + duration)
|
||||
}
|
||||
|
||||
func set(_ key: Key, at deadline: ContinuousClock.Instant) {
|
||||
nextOrder += 1
|
||||
entries[key] = Entry(deadline: deadline, order: nextOrder)
|
||||
reschedule()
|
||||
}
|
||||
|
||||
func cancel(_ key: Key) {
|
||||
guard entries.removeValue(forKey: key) != nil else { return }
|
||||
reschedule()
|
||||
}
|
||||
|
||||
func deadline(of key: Key) -> ContinuousClock.Instant? {
|
||||
entries[key]?.deadline
|
||||
}
|
||||
|
||||
/// Keys `onFire` re-arms at or before `now` fire in this same pass; doing that every time loops forever.
|
||||
func fireDue() {
|
||||
let now = clock.now
|
||||
while let (key, _) = entries
|
||||
.filter({ $0.value.deadline <= now })
|
||||
.min(by: { ($0.value.deadline, $0.value.order) < ($1.value.deadline, $1.value.order) }) {
|
||||
entries[key] = nil
|
||||
onFire(key)
|
||||
}
|
||||
reschedule()
|
||||
}
|
||||
|
||||
private func reschedule() {
|
||||
driver?.cancel()
|
||||
guard let next = entries.values.map(\.deadline).min() else {
|
||||
driver = nil
|
||||
return
|
||||
}
|
||||
driver = Task { [weak self, clock] in
|
||||
do { try await clock.sleep(until: next) } catch { return }
|
||||
guard !Task.isCancelled else { return } // Replaced while waking up.
|
||||
self?.fireDue()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/// `ContinuousClock` keeps counting while the Mac sleeps, so an expired idle countdown fires on wake.
|
||||
public protocol KnotchyClock: Sendable {
|
||||
var now: ContinuousClock.Instant { get }
|
||||
func sleep(until deadline: ContinuousClock.Instant) async throws
|
||||
}
|
||||
|
||||
public struct SystemClock: KnotchyClock {
|
||||
public init() {}
|
||||
|
||||
public var now: ContinuousClock.Instant { .now }
|
||||
|
||||
public func sleep(until deadline: ContinuousClock.Instant) async throws {
|
||||
try await Task.sleep(until: deadline, clock: .continuous)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import CoreImage
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import KnotchyCore
|
||||
import Synchronization
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// knotchyctl snapshot <url> <out.png> [--after <seconds>] [--click <render x>,<render y>]
|
||||
// [--proxy <http|socks5>://[user:pass@]host:port] [--wireguard <file.conf>] [--allow-local-proxy]
|
||||
|
||||
let arguments = Array(CommandLine.arguments.dropFirst())
|
||||
|
||||
func fail(_ message: String) -> Never {
|
||||
FileHandle.standardError.write(Data("knotchyctl: \(message)\n".utf8))
|
||||
exit(1)
|
||||
}
|
||||
|
||||
func vmSources() -> URL? {
|
||||
let repo = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent()
|
||||
.deletingLastPathComponent().appendingPathComponent("vm")
|
||||
return FileManager.default.fileExists(atPath: repo.appendingPathComponent("Containerfile").path) ? repo : nil
|
||||
}
|
||||
|
||||
func writePNG(_ buffer: CVPixelBuffer, to path: String) throws {
|
||||
let image = CIImage(cvPixelBuffer: buffer)
|
||||
guard let cgImage = CIContext().createCGImage(image, from: image.extent),
|
||||
let destination = CGImageDestinationCreateWithURL(URL(fileURLWithPath: path) as CFURL,
|
||||
UTType.png.identifier as CFString, 1, nil)
|
||||
else { throw CocoaError(.fileWriteUnknown) }
|
||||
CGImageDestinationAddImage(destination, cgImage, nil)
|
||||
guard CGImageDestinationFinalize(destination) else { throw CocoaError(.fileWriteUnknown) }
|
||||
}
|
||||
|
||||
// Thrown rather than fail()ed after the VM starts, so the VM still gets destroyed.
|
||||
struct SnapshotError: Error, CustomStringConvertible {
|
||||
let description: String
|
||||
init(_ description: String) { self.description = description }
|
||||
}
|
||||
|
||||
final class LatestFrame: Sendable {
|
||||
private let frame = Mutex<DecodedFrame?>(nil)
|
||||
func set(_ new: DecodedFrame) { frame.withLock { $0 = new } }
|
||||
func get() -> DecodedFrame? { frame.withLock { $0 } }
|
||||
}
|
||||
|
||||
func snapshot(url: URL, out: String, after: Double, click: (x: UInt16, y: UInt16)?, route: NetworkRoute) async throws {
|
||||
let backend = ContainerCLIBackend(vmSources: vmSources())
|
||||
backend.setNetworkRoute(route)
|
||||
if arguments.contains("--allow-local-proxy") { backend.allowLocalProxy() }
|
||||
// An outdated image would silently ignore the proxy or VPN route.
|
||||
guard case .ready = await backend.preflight() else { fail("the VM image isn't ready; build it with scripts/build-vm.sh") }
|
||||
let id = "knotchyctl-" + String(format: "%08x", UInt32.random(in: .min ... .max))
|
||||
let started = ContinuousClock.now
|
||||
let vm = try await backend.start(id: id, url: url, renderSize: SessionController.renderSize,
|
||||
blockCIDRs: HostNetworks.current())
|
||||
do {
|
||||
let connection = try await StreamClient(renderSize: SessionController.renderSize).connect(socketPath: vm.socketPath)
|
||||
let latest = LatestFrame()
|
||||
connection.setFrameHandler { latest.set($0) }
|
||||
var ready = false
|
||||
for await event in connection.events {
|
||||
if event == .contentReady { ready = true; break }
|
||||
if case .ended(let reason) = event { throw CocoaError(.featureUnsupported, userInfo: ["reason": "\(reason)"]) }
|
||||
}
|
||||
guard ready else { throw SnapshotError("the stream ended before content was ready") }
|
||||
print("content ready after \(ContinuousClock.now - started)")
|
||||
let pages = Task {
|
||||
for await event in connection.events {
|
||||
if case .pageURL(let page) = event { print("page \(page.absoluteString)") }
|
||||
}
|
||||
}
|
||||
defer { pages.cancel() }
|
||||
if let click {
|
||||
try await Task.sleep(for: .seconds(1))
|
||||
connection.send(.pointerMove(x: click.x, y: click.y, mods: []))
|
||||
connection.send(.pointerButton(.left, pressed: true, x: click.x, y: click.y, mods: .leftButton))
|
||||
connection.send(.pointerButton(.left, pressed: false, x: click.x, y: click.y, mods: []))
|
||||
}
|
||||
try await Task.sleep(for: .seconds(after))
|
||||
guard let frame = latest.get() else { throw SnapshotError("no frame decoded") }
|
||||
try writePNG(frame.pixelBuffer, to: out)
|
||||
print("wrote \(out)")
|
||||
connection.close()
|
||||
} catch {
|
||||
try? await backend.destroy(id: id)
|
||||
throw error
|
||||
}
|
||||
try await backend.destroy(id: id)
|
||||
}
|
||||
|
||||
guard arguments.first == "snapshot", arguments.count >= 3, let url = URL(string: arguments[1]) else {
|
||||
fail("usage: knotchyctl snapshot <url> <out.png> [--after <seconds>] [--click <x>,<y>] [--proxy <url>] [--wireguard <file>]")
|
||||
}
|
||||
var route = NetworkRoute.direct
|
||||
if let index = arguments.firstIndex(of: "--proxy") {
|
||||
guard let proxy = URLComponents(string: arguments[safe: index + 1] ?? ""), let host = proxy.host, let port = proxy.port,
|
||||
let kind = ProxySettings.Kind(rawValue: proxy.scheme ?? "")
|
||||
else { fail("--proxy needs http://[user:pass@]host:port or socks5://…") }
|
||||
route = .proxy(ProxySettings(kind: kind, host: host, port: port,
|
||||
username: proxy.user ?? "", password: proxy.password ?? ""))
|
||||
}
|
||||
if let index = arguments.firstIndex(of: "--wireguard") {
|
||||
guard let text = try? String(contentsOfFile: arguments[safe: index + 1] ?? "", encoding: .utf8) else {
|
||||
fail("--wireguard needs a readable .conf file")
|
||||
}
|
||||
do { route = .wireGuard(try WireGuardConfig.parse(text)) } catch { fail("the WireGuard config: \(error)") }
|
||||
}
|
||||
let after = arguments.firstIndex(of: "--after").flatMap { Double(arguments[safe: $0 + 1] ?? "") } ?? 3
|
||||
let click: (x: UInt16, y: UInt16)? = arguments.firstIndex(of: "--click").flatMap { index in
|
||||
let parts = (arguments[safe: index + 1] ?? "").split(separator: ",").compactMap { UInt16($0) }
|
||||
return parts.count == 2 ? (parts[0], parts[1]) : nil
|
||||
}
|
||||
do {
|
||||
try await snapshot(url: url, out: arguments[2], after: after, click: click, route: route)
|
||||
} catch {
|
||||
fail("\(error)")
|
||||
}
|
||||
|
||||
extension Array {
|
||||
subscript(safe index: Int) -> Element? { indices.contains(index) ? self[index] : nil }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
struct BackendChainTests {
|
||||
@Test func runsJobsOneAtATimeInOrder() async {
|
||||
let chain = BackendChain()
|
||||
var log: [String] = []
|
||||
for name in ["a", "b", "c"] {
|
||||
chain.enqueue {
|
||||
log.append("\(name) begins")
|
||||
await Task.yield()
|
||||
log.append("\(name) ends")
|
||||
}
|
||||
}
|
||||
await chain.waitUntilIdle()
|
||||
#expect(log == ["a begins", "a ends", "b begins", "b ends", "c begins", "c ends"])
|
||||
}
|
||||
|
||||
@Test func waitsForJobsEnqueuedByJobs() async {
|
||||
let chain = BackendChain()
|
||||
var log: [String] = []
|
||||
chain.enqueue {
|
||||
log.append("outer")
|
||||
chain.enqueue {
|
||||
await Task.yield() // Still running when the outer job's Task finishes.
|
||||
log.append("inner")
|
||||
}
|
||||
}
|
||||
await chain.waitUntilIdle()
|
||||
#expect(log == ["outer", "inner"])
|
||||
}
|
||||
|
||||
@Test func closingSkipsJobsThatHaveNotStarted() async {
|
||||
let chain = BackendChain()
|
||||
var log: [String] = []
|
||||
chain.enqueue {
|
||||
log.append("running")
|
||||
chain.close()
|
||||
}
|
||||
chain.enqueue { log.append("queued") }
|
||||
await chain.waitUntilIdle()
|
||||
chain.enqueue { log.append("after close") }
|
||||
await chain.waitUntilIdle()
|
||||
#expect(log == ["running"])
|
||||
}
|
||||
|
||||
@Test func liveIDsAreASnapshot() {
|
||||
let ids = LiveIDs()
|
||||
ids.insert("knotchy-00000001")
|
||||
ids.insert("knotchy-00000002")
|
||||
ids.remove("knotchy-00000001")
|
||||
#expect(ids.snapshot == ["knotchy-00000002"])
|
||||
}
|
||||
|
||||
@Test nonisolated func liveIDsCanBeUsedFromManyThreadsAtOnce() {
|
||||
let ids = LiveIDs()
|
||||
DispatchQueue.concurrentPerform(iterations: 1000) { i in
|
||||
ids.insert("knotchy-\(i)")
|
||||
_ = ids.snapshot
|
||||
if i.isMultiple(of: 2) { ids.remove("knotchy-\(i)") }
|
||||
}
|
||||
#expect(ids.snapshot.count == 500)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct CLIRunnerTests {
|
||||
@Test func findsARuntimeInstalledAfterLaunch() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("cli-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let tool = directory.appendingPathComponent("container")
|
||||
let runner = CLIRunner(searchPaths: [tool.path])
|
||||
#expect(runner.executable == nil)
|
||||
|
||||
try "#!/bin/sh\necho container CLI version 1.4.1\n".write(to: tool, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tool.path)
|
||||
#expect(runner.executable == tool.path)
|
||||
#expect(try runner.runBlocking(["--version"], timeout: 5).text.contains("1.4.1"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct TestClockTests {
|
||||
@Test func sleepReturnsOnlyOnceTimeIsAdvancedPastTheDeadline() async throws {
|
||||
let clock = TestClock()
|
||||
let deadline = clock.now + .seconds(10)
|
||||
let sleeper = Task { try await clock.sleep(until: deadline) }
|
||||
await waitUntil("the task sleeps") { clock.sleeperCount == 1 }
|
||||
|
||||
clock.advance(by: .seconds(9))
|
||||
#expect(clock.sleeperCount == 1)
|
||||
|
||||
clock.advance(by: .seconds(1))
|
||||
try await sleeper.value
|
||||
#expect(clock.sleeperCount == 0)
|
||||
}
|
||||
|
||||
@Test func sleepingUntilNowOrThePastReturnsImmediately() async throws {
|
||||
let clock = TestClock()
|
||||
try await clock.sleep(until: clock.now)
|
||||
try await clock.sleep(until: clock.now - .seconds(1))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func aSleepInAnAlreadyCancelledTaskThrows() async {
|
||||
@MainActor final class Outcome {
|
||||
var error: (any Error)?
|
||||
var finished = false
|
||||
}
|
||||
let clock = TestClock()
|
||||
let outcome = Outcome()
|
||||
Task { @MainActor in
|
||||
withUnsafeCurrentTask { $0?.cancel() }
|
||||
do { try await clock.sleep(until: clock.now + .seconds(10)) } catch { outcome.error = error }
|
||||
outcome.finished = true
|
||||
}
|
||||
// Bounded: a sleep that wrongly registers would never return.
|
||||
await waitUntil("the sleep ends") { outcome.finished }
|
||||
#expect(outcome.error is CancellationError)
|
||||
#expect(clock.sleeperCount == 0)
|
||||
}
|
||||
|
||||
@Test func cancellingASleeperThrowsAndForgetsIt() async {
|
||||
let clock = TestClock()
|
||||
let sleeper = Task { try await clock.sleep(until: clock.now + .seconds(10)) }
|
||||
await waitUntil("the task sleeps") { clock.sleeperCount == 1 }
|
||||
|
||||
sleeper.cancel()
|
||||
await #expect(throws: CancellationError.self) { try await sleeper.value }
|
||||
#expect(clock.sleeperCount == 0)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct DeadlineTimersTests {
|
||||
@Test func firesDueDeadlinesInOrder() {
|
||||
let clock = TestClock()
|
||||
let timers = DeadlineTimers<String>(clock: clock)
|
||||
var fired: [String] = []
|
||||
timers.onFire = { fired.append($0) }
|
||||
|
||||
// Seven keys share a deadline, so a lost tie-break shows up nearly every run.
|
||||
let ties = (1...6).map { "tie\($0)" }
|
||||
timers.set("late", after: .seconds(2))
|
||||
timers.set("early", after: .seconds(1))
|
||||
for key in ties { timers.set(key, after: .seconds(2)) }
|
||||
clock.advance(by: .milliseconds(999))
|
||||
timers.fireDue()
|
||||
#expect(fired == [])
|
||||
|
||||
clock.advance(by: .seconds(2))
|
||||
timers.fireDue()
|
||||
#expect(fired == ["early", "late"] + ties)
|
||||
|
||||
timers.fireDue()
|
||||
#expect(fired == ["early", "late"] + ties)
|
||||
}
|
||||
|
||||
@Test func settingAKeyAgainReplacesItsDeadline() {
|
||||
let clock = TestClock()
|
||||
let timers = DeadlineTimers<String>(clock: clock)
|
||||
var fired: [String] = []
|
||||
timers.onFire = { fired.append($0) }
|
||||
|
||||
timers.set("a", after: .seconds(1))
|
||||
timers.set("a", after: .seconds(5))
|
||||
#expect(timers.deadline(of: "a") == clock.now + .seconds(5))
|
||||
clock.advance(by: .seconds(4))
|
||||
timers.fireDue()
|
||||
#expect(fired == [])
|
||||
}
|
||||
|
||||
@Test func cancelledDeadlinesNeverFire() {
|
||||
let clock = TestClock()
|
||||
let timers = DeadlineTimers<String>(clock: clock)
|
||||
var fired: [String] = []
|
||||
timers.onFire = { fired.append($0) }
|
||||
|
||||
timers.set("a", after: .seconds(1))
|
||||
timers.cancel("a")
|
||||
clock.advance(by: .seconds(2))
|
||||
timers.fireDue()
|
||||
#expect(fired == [])
|
||||
#expect(timers.deadline(of: "a") == nil)
|
||||
}
|
||||
|
||||
@Test func onFireMaySetAndCancelTimers() {
|
||||
let clock = TestClock()
|
||||
let timers = DeadlineTimers<String>(clock: clock)
|
||||
var fired: [String] = []
|
||||
timers.onFire = { [unowned timers] key in
|
||||
fired.append(key)
|
||||
if key == "a" {
|
||||
timers.cancel("b")
|
||||
timers.set("c", after: .zero)
|
||||
timers.set("d", after: .seconds(1))
|
||||
}
|
||||
}
|
||||
timers.set("a", after: .seconds(1))
|
||||
timers.set("b", after: .seconds(1))
|
||||
clock.advance(by: .seconds(1))
|
||||
timers.fireDue()
|
||||
#expect(fired == ["a", "c"])
|
||||
#expect(timers.deadline(of: "d") == clock.now + .seconds(1))
|
||||
}
|
||||
|
||||
@Test func theDriverFiresEveryTimerInDeadlineOrder() async {
|
||||
let clock = TestClock()
|
||||
let timers = DeadlineTimers<String>(clock: clock)
|
||||
var fired: [String] = []
|
||||
timers.onFire = { fired.append($0) }
|
||||
|
||||
timers.set("b", after: .seconds(2))
|
||||
timers.set("a", after: .seconds(1))
|
||||
await waitUntil("the driver sleeps") { clock.sleeperCount == 1 }
|
||||
clock.advance(by: .seconds(1))
|
||||
await waitUntil("the driver fires a") { fired == ["a"] }
|
||||
await waitUntil("the driver sleeps again") { clock.sleeperCount == 1 }
|
||||
clock.advance(by: .seconds(1))
|
||||
await waitUntil("the driver fires b") { fired == ["a", "b"] }
|
||||
}
|
||||
|
||||
@Test func theDriverFiresWithoutHelp() async {
|
||||
let clock = TestClock()
|
||||
let timers = DeadlineTimers<String>(clock: clock)
|
||||
var fired: [String] = []
|
||||
timers.onFire = { fired.append($0) }
|
||||
|
||||
timers.set("a", after: .seconds(1))
|
||||
await waitUntil("the driver sleeps") { clock.sleeperCount == 1 }
|
||||
clock.advance(by: .seconds(1))
|
||||
await waitUntil("the driver fires") { fired == ["a"] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct ContainerCLIBackendRuntimeTests {
|
||||
/// A fake `container` that answers only `--version`.
|
||||
private func fakeCLI(printing line: String) throws -> (CLIRunner, URL) {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("cli-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let tool = directory.appendingPathComponent("container")
|
||||
let script = "#!/bin/sh\nif [ \"$1\" = --version ]; then echo '\(line)'; exit 0; fi\nexit 1\n"
|
||||
try script.write(to: tool, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tool.path)
|
||||
return (CLIRunner(searchPaths: [tool.path]), directory)
|
||||
}
|
||||
|
||||
@Test func anOldRuntimeIsFlaggedBeforeAnythingElse() async throws {
|
||||
let (cli, directory) = try fakeCLI(printing: "container CLI version 1.3.0 (build: release)")
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let backend = ContainerCLIBackend(vmSources: nil, cli: cli)
|
||||
#expect(await backend.runtimeStatus() == .outdated("1.3.0"))
|
||||
#expect(await backend.preflight() == .issue(.cliOutdated(found: "1.3.0")))
|
||||
}
|
||||
|
||||
@Test func aMissingRuntimeIsReported() async {
|
||||
let backend = ContainerCLIBackend(vmSources: nil, cli: CLIRunner(searchPaths: ["/nonexistent/container"]))
|
||||
#expect(await backend.runtimeStatus() == .missing)
|
||||
#expect(await backend.preflight() == .issue(.cliMissing))
|
||||
}
|
||||
|
||||
@Test func anOldRuntimeHasNoAutomaticFix() async throws {
|
||||
let backend = ContainerCLIBackend(vmSources: nil, cli: CLIRunner(searchPaths: []))
|
||||
await #expect(throws: VMBackendError.self) {
|
||||
try await backend.fix(.cliOutdated(found: "1.3.0")) { _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct ContainerRuntimeTests {
|
||||
@Test func readsTheVersionFromTheCLIOutput() {
|
||||
#expect(ContainerRuntime.version(in: "container CLI version 1.4.1 (build: release, commit: 9a8917c)\n") == "1.4.1")
|
||||
#expect(ContainerRuntime.version(in: "warning: x\ncontainer CLI version 2.0 (build: debug)") == "2.0")
|
||||
#expect(ContainerRuntime.version(in: "container version 1.4.1") == nil)
|
||||
}
|
||||
|
||||
@Test func comparesVersionsNumerically() {
|
||||
#expect(ContainerRuntime.isOlder("1.3.9", than: "1.4.0"))
|
||||
#expect(ContainerRuntime.isOlder("1.4", than: "1.4.1"))
|
||||
#expect(!ContainerRuntime.isOlder("1.10.0", than: "1.4.0"))
|
||||
#expect(!ContainerRuntime.isOlder("1.4.0", than: "1.4"))
|
||||
}
|
||||
|
||||
@Test func judgesAnInstalledRuntime() {
|
||||
#expect(ContainerRuntime.status(versionOutput: nil) == .missing)
|
||||
#expect(ContainerRuntime.status(versionOutput: "container CLI version 1.3.1 (build: release)") == .outdated("1.3.1"))
|
||||
#expect(ContainerRuntime.status(versionOutput: "container CLI version 1.4.1 (build: release)") == .current("1.4.1"))
|
||||
#expect(ContainerRuntime.status(versionOutput: "container CLI version 9.0.0") == .current("9.0.0"))
|
||||
}
|
||||
|
||||
@Test func unreadableOutputNeverTriggersAnUpdate() {
|
||||
#expect(ContainerRuntime.status(versionOutput: "container 3 (a new format)") == .current(nil))
|
||||
#expect(ContainerRuntime.status(versionOutput: "") == .current(nil))
|
||||
}
|
||||
|
||||
@Test func thePinnedReleaseIsNewEnough() {
|
||||
#expect(!ContainerRuntime.isOlder(ContainerRuntime.pinned.version, than: ContainerRuntime.minimumVersion))
|
||||
#expect(ContainerRuntime.pinned.sha256.count == 64)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct ContentReadinessTests {
|
||||
private let t0 = ContinuousClock.now
|
||||
|
||||
@Test func ignoresBootstrapFramesAndProgress() {
|
||||
// A bootstrap 100 after NAVIGATED never gets here: the agent's progress gate drops it.
|
||||
var r = ContentReadiness()
|
||||
r.hello(at: t0)
|
||||
r.frame(isUniform: false)
|
||||
r.progress(100)
|
||||
#expect(!r.isReady)
|
||||
}
|
||||
|
||||
@Test func readyOnTheFirstNonUniformFrameAfterNavigated() {
|
||||
var r = ContentReadiness()
|
||||
r.hello(at: t0)
|
||||
r.navigated(at: t0)
|
||||
r.frame(isUniform: true)
|
||||
#expect(!r.isReady)
|
||||
r.frame(isUniform: false)
|
||||
#expect(r.isReady)
|
||||
}
|
||||
|
||||
@Test func readyOnProgress100AfterNavigated() {
|
||||
var r = ContentReadiness()
|
||||
r.hello(at: t0)
|
||||
r.navigated(at: t0)
|
||||
r.progress(40)
|
||||
#expect(!r.isReady)
|
||||
r.progress(100)
|
||||
#expect(r.isReady)
|
||||
}
|
||||
|
||||
@Test func readyFifteenSecondsAfterNavigated() {
|
||||
var r = ContentReadiness()
|
||||
r.hello(at: t0)
|
||||
r.navigated(at: t0 + .seconds(1))
|
||||
#expect(r.nextDeadline == t0 + .seconds(16))
|
||||
r.tick(now: t0 + .seconds(16) - .milliseconds(1))
|
||||
#expect(!r.isReady)
|
||||
r.tick(now: t0 + .seconds(16))
|
||||
#expect(r.isReady)
|
||||
#expect(r.nextDeadline == nil)
|
||||
}
|
||||
|
||||
@Test func readyTwentyFiveSecondsAfterHelloIfNavigatedNeverComes() {
|
||||
var r = ContentReadiness()
|
||||
#expect(r.nextDeadline == nil)
|
||||
r.hello(at: t0)
|
||||
#expect(r.nextDeadline == t0 + .seconds(25))
|
||||
r.tick(now: t0 + .seconds(24))
|
||||
#expect(!r.isReady)
|
||||
r.tick(now: t0 + .seconds(25))
|
||||
#expect(r.isReady)
|
||||
}
|
||||
|
||||
@Test func theHelloBackstopStillAppliesAfterALateNavigated() {
|
||||
var r = ContentReadiness()
|
||||
r.hello(at: t0)
|
||||
r.navigated(at: t0 + .seconds(20))
|
||||
#expect(r.nextDeadline == t0 + .seconds(25))
|
||||
r.tick(now: t0 + .seconds(25))
|
||||
#expect(r.isReady)
|
||||
}
|
||||
|
||||
@Test func aLateViewerIsCaughtUpByTheReplay() {
|
||||
var loaded = ContentReadiness()
|
||||
loaded.hello(at: t0)
|
||||
loaded.navigated(at: t0)
|
||||
loaded.progress(100)
|
||||
#expect(loaded.isReady)
|
||||
|
||||
var loading = ContentReadiness()
|
||||
loading.hello(at: t0)
|
||||
loading.navigated(at: t0)
|
||||
loading.progress(40)
|
||||
#expect(!loading.isReady)
|
||||
loading.frame(isUniform: false)
|
||||
#expect(loading.isReady)
|
||||
}
|
||||
|
||||
@Test func keepsTheFirstHelloAndNavigated() {
|
||||
// A repeated message can't push the timeouts back.
|
||||
var r = ContentReadiness()
|
||||
r.hello(at: t0)
|
||||
r.hello(at: t0 + .seconds(10))
|
||||
#expect(r.nextDeadline == t0 + .seconds(25))
|
||||
r.navigated(at: t0 + .seconds(5))
|
||||
r.navigated(at: t0 + .seconds(9))
|
||||
#expect(r.nextDeadline == t0 + .seconds(20))
|
||||
}
|
||||
|
||||
@Test func staysReadyOnceReady() {
|
||||
var r = ContentReadiness()
|
||||
r.hello(at: t0)
|
||||
r.navigated(at: t0)
|
||||
r.frame(isUniform: false)
|
||||
r.frame(isUniform: true)
|
||||
r.progress(10)
|
||||
#expect(r.isReady)
|
||||
}
|
||||
}
|
||||
|
||||
struct FrameSamplerTests {
|
||||
private func frame(width: Int, height: Int, bytesPerRow: Int) -> [UInt8] {
|
||||
var bytes = [UInt8](repeating: 0, count: height * bytesPerRow)
|
||||
for y in 0..<height {
|
||||
for x in 0..<width * 4 { bytes[y * bytesPerRow + x] = 0xFF }
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
/// Bytes that differ almost everywhere, so an out-of-bounds read can't look uniform by chance.
|
||||
private func ramp(_ count: Int) -> [UInt8] {
|
||||
(0..<count).map { UInt8(truncatingIfNeeded: $0 &* 37 &+ 1) }
|
||||
}
|
||||
|
||||
@Test func aFlatFrameIsUniform() {
|
||||
let bytes = frame(width: 96, height: 60, bytesPerRow: 96 * 4 + 16)
|
||||
#expect(bytes.withUnsafeBytes { FrameSampler.isUniform($0, width: 96, height: 60, bytesPerRow: 96 * 4 + 16) })
|
||||
}
|
||||
|
||||
@Test func aSampledPixelThatDiffersMakesItContent() {
|
||||
var bytes = frame(width: 96, height: 60, bytesPerRow: 96 * 4)
|
||||
// Grid point (gx 24, gy 24) samples pixel (48, 30).
|
||||
bytes[30 * 96 * 4 + 48 * 4] = 0
|
||||
#expect(!bytes.withUnsafeBytes { FrameSampler.isUniform($0, width: 96, height: 60, bytesPerRow: 96 * 4) })
|
||||
}
|
||||
|
||||
@Test func aTightBufferIsSampledAndOneByteShortIsRejected() {
|
||||
let (width, height, bytesPerRow) = (8, 8, 40)
|
||||
let bytes = ramp((height - 1) * bytesPerRow + width * 4)
|
||||
#expect(!bytes.withUnsafeBytes { FrameSampler.isUniform($0, width: width, height: height, bytesPerRow: bytesPerRow) })
|
||||
#expect(Array(bytes.dropLast()).withUnsafeBytes {
|
||||
FrameSampler.isUniform($0, width: width, height: height, bytesPerRow: bytesPerRow)
|
||||
})
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(8, 8, -32), (8, 8, 31), (8, 0, 32), (0, 8, 32), (8, -1, 32),
|
||||
(Int.max / 2, 8, Int.max), (8, Int.max / 2, 32), (8, 8, Int.max), (1 << 16, 1, 1 << 18),
|
||||
])
|
||||
func malformedGeometryCountsAsUniform(_ geometry: (width: Int, height: Int, bytesPerRow: Int)) {
|
||||
#expect(ramp(4096).withUnsafeBytes {
|
||||
FrameSampler.isUniform($0, width: geometry.width, height: geometry.height, bytesPerRow: geometry.bytesPerRow)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct H264Tests {
|
||||
private let aud: [UInt8] = [0x09, 0xF0]
|
||||
private let sei: [UInt8] = [0x06, 0x05, 0x01, 0xAA, 0x80]
|
||||
private let sps: [UInt8] = [0x67, 0x42, 0xC0, 0x1F, 0x8C]
|
||||
private let sps2: [UInt8] = [0x67, 0x42, 0xC0, 0x28, 0x8C]
|
||||
private let pps: [UInt8] = [0x68, 0xCE, 0x3C, 0x80]
|
||||
private let pps2: [UInt8] = [0x68, 0xCE, 0x3C, 0x81]
|
||||
private let idr: [UInt8] = [0x65, 0x88, 0x84, 0x21]
|
||||
private let slice: [UInt8] = [0x41, 0x9A, 0x02]
|
||||
|
||||
private let four: [UInt8] = [0, 0, 0, 1]
|
||||
private let three: [UInt8] = [0, 0, 1]
|
||||
|
||||
private func avcc(_ nals: [UInt8]...) -> Data {
|
||||
nals.reduce(into: Data()) { data, nal in
|
||||
data.appendBE32(UInt32(nal.count))
|
||||
data.append(contentsOf: nal)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func splitsOnThreeAndFourByteStartCodes() {
|
||||
let stream = Data(four + aud + three + sps + four + pps + three + idr)
|
||||
#expect(AnnexB.nalUnits(in: stream) == [aud, sps, pps, idr].map { Data($0) })
|
||||
}
|
||||
|
||||
@Test func ignoresLeadingBytesAndTrailingZeros() {
|
||||
let stream = Data([0xFF, 0x00] + three + slice + [0, 0] + four + slice + [0])
|
||||
#expect(AnnexB.nalUnits(in: stream) == [Data(slice), Data(slice)])
|
||||
}
|
||||
|
||||
@Test func handlesStreamsWithoutStartCodes() {
|
||||
#expect(AnnexB.nalUnits(in: Data()) == [])
|
||||
#expect(AnnexB.nalUnits(in: Data([0x65, 0x88])) == [])
|
||||
#expect(AnnexB.nalUnits(in: Data(four)) == [])
|
||||
#expect(AnnexB.nalUnits(in: Data(three + idr + three)) == [Data(idr)])
|
||||
}
|
||||
|
||||
@Test func turnsAnIDRAccessUnitIntoAnAVCCSampleWithNewParameterSets() {
|
||||
var parser = H264Parser()
|
||||
let unit = parser.parse(Data(four + aud + four + sps + four + pps + four + sei + four + idr + four + idr))
|
||||
#expect(unit.sample == avcc(idr, idr))
|
||||
#expect(unit.isIDR)
|
||||
#expect(unit.newParameterSets == ParameterSets(sps: Data(sps), pps: Data(pps)))
|
||||
#expect(parser.parameterSets == unit.newParameterSets)
|
||||
}
|
||||
|
||||
@Test func repeatedParameterSetsAreNotAChange() {
|
||||
var parser = H264Parser()
|
||||
_ = parser.parse(Data(four + sps + four + pps + four + idr))
|
||||
let again = parser.parse(Data(four + aud + four + sps + four + pps + four + idr))
|
||||
#expect(again.newParameterSets == nil)
|
||||
let delta = parser.parse(Data(four + aud + four + slice))
|
||||
#expect(delta.sample == avcc(slice))
|
||||
#expect(!delta.isIDR)
|
||||
#expect(delta.newParameterSets == nil)
|
||||
// Repeated with every IDR: not a change, so the decoder isn't rebuilt.
|
||||
let nextIDR = parser.parse(Data(four + aud + four + sps + four + pps + four + idr))
|
||||
#expect(nextIDR.newParameterSets == nil)
|
||||
}
|
||||
|
||||
@Test func aChangedSPSOrPPSIsANewFormat() {
|
||||
var parser = H264Parser()
|
||||
_ = parser.parse(Data(four + sps + four + pps + four + idr))
|
||||
let changedSPS = parser.parse(Data(four + sps2 + four + pps + four + idr))
|
||||
#expect(changedSPS.newParameterSets == ParameterSets(sps: Data(sps2), pps: Data(pps)))
|
||||
let changedPPS = parser.parse(Data(four + sps2 + four + pps2 + four + idr))
|
||||
#expect(changedPPS.newParameterSets == ParameterSets(sps: Data(sps2), pps: Data(pps2)))
|
||||
}
|
||||
|
||||
@Test func aHalfPairIsNeverAFormat() {
|
||||
var parser = H264Parser()
|
||||
let spsOnly = parser.parse(Data(four + sps + four + idr))
|
||||
#expect(spsOnly.newParameterSets == nil)
|
||||
#expect(parser.parameterSets == nil)
|
||||
|
||||
_ = parser.parse(Data(four + sps + four + pps + four + idr))
|
||||
let lone = parser.parse(Data(four + sps2 + four + slice))
|
||||
#expect(lone.newParameterSets == nil)
|
||||
#expect(parser.parameterSets == ParameterSets(sps: Data(sps), pps: Data(pps)))
|
||||
}
|
||||
|
||||
@Test func onlyValidSliceUnitsAreDecoded() {
|
||||
var parser = H264Parser()
|
||||
// Type 21, a 3D extension slice.
|
||||
let extensionSlice = parser.parse(Data(four + [0x75, 0x88]))
|
||||
#expect(extensionSlice.sample.isEmpty)
|
||||
#expect(!extensionSlice.isIDR)
|
||||
// Forbidden bit set.
|
||||
let forbidden = parser.parse(Data(four + [0xE5, 0x88] + four + slice))
|
||||
#expect(forbidden.sample == avcc(slice))
|
||||
#expect(!forbidden.isIDR)
|
||||
#expect(parser.parse(Data(four + idr + four + slice)).isIDR)
|
||||
let long = [UInt8(0x41)] + [UInt8](repeating: 0x9A, count: 299)
|
||||
#expect(parser.parse(Data(four + long)).sample.prefix(4) == Data([0, 0, 0x01, 0x2C]))
|
||||
}
|
||||
|
||||
@Test func slicesBeforeAnyParameterSetsHaveNoFormat() {
|
||||
var parser = H264Parser()
|
||||
let unit = parser.parse(Data(four + slice))
|
||||
#expect(unit.sample == avcc(slice))
|
||||
#expect(unit.newParameterSets == nil)
|
||||
#expect(parser.parameterSets == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct HostChecksTests {
|
||||
@Test func unsupportedMacsAlwaysSeeTheWindow() {
|
||||
#expect(SetupLaunchRule.shouldOpen(hostSupported: false, setupCompleted: true, runtime: .current("1.4.1")))
|
||||
}
|
||||
|
||||
@Test func theFirstLaunchAlwaysSeesTheWindow() {
|
||||
#expect(SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: false, runtime: .current("1.4.1")))
|
||||
}
|
||||
|
||||
@Test func anUnsupportedMacOrAFirstLaunchOpensItWhateverTheRuntime() {
|
||||
#expect(SetupLaunchRule.shouldOpen(hostSupported: false, setupCompleted: false, runtime: .missing))
|
||||
#expect(SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: false, runtime: .missing))
|
||||
#expect(SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: false, runtime: .outdated("1.3.0")))
|
||||
}
|
||||
|
||||
@Test func laterOnlyAMissingOrOldRuntimeOpensIt() {
|
||||
#expect(SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: true, runtime: .missing))
|
||||
#expect(SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: true, runtime: .outdated("1.3.0")))
|
||||
#expect(!SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: true, runtime: .current("1.4.1")))
|
||||
#expect(!SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: true, runtime: .current(nil)))
|
||||
}
|
||||
|
||||
@MainActor @Test func thisMacCanRunVirtualMachines() {
|
||||
#expect(SystemHost.unsupported() == nil)
|
||||
}
|
||||
|
||||
@Test func factsIncludeTheSystemAndFreeSpace() async {
|
||||
let facts = await SystemHost().facts()
|
||||
#expect(facts.osVersion.hasPrefix("macOS "))
|
||||
#expect((facts.bytesFree ?? 0) > 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct LinkIntakeTests {
|
||||
private func extract(_ text: String) -> String? {
|
||||
LinkIntake.extractURL(from: text)?.absoluteString
|
||||
}
|
||||
|
||||
@Test func findsAURLInsideProse() {
|
||||
#expect(extract("Your parcel is waiting at https://example.com/track?id=42 today") == "https://example.com/track?id=42")
|
||||
}
|
||||
|
||||
@Test func acceptsABareURL() {
|
||||
#expect(extract("http://example.com") == "http://example.com")
|
||||
}
|
||||
|
||||
@Test func stripsTrailingPunctuation() {
|
||||
#expect(extract("Go to https://example.com/page.") == "https://example.com/page")
|
||||
#expect(extract("**https://example.com/page**") == "https://example.com/page")
|
||||
#expect(extract("https://example.com/search?q=*") == "https://example.com/search?q=*")
|
||||
#expect(extract("Really? https://example.com/page!?") == "https://example.com/page")
|
||||
#expect(extract("'https://example.com/a'") == "https://example.com/a")
|
||||
#expect(extract("\"https://example.com/a\",") == "https://example.com/a")
|
||||
#expect(extract("see https://example.com/a;") == "https://example.com/a")
|
||||
}
|
||||
|
||||
@Test func keepsBalancedBracketsAndStripsUnbalancedOnes() {
|
||||
#expect(extract("https://en.wikipedia.org/wiki/Foo_(bar)") == "https://en.wikipedia.org/wiki/Foo_(bar)")
|
||||
#expect(extract("(see https://en.wikipedia.org/wiki/Foo_(bar)).") == "https://en.wikipedia.org/wiki/Foo_(bar)")
|
||||
#expect(extract("(see https://example.com/a)") == "https://example.com/a")
|
||||
#expect(extract("(https://example.com/a)and more") == "https://example.com/a")
|
||||
#expect(extract("(https://en.wikipedia.org/wiki/Foo_(bar))") == "https://en.wikipedia.org/wiki/Foo_(bar)")
|
||||
#expect(extract("[link](https://example.com/a)") == "https://example.com/a")
|
||||
#expect(extract("https://example.com/a]") == "https://example.com/a")
|
||||
#expect(extract("{https://example.com/a}") == "https://example.com/a")
|
||||
}
|
||||
|
||||
@Test func handlesMarkdownLinks() {
|
||||
#expect(extract("[https://example.com/a](https://example.com/a)") == "https://example.com/a")
|
||||
#expect(extract("[https://example.com/a](https://example.org/b)") == "https://example.com/a")
|
||||
#expect(extract("[httpbin](https://httpbin.org)") == "https://httpbin.org")
|
||||
#expect(extract("See [XMLHttpRequest](https://developer.mozilla.org/docs/XMLHttpRequest).")
|
||||
== "https://developer.mozilla.org/docs/XMLHttpRequest")
|
||||
#expect(extract("**[Example](https://example.com)**") == "https://example.com")
|
||||
#expect(extract("*[Example](https://example.com/a)*") == "https://example.com/a")
|
||||
#expect(extract("Get it: **[httpbin](https://httpbin.org)**.") == "https://httpbin.org")
|
||||
#expect(extract("[x](https://example.com)and more") == "https://example.com")
|
||||
#expect(extract("[w](https://en.wikipedia.org/wiki/Foo_(bar))**") == "https://en.wikipedia.org/wiki/Foo_(bar)")
|
||||
}
|
||||
|
||||
@Test func smartQuotesAndCJKPunctuationEndALink() {
|
||||
#expect(extract("\u{201C}https://example.com\u{201D}") == "https://example.com")
|
||||
#expect(extract("\u{201C}https://example.com/page\u{201D}") == "https://example.com/page")
|
||||
#expect(extract("\u{00AB}https://example.com/page\u{00BB}") == "https://example.com/page")
|
||||
#expect(extract("请访问https://example.com。谢谢") == "https://example.com")
|
||||
#expect(extract("请访问https://example.com.谢谢") == "https://example.com")
|
||||
#expect(extract("https://example.com。テスト") == "https://example.com")
|
||||
#expect(extract("见(https://example.com/a)") == "https://example.com/a")
|
||||
#expect(extract("「https://example.com/a」") == "https://example.com/a")
|
||||
}
|
||||
|
||||
@Test func unwrapsAngleBrackets() {
|
||||
#expect(extract("Open <https://example.com/a> now") == "https://example.com/a")
|
||||
}
|
||||
|
||||
@Test func usesTheFirstWebURL() {
|
||||
#expect(extract("ftp://files.example.com then https://b.example.com and https://c.example.com") == "https://b.example.com")
|
||||
}
|
||||
|
||||
@Test func textWithoutAWebLinkHasNoLink() {
|
||||
#expect(extract("javascript:alert(1)") == nil)
|
||||
#expect(extract("file:///etc/passwd") == nil)
|
||||
#expect(extract("mailto:someone@example.com") == nil)
|
||||
#expect(extract("ftp://files.example.com") == nil)
|
||||
#expect(extract("httpx://example.com") == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsEmptyAndHostlessInput() {
|
||||
#expect(extract("") == nil)
|
||||
#expect(extract(" \n") == nil)
|
||||
#expect(extract("no links here, just httpbin and https://") == nil)
|
||||
}
|
||||
|
||||
@Test func acceptsUppercaseSchemes() {
|
||||
#expect(extract("HTTPS://EXAMPLE.COM/") == "HTTPS://EXAMPLE.COM/")
|
||||
}
|
||||
|
||||
@Test func acceptsLinksUpToTheLengthLimit() {
|
||||
let base = "https://example.com/"
|
||||
let longest = base + String(repeating: "a", count: LinkIntake.maxURLBytes - base.utf8.count)
|
||||
#expect(extract(longest) == longest)
|
||||
#expect(extract("Go to " + longest + ".") == longest) // Measured after trimming.
|
||||
#expect(extract(longest + "a") == nil)
|
||||
// Measured as encoded: each `{` becomes `%7B`.
|
||||
#expect(extract(base + String(repeating: "{", count: 6000)) == nil)
|
||||
}
|
||||
|
||||
@Test func skipsAnOverlongTokenWholeIncludingLinksNestedInIt() {
|
||||
let overlong = "https://example.com/" + String(repeating: "a", count: LinkIntake.maxURLBytes)
|
||||
#expect(extract(overlong + "https://nested.example.com/") == nil)
|
||||
#expect(extract(overlong + " https://next.example.com/") == "https://next.example.com/")
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
String(repeating: "http", count: 256 * 1024),
|
||||
String(repeating: "http ", count: 100_000),
|
||||
String(repeating: "http" + String(repeating: ")", count: 16_000) + " ", count: 16),
|
||||
String(repeating: "http](", count: 200_000),
|
||||
])
|
||||
func hostileTextIsScannedInLinearTime(_ hostile: String) {
|
||||
// Quadratic versions took 10 s to minutes on these; linear takes well under a second.
|
||||
let start = ContinuousClock.now
|
||||
#expect(extract(hostile) == nil)
|
||||
#expect(ContinuousClock.now - start < .seconds(5))
|
||||
}
|
||||
|
||||
@Test func onlyLinksStartingNearTheStartOfHugeTextCount() {
|
||||
let filler = String(repeating: "x", count: LinkIntake.maxScannedCharacters)
|
||||
#expect(extract(filler + " https://example.com/") == nil)
|
||||
#expect(extract("https://example.com/ " + filler) == "https://example.com/")
|
||||
}
|
||||
|
||||
@Test func aLinkThatStartsBeforeTheScanLimitIsReadWhole() {
|
||||
let link = "https://example.com/very/long/path?token=abcdef"
|
||||
let filler = String(repeating: "x", count: LinkIntake.maxScannedCharacters - 30)
|
||||
#expect(extract(filler + " " + link) == link)
|
||||
}
|
||||
|
||||
@Test func findsTheLinkBehindSelectedHyperlinkText() {
|
||||
func extract(html: String) -> String? { LinkIntake.extractURL(fromHTML: html)?.absoluteString }
|
||||
#expect(extract(html: #"<a href="https://www.theverge.com/transportation/909060/amflows-latest-e-bikes">Amflow's latest e-bikes</a>"#)
|
||||
== "https://www.theverge.com/transportation/909060/amflows-latest-e-bikes")
|
||||
#expect(extract(html: #"<a href='https://www.google.com/url?q=https://example.com/&sa=D'>Verify</a>"#)
|
||||
== "https://www.google.com/url?q=https://example.com/&sa=D")
|
||||
#expect(extract(html: "<A HREF=https://example.com/a>x</A>") == "https://example.com/a")
|
||||
#expect(extract(html: #"<a href="mailto:a@example.com">mail</a> <a href="https://example.org/">web</a>"#) == "https://example.org/")
|
||||
#expect(extract(html: "<p>see https://example.com/page.</p>") == "https://example.com/page")
|
||||
#expect(extract(html: "<p>no links</p>") == nil)
|
||||
}
|
||||
|
||||
@Test func validatesURLObjects() throws {
|
||||
let web = try #require(URL(string: "https://example.com/x"))
|
||||
#expect(LinkIntake.validate(web) == web)
|
||||
#expect(LinkIntake.validate(try #require(URL(string: "HTTP://example.com/"))) != nil)
|
||||
#expect(LinkIntake.validate(URL(filePath: "/etc/passwd")) == nil)
|
||||
#expect(LinkIntake.validate(try #require(URL(string: "ftp://files.example.com/"))) == nil)
|
||||
#expect(LinkIntake.validate(try #require(URL(string: "mailto:a@example.com"))) == nil)
|
||||
#expect(LinkIntake.validate(try #require(URL(string: "https:///no-host"))) == nil)
|
||||
#expect(LinkIntake.validate(try #require(URL(string: "http://:80/"))) == nil)
|
||||
let long = "https://example.com/" + String(repeating: "a", count: LinkIntake.maxURLBytes)
|
||||
#expect(LinkIntake.validate(try #require(URL(string: long))) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@Suite struct NetworkRouteTests {
|
||||
static let key = Data(repeating: 7, count: 32).base64EncodedString()
|
||||
static let otherKey = Data(repeating: 9, count: 32).base64EncodedString()
|
||||
|
||||
static let mullvadStyle = """
|
||||
[Interface]
|
||||
# Device: Quick Fox
|
||||
PrivateKey = \(key)
|
||||
Address = 10.64.12.34/32,fc00:bbbb:bbbb:bb01::1:c21/128
|
||||
DNS = 10.64.0.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = \(otherKey)
|
||||
AllowedIPs = 0.0.0.0/0,::0/0
|
||||
Endpoint = se-got-wg-001.relays.mullvad.net:51820
|
||||
"""
|
||||
|
||||
@Test func parsesAProviderConfig() throws {
|
||||
let config = try WireGuardConfig.parse(Self.mullvadStyle)
|
||||
#expect(config.privateKey == Self.key)
|
||||
#expect(config.addresses == ["10.64.12.34/32"])
|
||||
#expect(config.dns == ["10.64.0.1"])
|
||||
#expect(config.peerPublicKey == Self.otherKey)
|
||||
#expect(config.endpointHost == "se-got-wg-001.relays.mullvad.net")
|
||||
#expect(config.endpointPort == 51820)
|
||||
#expect(config.mtu == nil)
|
||||
#expect(config.persistentKeepalive == nil)
|
||||
}
|
||||
|
||||
@Test func keysAreCaseInsensitiveAndRepeatsJoin() throws {
|
||||
let text = """
|
||||
[interface]
|
||||
privatekey=\(Self.key)
|
||||
Address = 10.2.0.2
|
||||
Address = 10.2.0.3/32
|
||||
DNS = 10.2.0.1, 1.1.1.1, example.search.domain
|
||||
MTU = 1380
|
||||
[PEER]
|
||||
publickey = \(Self.otherKey)
|
||||
PresharedKey = \(Self.key)
|
||||
endpoint = 203.0.113.9:443
|
||||
PersistentKeepalive = 15
|
||||
"""
|
||||
let config = try WireGuardConfig.parse(text)
|
||||
#expect(config.addresses == ["10.2.0.2/32", "10.2.0.3/32"])
|
||||
#expect(config.dns == ["10.2.0.1", "1.1.1.1"])
|
||||
#expect(config.mtu == 1380)
|
||||
#expect(config.presharedKey == Self.key)
|
||||
#expect(config.persistentKeepalive == 15)
|
||||
}
|
||||
|
||||
@Test func rejectsWhatTheVMCannotUse() {
|
||||
func parse(_ text: String) -> WireGuardConfig.ParseError? {
|
||||
do { _ = try WireGuardConfig.parse(text); return nil } catch { return error }
|
||||
}
|
||||
let peer = "[Peer]\nPublicKey = \(Self.otherKey)\nEndpoint = 1.2.3.4:51820\n"
|
||||
#expect(parse("[Interface]\nAddress = 10.0.0.2/32\n" + peer) == .missing("PrivateKey"))
|
||||
#expect(parse("[Interface]\nPrivateKey = nope\nAddress = 10.0.0.2/32\n" + peer) == .invalid("PrivateKey"))
|
||||
#expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = fd00::2/128\n" + peer) == .missing("an IPv4 Address"))
|
||||
#expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = 10.0.0.2/32\n") == .missing("[Peer]"))
|
||||
#expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = 10.0.0.2/32\n" + peer + peer) == .multiplePeers)
|
||||
let v6Endpoint = "[Peer]\nPublicKey = \(Self.otherKey)\nEndpoint = [2001:db8::1]:51820\n"
|
||||
#expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = 10.0.0.2/32\n" + v6Endpoint) != nil)
|
||||
let shellEndpoint = "[Peer]\nPublicKey = \(Self.otherKey)\nEndpoint = $(reboot):51820\n"
|
||||
#expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = 10.0.0.2/32\n" + shellEndpoint) == .invalid("Endpoint"))
|
||||
#expect(parse("[Interface]\nPrivateKey = \(Self.key)\nAddress = 10.0.0.2/32\nPostUp = rm -rf /\n" + peer) == nil)
|
||||
}
|
||||
|
||||
@Test func setconfForcesAFullTunnelAndAKeepalive() throws {
|
||||
let config = try WireGuardConfig.parse(Self.mullvadStyle)
|
||||
let setconf = config.setconf(endpointAddress: "185.213.154.66")
|
||||
#expect(setconf == """
|
||||
[Interface]
|
||||
PrivateKey = \(Self.key)
|
||||
|
||||
[Peer]
|
||||
PublicKey = \(Self.otherKey)
|
||||
Endpoint = 185.213.154.66:51820
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
PersistentKeepalive = 25
|
||||
|
||||
""")
|
||||
}
|
||||
|
||||
@Test func proxyURLsEncodeCredentialsAndUseTheAddress() {
|
||||
let proxy = ProxySettings(kind: .socks5, host: "proxy.example", port: 1080, username: "me@home", password: "p:w/d@!")
|
||||
#expect(proxy.url(address: "203.0.113.7") == "socks5://me%40home:p%3Aw%2Fd%40%21@203.0.113.7:1080")
|
||||
#expect(ProxySettings(kind: .http, host: "h", port: 8080).url(address: "10.0.0.5") == "http://10.0.0.5:8080")
|
||||
#expect(ProxySettings(kind: .http, host: "h", port: 8080, username: "u").url(address: "10.0.0.5") == "http://u@10.0.0.5:8080")
|
||||
}
|
||||
|
||||
@Test func launchKeepsSecretsOutOfTheEnvironment() throws {
|
||||
var asked: [String] = []
|
||||
let resolve = { (host: String) -> String in asked.append(host); return "198.51.100.4" }
|
||||
#expect(try NetworkRoute.direct.launch(resolve: resolve) == RouteLaunch(environment: ["KNOTCHY_ROUTE=direct"], secrets: []))
|
||||
|
||||
let proxy = NetworkRoute.proxy(ProxySettings(kind: .http, host: "proxy.corp", port: 3128, username: "u", password: "p"))
|
||||
#expect(try proxy.launch(resolve: resolve) == RouteLaunch(
|
||||
environment: ["KNOTCHY_ROUTE=proxy", "KNOTCHY_PROXY_ADDR=198.51.100.4:3128"],
|
||||
secrets: ["KNOTCHY_PROXY_URL=http://u:p@198.51.100.4:3128"]
|
||||
))
|
||||
|
||||
let wireGuard = try NetworkRoute.wireGuard(WireGuardConfig.parse(Self.mullvadStyle)).launch(resolve: resolve)
|
||||
#expect(asked == ["proxy.corp", "se-got-wg-001.relays.mullvad.net"])
|
||||
#expect(wireGuard.environment == [
|
||||
"KNOTCHY_ROUTE=wireguard", "KNOTCHY_WG_ENDPOINT=198.51.100.4:51820", "KNOTCHY_WG_ADDRESS=10.64.12.34/32",
|
||||
"KNOTCHY_WG_DNS=10.64.0.1", "KNOTCHY_WG_MTU=1420",
|
||||
])
|
||||
#expect(wireGuard.environment.allSatisfy { !$0.contains(Self.key) })
|
||||
#expect(wireGuard.secrets.count == 1 && wireGuard.secrets[0].hasPrefix("KNOTCHY_WG_SETCONF="))
|
||||
#expect((wireGuard.environment + wireGuard.secrets).allSatisfy { !$0.contains("\n") })
|
||||
|
||||
#expect(throws: VMBackendError.failed("no")) { try NetworkRoute.unusable(reason: "no").launch(resolve: resolve) }
|
||||
}
|
||||
|
||||
@Test func routeLabels() throws {
|
||||
#expect(NetworkRoute.direct.label == nil)
|
||||
#expect(NetworkRoute.proxy(ProxySettings(kind: .socks5, host: "h", port: 1)).label == "SOCKS5 proxy")
|
||||
var config = try WireGuardConfig.parse(Self.mullvadStyle)
|
||||
#expect(NetworkRoute.wireGuard(config).label == "VPN")
|
||||
config.name = "sg-sin-wg-001"
|
||||
#expect(NetworkRoute.wireGuard(config).label == "sg-sin-wg-001")
|
||||
}
|
||||
|
||||
@Test func proxiesOnTheMacOrItsNetworkAreRefused() throws {
|
||||
for local in ["10.1.2.3", "127.0.0.1", "169.254.169.254", "172.20.0.1", "192.168.64.1", "100.100.1.1", "0.0.0.0"] {
|
||||
#expect(ProxySettings.isLocal(local, hostNetworks: []), "\(local)")
|
||||
}
|
||||
#expect(ProxySettings.isLocal("203.0.113.9", hostNetworks: ["203.0.113.0/24"]))
|
||||
#expect(!ProxySettings.isLocal("203.0.113.9", hostNetworks: ["198.51.100.0/24"]))
|
||||
#expect(!ProxySettings.isLocal("8.8.8.8", hostNetworks: []))
|
||||
#expect(!ProxySettings.isLocal("172.32.0.1", hostNetworks: []))
|
||||
|
||||
let proxy = NetworkRoute.proxy(ProxySettings(kind: .http, host: "proxy.lan", port: 3128))
|
||||
#expect(throws: VMBackendError.self) { try proxy.launch(resolve: { _ in "192.168.1.10" }) }
|
||||
#expect(try proxy.launch(resolve: { _ in "192.168.1.10" }, allowLocalProxy: true).environment.count == 2)
|
||||
// A local WireGuard server is fine: only wireguard-go can reach it.
|
||||
let wireGuard = NetworkRoute.wireGuard(try WireGuardConfig.parse(Self.mullvadStyle))
|
||||
#expect(try wireGuard.launch(resolve: { _ in "192.168.1.1" }).environment.contains("KNOTCHY_WG_ENDPOINT=192.168.1.1:51820"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import CoreGraphics
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct NotchGeometryTests {
|
||||
private let macBookPro = ScreenInfo(
|
||||
frame: CGRect(x: 0, y: 0, width: 1512, height: 982),
|
||||
visibleFrame: CGRect(x: 0, y: 80, width: 1512, height: 870),
|
||||
safeAreaTop: 32,
|
||||
auxLeft: CGRect(x: 0, y: 950, width: 662, height: 32),
|
||||
auxRight: CGRect(x: 850, y: 950, width: 662, height: 32),
|
||||
menuBarThickness: 24
|
||||
)
|
||||
|
||||
private let external = ScreenInfo(
|
||||
frame: CGRect(x: 1512, y: -200, width: 1920, height: 1080),
|
||||
visibleFrame: CGRect(x: 1512, y: -200, width: 1920, height: 1055),
|
||||
safeAreaTop: 0, auxLeft: .zero, auxRight: .zero, menuBarThickness: 24
|
||||
)
|
||||
|
||||
@Test func peekUnderAPhysicalNotch() {
|
||||
let layout = NotchGeometry.layout(for: macBookPro, mode: .peek)
|
||||
#expect(layout.notch == CGRect(x: 662, y: 950, width: 188, height: 32))
|
||||
#expect(layout.pill == CGRect(x: 590, y: 950, width: 332, height: 32))
|
||||
#expect(layout.panel == CGRect(x: 430, y: 510, width: 652, height: 472))
|
||||
#expect(layout.toolbar == CGRect(x: 436, y: 916, width: 640, height: 34))
|
||||
#expect(layout.content == CGRect(x: 436, y: 516, width: 640, height: 400))
|
||||
#expect(layout.contentScale == 0.625)
|
||||
}
|
||||
|
||||
@Test func expandedUnderAPhysicalNotch() {
|
||||
let layout = NotchGeometry.layout(for: macBookPro, mode: .expanded)
|
||||
#expect(layout.panel == CGRect(x: 238, y: 270, width: 1036, height: 712))
|
||||
#expect(layout.content == CGRect(x: 244, y: 276, width: 1024, height: 640))
|
||||
#expect(layout.contentScale == 1)
|
||||
}
|
||||
|
||||
@Test func theNotchHeightIsTheSafeArea() {
|
||||
var screen = macBookPro
|
||||
screen.safeAreaTop = 37
|
||||
screen.auxLeft = CGRect(x: 0, y: 945, width: 662, height: 37)
|
||||
screen.auxRight = CGRect(x: 850, y: 945, width: 662, height: 37)
|
||||
let layout = NotchGeometry.layout(for: screen, mode: .peek)
|
||||
#expect(layout.notch == CGRect(x: 662, y: 945, width: 188, height: 37))
|
||||
#expect(layout.panel.height == CGFloat(37 + 34 + 400 + 6))
|
||||
}
|
||||
|
||||
@Test func aNotchNeedsASafeAreaAndBothMenuBarAreas() {
|
||||
let virtualNotch = CGRect(x: 656, y: 950, width: 200, height: 32)
|
||||
var noRightArea = macBookPro
|
||||
noRightArea.auxRight = .zero
|
||||
#expect(!noRightArea.hasNotch)
|
||||
#expect(NotchGeometry.layout(for: noRightArea, mode: .peek).notch == virtualNotch)
|
||||
|
||||
var noSafeArea = macBookPro
|
||||
noSafeArea.safeAreaTop = 0
|
||||
#expect(!noSafeArea.hasNotch)
|
||||
#expect(NotchGeometry.layout(for: noSafeArea, mode: .peek).notch == virtualNotch)
|
||||
}
|
||||
|
||||
@Test func asymmetricEarsAndNotchWidthClamping() {
|
||||
var screen = macBookPro
|
||||
screen.auxLeft = CGRect(x: 0, y: 950, width: 600, height: 32)
|
||||
screen.auxRight = CGRect(x: 850, y: 950, width: 662, height: 32)
|
||||
#expect(NotchGeometry.layout(for: screen, mode: .peek).notch == CGRect(x: 600, y: 950, width: 250, height: 32))
|
||||
|
||||
screen.auxLeft = CGRect(x: 0, y: 950, width: 500, height: 32)
|
||||
screen.auxRight = CGRect(x: 1000, y: 950, width: 512, height: 32)
|
||||
#expect(NotchGeometry.layout(for: screen, mode: .peek).notch.width == 300)
|
||||
|
||||
screen.auxLeft = CGRect(x: 0, y: 950, width: 700, height: 32)
|
||||
screen.auxRight = CGRect(x: 760, y: 950, width: 752, height: 32)
|
||||
#expect(NotchGeometry.layout(for: screen, mode: .peek).notch == CGRect(x: 670, y: 950, width: 120, height: 32))
|
||||
}
|
||||
|
||||
@Test func virtualNotchOnAScreenWithoutOne() {
|
||||
let layout = NotchGeometry.layout(for: external, mode: .peek)
|
||||
#expect(layout.notch == CGRect(x: 2372, y: 855, width: 200, height: 25))
|
||||
#expect(layout.panel == CGRect(x: 2146, y: 415, width: 652, height: 465))
|
||||
#expect(layout.content == CGRect(x: 2152, y: 421, width: 640, height: 400))
|
||||
}
|
||||
|
||||
@Test func aScreenLeftOfTheMainOne() {
|
||||
var screen = external
|
||||
screen.frame.origin = CGPoint(x: -1920, y: 0)
|
||||
screen.visibleFrame.origin = CGPoint(x: -1920, y: 0)
|
||||
let layout = NotchGeometry.layout(for: screen, mode: .peek)
|
||||
#expect(layout.notch == CGRect(x: -1060, y: 1055, width: 200, height: 25))
|
||||
#expect(layout.panel == CGRect(x: -1286, y: 615, width: 652, height: 465))
|
||||
}
|
||||
|
||||
@Test func autoHiddenMenuBarKeepsAVisibleNotch() {
|
||||
var screen = external
|
||||
screen.visibleFrame = screen.frame
|
||||
screen.menuBarThickness = 0
|
||||
#expect(NotchGeometry.layout(for: screen, mode: .peek).notch.height == 24)
|
||||
|
||||
screen.menuBarThickness = 30
|
||||
#expect(NotchGeometry.layout(for: screen, mode: .peek).notch.height == 30)
|
||||
}
|
||||
|
||||
@Test func expandedScalesDownOnAShortScreenKeeping16by10() {
|
||||
let short = ScreenInfo(
|
||||
frame: CGRect(x: 0, y: 0, width: 1280, height: 720),
|
||||
visibleFrame: CGRect(x: 0, y: 60, width: 1280, height: 636),
|
||||
safeAreaTop: 0, auxLeft: .zero, auxRight: .zero, menuBarThickness: 24
|
||||
)
|
||||
let layout = NotchGeometry.layout(for: short, mode: .expanded)
|
||||
// Usable height: 720 - 60 (Dock) - 24 (notch) - 34 - 6 - 8 = 588, rounded down to 585.
|
||||
#expect(layout.content.size == CGSize(width: 936, height: 585))
|
||||
#expect(layout.content.width / layout.content.height == 1.6)
|
||||
#expect(layout.panel.minY >= short.visibleFrame.minY)
|
||||
#expect(layout.contentScale == CGFloat(936) / 1024)
|
||||
#expect(NotchGeometry.layout(for: short, mode: .peek).content.size == CGSize(width: 640, height: 400))
|
||||
}
|
||||
|
||||
@Test func expandedScalesDownOnANarrowScreen() {
|
||||
let portrait = ScreenInfo(
|
||||
frame: CGRect(x: 0, y: 0, width: 900, height: 1600),
|
||||
visibleFrame: CGRect(x: 0, y: 0, width: 900, height: 1575),
|
||||
safeAreaTop: 0, auxLeft: .zero, auxRight: .zero, menuBarThickness: 25
|
||||
)
|
||||
let layout = NotchGeometry.layout(for: portrait, mode: .expanded)
|
||||
// Usable width: 900 - 16 - 12 = 872, which is exactly 16:10 at 545 high.
|
||||
#expect(layout.panel == CGRect(x: 8, y: 990, width: 884, height: 610))
|
||||
#expect(layout.content == CGRect(x: 14, y: 996, width: 872, height: 545))
|
||||
}
|
||||
|
||||
@Test func aVirtualNotchIsOnWholePoints() {
|
||||
var odd = external
|
||||
odd.frame.size.width = 1365
|
||||
odd.visibleFrame.size.width = 1365
|
||||
let layout = NotchGeometry.layout(for: odd, mode: .peek)
|
||||
#expect(layout.notch.minX == layout.notch.minX.rounded())
|
||||
#expect(layout.panel.minX == layout.panel.minX.rounded())
|
||||
}
|
||||
|
||||
@Test func mapsSurfacePointsToRenderPixels() {
|
||||
let render = PixelSize(width: 1024, height: 640)
|
||||
let peek = CGSize(width: 640, height: 400)
|
||||
func point(_ p: CGPoint, _ surface: CGSize) -> [UInt16]? {
|
||||
NotchGeometry.videoPoint(p, surfaceSize: surface, renderSize: render).map { [$0.x, $0.y] }
|
||||
}
|
||||
#expect(point(CGPoint(x: 320, y: 200), peek) == [512, 320])
|
||||
#expect(point(.zero, peek) == [0, 0])
|
||||
#expect(point(CGPoint(x: 0.5, y: 0.5), peek) == [0, 0])
|
||||
#expect(point(CGPoint(x: 640, y: 400), peek) == [1023, 639])
|
||||
#expect(point(CGPoint(x: -5, y: 900), peek) == [0, 639])
|
||||
// Letterboxing: a wider surface gets 30 pt bars at the sides, a taller one 50 pt above and below.
|
||||
#expect(point(CGPoint(x: 30, y: 0), CGSize(width: 700, height: 400)) == [0, 0])
|
||||
#expect(point(CGPoint(x: 0, y: 450), CGSize(width: 640, height: 500)) == [0, 639])
|
||||
#expect(point(CGPoint(x: 0, y: 50), CGSize(width: 640, height: 500)) == [0, 0])
|
||||
}
|
||||
|
||||
@Test func nothingToMapOntoGivesNoPoint() {
|
||||
let render = PixelSize(width: 1024, height: 640)
|
||||
let peek = CGSize(width: 640, height: 400)
|
||||
#expect(NotchGeometry.videoPoint(.zero, surfaceSize: .zero, renderSize: render) == nil)
|
||||
#expect(NotchGeometry.videoPoint(CGPoint(x: CGFloat.nan, y: 5), surfaceSize: peek, renderSize: render) == nil)
|
||||
#expect(NotchGeometry.videoPoint(.zero, surfaceSize: CGSize(width: CGFloat.infinity, height: CGFloat.infinity), renderSize: render) == nil)
|
||||
#expect(NotchGeometry.videoPoint(.zero, surfaceSize: CGSize(width: CGFloat.leastNonzeroMagnitude, height: 1), renderSize: render) == nil)
|
||||
#expect(NotchGeometry.videoPoint(.zero, surfaceSize: peek, renderSize: PixelSize(width: 70_000, height: 640)) == nil)
|
||||
#expect(NotchGeometry.videoPoint(.zero, surfaceSize: peek, renderSize: PixelSize(width: 0, height: 640)) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
// Serialized: each download clears leftover download folders, which other tests would still be using.
|
||||
@Suite(.serialized)
|
||||
struct RuntimeInstallerTests {
|
||||
private func release(for data: Data, url: URL) -> ContainerRuntime.Release {
|
||||
let hash = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||
return ContainerRuntime.Release(version: "1.4.1", url: url, sha256: hash, size: Int64(data.count))
|
||||
}
|
||||
|
||||
private func source(_ data: Data) throws -> URL {
|
||||
let file = FileManager.default.temporaryDirectory.appendingPathComponent("source-\(UUID().uuidString).pkg")
|
||||
try data.write(to: file)
|
||||
return file
|
||||
}
|
||||
|
||||
private static func downloadDirectories() -> Set<String> {
|
||||
let names = (try? FileManager.default.contentsOfDirectory(atPath: NSTemporaryDirectory())) ?? []
|
||||
return Set(names.filter { $0.hasPrefix("knotchy-runtime-") })
|
||||
}
|
||||
|
||||
@Test func aMatchingDownloadIsKeptUntilDiscarded() async throws {
|
||||
let data = Data("package".utf8)
|
||||
let url = try source(data)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
let installer = SystemRuntimeInstaller()
|
||||
let package = try await installer.download(release(for: data, url: url)) { _ in }
|
||||
#expect(try Data(contentsOf: package.file) == data)
|
||||
#expect(package.file.lastPathComponent == url.lastPathComponent)
|
||||
installer.discard(package)
|
||||
#expect(!FileManager.default.fileExists(atPath: package.directory.path))
|
||||
}
|
||||
|
||||
@Test func aMismatchedDownloadIsDeleted() async throws {
|
||||
let url = try source(Data("tampered".utf8))
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
let expected = release(for: Data("package".utf8), url: url)
|
||||
await #expect(throws: DownloadError.mismatch) {
|
||||
_ = try await SystemRuntimeInstaller().download(expected) { _ in }
|
||||
}
|
||||
#expect(Self.downloadDirectories().isEmpty)
|
||||
}
|
||||
|
||||
@Test func aNewDownloadClearsLeftovers() async throws {
|
||||
let leftover = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("knotchy-runtime-LEFTOVER")
|
||||
try FileManager.default.createDirectory(at: leftover, withIntermediateDirectories: true)
|
||||
let data = Data("package".utf8)
|
||||
let url = try source(data)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
let installer = SystemRuntimeInstaller()
|
||||
let package = try await installer.download(release(for: data, url: url)) { _ in }
|
||||
#expect(!FileManager.default.fileExists(atPath: leftover.path))
|
||||
installer.discard(package)
|
||||
}
|
||||
|
||||
@Test func aFailedDownloadSaysSo() async {
|
||||
let missing = URL(fileURLWithPath: "/nonexistent/container.pkg")
|
||||
await #expect(throws: DownloadError.self) {
|
||||
_ = try await SystemRuntimeInstaller().download(release(for: Data(), url: missing)) { _ in }
|
||||
}
|
||||
}
|
||||
|
||||
// Only this path of the real `openInInstaller` may run in tests: any other would open Installer.
|
||||
@MainActor
|
||||
@Test func aChangedPackageIsNotOpened() async throws {
|
||||
let data = Data("package".utf8)
|
||||
let url = try source(data)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
let installer = SystemRuntimeInstaller()
|
||||
let expected = release(for: data, url: url)
|
||||
let package = try await installer.download(expected) { _ in }
|
||||
try Data("pAckage".utf8).write(to: package.file) // Same size, so the hash has to catch it.
|
||||
guard case .changed = await installer.openInInstaller(package, release: expected) else {
|
||||
Issue.record("Expected the changed package not to be opened")
|
||||
installer.discard(package)
|
||||
return
|
||||
}
|
||||
#expect(!FileManager.default.fileExists(atPath: package.directory.path))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SessionLifecycleTests {
|
||||
private let first = "knotchy-00000001"
|
||||
private let second = "knotchy-00000002"
|
||||
|
||||
@Test func launchSweepsOrphansFirst() async {
|
||||
let h = Harness()
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: [])])
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
|
||||
@Test func aLinkBootsConnectsAndLoadsThenPeeks() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.start)
|
||||
h.connector.hold()
|
||||
h.controller.open(Harness.url)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
#expect(h.controller.openedURL == Harness.url)
|
||||
#expect(h.controller.liveIDs.snapshot == [first])
|
||||
|
||||
await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(first)])
|
||||
#expect(h.backend.startArguments == [.init(id: first, url: Harness.url,
|
||||
renderSize: PixelSize(width: 1024, height: 640),
|
||||
blockCIDRs: ["192.168.1.0/24"])])
|
||||
|
||||
h.backend.release(.start)
|
||||
await waitUntil("connecting") { h.state == .starting(.connecting, folded: false) }
|
||||
await waitUntil("connect runs") { h.connector.parkedCount == 1 }
|
||||
#expect(h.connector.socketPaths == ["/fake/\(first).sock"])
|
||||
|
||||
h.connector.release()
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
let stream = h.connector.connections[0]
|
||||
#expect(h.controller.connection === stream)
|
||||
|
||||
stream.emit(.hello(version: 1, size: SessionController.renderSize))
|
||||
stream.emit(.contentReady)
|
||||
await waitUntil("live") { h.state == .live(.peek) }
|
||||
}
|
||||
|
||||
@Test func thePageURLFollowsTheVMButNeverReplacesTheOpenedLink() async {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
#expect(h.controller.pageURL == nil)
|
||||
let next = URL(string: "https://example.org/next")!
|
||||
stream.emit(.pageURL(next))
|
||||
await waitUntil("the page URL shows") { h.controller.pageURL == next }
|
||||
#expect(h.controller.openedURL == Harness.url)
|
||||
|
||||
h.controller.open(URL(string: "https://example.net/")!)
|
||||
#expect(h.controller.pageURL == nil)
|
||||
}
|
||||
|
||||
@Test func destroyIsConfirmedBeforeDestroyedShowsThenGoesIdle() async {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
h.backend.hold(.destroy)
|
||||
h.controller.destroy()
|
||||
#expect(h.state == .destroying)
|
||||
#expect(stream.isClosed)
|
||||
#expect(h.controller.connection == nil)
|
||||
|
||||
await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
|
||||
#expect(h.state == .destroying)
|
||||
#expect(h.controller.liveIDs.snapshot == [first])
|
||||
|
||||
h.backend.release(.destroy)
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
#expect(h.controller.liveIDs.snapshot.isEmpty)
|
||||
h.advance(by: .milliseconds(1499))
|
||||
#expect(h.state == .destroyed)
|
||||
h.advance(by: .milliseconds(1))
|
||||
#expect(h.state == .idle)
|
||||
#expect(h.controller.openedURL == nil)
|
||||
}
|
||||
|
||||
@Test(arguments: [LiveMode.peek, .expanded, .collapsed])
|
||||
func destroyWorksFromEveryLiveMode(_ mode: LiveMode) async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.enter(mode)
|
||||
#expect(h.state == .live(mode))
|
||||
h.controller.destroy()
|
||||
#expect(h.state == .destroying)
|
||||
#expect(h.controller.countdown == nil)
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
}
|
||||
|
||||
@Test func destroyDuringPreflightNeverCallsTheBackendsDestroy() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.preflight)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("preflight runs") { h.backend.parkedCount(.preflight) == 1 }
|
||||
h.controller.destroy()
|
||||
h.backend.release(.preflight)
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight])
|
||||
#expect(h.controller.liveIDs.snapshot.isEmpty)
|
||||
}
|
||||
|
||||
@Test func destroyWhileConnectingClosesTheLateStream() async {
|
||||
let h = Harness()
|
||||
h.connector.hold()
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("connect runs") { h.connector.parkedCount == 1 }
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.connector.release()
|
||||
await waitUntil("the late stream is closed") { h.connector.connections.first?.isClosed == true }
|
||||
#expect(h.state == .destroyed)
|
||||
#expect(h.controller.connection == nil)
|
||||
h.advance(by: .milliseconds(1500))
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
|
||||
@Test func aConnectThatFailsAfterDestroyIsIgnored() async {
|
||||
let h = Harness()
|
||||
h.connector.hold()
|
||||
h.connector.failNextConnect(with: .timedOut)
|
||||
h.backend.hold(.destroy)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("connect runs") { h.connector.parkedCount == 1 }
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
|
||||
h.connector.release()
|
||||
await h.controller.waitForSessionTasks()
|
||||
#expect(h.state == .destroying) // Not the failed connect's error card.
|
||||
h.backend.release(.destroy)
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
}
|
||||
|
||||
@Test func destroyWhileBootingDestroysTheVMOnceStartReturns() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.start)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
|
||||
h.controller.destroy()
|
||||
#expect(h.state == .destroying)
|
||||
h.backend.release(.start)
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(first), .destroy(first)])
|
||||
#expect(h.connector.socketPaths.isEmpty)
|
||||
}
|
||||
|
||||
@Test func aStuckDestroyShowsTheAmberPillAndCanBeRetried() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
|
||||
h.controller.destroy()
|
||||
await waitUntil("stuck") { h.state == .failed(.destroyStuck) }
|
||||
#expect(h.controller.liveIDs.snapshot == [first])
|
||||
|
||||
h.backend.failDestroys(with: nil)
|
||||
h.controller.retry()
|
||||
#expect(h.state == .destroying)
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
#expect(h.backend.calls.filter { $0 == .destroy(first) }.count == 2)
|
||||
#expect(h.controller.liveIDs.snapshot.isEmpty)
|
||||
}
|
||||
|
||||
@Test func aRetryNeverRetriesItself() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
|
||||
h.controller.destroy()
|
||||
await waitUntil("stuck") { h.state == .failed(.destroyStuck) }
|
||||
|
||||
h.controller.retry() // Fails again: its own job must not also force-destroy the VM.
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.state == .failed(.destroyStuck))
|
||||
|
||||
h.backend.failDestroys(with: nil)
|
||||
h.controller.retry()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(!h.backend.calls.contains(.forceDestroy(first)))
|
||||
}
|
||||
|
||||
@Test func theStuckPillGoesIdleAfter30sAndTheVMStaysListed() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
|
||||
h.controller.destroy()
|
||||
await waitUntil("stuck") { h.state == .failed(.destroyStuck) }
|
||||
h.advance(by: .seconds(29))
|
||||
#expect(h.state == .failed(.destroyStuck))
|
||||
h.advance(by: .seconds(1))
|
||||
#expect(h.state == .idle)
|
||||
#expect(h.controller.liveIDs.snapshot == [first])
|
||||
}
|
||||
|
||||
@Test func eachLaterJobRetriesAStuckVMOnce() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
|
||||
h.controller.destroy()
|
||||
await waitUntil("stuck") { h.state == .failed(.destroyStuck) }
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(!h.backend.calls.contains(.forceDestroy(first)))
|
||||
|
||||
h.backend.failDestroys(with: nil)
|
||||
h.backend.failForceDestroys(with: VMBackendError.destroyStuck(id: first))
|
||||
await h.goLive(Harness.otherURL)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls.filter { $0 == .forceDestroy(first) }.count == 1)
|
||||
#expect(h.controller.liveIDs.snapshot == [first, second])
|
||||
|
||||
h.backend.failForceDestroys(with: nil)
|
||||
h.controller.destroy() // Destroying `second` is a later job: `first` gets another retry.
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls.filter { $0 == .forceDestroy(first) }.count == 2)
|
||||
#expect(h.controller.liveIDs.snapshot.isEmpty)
|
||||
}
|
||||
|
||||
@Test func stuckVMsTakeTurnsForRetries() async {
|
||||
let h = Harness()
|
||||
h.backend.failDestroys(with: VMBackendError.destroyStuck(id: "any"))
|
||||
h.backend.failForceDestroys(with: VMBackendError.destroyStuck(id: "any"))
|
||||
await h.goLive()
|
||||
h.controller.destroy()
|
||||
await waitUntil("the first VM is stuck") { h.state == .failed(.destroyStuck) }
|
||||
await h.goLive(Harness.otherURL)
|
||||
h.controller.destroy()
|
||||
await waitUntil("the second VM is stuck") { h.state == .failed(.destroyStuck) }
|
||||
await h.goLive()
|
||||
await h.controller.waitForBackendIdle()
|
||||
let retries = h.backend.calls.filter { if case .forceDestroy = $0 { true } else { false } }
|
||||
#expect(retries == [.forceDestroy(first), .forceDestroy(first), .forceDestroy(second)])
|
||||
}
|
||||
|
||||
@Test func aStartFailureShowsTheErrorCardAndQueuesDestroy() async {
|
||||
let h = Harness()
|
||||
h.backend.failStarts(with: VMBackendError.failed("container run timed out"))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("failed") { h.state == .failed(.start("container run timed out")) }
|
||||
await waitUntil("the VM is destroyed") { h.backend.calls.contains(.destroy(first)) }
|
||||
await waitUntil("gone") { h.controller.liveIDs.snapshot.isEmpty }
|
||||
|
||||
h.backend.failStarts(with: nil)
|
||||
h.controller.retry()
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
#expect(h.controller.openedURL == Harness.url)
|
||||
await waitUntil("the retry starts") { h.backend.calls.contains(.start(second)) }
|
||||
}
|
||||
|
||||
@Test func dismissingTheErrorCardGoesIdle() async {
|
||||
let h = Harness()
|
||||
h.backend.failStarts(with: VMBackendError.failed("boom"))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("failed") { h.state == .failed(.start("boom")) }
|
||||
h.controller.dismiss()
|
||||
#expect(h.state == .idle)
|
||||
#expect(h.controller.openedURL == nil)
|
||||
}
|
||||
|
||||
@Test func aConnectFailureFailsTheStart() async {
|
||||
let h = Harness()
|
||||
h.connector.failNextConnect(with: .timedOut)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("failed") { h.state == .failed(.start("The VM didn't answer in time.")) }
|
||||
await waitUntil("the VM is destroyed") { h.backend.calls.contains(.destroy(first)) }
|
||||
}
|
||||
|
||||
@Test func aVersionMismatchAsksForARebuildAndDestroysTheVM() async {
|
||||
let h = Harness()
|
||||
h.connector.failNextConnect(with: .versionMismatch(found: 0, need: 1))
|
||||
h.backend.hold(.destroy)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") {
|
||||
h.state == .setupRequired(.imageOutdated(found: 0, need: 1), error: nil)
|
||||
}
|
||||
await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
|
||||
#expect(h.controller.liveIDs.snapshot == [first]) // Kept until the destroy is confirmed.
|
||||
h.backend.release(.destroy)
|
||||
await waitUntil("gone") { h.controller.liveIDs.snapshot.isEmpty }
|
||||
}
|
||||
|
||||
@Test func theStreamEndingWhileStartingFailsTheStart() async {
|
||||
let h = Harness()
|
||||
let stream = await h.openAndConnect()
|
||||
stream.emit(.ended(.noFirstFrame))
|
||||
await waitUntil("failed") { h.state == .failed(.start("The VM didn't send a first frame in time.")) }
|
||||
await waitUntil("the VM is destroyed") { h.backend.calls.contains(.destroy(first)) }
|
||||
}
|
||||
|
||||
@Test func theStreamEndingWhileLiveShowsSessionEndedFor3s() async {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
stream.emit(.ended(.closed))
|
||||
await waitUntil("ended") { h.state == .failed(.ended(.closed)) }
|
||||
await waitUntil("the VM is destroyed") { h.backend.calls.contains(.destroy(first)) }
|
||||
h.advance(by: .milliseconds(2999))
|
||||
#expect(h.state == .failed(.ended(.closed)))
|
||||
h.advance(by: .milliseconds(1))
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
|
||||
@Test(arguments: [LiveMode.peek, .expanded, .collapsed])
|
||||
func theStreamEndingInEveryLiveModeShowsSessionEnded(_ mode: LiveMode) async {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
h.enter(mode)
|
||||
stream.emit(.ended(.decodeErrors))
|
||||
await waitUntil("ended") { h.state == .failed(.ended(.decodeErrors)) }
|
||||
#expect(h.controller.countdown == nil)
|
||||
#expect(h.controller.connection == nil)
|
||||
}
|
||||
|
||||
@Test func aStreamThatFinishesWithoutEndedStillEnds() async {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
stream.finishEvents()
|
||||
await waitUntil("ended") { h.state == .failed(.ended(.closed)) }
|
||||
await waitUntil("the VM is destroyed") { h.backend.calls.contains(.destroy(first)) }
|
||||
}
|
||||
|
||||
@Test func theAppQuitsItselfAfter60sIdle() {
|
||||
let h = Harness()
|
||||
h.advance(by: .seconds(59))
|
||||
#expect(h.recorder.quitRequests == 0)
|
||||
h.advance(by: .seconds(1))
|
||||
#expect(h.recorder.quitRequests == 1)
|
||||
}
|
||||
|
||||
@Test func theIdleQuitTimerRestartsWheneverIdleStarts() async {
|
||||
let h = Harness()
|
||||
h.advance(by: .seconds(30))
|
||||
await h.goLive()
|
||||
h.advance(by: .seconds(60))
|
||||
#expect(h.recorder.quitRequests == 0)
|
||||
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.advance(by: .milliseconds(1500))
|
||||
#expect(h.state == .idle)
|
||||
h.advance(by: .seconds(59))
|
||||
#expect(h.recorder.quitRequests == 0)
|
||||
h.advance(by: .seconds(1))
|
||||
#expect(h.recorder.quitRequests == 1)
|
||||
}
|
||||
|
||||
@Test func noLinkOpensOnceTheChainIsClosedForQuitting() async {
|
||||
let h = Harness()
|
||||
h.controller.closeBackendChain()
|
||||
h.controller.open(Harness.url)
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
|
||||
@Test func theQuitSnapshotIncludesAStartInFlight() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.start)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
|
||||
h.controller.closeBackendChain()
|
||||
#expect(h.controller.liveIDs.snapshot == [first])
|
||||
|
||||
// Nothing may destroy the VM behind the quit path's back.
|
||||
h.backend.release(.start)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(!h.backend.calls.contains(.destroy(first)))
|
||||
#expect(h.controller.liveIDs.snapshot == [first])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SessionNewLinkTests {
|
||||
private let first = "knotchy-00000001"
|
||||
private let second = "knotchy-00000002"
|
||||
|
||||
@Test func fromLiveTheOldVMIsDestroyedAndTheNewOneStartsAtOnce() async {
|
||||
let h = Harness()
|
||||
let old = await h.goLive()
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
#expect(h.controller.openedURL == Harness.otherURL)
|
||||
#expect(old.isClosed)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(first), .destroy(first), .preflight, .start(second)])
|
||||
}
|
||||
|
||||
@Test func aStartThatHasNotBegunNeverRuns() async {
|
||||
let h = Harness()
|
||||
h.controller.open(Harness.url)
|
||||
h.controller.open(Harness.otherURL)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(second)])
|
||||
#expect(h.controller.liveIDs.snapshot == [second])
|
||||
}
|
||||
|
||||
@Test func aSupersededStartIsDestroyedRightAfterItReturns() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.start)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.controller.liveIDs.snapshot == [first, second])
|
||||
|
||||
h.backend.release(.start)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(first), .destroy(first), .preflight, .start(second)])
|
||||
await waitUntil("the new session connects") { h.connector.socketPaths == ["/fake/\(second).sock"] }
|
||||
#expect(h.controller.liveIDs.snapshot == [second])
|
||||
}
|
||||
|
||||
@Test func aConnectThatReturnsAfterANewLinkIsClosed() async {
|
||||
let h = Harness()
|
||||
h.connector.hold()
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("connect runs") { h.connector.parkedCount == 1 }
|
||||
h.backend.hold(.start)
|
||||
h.controller.open(Harness.otherURL)
|
||||
await waitUntil("the new start runs") { h.backend.parkedCount(.start) == 1 }
|
||||
|
||||
h.connector.release()
|
||||
await waitUntil("the late stream is closed") { h.connector.connections.first?.isClosed == true }
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
#expect(h.controller.connection == nil)
|
||||
|
||||
h.backend.release(.start)
|
||||
await waitUntil("the new session connects") { h.state == .starting(.loading, folded: false) }
|
||||
#expect((h.controller.connection as? FakeStreamConnection)?.socketPath == "/fake/\(second).sock")
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .start(first), .destroy(first), .preflight, .start(second)])
|
||||
}
|
||||
|
||||
@Test func aSupersededStartThatFailsLeavesTheNewSessionAlone() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.start)
|
||||
h.backend.hold(.destroy)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
|
||||
h.controller.open(Harness.otherURL)
|
||||
h.backend.failStarts(with: VMBackendError.failed("boom"))
|
||||
h.backend.release(.start)
|
||||
await waitUntil("the old VM's destroy runs") { h.backend.parkedCount(.destroy) == 1 }
|
||||
#expect(h.state == .starting(.booting, folded: false)) // Not the old start's error card.
|
||||
h.backend.failStarts(with: nil)
|
||||
h.backend.release(.destroy)
|
||||
await waitUntil("the new session connects") { h.state == .starting(.loading, folded: false) }
|
||||
}
|
||||
|
||||
@Test func anOldSessionsStuckDestroyDoesNotDriveTheCurrentPill() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.backend.hold(.destroy)
|
||||
h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
|
||||
h.controller.open(Harness.otherURL)
|
||||
h.controller.destroy() // The new session never started a VM, so this one succeeds.
|
||||
h.backend.release(.destroy)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.state == .destroyed)
|
||||
}
|
||||
|
||||
@Test func liveIDsCoverASupersededSessionUntilItsDestroyIsConfirmed() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.backend.hold(.destroy)
|
||||
h.controller.open(Harness.otherURL)
|
||||
await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
|
||||
#expect(h.controller.liveIDs.snapshot == [first, second])
|
||||
h.backend.release(.destroy)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.controller.liveIDs.snapshot == [second])
|
||||
}
|
||||
|
||||
@Test func fromDestroyingTheNewSessionStartsAfterTheDestroy() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.backend.hold(.destroy)
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroy runs") { h.backend.parkedCount(.destroy) == 1 }
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
|
||||
h.backend.release(.destroy)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls.suffix(3) == [.destroy(first), .preflight, .start(second)])
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
}
|
||||
|
||||
@Test func fromTheCollapsedPillTheNewLinkOpensThePanel() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.clickedOutside()
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
#expect(h.controller.countdown == nil)
|
||||
}
|
||||
|
||||
@Test func fromDestroyedTheCheckmarkIsCutShort() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
h.advance(by: .seconds(2))
|
||||
#expect(h.state != .idle)
|
||||
}
|
||||
|
||||
@Test func fromSessionEndedThePillIsCutShort() async {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
stream.emit(.ended(.closed))
|
||||
await waitUntil("ended") { h.state == .failed(.ended(.closed)) }
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
h.advance(by: .seconds(3))
|
||||
#expect(h.state != .idle)
|
||||
}
|
||||
|
||||
@Test func fromTheErrorCardTheNewLinkStarts() async {
|
||||
let h = Harness()
|
||||
h.backend.failStarts(with: VMBackendError.failed("boom"))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("failed") { h.state == .failed(.start("boom")) }
|
||||
h.backend.failStarts(with: nil)
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
await waitUntil("the new start runs") { h.backend.calls.contains(.start(second)) }
|
||||
}
|
||||
|
||||
@Test func fromAStuckDestroyTheStuckVMStaysOnPendingCleanup() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.backend.failDestroys(with: VMBackendError.destroyStuck(id: first))
|
||||
h.backend.failForceDestroys(with: VMBackendError.destroyStuck(id: first))
|
||||
h.controller.destroy()
|
||||
await waitUntil("stuck") { h.state == .failed(.destroyStuck) }
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.controller.liveIDs.snapshot == [first, second])
|
||||
}
|
||||
|
||||
@Test func fromSetupRequiredPreflightRunsAgain() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .preflight, .start(second)])
|
||||
}
|
||||
|
||||
@Test func backendCallsNeverOverlap() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.start)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("start runs") { h.backend.parkedCount(.start) == 1 }
|
||||
h.controller.open(Harness.otherURL)
|
||||
h.controller.open(Harness.url)
|
||||
h.controller.destroy()
|
||||
h.controller.open(Harness.otherURL)
|
||||
h.backend.release(.start)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.maxConcurrentCalls == 1)
|
||||
#expect(h.controller.liveIDs.snapshot == ["knotchy-00000004"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SessionPanelTests {
|
||||
@Test func aFreshPanelStaysOpenUntilEnteredOrFor30s() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.advance(by: .milliseconds(29_999))
|
||||
#expect(h.state == .live(.peek))
|
||||
h.advance(by: .milliseconds(1))
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func theFirstClickOutsideFoldsAnUnengagedPanel() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.clickedOutside()
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func anEnteredPeekFolds400msAfterThePointerLeaves() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.pointerEntered()
|
||||
h.advance(by: .seconds(60))
|
||||
#expect(h.state == .live(.peek)) // Entered: the 30 s fold no longer applies.
|
||||
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .milliseconds(399))
|
||||
#expect(h.state == .live(.peek))
|
||||
h.advance(by: .milliseconds(1))
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func comingBackWithinTheGraceKeepsItOpen() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.pointerEntered()
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .milliseconds(200))
|
||||
h.controller.pointerEntered()
|
||||
h.advance(by: .seconds(1))
|
||||
#expect(h.state == .live(.peek))
|
||||
}
|
||||
|
||||
@Test func afterAClickInsideOnlyAClickOutsideFolds() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.pointerEntered()
|
||||
h.controller.panelClicked()
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .seconds(60))
|
||||
#expect(h.state == .live(.peek))
|
||||
h.controller.clickedOutside()
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func expandedFoldsOnlyOnAClickOutside() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.pointerEntered()
|
||||
h.controller.expand()
|
||||
#expect(h.state == .live(.expanded))
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .seconds(60))
|
||||
#expect(h.state == .live(.expanded))
|
||||
|
||||
h.controller.shrink()
|
||||
#expect(h.state == .live(.peek))
|
||||
h.controller.pointerEntered()
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .seconds(1))
|
||||
#expect(h.state == .live(.peek)) // Still key after Expanded: pointer-away doesn't fold.
|
||||
|
||||
h.controller.expand()
|
||||
h.controller.clickedOutside()
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func expandAndShrinkOnlyApplyToTheirModes() async {
|
||||
let h = Harness()
|
||||
h.controller.expand()
|
||||
#expect(h.state == .idle)
|
||||
await h.goLive()
|
||||
h.controller.shrink()
|
||||
#expect(h.state == .live(.peek))
|
||||
h.controller.clickedOutside()
|
||||
h.controller.expand()
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func hoveringThePillReopensPeek() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
h.controller.clickedOutside()
|
||||
h.controller.pointerEntered()
|
||||
#expect(h.state == .live(.peek))
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .milliseconds(400))
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func theIdleCountdownRunsOnlyWhileCollapsedAndResetsOnReopen() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
#expect(h.controller.countdown == nil)
|
||||
h.controller.clickedOutside()
|
||||
#expect(h.controller.countdown == .seconds(300))
|
||||
|
||||
h.advance(by: .seconds(240))
|
||||
#expect(h.controller.countdown == .seconds(60))
|
||||
h.controller.pointerEntered()
|
||||
#expect(h.controller.countdown == nil)
|
||||
h.advance(by: .seconds(120))
|
||||
#expect(h.state == .live(.peek))
|
||||
|
||||
h.controller.clickedOutside()
|
||||
#expect(h.controller.countdown == .seconds(300))
|
||||
h.advance(by: .seconds(300) - .milliseconds(1))
|
||||
#expect(h.state == .live(.collapsed))
|
||||
h.advance(by: .milliseconds(1))
|
||||
#expect(h.state == .destroying)
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
}
|
||||
|
||||
@Test func decodingPausesWhileCollapsed() async {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
h.controller.clickedOutside()
|
||||
#expect(stream.decodeChanges == [false])
|
||||
h.controller.pointerEntered()
|
||||
#expect(stream.decodeChanges == [false, true])
|
||||
}
|
||||
|
||||
@Test func foldingWhileStartingShowsThePillAndStaysFoldedWhenReady() async {
|
||||
let h = Harness()
|
||||
h.connector.hold()
|
||||
h.controller.open(Harness.url)
|
||||
h.controller.clickedOutside()
|
||||
#expect(h.state == .starting(.booting, folded: true))
|
||||
|
||||
await waitUntil("connecting") { h.state == .starting(.connecting, folded: true) }
|
||||
h.connector.release()
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: true) }
|
||||
|
||||
let stream = h.connector.connections[0]
|
||||
stream.emit(.contentReady)
|
||||
await waitUntil("collapsed") { h.state == .live(.collapsed) }
|
||||
#expect(h.controller.countdown == .seconds(300))
|
||||
#expect(stream.decodeChanges == [false])
|
||||
}
|
||||
|
||||
@Test func hoveringTheStartingPillUnfoldsIt() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.start)
|
||||
h.controller.open(Harness.url)
|
||||
h.controller.clickedOutside()
|
||||
h.controller.pointerEntered()
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .milliseconds(400))
|
||||
#expect(h.state == .starting(.booting, folded: true))
|
||||
h.backend.release(.start)
|
||||
}
|
||||
|
||||
@Test func aGraceFoldStartedWhileStartingCarriesIntoPeek() async {
|
||||
let h = Harness()
|
||||
let stream = await h.openAndConnect()
|
||||
h.controller.pointerEntered()
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .milliseconds(200))
|
||||
stream.emit(.contentReady)
|
||||
await waitUntil("live") { h.state == .live(.peek) }
|
||||
h.advance(by: .milliseconds(200))
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func retryKeepsThePanelEnteredWhenThePointerIsOnIt() async {
|
||||
let h = Harness()
|
||||
h.backend.failStarts(with: VMBackendError.failed("boom"))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("failed") { h.state == .failed(.start("boom")) }
|
||||
h.backend.failStarts(with: nil)
|
||||
h.controller.pointerEntered()
|
||||
h.controller.retry()
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
h.connector.connections[0].emit(.contentReady)
|
||||
await waitUntil("live") { h.state == .live(.peek) }
|
||||
|
||||
h.advance(by: .seconds(30))
|
||||
#expect(h.state == .live(.peek))
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: .milliseconds(400))
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func pointerInputDoesNothingWithoutAnOpenPanel() async {
|
||||
let h = Harness()
|
||||
h.controller.clickedOutside()
|
||||
h.controller.pointerEntered()
|
||||
h.controller.pointerExited()
|
||||
h.controller.panelClicked()
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SessionProgressTests {
|
||||
@Test func showsTheLatestProgressWhileStarting() async {
|
||||
let h = Harness()
|
||||
let stream = await h.openAndConnect()
|
||||
stream.emit(.progress(10))
|
||||
await waitUntil("10 %") { h.controller.progress == 10 }
|
||||
stream.emit(.progress(60))
|
||||
await waitUntil("60 %") { h.controller.progress == 60 }
|
||||
h.advance(by: .seconds(10))
|
||||
#expect(h.controller.progress == 60) // No hiding before content is ready.
|
||||
}
|
||||
|
||||
@Test func hidesAtProgress100() async {
|
||||
let h = Harness()
|
||||
let stream = await h.openAndConnect()
|
||||
stream.emit(.progress(30))
|
||||
await waitUntil("30 %") { h.controller.progress == 30 }
|
||||
stream.emit(.progress(100))
|
||||
await waitUntil("hidden") { h.controller.progress == nil }
|
||||
}
|
||||
|
||||
@Test func onceReadyItHides2sAfterTheLastProgress() async {
|
||||
let h = Harness()
|
||||
let stream = await h.openAndConnect()
|
||||
stream.emit(.progress(30))
|
||||
stream.emit(.contentReady)
|
||||
await waitUntil("live") { h.state == .live(.peek) }
|
||||
#expect(h.controller.progress == 30)
|
||||
h.advance(by: .milliseconds(1999))
|
||||
#expect(h.controller.progress == 30)
|
||||
h.advance(by: .milliseconds(1))
|
||||
#expect(h.controller.progress == nil)
|
||||
}
|
||||
|
||||
@Test func aLoadThatNeverReports100StillHidesWhenReady() async {
|
||||
let h = Harness()
|
||||
let stream = await h.openAndConnect()
|
||||
stream.emit(.progress(30))
|
||||
await waitUntil("30 %") { h.controller.progress == 30 }
|
||||
h.advance(by: .seconds(5))
|
||||
stream.emit(.contentReady)
|
||||
await waitUntil("live") { h.state == .live(.peek) }
|
||||
#expect(h.controller.progress == nil)
|
||||
}
|
||||
|
||||
@Test func endingASessionHidesTheBar() async {
|
||||
let h = Harness()
|
||||
let stream = await h.openAndConnect()
|
||||
stream.emit(.progress(40))
|
||||
await waitUntil("40 %") { h.controller.progress == 40 }
|
||||
stream.emit(.ended(.closed))
|
||||
await waitUntil("failed") { h.state == .failed(.start("The VM closed the connection.")) }
|
||||
#expect(h.controller.progress == nil)
|
||||
}
|
||||
|
||||
@Test func progressAfterReadyShowsAgainThenHides() async {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
stream.emit(.progress(20))
|
||||
await waitUntil("20 %") { h.controller.progress == 20 }
|
||||
h.advance(by: .seconds(2))
|
||||
#expect(h.controller.progress == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SessionToolbarTests {
|
||||
@Test func navigateSendsTheURLAndUpdatesTheAddress() async throws {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
let url = try #require(URL(string: "https://example.net/page"))
|
||||
h.controller.navigate(url)
|
||||
#expect(stream.sent == [.navigate("https://example.net/page")])
|
||||
#expect(h.controller.openedURL == url)
|
||||
}
|
||||
|
||||
@Test func backForwardAndReloadRunScripts() async {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
h.controller.goBack()
|
||||
h.controller.goForward()
|
||||
h.controller.reload()
|
||||
#expect(stream.sent == [.script("history.back()"), .script("history.forward()"), .script("location.reload()")])
|
||||
}
|
||||
|
||||
@Test func toolbarActionsNeedALiveSession() async {
|
||||
let h = Harness()
|
||||
let stream = await h.openAndConnect()
|
||||
h.controller.reload()
|
||||
h.controller.navigate(Harness.otherURL)
|
||||
#expect(stream.sent.isEmpty)
|
||||
#expect(h.controller.openedURL == Harness.url)
|
||||
}
|
||||
|
||||
@Test func aNonWebLinkShowsANoticeFor2s() async throws {
|
||||
let h = Harness()
|
||||
h.controller.open(try #require(URL(string: "ftp://files.example.com")))
|
||||
#expect(h.controller.notice == .noWebLink)
|
||||
#expect(h.state == .idle)
|
||||
h.advance(by: .milliseconds(1999))
|
||||
#expect(h.controller.notice == .noWebLink)
|
||||
h.advance(by: .milliseconds(1))
|
||||
#expect(h.controller.notice == nil)
|
||||
}
|
||||
|
||||
@Test func navigateRejectsNonWebLinks() async throws {
|
||||
let h = Harness()
|
||||
let stream = await h.goLive()
|
||||
h.controller.navigate(try #require(URL(string: "file:///etc/passwd")))
|
||||
#expect(stream.sent.isEmpty)
|
||||
#expect(h.controller.notice == .noWebLink)
|
||||
#expect(h.controller.openedURL == Harness.url)
|
||||
}
|
||||
|
||||
@Test func textWithoutALinkShowsANotice() {
|
||||
let h = Harness()
|
||||
h.controller.open(text: "call me maybe")
|
||||
#expect(h.controller.notice == .noWebLink)
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
|
||||
@Test func textWithALinkOpensIt() {
|
||||
let h = Harness()
|
||||
h.controller.open(text: "Your parcel: https://example.com/ (tracking)")
|
||||
#expect(h.state == .starting(.booting, folded: false))
|
||||
#expect(h.controller.openedURL == Harness.url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SessionRuntimeInstallTests {
|
||||
@Test func aFreshInstallHoldsLinksThenStartsTheServiceAndBuilds() async {
|
||||
let h = Harness()
|
||||
let hold = await h.controller.beginRuntimeInstall(replacing: nil)
|
||||
#expect((try? hold.get()) != nil)
|
||||
#expect(h.state == .settingUp(.cliMissing))
|
||||
#expect(h.controller.setupFolded)
|
||||
h.controller.open(Harness.url)
|
||||
#expect(h.state == .settingUp(.cliMissing))
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: [])]) // The link waits: no preflight, no start.
|
||||
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
#expect(await h.controller.finishRuntimeInstall(.installed) == .ready)
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(Array(h.backend.calls.prefix(5))
|
||||
== [.sweep(keeping: []), .fix(.serviceStopped), .preflight, .fix(.imageMissing), .preflight])
|
||||
}
|
||||
|
||||
@Test func anUpdateStopsTheServiceFirst() async {
|
||||
let h = Harness()
|
||||
let hold = await h.controller.beginRuntimeInstall(replacing: "1.3.0")
|
||||
#expect((try? hold.get()) != nil)
|
||||
#expect(h.state == .settingUp(.cliOutdated(found: "1.3.0")))
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .stopService])
|
||||
}
|
||||
|
||||
@Test func aServiceThatWontStopEndsTheHold() async {
|
||||
let h = Harness()
|
||||
h.backend.failStopService(with: VMBackendError.failed("still running"))
|
||||
guard case .failure(let refusal) = await h.controller.beginRuntimeInstall(replacing: "1.3.0") else {
|
||||
Issue.record("The hold should have been refused")
|
||||
return
|
||||
}
|
||||
#expect(refusal == .stopFailed("still running"))
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
|
||||
@Test func theHoldWaitsForTheBrowserToClose() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
guard case .failure(let refusal) = await h.controller.beginRuntimeInstall(replacing: "1.3.0") else {
|
||||
Issue.record("The hold should have been refused")
|
||||
return
|
||||
}
|
||||
#expect(refusal == .sessionOpen)
|
||||
#expect(h.state == .live(.peek))
|
||||
#expect(!h.backend.calls.contains(.stopService))
|
||||
}
|
||||
|
||||
@Test func theHoldIsAcceptedAfterAFailedStart() async {
|
||||
let h = Harness()
|
||||
h.backend.failStarts(with: VMBackendError.failed("boot failed"))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("the start fails") { if case .failed(.start) = h.state { true } else { false } }
|
||||
let hold = await h.controller.beginRuntimeInstall(replacing: nil)
|
||||
#expect((try? hold.get()) != nil)
|
||||
#expect(h.state == .settingUp(.cliMissing))
|
||||
}
|
||||
|
||||
@Test func aCancelledInstallGoesBackToIdle() async {
|
||||
let h = Harness()
|
||||
_ = await h.controller.beginRuntimeInstall(replacing: nil)
|
||||
#expect(await h.controller.finishRuntimeInstall(.cancelled) == .needs(.cliMissing))
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
|
||||
@Test func aFailedInstallShowsItsCardToAWaitingLink() async {
|
||||
let h = Harness()
|
||||
_ = await h.controller.beginRuntimeInstall(replacing: nil)
|
||||
h.controller.open(Harness.url)
|
||||
#expect(await h.controller.finishRuntimeInstall(.failed("Couldn't install the runtime."))
|
||||
== .failed(.cliMissing, "Couldn't install the runtime."))
|
||||
#expect(h.state == .setupRequired(.cliMissing, error: "Couldn't install the runtime."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SessionSetupRunTests {
|
||||
@Test func aWindowRunFixesEachIssueInTurnAndEndsIdle() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
#expect(await h.controller.runSetup(.serviceStopped, continuing: true) == .ready)
|
||||
#expect(h.state == .idle)
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .fix(.serviceStopped), .preflight, .fix(.imageMissing), .preflight])
|
||||
}
|
||||
|
||||
@Test func aWindowRunStartsFolded() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.fix)
|
||||
let run = Task { await h.controller.runSetup(.imageMissing, continuing: false) }
|
||||
await waitUntil("building") { h.backend.parkedCount(.fix) == 1 }
|
||||
#expect(h.state == .settingUp(.imageMissing))
|
||||
#expect(h.controller.setupFolded)
|
||||
h.backend.release(.fix)
|
||||
#expect(await run.value == .ready)
|
||||
}
|
||||
|
||||
@Test func aWindowRunNeverFixesTheSameIssueTwice() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.serviceStopped))
|
||||
#expect(await h.controller.runSetup(.serviceStopped, continuing: true)
|
||||
== .failed(.serviceStopped, SessionController.stillBrokenMessage))
|
||||
#expect(h.state == .idle)
|
||||
#expect(h.backend.calls.filter { $0 == .fix(.serviceStopped) }.count == 1)
|
||||
}
|
||||
|
||||
@Test func anotherImageIssueAfterABuildIsTheSameFix() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageOutdated(found: 2, need: 3)))
|
||||
#expect(await h.controller.runSetup(.imageMissing, continuing: true)
|
||||
== .failed(.imageOutdated(found: 2, need: 3), SessionController.stillBrokenMessage))
|
||||
#expect(h.backend.calls.filter { if case .fix = $0 { true } else { false } }.count == 1)
|
||||
}
|
||||
|
||||
@Test func aFailedWindowRunLeavesNoCardWithoutALink() async {
|
||||
let h = Harness()
|
||||
h.backend.scriptFix(output: [], error: VMBackendError.failed("container build failed"))
|
||||
#expect(await h.controller.runSetup(.imageMissing, continuing: true) == .failed(.imageMissing, "container build failed"))
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
|
||||
@Test func aLinkDuringAWindowRunWaitsForTheWholeChain() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.backend.hold(.fix)
|
||||
let run = Task { await h.controller.runSetup(.serviceStopped, continuing: true) }
|
||||
await waitUntil("starting the service") { h.backend.parkedCount(.fix) == 1 }
|
||||
h.controller.open(Harness.url)
|
||||
#expect(h.state == .settingUp(.serviceStopped))
|
||||
#expect(!h.controller.setupFolded) // A waiting link shows its card.
|
||||
h.backend.release(.fix)
|
||||
#expect(await run.value == .ready)
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(Array(h.backend.calls.prefix(5))
|
||||
== [.sweep(keeping: []), .fix(.serviceStopped), .preflight, .fix(.imageMissing), .preflight])
|
||||
}
|
||||
|
||||
@Test func aBusyControllerRefusesTheWindow() async {
|
||||
let h = Harness()
|
||||
await h.goLive()
|
||||
#expect(await h.controller.runSetup(.imageMissing, continuing: true) == nil)
|
||||
#expect(await h.controller.checkSetup() == nil)
|
||||
}
|
||||
|
||||
@Test func checkingStartsAStoppedServiceButNeverBuilds() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.serviceStopped), .issue(.imageMissing))
|
||||
#expect(await h.controller.checkSetup() == .needs(.imageMissing))
|
||||
#expect(h.state == .idle)
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .fix(.serviceStopped), .preflight])
|
||||
}
|
||||
|
||||
@Test func checkingOpensALinkACardWasHolding() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
#expect(await h.controller.checkSetup() == .ready)
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(h.controller.openedURL == Harness.url)
|
||||
}
|
||||
|
||||
@Test func checkingAReadyMacFixesNothing() async {
|
||||
let h = Harness()
|
||||
#expect(await h.controller.checkSetup() == .ready)
|
||||
#expect(!h.backend.calls.contains { if case .fix = $0 { true } else { false } })
|
||||
}
|
||||
|
||||
@Test func setupOutputKeepsTheLast200Lines() async {
|
||||
let h = Harness()
|
||||
h.backend.scriptFix(output: (1...250).map { "line \($0)" })
|
||||
_ = await h.controller.runSetup(.imageMissing, continuing: false)
|
||||
#expect(h.controller.setupOutput.count == 200)
|
||||
#expect(h.controller.setupOutput.first == "line 51")
|
||||
#expect(h.controller.setupOutput.last == "line 250")
|
||||
}
|
||||
|
||||
@Test func waitingForSetupReturnsOnlyWhenItEnds() async {
|
||||
let h = Harness()
|
||||
h.backend.hold(.fix)
|
||||
let run = Task { await h.controller.runSetup(.imageMissing, continuing: false) }
|
||||
await waitUntil("building") { h.backend.parkedCount(.fix) == 1 }
|
||||
let waiter = Task {
|
||||
await h.controller.waitWhileSettingUp()
|
||||
return h.state
|
||||
}
|
||||
await Task.yield()
|
||||
h.backend.release(.fix)
|
||||
#expect(await waiter.value == .idle)
|
||||
_ = await run.value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
@Suite(.timeLimit(.minutes(1)))
|
||||
struct SessionSetupTests {
|
||||
@Test func aPreflightIssueNeedsSetupWithoutStartingAVM() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight])
|
||||
#expect(h.controller.liveIDs.snapshot.isEmpty)
|
||||
}
|
||||
|
||||
@Test func theSetupActionShowsItsOutputThenTheLinkOpens() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.backend.scriptFix(output: ["Step 1/9", "Step 9/9"])
|
||||
h.backend.hold(.fix)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
|
||||
h.controller.performSetupAction()
|
||||
#expect(h.state == .settingUp(.imageMissing))
|
||||
await waitUntil("the output shows") { h.controller.setupLog == "Step 9/9" }
|
||||
|
||||
h.backend.release(.fix)
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(h.controller.openedURL == Harness.url)
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .fix(.imageMissing), .preflight, .start("knotchy-00000002")])
|
||||
}
|
||||
|
||||
@Test func aSetupActionFoldsAwayAndItsLinkStartsFolded() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.backend.hold(.fix)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
h.controller.clickedOutside()
|
||||
#expect(!h.controller.setupFolded) // Only a running action folds.
|
||||
|
||||
h.controller.performSetupAction()
|
||||
h.controller.clickedOutside()
|
||||
#expect(h.controller.setupFolded)
|
||||
h.controller.pointerEntered()
|
||||
#expect(!h.controller.setupFolded)
|
||||
h.controller.foldSetup()
|
||||
#expect(h.controller.setupFolded)
|
||||
|
||||
h.backend.release(.fix)
|
||||
await waitUntil("loading, folded") { h.state == .starting(.loading, folded: true) }
|
||||
#expect(!h.controller.setupFolded)
|
||||
}
|
||||
|
||||
@Test func aFoldedSetupOpenedByHoveringFoldsWhenThePointerLeaves() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.backend.hold(.fix)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
h.controller.performSetupAction()
|
||||
h.controller.foldSetup()
|
||||
|
||||
h.controller.pointerEntered()
|
||||
#expect(!h.controller.setupFolded)
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: SessionController.foldGrace)
|
||||
#expect(h.controller.setupFolded)
|
||||
|
||||
h.controller.showPanel()
|
||||
h.controller.pointerEntered()
|
||||
h.controller.pointerExited()
|
||||
h.advance(by: SessionController.foldGrace)
|
||||
#expect(!h.controller.setupFolded)
|
||||
}
|
||||
|
||||
@Test func aFailedSetupShowsTheErrorOnTheCard() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing))
|
||||
h.backend.scriptFix(output: [], error: VMBackendError.failed("container build failed"))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
h.controller.performSetupAction()
|
||||
await waitUntil("the error shows") {
|
||||
h.state == .setupRequired(.imageMissing, error: "container build failed")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func aSecondIssueAfterSetupShowsItsOwnCard() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.imageMissing), .issue(.imageOutdated(found: 2, need: 3)))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
h.controller.performSetupAction()
|
||||
await waitUntil("the next issue") { h.state == .setupRequired(.imageOutdated(found: 2, need: 3), error: nil) }
|
||||
}
|
||||
|
||||
@Test func aMissingOrOldRuntimeOpensSetup() async {
|
||||
for issue in [SetupIssue.cliMissing, .cliOutdated(found: "1.3.0")] {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(issue))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(issue, error: nil) }
|
||||
h.controller.performSetupAction()
|
||||
#expect(h.recorder.setupRequests == 1)
|
||||
#expect(h.state == .setupRequired(issue, error: nil))
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(!h.backend.calls.contains(.fix(issue)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func lowDiskNeedsSetupAndHasNoAction() async {
|
||||
let h = Harness()
|
||||
h.disk.available = 1_000_000_000
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.lowDisk(bytesFree: 1_000_000_000), error: nil) }
|
||||
h.controller.performSetupAction()
|
||||
#expect(h.state == .setupRequired(.lowDisk(bytesFree: 1_000_000_000), error: nil))
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight])
|
||||
}
|
||||
|
||||
@Test func dismissDropsThePendingLink() async {
|
||||
// Only visible when setup runs again afterwards: here, a requested rebuild.
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)),
|
||||
.issue(.imageMissing))
|
||||
await h.goLive()
|
||||
h.controller.requestImageUpdate()
|
||||
h.controller.open(Harness.otherURL)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
|
||||
h.controller.dismiss()
|
||||
#expect(h.state == .settingUp(.imageStale(builtDaysAgo: 20)))
|
||||
await waitUntil("idle after the rebuild") { h.state == .idle }
|
||||
#expect(!h.backend.calls.contains(.start("knotchy-00000003")))
|
||||
}
|
||||
|
||||
@Test func aNewLinkDuringAnAutomaticStartOpensAfterIt() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.serviceStopped))
|
||||
h.backend.hold(.fix)
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("starting the service") { h.backend.parkedCount(.fix) == 1 }
|
||||
#expect(h.state == .settingUp(.serviceStopped))
|
||||
#expect(!h.controller.setupFolded)
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .settingUp(.serviceStopped))
|
||||
h.backend.release(.fix)
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(h.controller.openedURL == Harness.otherURL)
|
||||
}
|
||||
|
||||
@Test func aStaleImageWarnsButNeverBlocks() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)))
|
||||
await h.goLive()
|
||||
#expect(h.controller.staleImage == StaleImage(builtDaysAgo: 20))
|
||||
}
|
||||
|
||||
@Test func updateNowRebuildsOnceTheSessionEnds() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)))
|
||||
await h.goLive()
|
||||
h.controller.requestImageUpdate()
|
||||
#expect(h.controller.staleImage == nil)
|
||||
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.advance(by: .milliseconds(1500))
|
||||
#expect(h.state == .settingUp(.imageStale(builtDaysAgo: 20)))
|
||||
await waitUntil("idle after the rebuild") { h.state == .idle }
|
||||
#expect(h.backend.calls.suffix(2) == [.fix(.imageStale(builtDaysAgo: 20)), .preflight])
|
||||
}
|
||||
|
||||
@Test func aRebuildThatEndsInAnotherIssueShowsItsCard() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)),
|
||||
.issue(.serviceStopped))
|
||||
await h.goLive()
|
||||
h.controller.requestImageUpdate()
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.advance(by: .milliseconds(1500))
|
||||
await waitUntil("the next issue") { h.state == .setupRequired(.serviceStopped, error: nil) }
|
||||
}
|
||||
|
||||
@Test func aFailedRebuildStillOpensAPendingLink() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)))
|
||||
h.backend.scriptFix(output: [], error: VMBackendError.failed("container build failed"))
|
||||
h.backend.hold(.fix)
|
||||
await h.goLive()
|
||||
h.controller.requestImageUpdate()
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.advance(by: .milliseconds(1500))
|
||||
await waitUntil("rebuilding") { h.backend.parkedCount(.fix) == 1 }
|
||||
|
||||
h.controller.open(Harness.otherURL)
|
||||
#expect(h.state == .settingUp(.imageStale(builtDaysAgo: 20)))
|
||||
h.backend.release(.fix)
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(h.controller.openedURL == Harness.otherURL)
|
||||
}
|
||||
|
||||
@Test func aLinkOpenedAfterARebuildStartsUnengaged() async {
|
||||
// The pointer was on the panel before it hid; nothing says it is there now.
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)))
|
||||
h.backend.hold(.fix)
|
||||
await h.goLive()
|
||||
h.controller.pointerEntered()
|
||||
h.controller.requestImageUpdate()
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.advance(by: .milliseconds(1500))
|
||||
await waitUntil("rebuilding") { h.backend.parkedCount(.fix) == 1 }
|
||||
h.controller.open(Harness.otherURL)
|
||||
h.backend.release(.fix)
|
||||
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
h.connector.connections.last!.emit(.contentReady)
|
||||
await waitUntil("live") { h.state == .live(.peek) }
|
||||
h.advance(by: .seconds(30))
|
||||
#expect(h.state == .live(.collapsed))
|
||||
}
|
||||
|
||||
@Test func aFreshImageBuildSatisfiesARequestedRebuild() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: 20)),
|
||||
.issue(.imageMissing))
|
||||
await h.goLive()
|
||||
h.controller.requestImageUpdate()
|
||||
h.controller.open(Harness.otherURL)
|
||||
await waitUntil("setup required") { h.state == .setupRequired(.imageMissing, error: nil) }
|
||||
h.controller.performSetupAction()
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.advance(by: .milliseconds(1500))
|
||||
#expect(h.state == .idle)
|
||||
await h.controller.waitForBackendIdle()
|
||||
#expect(h.backend.calls.filter { if case .fix = $0 { true } else { false } } == [.fix(.imageMissing)])
|
||||
}
|
||||
|
||||
@Test func laterHidesTheWarningForThisSession() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.ready(appRoot: "/fake/appRoot", staleImage: StaleImage(builtDaysAgo: nil)))
|
||||
await h.goLive()
|
||||
h.controller.dismissStaleImageWarning()
|
||||
#expect(h.controller.staleImage == nil)
|
||||
|
||||
h.controller.destroy()
|
||||
await waitUntil("destroyed") { h.state == .destroyed }
|
||||
h.advance(by: .milliseconds(1500))
|
||||
#expect(h.state == .idle)
|
||||
}
|
||||
|
||||
@Test func aStoppedServiceStartsByItselfAndTheLinkOpens() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.serviceStopped))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .fix(.serviceStopped), .preflight,
|
||||
.start("knotchy-00000002")])
|
||||
}
|
||||
|
||||
@Test func aServiceThatWontStayUpShowsItsCardOnce() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.serviceStopped), .issue(.serviceStopped))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("setup required") {
|
||||
h.state == .setupRequired(.serviceStopped, error: SessionController.stillBrokenMessage)
|
||||
}
|
||||
#expect(h.backend.calls.filter { $0 == .fix(.serviceStopped) }.count == 1)
|
||||
|
||||
h.backend.queuePreflight(.issue(.serviceStopped))
|
||||
h.controller.open(Harness.url) // A new request gets its own automatic start.
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(h.backend.calls.filter { $0 == .fix(.serviceStopped) }.count == 2)
|
||||
}
|
||||
|
||||
@Test func retryStartsTheServiceAgain() async {
|
||||
let h = Harness()
|
||||
h.backend.queuePreflight(.issue(.serviceStopped))
|
||||
h.backend.failStarts(with: VMBackendError.failed("boot failed"))
|
||||
h.controller.open(Harness.url)
|
||||
await waitUntil("the start fails") { if case .failed(.start) = h.state { true } else { false } }
|
||||
|
||||
h.backend.failStarts(with: nil)
|
||||
h.backend.queuePreflight(.issue(.serviceStopped))
|
||||
h.controller.retry()
|
||||
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
|
||||
#expect(h.backend.calls.filter { $0 == .fix(.serviceStopped) }.count == 2)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import Foundation
|
||||
import Synchronization
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
/// A forgotten `release` fails the test after this instead of hanging the run.
|
||||
private let heldCallLimit: Duration = .seconds(10)
|
||||
|
||||
final class FakeVMBackend: VMBackend {
|
||||
enum Call: Equatable, Sendable {
|
||||
case sweep(keeping: Set<String>)
|
||||
case preflight
|
||||
case start(String)
|
||||
case destroy(String)
|
||||
case forceDestroy(String)
|
||||
case fix(SetupIssue)
|
||||
case destroyBlocking([String])
|
||||
case stopService
|
||||
|
||||
var kind: Kind {
|
||||
switch self {
|
||||
case .sweep: .sweep
|
||||
case .preflight: .preflight
|
||||
case .start: .start
|
||||
case .destroy: .destroy
|
||||
case .forceDestroy: .forceDestroy
|
||||
case .fix: .fix
|
||||
case .destroyBlocking: .destroyBlocking
|
||||
case .stopService: .stopService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Kind: Hashable, Sendable {
|
||||
case sweep, preflight, start, destroy, forceDestroy, fix, destroyBlocking, stopService
|
||||
}
|
||||
|
||||
struct StartArguments: Equatable, Sendable {
|
||||
var id: String
|
||||
var url: URL
|
||||
var renderSize: PixelSize
|
||||
var blockCIDRs: [String]
|
||||
}
|
||||
|
||||
private struct State {
|
||||
var calls: [Call] = []
|
||||
var startArguments: [StartArguments] = []
|
||||
var active = 0
|
||||
var maxActive = 0
|
||||
var held: Set<Kind> = []
|
||||
var nextParkID = 0
|
||||
var parked: [Kind: [(id: Int, continuation: CheckedContinuation<Void, Never>)]] = [:]
|
||||
var preflightResults: [PreflightResult] = []
|
||||
var startError: (any Error)?
|
||||
var destroyError: (any Error)?
|
||||
var forceDestroyError: (any Error)?
|
||||
var fixOutput: [String] = []
|
||||
var fixError: (any Error)?
|
||||
var runtimeStatus: RuntimeStatus = .current("1.4.1")
|
||||
var stopError: (any Error)?
|
||||
}
|
||||
|
||||
private let state = Mutex(State())
|
||||
|
||||
var calls: [Call] { state.withLock { $0.calls } }
|
||||
var startArguments: [StartArguments] { state.withLock { $0.startArguments } }
|
||||
var maxConcurrentCalls: Int { state.withLock { $0.maxActive } }
|
||||
|
||||
/// Used in order; after them, preflight is ready.
|
||||
func queuePreflight(_ results: PreflightResult...) {
|
||||
state.withLock { $0.preflightResults += results }
|
||||
}
|
||||
|
||||
func failStarts(with error: (any Error)?) { state.withLock { $0.startError = error } }
|
||||
func failDestroys(with error: (any Error)?) { state.withLock { $0.destroyError = error } }
|
||||
func failForceDestroys(with error: (any Error)?) { state.withLock { $0.forceDestroyError = error } }
|
||||
|
||||
func scriptFix(output: [String], error: (any Error)? = nil) {
|
||||
state.withLock {
|
||||
$0.fixOutput = output
|
||||
$0.fixError = error
|
||||
}
|
||||
}
|
||||
|
||||
func hold(_ kind: Kind) {
|
||||
precondition(kind != .destroyBlocking, "destroyBlocking is synchronous and can't be held")
|
||||
_ = state.withLock { $0.held.insert(kind) }
|
||||
}
|
||||
|
||||
func release(_ kind: Kind) {
|
||||
let parked = state.withLock { s in
|
||||
s.held.remove(kind)
|
||||
return s.parked.removeValue(forKey: kind) ?? []
|
||||
}
|
||||
for call in parked { call.continuation.resume() }
|
||||
}
|
||||
|
||||
func parkedCount(_ kind: Kind) -> Int {
|
||||
state.withLock { $0.parked[kind]?.count ?? 0 }
|
||||
}
|
||||
|
||||
func sweepOrphans(keeping: Set<String>) async {
|
||||
await begin(.sweep(keeping: keeping))
|
||||
end()
|
||||
}
|
||||
|
||||
func preflight() async -> PreflightResult {
|
||||
await begin(.preflight)
|
||||
defer { end() }
|
||||
return state.withLock { s in
|
||||
s.preflightResults.isEmpty ? .ready(appRoot: "/fake/appRoot", staleImage: nil) : s.preflightResults.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
func start(id: String, url: URL, renderSize: PixelSize, blockCIDRs: [String]) async throws -> VMSession {
|
||||
state.withLock {
|
||||
$0.startArguments.append(StartArguments(id: id, url: url, renderSize: renderSize, blockCIDRs: blockCIDRs))
|
||||
}
|
||||
await begin(.start(id))
|
||||
defer { end() }
|
||||
if let error = state.withLock({ $0.startError }) { throw error }
|
||||
return VMSession(id: id, socketPath: "/fake/\(id).sock")
|
||||
}
|
||||
|
||||
func destroy(id: String) async throws {
|
||||
await begin(.destroy(id))
|
||||
defer { end() }
|
||||
if let error = state.withLock({ $0.destroyError }) { throw error }
|
||||
}
|
||||
|
||||
func forceDestroy(id: String) async throws {
|
||||
await begin(.forceDestroy(id))
|
||||
defer { end() }
|
||||
if let error = state.withLock({ $0.forceDestroyError }) { throw error }
|
||||
}
|
||||
|
||||
func fix(_ issue: SetupIssue, output: @escaping @Sendable (String) -> Void) async throws {
|
||||
record(.fix(issue))
|
||||
state.withLock { $0.fixOutput }.forEach(output)
|
||||
await parkIfHeld(.fix)
|
||||
defer { end() }
|
||||
if let error = state.withLock({ $0.fixError }) { throw error }
|
||||
}
|
||||
|
||||
func destroyBlocking(ids: [String], deadline: Duration) {
|
||||
state.withLock { $0.calls.append(.destroyBlocking(ids)) }
|
||||
}
|
||||
|
||||
func setRuntimeStatus(_ status: RuntimeStatus) { state.withLock { $0.runtimeStatus = status } }
|
||||
func failStopService(with error: (any Error)?) { state.withLock { $0.stopError = error } }
|
||||
|
||||
func runtimeStatus() async -> RuntimeStatus {
|
||||
state.withLock { $0.runtimeStatus }
|
||||
}
|
||||
|
||||
func stopService() async throws {
|
||||
await begin(.stopService)
|
||||
defer { end() }
|
||||
if let error = state.withLock({ $0.stopError }) { throw error }
|
||||
}
|
||||
|
||||
private func begin(_ call: Call) async {
|
||||
record(call)
|
||||
await parkIfHeld(call.kind)
|
||||
}
|
||||
|
||||
private func record(_ call: Call) {
|
||||
state.withLock { s in
|
||||
s.calls.append(call)
|
||||
s.active += 1
|
||||
s.maxActive = max(s.maxActive, s.active)
|
||||
}
|
||||
}
|
||||
|
||||
private func end() {
|
||||
state.withLock { $0.active -= 1 }
|
||||
}
|
||||
|
||||
private func parkIfHeld(_ kind: Kind) async {
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
let id: Int? = state.withLock { s in
|
||||
guard s.held.contains(kind) else { return nil }
|
||||
s.nextParkID += 1
|
||||
s.parked[kind, default: []].append((s.nextParkID, continuation))
|
||||
return s.nextParkID
|
||||
}
|
||||
guard let id else { return continuation.resume() }
|
||||
Task {
|
||||
try? await Task.sleep(for: heldCallLimit)
|
||||
let forgotten = self.state.withLock { s -> CheckedContinuation<Void, Never>? in
|
||||
guard let index = s.parked[kind]?.firstIndex(where: { $0.id == id }) else { return nil }
|
||||
return s.parked[kind]?.remove(at: index).continuation
|
||||
}
|
||||
if let forgotten {
|
||||
Issue.record("A held \(kind) call was never released")
|
||||
forgotten.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queued errors go to connects in call order; `connections` is in completion order.
|
||||
final class FakeStreamConnector: StreamConnector {
|
||||
private struct State {
|
||||
var socketPaths: [String] = []
|
||||
var connections: [FakeStreamConnection] = []
|
||||
var errors: [StreamError] = []
|
||||
var held = false
|
||||
var nextParkID = 0
|
||||
var parked: [(id: Int, continuation: CheckedContinuation<Void, Never>)] = []
|
||||
}
|
||||
|
||||
private let state = Mutex(State())
|
||||
|
||||
var socketPaths: [String] { state.withLock { $0.socketPaths } }
|
||||
var connections: [FakeStreamConnection] { state.withLock { $0.connections } }
|
||||
var parkedCount: Int { state.withLock { $0.parked.count } }
|
||||
|
||||
func failNextConnect(with error: StreamError) {
|
||||
state.withLock { $0.errors.append(error) }
|
||||
}
|
||||
|
||||
func hold() {
|
||||
state.withLock { $0.held = true }
|
||||
}
|
||||
|
||||
func release() {
|
||||
let parked = state.withLock { s in
|
||||
s.held = false
|
||||
defer { s.parked = [] }
|
||||
return s.parked
|
||||
}
|
||||
for call in parked { call.continuation.resume() }
|
||||
}
|
||||
|
||||
func connect(socketPath: String) async throws -> any StreamConnection {
|
||||
let scriptedError = state.withLock { s -> StreamError? in
|
||||
s.socketPaths.append(socketPath)
|
||||
return s.errors.isEmpty ? nil : s.errors.removeFirst()
|
||||
}
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
let id: Int? = state.withLock { s in
|
||||
guard s.held else { return nil }
|
||||
s.nextParkID += 1
|
||||
s.parked.append((s.nextParkID, continuation))
|
||||
return s.nextParkID
|
||||
}
|
||||
guard let id else { return continuation.resume() }
|
||||
Task {
|
||||
try? await Task.sleep(for: heldCallLimit)
|
||||
let forgotten = self.state.withLock { s -> CheckedContinuation<Void, Never>? in
|
||||
guard let index = s.parked.firstIndex(where: { $0.id == id }) else { return nil }
|
||||
return s.parked.remove(at: index).continuation
|
||||
}
|
||||
if let forgotten {
|
||||
Issue.record("A held connect was never released")
|
||||
forgotten.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
if let scriptedError { throw scriptedError }
|
||||
let connection = FakeStreamConnection(socketPath: socketPath)
|
||||
state.withLock { $0.connections.append(connection) }
|
||||
return connection
|
||||
}
|
||||
}
|
||||
|
||||
final class FakeStreamConnection: StreamConnection {
|
||||
let socketPath: String
|
||||
let events: AsyncStream<StreamEvent>
|
||||
private let continuation: AsyncStream<StreamEvent>.Continuation
|
||||
|
||||
private struct State {
|
||||
var decodeEnabled = true
|
||||
var decodeChanges: [Bool] = []
|
||||
var sent: [InputEvent] = []
|
||||
var closed = false
|
||||
}
|
||||
|
||||
private let state = Mutex(State())
|
||||
|
||||
init(socketPath: String) {
|
||||
self.socketPath = socketPath
|
||||
let (stream, continuation) = AsyncStream.makeStream(of: StreamEvent.self)
|
||||
events = stream
|
||||
self.continuation = continuation
|
||||
}
|
||||
|
||||
var decodeEnabled: Bool {
|
||||
get { state.withLock { $0.decodeEnabled } }
|
||||
set {
|
||||
state.withLock {
|
||||
$0.decodeEnabled = newValue
|
||||
$0.decodeChanges.append(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var decodeChanges: [Bool] { state.withLock { $0.decodeChanges } }
|
||||
var sent: [InputEvent] { state.withLock { $0.sent } }
|
||||
var isClosed: Bool { state.withLock { $0.closed } }
|
||||
|
||||
func setFrameHandler(_ handler: @escaping @Sendable (DecodedFrame) -> Void) {}
|
||||
|
||||
func send(_ input: InputEvent) {
|
||||
state.withLock { $0.sent.append(input) }
|
||||
}
|
||||
|
||||
func close() {
|
||||
state.withLock { $0.closed = true }
|
||||
continuation.finish()
|
||||
}
|
||||
|
||||
func emit(_ event: StreamEvent) {
|
||||
continuation.yield(event)
|
||||
}
|
||||
|
||||
/// Without an `.ended` event, as a buggy client might.
|
||||
func finishEvents() {
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
final class FakeDiskSpace: DiskSpaceChecking {
|
||||
private let free = Mutex<Int64?>(100_000_000_000)
|
||||
|
||||
var available: Int64? {
|
||||
get { free.withLock { $0 } }
|
||||
set { free.withLock { $0 = newValue } }
|
||||
}
|
||||
|
||||
func bytesFree(atPath path: String) -> Int64? {
|
||||
free.withLock { $0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
final class Recorder {
|
||||
var quitRequests = 0
|
||||
var setupRequests = 0
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Harness {
|
||||
static let url = URL(string: "https://example.com/")!
|
||||
static let otherURL = URL(string: "https://example.org/")!
|
||||
|
||||
let backend = FakeVMBackend()
|
||||
let connector = FakeStreamConnector()
|
||||
let disk = FakeDiskSpace()
|
||||
let clock = TestClock()
|
||||
let recorder: Recorder
|
||||
let controller: SessionController
|
||||
|
||||
init(hostNetworks: [String] = ["192.168.1.0/24"]) {
|
||||
let recorder = Recorder()
|
||||
self.recorder = recorder
|
||||
var sessions = 0
|
||||
controller = SessionController(
|
||||
backend: backend,
|
||||
connector: connector,
|
||||
diskSpace: disk,
|
||||
clock: clock,
|
||||
hostNetworks: { hostNetworks },
|
||||
requestQuit: { recorder.quitRequests += 1 },
|
||||
makeSessionID: {
|
||||
sessions += 1
|
||||
return String(format: "knotchy-%08x", sessions)
|
||||
}
|
||||
)
|
||||
controller.showSetup = { recorder.setupRequests += 1 }
|
||||
}
|
||||
|
||||
/// Lets go of held calls, so the fakes' 10 s check can't fire later against another test.
|
||||
deinit {
|
||||
for kind in [FakeVMBackend.Kind.sweep, .preflight, .start, .destroy, .forceDestroy, .fix, .stopService] {
|
||||
backend.release(kind)
|
||||
}
|
||||
connector.release()
|
||||
for stream in connector.connections { stream.close() }
|
||||
}
|
||||
|
||||
var state: SessionState { controller.state }
|
||||
|
||||
func advance(by duration: Duration) {
|
||||
clock.advance(by: duration)
|
||||
controller.fireDueTimers()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func openAndConnect(_ url: URL = Harness.url) async -> FakeStreamConnection {
|
||||
let before = connector.connections.count
|
||||
controller.open(url)
|
||||
await waitUntil("the stream connects") {
|
||||
self.connector.connections.count > before && self.state == .starting(.loading, folded: false)
|
||||
}
|
||||
return connector.connections.last!
|
||||
}
|
||||
|
||||
func enter(_ mode: LiveMode) {
|
||||
switch mode {
|
||||
case .peek:
|
||||
break
|
||||
case .expanded:
|
||||
controller.pointerEntered()
|
||||
controller.expand()
|
||||
case .collapsed:
|
||||
controller.clickedOutside()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func goLive(_ url: URL = Harness.url) async -> FakeStreamConnection {
|
||||
let stream = await openAndConnect(url)
|
||||
stream.emit(.contentReady)
|
||||
await waitUntil("the session is live") { self.state == .live(.peek) }
|
||||
return stream
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import Foundation
|
||||
import Synchronization
|
||||
@testable import KnotchyCore
|
||||
|
||||
final class FakeHost: HostChecking {
|
||||
private let value = Mutex(HostFacts(unsupported: nil, osVersion: "macOS 27.0", bytesFree: 212_000_000_000))
|
||||
|
||||
var current: HostFacts {
|
||||
get { value.withLock { $0 } }
|
||||
set { value.withLock { $0 = newValue } }
|
||||
}
|
||||
|
||||
func facts() async -> HostFacts { current }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FakeInstallerSession: InstallerSession {
|
||||
var hasQuit: Bool
|
||||
|
||||
nonisolated init(hasQuit: Bool = true) {
|
||||
self.hasQuit = hasQuit
|
||||
}
|
||||
}
|
||||
|
||||
final class FakeInstaller: RuntimeInstalling {
|
||||
enum Opening: Sendable {
|
||||
case opened
|
||||
case changed
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
private struct State {
|
||||
var downloadError: DownloadError?
|
||||
var opening: Opening = .opened
|
||||
var calls: [String] = []
|
||||
}
|
||||
|
||||
private let state = Mutex(State())
|
||||
/// What `.opened` hands out; it has already quit unless a test says otherwise.
|
||||
let session = FakeInstallerSession()
|
||||
|
||||
var calls: [String] { state.withLock { $0.calls } }
|
||||
func failDownload(with error: DownloadError) { state.withLock { $0.downloadError = error } }
|
||||
func open(with opening: Opening) { state.withLock { $0.opening = opening } }
|
||||
|
||||
func download(_ release: ContainerRuntime.Release,
|
||||
progress: @escaping @Sendable (Int64) -> Void) async throws -> DownloadedPackage {
|
||||
let error = state.withLock { s -> DownloadError? in
|
||||
s.calls.append("download")
|
||||
return s.downloadError
|
||||
}
|
||||
if let error { throw error }
|
||||
return DownloadedPackage(directory: URL(fileURLWithPath: "/fake/knotchy-runtime"), name: release.url.lastPathComponent)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func openInInstaller(_ package: DownloadedPackage, release: ContainerRuntime.Release) async -> InstallerOpening {
|
||||
let opening = state.withLock { s -> Opening in
|
||||
s.calls.append("open")
|
||||
return s.opening
|
||||
}
|
||||
switch opening {
|
||||
case .opened: return .opened(session)
|
||||
case .changed: return .changed
|
||||
case .failed(let message): return .failed(message)
|
||||
}
|
||||
}
|
||||
|
||||
func discard(_ package: DownloadedPackage) {
|
||||
state.withLock { $0.calls.append("discard") }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FakeSetupDriver: SetupDriving {
|
||||
var state: SessionState = .idle
|
||||
var setupLog: String?
|
||||
var setupOutput: [String] = []
|
||||
/// Used in order; the last one repeats.
|
||||
var runtimeStatuses: [RuntimeStatus] = [.current("1.4.1")]
|
||||
/// Used in order; then `.ready`.
|
||||
var checkResults: [SetupRunOutcome?] = []
|
||||
var runResults: [SetupRunOutcome?] = []
|
||||
var holdResult: Result<Void, RuntimeHoldRefusal> = .success(())
|
||||
var finishResult: SetupRunOutcome = .ready
|
||||
private(set) var calls: [String] = []
|
||||
|
||||
func runtimeStatus() async -> RuntimeStatus {
|
||||
calls.append("runtimeStatus")
|
||||
return runtimeStatuses.count > 1 ? runtimeStatuses.removeFirst() : runtimeStatuses[0]
|
||||
}
|
||||
|
||||
func checkSetup() async -> SetupRunOutcome? {
|
||||
calls.append("checkSetup")
|
||||
return checkResults.isEmpty ? .ready : checkResults.removeFirst()
|
||||
}
|
||||
|
||||
func runSetup(_ issue: SetupIssue, continuing: Bool) async -> SetupRunOutcome? {
|
||||
calls.append("runSetup(\(issue), continuing: \(continuing))")
|
||||
return runResults.isEmpty ? .ready : runResults.removeFirst()
|
||||
}
|
||||
|
||||
func beginRuntimeInstall(replacing found: String?) async -> Result<Void, RuntimeHoldRefusal> {
|
||||
calls.append("begin(\(found ?? "nil"))")
|
||||
return holdResult
|
||||
}
|
||||
|
||||
func finishRuntimeInstall(_ result: RuntimeInstallResult) async -> SetupRunOutcome {
|
||||
calls.append("finish(\(result))")
|
||||
return finishResult
|
||||
}
|
||||
|
||||
func waitWhileSettingUp() async {
|
||||
calls.append("wait")
|
||||
state = .idle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Synchronization
|
||||
@testable import KnotchyCore
|
||||
|
||||
final class TestClock: KnotchyClock {
|
||||
private struct Sleeper {
|
||||
let id: Int
|
||||
let deadline: ContinuousClock.Instant
|
||||
let continuation: CheckedContinuation<Void, any Error>
|
||||
}
|
||||
|
||||
private struct State {
|
||||
var now: ContinuousClock.Instant
|
||||
var nextID = 0
|
||||
var sleepers: [Sleeper] = []
|
||||
}
|
||||
|
||||
private let state: Mutex<State>
|
||||
|
||||
init(now: ContinuousClock.Instant = .now) {
|
||||
state = Mutex(State(now: now))
|
||||
}
|
||||
|
||||
var now: ContinuousClock.Instant {
|
||||
state.withLock { $0.now }
|
||||
}
|
||||
|
||||
var sleeperCount: Int {
|
||||
state.withLock { $0.sleepers.count }
|
||||
}
|
||||
|
||||
func sleep(until deadline: ContinuousClock.Instant) async throws {
|
||||
let id = state.withLock { s in
|
||||
s.nextID += 1
|
||||
return s.nextID
|
||||
}
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
let outcome: Result<Void, any Error>? = state.withLock { s in
|
||||
if Task.isCancelled { return .failure(CancellationError()) }
|
||||
if deadline <= s.now { return .success(()) }
|
||||
s.sleepers.append(Sleeper(id: id, deadline: deadline, continuation: continuation))
|
||||
return nil
|
||||
}
|
||||
if let outcome { continuation.resume(with: outcome) }
|
||||
}
|
||||
} onCancel: {
|
||||
let sleeper = state.withLock { s -> Sleeper? in
|
||||
guard let index = s.sleepers.firstIndex(where: { $0.id == id }) else { return nil }
|
||||
return s.sleepers.remove(at: index)
|
||||
}
|
||||
sleeper?.continuation.resume(throwing: CancellationError())
|
||||
}
|
||||
}
|
||||
|
||||
/// The woken tasks run concurrently, in no particular order.
|
||||
func advance(by duration: Duration) {
|
||||
let due = state.withLock { s -> [Sleeper] in
|
||||
s.now += duration
|
||||
let now = s.now
|
||||
let due = s.sleepers.filter { $0.deadline <= now }.sorted { $0.deadline < $1.deadline }
|
||||
s.sleepers.removeAll { $0.deadline <= now }
|
||||
return due
|
||||
}
|
||||
for sleeper in due { sleeper.continuation.resume() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Testing
|
||||
|
||||
/// For work on other executors; controller timers run on the `TestClock` and never need it.
|
||||
@MainActor
|
||||
func waitUntil(
|
||||
_ description: String,
|
||||
timeout: Duration = .seconds(5),
|
||||
sourceLocation: SourceLocation = #_sourceLocation,
|
||||
_ condition: @MainActor () -> Bool
|
||||
) async {
|
||||
let deadline = ContinuousClock.now + timeout
|
||||
while !condition() {
|
||||
if ContinuousClock.now >= deadline {
|
||||
Issue.record("Timed out waiting until \(description)", sourceLocation: sourceLocation)
|
||||
return
|
||||
}
|
||||
do { try await Task.sleep(for: .milliseconds(1)) } catch { return }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import KnotchyCore
|
||||
|
||||
struct WireProtocolTests {
|
||||
private let size = PixelSize(width: 1024, height: 640)
|
||||
|
||||
private func decodeAll(_ bytes: Data, into decoder: inout AgentMessageDecoder) throws -> [AgentMessage] {
|
||||
decoder.append(bytes)
|
||||
var messages: [AgentMessage] = []
|
||||
while let message = try decoder.next() { messages.append(message) }
|
||||
return messages
|
||||
}
|
||||
|
||||
private func decodeAll(_ bytes: Data) throws -> [AgentMessage] {
|
||||
var decoder = AgentMessageDecoder()
|
||||
return try decodeAll(bytes, into: &decoder)
|
||||
}
|
||||
|
||||
private func violation(_ bytes: Data) -> AgentMessageDecoder.ProtocolViolation? {
|
||||
do {
|
||||
_ = try decodeAll(bytes)
|
||||
return nil
|
||||
} catch {
|
||||
return error as? AgentMessageDecoder.ProtocolViolation
|
||||
}
|
||||
}
|
||||
|
||||
private func raw(_ type: UInt8, _ payload: [UInt8]) -> Data {
|
||||
framed(type, Data(payload))
|
||||
}
|
||||
|
||||
@Test func matchesTheImageProtocolVersion() throws {
|
||||
let root = URL(filePath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
|
||||
let containerfile = try String(contentsOf: root.appending(path: "vm/Containerfile"), encoding: .utf8)
|
||||
let arg = containerfile.split(separator: "\n").first { $0.hasPrefix("ARG KNOTCHY_PROTOCOL=") }
|
||||
#expect(arg == "ARG KNOTCHY_PROTOCOL=\(WireProtocol.version)")
|
||||
}
|
||||
|
||||
@Test func pageURLMustBeShortUTF8() throws {
|
||||
let hello = raw(MessageType.hello, [1, 0x04, 0x00, 0x02, 0x80])
|
||||
#expect(try decodeAll(hello + raw(MessageType.pageURL, Array("https://a.test/ü".utf8)))
|
||||
== [.hello(version: 1, size: size), .pageURL("https://a.test/ü")])
|
||||
#expect(violation(hello + raw(MessageType.pageURL, [0x68, 0xC3])) != nil) // cut-off UTF-8
|
||||
#expect(violation(hello + raw(MessageType.pageURL, [])) != nil)
|
||||
let long = [UInt8](repeating: 0x61, count: WireProtocol.maxPageURL + 1)
|
||||
#expect(violation(hello + raw(MessageType.pageURL, long)) != nil)
|
||||
}
|
||||
|
||||
@Test func helloAndVideoBytesAreAsSpecified() throws {
|
||||
#expect(AgentMessage.hello(version: 1, size: size).encoded() == Data([0x01, 0, 0, 0, 5, 1, 0x04, 0x00, 0x02, 0x80]))
|
||||
let hello = raw(MessageType.hello, [1, 0x04, 0x00, 0x02, 0x80])
|
||||
let video = raw(MessageType.video, [0x04, 0x00, 0x02, 0x80, 0xAA])
|
||||
#expect(try decodeAll(hello + video) == [.hello(version: 1, size: size), .video(size: size, accessUnit: Data([0xAA]))])
|
||||
}
|
||||
|
||||
@Test func roundTripsEveryAgentMessage() throws {
|
||||
let messages: [AgentMessage] = [
|
||||
.hello(version: 1, size: size),
|
||||
.navigated,
|
||||
.progress(0),
|
||||
.progress(100),
|
||||
.video(size: size, accessUnit: Data([0, 0, 0, 1, 0x09, 0xF0])),
|
||||
.pageURL("https://example.com/a?b=c#d"),
|
||||
.unknown(type: 0x7F),
|
||||
]
|
||||
let stream = messages.reduce(into: Data()) { $0.append($1.encoded()) }
|
||||
#expect(try decodeAll(stream) == messages)
|
||||
}
|
||||
|
||||
@Test func decodesAcrossArbitraryChunkBoundaries() throws {
|
||||
let messages: [AgentMessage] = [
|
||||
.hello(version: 1, size: size),
|
||||
.video(size: size, accessUnit: Data(repeating: 0xAB, count: 3000)),
|
||||
.progress(42),
|
||||
]
|
||||
let stream = messages.reduce(into: Data()) { $0.append($1.encoded()) }
|
||||
var decoder = AgentMessageDecoder()
|
||||
var decoded: [AgentMessage] = []
|
||||
for chunkStart in stride(from: 0, to: stream.count, by: 7) {
|
||||
decoded += try decodeAll(stream.subdata(in: chunkStart..<min(chunkStart + 7, stream.count)), into: &decoder)
|
||||
}
|
||||
#expect(decoded == messages)
|
||||
#expect(decoder.bufferedByteCount == 0)
|
||||
}
|
||||
|
||||
@Test func readsTheHelloVersionAndAPreV1HelloIsVersionZero() throws {
|
||||
#expect(try decodeAll(raw(MessageType.hello, [2, 0x04, 0x00, 0x02, 0x80])) == [.hello(version: 2, size: size)])
|
||||
let legacy = raw(MessageType.hello, [0x04, 0x00, 0x02, 0x80])
|
||||
#expect(try decodeAll(legacy) == [.hello(version: 0, size: size)])
|
||||
#expect(AgentMessage.hello(version: 0, size: size).encoded() == legacy)
|
||||
}
|
||||
|
||||
@Test func waitsForTheRestOfATruncatedMessage() throws {
|
||||
var decoder = AgentMessageDecoder()
|
||||
let hello = AgentMessage.hello(version: 1, size: size).encoded()
|
||||
#expect(try decodeAll(hello.prefix(7), into: &decoder) == [])
|
||||
#expect(decoder.bufferedByteCount == 7)
|
||||
#expect(try decodeAll(hello.dropFirst(7), into: &decoder) == [.hello(version: 1, size: size)])
|
||||
}
|
||||
|
||||
@Test func rejectsWrongFixedSizes() {
|
||||
let hello = AgentMessage.hello(version: 1, size: size).encoded()
|
||||
#expect(violation(raw(MessageType.hello, [1, 2, 3])) != nil)
|
||||
#expect(violation(raw(MessageType.hello, [1, 0x04, 0x00, 0x02, 0x80, 0])) != nil)
|
||||
#expect(violation(hello + raw(MessageType.progress, [])) != nil)
|
||||
#expect(violation(hello + raw(MessageType.progress, [1, 2])) != nil)
|
||||
#expect(violation(hello + raw(MessageType.navigated, [0])) != nil)
|
||||
#expect(violation(hello + raw(MessageType.video, [0x04, 0x00, 0x02])) != nil)
|
||||
}
|
||||
|
||||
@Test func rejectsProgressOver100() {
|
||||
let hello = AgentMessage.hello(version: 1, size: size).encoded()
|
||||
#expect(violation(hello + raw(MessageType.progress, [101])) != nil)
|
||||
}
|
||||
|
||||
@Test func rejectsOversizedMessagesAsSoonAsTheHeaderArrives() {
|
||||
let hello = AgentMessage.hello(version: 1, size: size).encoded()
|
||||
var video = Data([MessageType.video])
|
||||
video.appendBE32(UInt32(WireProtocol.maxVideoPayload + 1))
|
||||
#expect(violation(hello + video) != nil)
|
||||
|
||||
var unknown = Data([0x7F])
|
||||
unknown.appendBE32(UInt32(WireProtocol.maxOtherPayload + 1))
|
||||
#expect(violation(hello + unknown) != nil)
|
||||
}
|
||||
|
||||
@Test func acceptsMaximumSizeMessages() throws {
|
||||
let hello = AgentMessage.hello(version: 1, size: size)
|
||||
let video = AgentMessage.video(size: size, accessUnit: Data(count: WireProtocol.maxVideoPayload - 4))
|
||||
let unknown = raw(0x7F, [UInt8](repeating: 0, count: WireProtocol.maxOtherPayload))
|
||||
#expect(try decodeAll(hello.encoded() + video.encoded() + unknown) == [hello, video, .unknown(type: 0x7F)])
|
||||
}
|
||||
|
||||
@Test func rejectsVideoWhoseSizeDiffersFromHello() {
|
||||
let hello = AgentMessage.hello(version: 1, size: size).encoded()
|
||||
for other in [PixelSize(width: 800, height: 600), PixelSize(width: 1024, height: 600), PixelSize(width: 800, height: 640)] {
|
||||
#expect(violation(hello + AgentMessage.video(size: other, accessUnit: Data([1])).encoded()) != nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func aViolationIsFinal() throws {
|
||||
var decoder = AgentMessageDecoder()
|
||||
decoder.append(AgentMessage.hello(version: 1, size: size).encoded()
|
||||
+ raw(MessageType.progress, [101]) + AgentMessage.progress(50).encoded())
|
||||
#expect(try decoder.next() == .hello(version: 1, size: size))
|
||||
#expect(throws: AgentMessageDecoder.ProtocolViolation.self) { try decoder.next() }
|
||||
#expect(throws: AgentMessageDecoder.ProtocolViolation.self) { try decoder.next() }
|
||||
decoder.append(Data(count: 1000))
|
||||
#expect(decoder.storedByteCount == 0)
|
||||
}
|
||||
|
||||
@Test func staysBoundedWhenReadsNeverEndOnAMessageBoundary() throws {
|
||||
let readSize = 4099 // Not a multiple of the 5-byte NAVIGATED, so reads end mid-message.
|
||||
var decoder = AgentMessageDecoder()
|
||||
var stream = AgentMessage.hello(version: 1, size: size).encoded()
|
||||
for _ in 0..<100_000 { stream.append(AgentMessage.navigated.encoded()) }
|
||||
var decoded = 0
|
||||
var largest = 0
|
||||
for chunkStart in stride(from: 0, to: stream.count, by: readSize) {
|
||||
decoded += try decodeAll(stream.subdata(in: chunkStart..<min(chunkStart + readSize, stream.count)), into: &decoder).count
|
||||
largest = max(largest, decoder.storedByteCount)
|
||||
}
|
||||
#expect(decoded == 100_001)
|
||||
#expect(largest <= readSize + WireProtocol.headerSize)
|
||||
}
|
||||
|
||||
@Test func rejectsAnythingBeforeHelloAndASecondHello() {
|
||||
let hello = AgentMessage.hello(version: 1, size: size).encoded()
|
||||
#expect(violation(AgentMessage.navigated.encoded()) != nil)
|
||||
#expect(violation(AgentMessage.progress(10).encoded()) != nil)
|
||||
#expect(violation(raw(MessageType.video, [0x04, 0x00, 0x02, 0x80, 0xAA])) != nil)
|
||||
#expect(violation(AgentMessage.unknown(type: 0x7F).encoded()) != nil)
|
||||
#expect(violation(hello + hello) != nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct InputEventTests {
|
||||
@Test func encodesPointerMessages() {
|
||||
#expect(InputEvent.pointerMove(x: 0x0102, y: 0x0304, mods: [.shift, .leftButton]).encoded()
|
||||
== Data([0x10, 0, 0, 0, 5, 0x01, 0x02, 0x03, 0x04, 0x11]))
|
||||
#expect(InputEvent.pointerButton(.right, pressed: true, x: 10, y: 20, mods: .control).encoded()
|
||||
== Data([0x11, 0, 0, 0, 7, 2, 1, 0, 10, 0, 20, 0x02]))
|
||||
#expect(InputEvent.pointerButton(.left, pressed: false, x: 0, y: 0, mods: []).encoded()
|
||||
== Data([0x11, 0, 0, 0, 7, 1, 0, 0, 0, 0, 0, 0]))
|
||||
}
|
||||
|
||||
@Test func encodesScrollTicksAsSignedBigEndian() {
|
||||
#expect(InputEvent.scroll(ticksX: 0, ticksY: -3, x: 1, y: 2).encoded()
|
||||
== Data([0x12, 0, 0, 0, 8, 0, 0, 0xFF, 0xFD, 0, 1, 0, 2]))
|
||||
}
|
||||
|
||||
@Test func encodesKeysAndStrings() {
|
||||
#expect(InputEvent.key(pressed: true, mods: .meta, name: "Return").encoded()
|
||||
== Data([0x13, 0, 0, 0, 8, 1, 0x08]) + Data("Return".utf8))
|
||||
#expect(InputEvent.navigate("https://a.example").encoded()
|
||||
== Data([0x14, 0, 0, 0, 17]) + Data("https://a.example".utf8))
|
||||
#expect(InputEvent.script("history.back()").encoded()
|
||||
== Data([0x15, 0, 0, 0, 14]) + Data("history.back()".utf8))
|
||||
#expect(InputEvent.keyframe.encoded() == Data([0x16, 0, 0, 0, 0]))
|
||||
}
|
||||
|
||||
@Test func modifierBitsMatchTheAgent() {
|
||||
let bits: [Modifiers] = [.shift, .control, .alt, .meta, .leftButton, .rightButton, .middleButton]
|
||||
#expect(bits.map(\.rawValue) == [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40])
|
||||
}
|
||||
|
||||
@Test func refusesMessagesTheAgentCouldNotUse() {
|
||||
#expect(InputEvent.script(String(repeating: "a", count: WireProtocol.maxOtherPayload)).encoded() != nil)
|
||||
#expect(InputEvent.script(String(repeating: "a", count: WireProtocol.maxOtherPayload + 1)).encoded() == nil)
|
||||
#expect(InputEvent.key(pressed: true, mods: [], name: "").encoded() == nil)
|
||||
#expect(InputEvent.key(pressed: true, mods: [], name: "\u{0}x").encoded() == nil)
|
||||
#expect(InputEvent.navigate("https://a.example/\u{0}").encoded() == nil)
|
||||
#expect(InputEvent.script("history.back()\u{0}").encoded() == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="22.5" fill="#132441"/><g fill="none" stroke="#FF8A5B" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round"><path d="M43.15,35.60 L42.62,36.30 L42.10,37.02 L41.60,37.76 L41.11,38.50 L40.64,39.26 L40.19,40.02 L39.75,40.80 L39.33,41.58 L38.93,42.38 L38.55,43.18 L38.18,43.99 L37.83,44.80 L37.51,45.62 L37.20,46.44 L36.91,47.27 L36.64,48.11 L36.39,48.94 L36.16,49.78 L35.95,50.62 L35.76,51.46 L35.59,52.30 L35.44,53.14 L35.31,53.98 L35.20,54.82 L35.11,55.65 L35.04,56.48 L35.00,57.30 L34.97,58.12 L34.96,58.94 L34.97,59.74 L35.01,60.54 L35.06,61.34 L35.13,62.12 L35.23,62.89 L35.34,63.66 L35.47,64.41 L35.62,65.15 L35.79,65.88 L35.97,66.60 L36.18,67.30 L36.40,67.99 L36.64,68.66 L36.90,69.32 L37.17,69.97 L37.46,70.59 L37.77,71.20 L38.09,71.80 L38.43,72.37 L38.78,72.93 L39.14,73.46 L39.52,73.98 L39.91,74.48 L40.32,74.96 L40.73,75.41 L41.16,75.85 L41.60,76.26 L42.05,76.65 L42.51,77.02 L42.98,77.37 L43.46,77.69 L43.94,77.99 L44.44,78.26 L44.94,78.51 L45.44,78.74 L45.96,78.95 L46.47,79.13 L46.99,79.28 L47.52,79.41 L48.05,79.51 L48.58,79.59 L49.11,79.65 L49.64,79.68 L50.18,79.68 L50.71,79.66 L51.24,79.61 L51.78,79.54 L52.30,79.45 L52.83,79.33 L53.35,79.18 L53.87,79.01 L54.39,78.81 L54.89,78.59 L55.40,78.35 L55.89,78.08 L56.38,77.79 L56.86,77.48 L57.33,77.14 L57.80,76.78 L58.25,76.39 L58.69,75.99 L59.12,75.56 L59.55,75.11 L59.95,74.64 L60.35,74.15 L60.73,73.64 L61.10,73.11 L61.46,72.56 L61.80,71.99 L62.13,71.40 L62.44,70.80 L62.73,70.18 L63.01,69.54 L63.28,68.89 L63.52,68.22 L63.75,67.53 L63.96,66.83 L64.15,66.12 L64.33,65.40 L64.48,64.66 L64.58,64.16"/><path d="M62.17,44.80 L61.82,43.99 L61.45,43.18 L61.07,42.38 L60.67,41.58 L60.25,40.80 L59.81,40.02 L59.36,39.26 L58.89,38.50 L58.40,37.76 L57.90,37.02 L57.38,36.30 L56.85,35.60 L56.31,34.90 L55.75,34.22 L55.17,33.56 L54.59,32.91 L53.99,32.27 L53.38,31.65 L52.75,31.05 L52.12,30.47 L51.48,29.90 L50.83,29.35 L50.16,28.82 L49.50,28.31 L48.82,27.81 L48.13,27.34 L47.44,26.89 L46.75,26.45 L46.05,26.04 L45.34,25.65 L44.63,25.28 L43.92,24.93 L43.20,24.60 L42.49,24.29 L41.77,24.01 L41.05,23.74 L40.34,23.50 L39.62,23.28 L38.91,23.09 L38.19,22.91 L37.49,22.76 L36.78,22.63 L36.08,22.52 L35.39,22.44 L34.70,22.38 L34.02,22.34 L33.34,22.32 L32.68,22.32 L32.02,22.35 L31.37,22.40 L30.74,22.47 L30.11,22.56 L29.49,22.67 L28.89,22.80 L28.30,22.96 L27.72,23.13 L27.16,23.32 L26.61,23.54 L26.08,23.77 L25.56,24.02 L25.06,24.29 L24.57,24.58 L24.10,24.89 L23.65,25.22 L23.22,25.56 L22.81,25.92 L22.41,26.29 L22.04,26.68 L21.68,27.09 L21.35,27.50 L21.04,27.94 L20.74,28.39 L20.47,28.85 L20.22,29.32 L20.00,29.80 L19.79,30.30 L19.61,30.81 L19.45,31.32 L19.32,31.85 L19.21,32.38 L19.12,32.92 L19.06,33.48 L19.02,34.03 L19.00,34.60 L19.01,35.16 L19.04,35.74 L19.10,36.32 L19.18,36.90 L19.28,37.48 L19.41,38.07 L19.57,38.66 L19.75,39.24 L19.95,39.83 L20.18,40.42 L20.43,41.01 L20.70,41.59 L21.00,42.18 L21.32,42.76 L21.67,43.33 L22.03,43.90 L22.42,44.47 L22.84,45.03 L23.27,45.59 L23.73,46.13 L24.21,46.67 L24.71,47.21 L25.23,47.73 L25.77,48.24 L26.33,48.75 L26.71,49.08"/><path d="M44.69,56.67 L45.57,56.78 L46.45,56.86 L47.33,56.93 L48.22,56.98 L49.11,57.01 L50.00,57.02 L50.89,57.01 L51.78,56.98 L52.67,56.93 L53.55,56.86 L54.43,56.78 L55.31,56.67 L56.19,56.54 L57.06,56.40 L57.92,56.23 L58.78,56.05 L59.62,55.85 L60.47,55.63 L61.30,55.39 L62.12,55.14 L62.93,54.86 L63.74,54.57 L64.53,54.27 L65.30,53.94 L66.07,53.60 L66.82,53.25 L67.56,52.88 L68.28,52.49 L68.99,52.09 L69.68,51.67 L70.36,51.24 L71.02,50.80 L71.66,50.35 L72.29,49.88 L72.89,49.40 L73.48,48.91 L74.05,48.41 L74.59,47.90 L75.12,47.38 L75.63,46.85 L76.11,46.32 L76.58,45.77 L77.02,45.22 L77.44,44.66 L77.84,44.09 L78.21,43.52 L78.57,42.95 L78.90,42.37 L79.20,41.79 L79.48,41.20 L79.74,40.62 L79.98,40.03 L80.19,39.44 L80.37,38.85 L80.54,38.26 L80.68,37.68 L80.79,37.09 L80.88,36.51 L80.94,35.93 L80.98,35.35 L81.00,34.78 L80.99,34.22 L80.96,33.66 L80.90,33.11 L80.82,32.56 L80.72,32.02 L80.59,31.50 L80.44,30.98 L80.27,30.47 L80.07,29.97 L79.85,29.48 L79.61,29.00 L79.35,28.54 L79.06,28.09 L78.76,27.65 L78.43,27.22 L78.08,26.81 L77.71,26.42 L77.33,26.04 L76.92,25.67 L76.49,25.33 L76.05,25.00 L75.59,24.68 L75.11,24.39 L74.61,24.11 L74.10,23.85 L73.57,23.61 L73.02,23.39 L72.47,23.19 L71.89,23.01 L71.31,22.85 L70.71,22.71 L70.10,22.59 L69.47,22.49 L68.84,22.42 L68.19,22.36 L67.54,22.33 L66.88,22.32 L66.21,22.33 L65.53,22.36 L64.84,22.42 L64.15,22.49 L63.45,22.59 L62.75,22.71 L62.04,22.86 L61.33,23.03 L60.62,23.22 L59.90,23.43 L59.19,23.66 L58.71,23.83"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="15 18 70 66"><g fill="none" stroke="#FF8A5B" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round"><path d="M43.15,35.60 L42.62,36.30 L42.10,37.02 L41.60,37.76 L41.11,38.50 L40.64,39.26 L40.19,40.02 L39.75,40.80 L39.33,41.58 L38.93,42.38 L38.55,43.18 L38.18,43.99 L37.83,44.80 L37.51,45.62 L37.20,46.44 L36.91,47.27 L36.64,48.11 L36.39,48.94 L36.16,49.78 L35.95,50.62 L35.76,51.46 L35.59,52.30 L35.44,53.14 L35.31,53.98 L35.20,54.82 L35.11,55.65 L35.04,56.48 L35.00,57.30 L34.97,58.12 L34.96,58.94 L34.97,59.74 L35.01,60.54 L35.06,61.34 L35.13,62.12 L35.23,62.89 L35.34,63.66 L35.47,64.41 L35.62,65.15 L35.79,65.88 L35.97,66.60 L36.18,67.30 L36.40,67.99 L36.64,68.66 L36.90,69.32 L37.17,69.97 L37.46,70.59 L37.77,71.20 L38.09,71.80 L38.43,72.37 L38.78,72.93 L39.14,73.46 L39.52,73.98 L39.91,74.48 L40.32,74.96 L40.73,75.41 L41.16,75.85 L41.60,76.26 L42.05,76.65 L42.51,77.02 L42.98,77.37 L43.46,77.69 L43.94,77.99 L44.44,78.26 L44.94,78.51 L45.44,78.74 L45.96,78.95 L46.47,79.13 L46.99,79.28 L47.52,79.41 L48.05,79.51 L48.58,79.59 L49.11,79.65 L49.64,79.68 L50.18,79.68 L50.71,79.66 L51.24,79.61 L51.78,79.54 L52.30,79.45 L52.83,79.33 L53.35,79.18 L53.87,79.01 L54.39,78.81 L54.89,78.59 L55.40,78.35 L55.89,78.08 L56.38,77.79 L56.86,77.48 L57.33,77.14 L57.80,76.78 L58.25,76.39 L58.69,75.99 L59.12,75.56 L59.55,75.11 L59.95,74.64 L60.35,74.15 L60.73,73.64 L61.10,73.11 L61.46,72.56 L61.80,71.99 L62.13,71.40 L62.44,70.80 L62.73,70.18 L63.01,69.54 L63.28,68.89 L63.52,68.22 L63.75,67.53 L63.96,66.83 L64.15,66.12 L64.33,65.40 L64.48,64.66 L64.58,64.16"/><path d="M62.17,44.80 L61.82,43.99 L61.45,43.18 L61.07,42.38 L60.67,41.58 L60.25,40.80 L59.81,40.02 L59.36,39.26 L58.89,38.50 L58.40,37.76 L57.90,37.02 L57.38,36.30 L56.85,35.60 L56.31,34.90 L55.75,34.22 L55.17,33.56 L54.59,32.91 L53.99,32.27 L53.38,31.65 L52.75,31.05 L52.12,30.47 L51.48,29.90 L50.83,29.35 L50.16,28.82 L49.50,28.31 L48.82,27.81 L48.13,27.34 L47.44,26.89 L46.75,26.45 L46.05,26.04 L45.34,25.65 L44.63,25.28 L43.92,24.93 L43.20,24.60 L42.49,24.29 L41.77,24.01 L41.05,23.74 L40.34,23.50 L39.62,23.28 L38.91,23.09 L38.19,22.91 L37.49,22.76 L36.78,22.63 L36.08,22.52 L35.39,22.44 L34.70,22.38 L34.02,22.34 L33.34,22.32 L32.68,22.32 L32.02,22.35 L31.37,22.40 L30.74,22.47 L30.11,22.56 L29.49,22.67 L28.89,22.80 L28.30,22.96 L27.72,23.13 L27.16,23.32 L26.61,23.54 L26.08,23.77 L25.56,24.02 L25.06,24.29 L24.57,24.58 L24.10,24.89 L23.65,25.22 L23.22,25.56 L22.81,25.92 L22.41,26.29 L22.04,26.68 L21.68,27.09 L21.35,27.50 L21.04,27.94 L20.74,28.39 L20.47,28.85 L20.22,29.32 L20.00,29.80 L19.79,30.30 L19.61,30.81 L19.45,31.32 L19.32,31.85 L19.21,32.38 L19.12,32.92 L19.06,33.48 L19.02,34.03 L19.00,34.60 L19.01,35.16 L19.04,35.74 L19.10,36.32 L19.18,36.90 L19.28,37.48 L19.41,38.07 L19.57,38.66 L19.75,39.24 L19.95,39.83 L20.18,40.42 L20.43,41.01 L20.70,41.59 L21.00,42.18 L21.32,42.76 L21.67,43.33 L22.03,43.90 L22.42,44.47 L22.84,45.03 L23.27,45.59 L23.73,46.13 L24.21,46.67 L24.71,47.21 L25.23,47.73 L25.77,48.24 L26.33,48.75 L26.71,49.08"/><path d="M44.69,56.67 L45.57,56.78 L46.45,56.86 L47.33,56.93 L48.22,56.98 L49.11,57.01 L50.00,57.02 L50.89,57.01 L51.78,56.98 L52.67,56.93 L53.55,56.86 L54.43,56.78 L55.31,56.67 L56.19,56.54 L57.06,56.40 L57.92,56.23 L58.78,56.05 L59.62,55.85 L60.47,55.63 L61.30,55.39 L62.12,55.14 L62.93,54.86 L63.74,54.57 L64.53,54.27 L65.30,53.94 L66.07,53.60 L66.82,53.25 L67.56,52.88 L68.28,52.49 L68.99,52.09 L69.68,51.67 L70.36,51.24 L71.02,50.80 L71.66,50.35 L72.29,49.88 L72.89,49.40 L73.48,48.91 L74.05,48.41 L74.59,47.90 L75.12,47.38 L75.63,46.85 L76.11,46.32 L76.58,45.77 L77.02,45.22 L77.44,44.66 L77.84,44.09 L78.21,43.52 L78.57,42.95 L78.90,42.37 L79.20,41.79 L79.48,41.20 L79.74,40.62 L79.98,40.03 L80.19,39.44 L80.37,38.85 L80.54,38.26 L80.68,37.68 L80.79,37.09 L80.88,36.51 L80.94,35.93 L80.98,35.35 L81.00,34.78 L80.99,34.22 L80.96,33.66 L80.90,33.11 L80.82,32.56 L80.72,32.02 L80.59,31.50 L80.44,30.98 L80.27,30.47 L80.07,29.97 L79.85,29.48 L79.61,29.00 L79.35,28.54 L79.06,28.09 L78.76,27.65 L78.43,27.22 L78.08,26.81 L77.71,26.42 L77.33,26.04 L76.92,25.67 L76.49,25.33 L76.05,25.00 L75.59,24.68 L75.11,24.39 L74.61,24.11 L74.10,23.85 L73.57,23.61 L73.02,23.39 L72.47,23.19 L71.89,23.01 L71.31,22.85 L70.71,22.71 L70.10,22.59 L69.47,22.49 L68.84,22.42 L68.19,22.36 L67.54,22.33 L66.88,22.32 L66.21,22.33 L65.53,22.36 L64.84,22.42 L64.15,22.49 L63.45,22.59 L62.75,22.71 L62.04,22.86 L61.33,23.03 L60.62,23.22 L59.90,23.43 L59.19,23.66 L58.71,23.83"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -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
|
||||
@@ -0,0 +1,76 @@
|
||||
# Chrome Web Store listing
|
||||
|
||||
Everything the developer dashboard asks for, in the order it asks.
|
||||
|
||||
## Package
|
||||
|
||||
`scripts/package-extension.sh` at the repository root builds `build/open-in-knotchy-<version>.zip`. It holds only
|
||||
`manifest.json`, `background.js` and `icons/`. Upload that zip.
|
||||
|
||||
## Store listing
|
||||
|
||||
**Title** (from the manifest): Open in Knotchy
|
||||
|
||||
**Summary** (from the manifest): Adds "Open in Knotchy" to the right-click menu of links: opens them in Knotchy's
|
||||
disposable, isolated browser.
|
||||
|
||||
**Description:**
|
||||
|
||||
> Right-click any link and choose Open in Knotchy to open it in Knotchy's disposable browser instead of Chrome.
|
||||
>
|
||||
> Knotchy is a free, open-source Mac app. Each link opens in its own throwaway Linux virtual machine, shown in a
|
||||
> panel under your MacBook's notch. The page can reach the internet but not your Mac or your local network, and the
|
||||
> whole machine is destroyed when you're done. It's meant for links you don't trust: unexpected email links,
|
||||
> shortened URLs, and sites you'd rather not open in your own browser.
|
||||
>
|
||||
> This extension does one thing: it passes the address of the link you right-click to the Knotchy app on your Mac.
|
||||
> It passes only http and https links, can't see the pages you visit, and sends nothing anywhere else.
|
||||
>
|
||||
> Requires the Knotchy app, on a Mac with Apple silicon and macOS 26 or later. The first time, Chrome asks whether to
|
||||
> open Knotchy; allow it.
|
||||
|
||||
**Category:** Privacy & Security. **Language:** English.
|
||||
|
||||
**Graphics:**
|
||||
|
||||
| Asset | File |
|
||||
|---|---|
|
||||
| Store icon, 128 × 128 | `icons/icon-128.png` |
|
||||
| Screenshots, 1280 × 800 (at least one) | `store/screenshot-*.png`, made as below |
|
||||
| Small promo tile, 440 × 280 | `store/promo-440x280.png` |
|
||||
| Marquee, 1400 × 560 (optional) | `store/marquee-1400x560.png` |
|
||||
|
||||
`scripts/make-icons.sh` regenerates the promo images with the icons.
|
||||
|
||||
**Screenshots** must be real captures:
|
||||
|
||||
1. With the extension loaded in Chrome, press ⌘⇧5, set a 5-second timer and capture the screen. During the countdown,
|
||||
right-click a link so the menu shows **Open in Knotchy**.
|
||||
2. Open a link that way and capture the Knotchy panel under the notch with ⌘⇧4.
|
||||
3. Fit each capture to the store's frame:
|
||||
`swift scripts/icons/frame.swift ~/Desktop/capture.png extensions/chrome/store/screenshot-1.png`
|
||||
|
||||
**Homepage and support URLs:** the GitHub repository and its issues page, once the repository is public.
|
||||
|
||||
## Privacy practices
|
||||
|
||||
**Single purpose:** Open a link in the Knotchy Mac app from Chrome's right-click menu.
|
||||
|
||||
**Permission justification, `contextMenus`:** Adds the Open in Knotchy item to the right-click menu of links, and
|
||||
nothing else.
|
||||
|
||||
**Host permissions:** none requested.
|
||||
|
||||
**Remote code:** No. All code ships in the package.
|
||||
|
||||
**Data usage:** tick none of the data types. The extension collects nothing: the clicked link goes only to the
|
||||
Knotchy app on the same Mac, through its `knotchy://` URL scheme, and never to a server. Tick the three
|
||||
certifications (no selling or transferring data, no use unrelated to the single purpose, no use for creditworthiness).
|
||||
|
||||
**Privacy policy:** not required, since no user data is collected. If the dashboard asks anyway, link a page that
|
||||
says: "Open in Knotchy collects no data. It passes the link you choose to the Knotchy app on your Mac and sends
|
||||
nothing to any server."
|
||||
|
||||
## Distribution
|
||||
|
||||
Public, free, all regions.
|
||||
@@ -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 });
|
||||
});
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 866 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -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" }
|
||||
}
|
||||
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -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"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,28 @@
|
||||
// swift scripts/icons/frame.swift <screenshot> <out.png>: fits a screenshot into the 1280×800 frame the
|
||||
// Chrome Web Store requires, on the icon's navy, as a 24-bit PNG. Window captures keep their own shadow.
|
||||
import AppKit
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
let args = CommandLine.arguments
|
||||
guard args.count == 3, let shot = NSImage(contentsOfFile: args[1]),
|
||||
let image = shot.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
print("usage: swift scripts/icons/frame.swift <screenshot> <out.png>")
|
||||
exit(1)
|
||||
}
|
||||
let width = 1280, height = 800, margin: CGFloat = 40
|
||||
let ctx = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)!
|
||||
let colors = [CGColor(srgbRed: 0x1C / 255, green: 0x35 / 255, blue: 0x63 / 255, alpha: 1),
|
||||
CGColor(srgbRed: 0x0D / 255, green: 0x1A / 255, blue: 0x33 / 255, alpha: 1)] as CFArray
|
||||
let gradient = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), colors: colors, locations: [0, 1])!
|
||||
ctx.drawLinearGradient(gradient, start: CGPoint(x: 0, y: CGFloat(height)), end: .zero, options: [])
|
||||
let scale = min((CGFloat(width) - 2 * margin) / CGFloat(image.width), (CGFloat(height) - 2 * margin) / CGFloat(image.height))
|
||||
let size = CGSize(width: CGFloat(image.width) * scale, height: CGFloat(image.height) * scale)
|
||||
ctx.interpolationQuality = .high
|
||||
ctx.draw(image, in: CGRect(x: (CGFloat(width) - size.width) / 2, y: (CGFloat(height) - size.height) / 2,
|
||||
width: size.width, height: size.height))
|
||||
let dest = CGImageDestinationCreateWithURL(URL(fileURLWithPath: args[2]) as CFURL, UTType.png.identifier as CFString, 1, nil)!
|
||||
CGImageDestinationAddImage(dest, ctx.makeImage()!, nil)
|
||||
CGImageDestinationFinalize(dest)
|
||||
print("wrote \(args[2])")
|
||||
@@ -0,0 +1 @@
|
||||
{"icon": [[[43.147358872228004, 35.597261122187355], [42.61532507762965, 36.304681642555074], [42.09814885584686, 37.02497342074467], [41.59628295059067, 37.75754407139881], [41.11016314262925, 38.501788171305485], [40.64020783440553, 39.25708782787771], [40.186817652259094, 40.02281326017288], [39.7503750666409, 40.798323391885674], [39.331244030691536, 41.5829664557369], [38.92976963753638, 42.37608060866765], [38.54627779663111, 43.17699455723982], [38.18107492947324, 43.985028192631795], [37.83444768497557, 44.79949323460966], [37.506662674778056, 45.61969388384524], [37.19796622875481, 46.444927481943765], [36.90858417095287, 47.274485178536395], [36.638721616179424, 48.107652604785535], [36.38856278743295, 48.94371055264535], [36.158270854354186, 49.78193565921238], [35.94798779285065, 50.6216010954986], [35.757834266028794, 51.46197725895178], [35.58790952654568, 52.30233246904732], [35.43829134047141, 53.14193366527007], [35.3090359327321, 53.980047106803596], [35.20017795418167, 54.81593907324261], [35.11173047032945, 55.64887656564209], [35.04368497172906, 56.47812800721761], [34.99601140601252, 57.30296394301044], [34.96865823153224, 58.12265773783302], [34.961552492551895, 58.93648627181062], [34.974599915906126, 59.743730632839], [35.00768502902749, 60.54367680527923], [35.06067129921806, 61.335616354215425], [35.13340129402195, 62.11884710460527], [35.225696862534214, 62.89267381465754], [35.337359337460796, 63.65640884277764], [35.46816975772352, 64.40937280742695], [35.617889111384144, 65.15089523925003], [35.786258598640785, 65.88031522483004], [35.97299991463079, 66.59698204144215], [36.17781555175401, 67.30025578218226], [36.40038912121131, 67.98950797085851], [36.64038569343427, 68.66412216604321], [36.89745215706282, 69.32349455369211], [37.17121759611, 69.96703452775148], [37.46129368493396, 70.5941652581821], [37.767275100620964, 71.20432424584489], [38.08873995236454, 71.79696386370284], [38.425250227410274, 72.37155188380925], [38.776352253118695, 72.92757198956546], [39.14157717468284, 73.46452427274542], [39.52044144802173, 73.98192571480013], [39.91244734735576, 74.47931065196943], [40.31708348695592, 74.95623122374559], [40.73382535654387, 75.41225780424828], [41.1621358698074, 75.84697941608812], [41.60146592548216, 76.26000412631285], [42.051254980438095, 76.65095942404778], [42.510931634197796, 77.01949257945994], [42.97991422430148, 77.36527098369383], [43.4576114319241, 77.68798246944569], [43.94342289713845, 77.98733561186113], [44.43673984320961, 78.26306000946163], [44.9369457092966, 78.5149065448237], [45.443416790929255, 78.74264762475569], [45.95552288761989, 78.94607739973573], [46.47262795696309, 79.1250119623963], [46.99409077457018, 79.27928952486037], [47.51926559917878, 79.408770574755], [48.04750284227361, 79.5133380097496], [48.578149741549126, 79.59289725048669], [49.1105510375421, 79.64737633179448], [49.644049652757644, 79.67672597209153], [50.177987372610914, 79.68091962091582], [50.711705527504435, 79.65995348453107], [51.24454567535968, 79.61384652958573], [51.77585028392153, 79.54264046482056], [52.304963412154024, 79.44639970084314], [52.83123139004737, 79.32521128800849], [53.354003496156544, 79.1791848324669], [53.87263263219571, 79.00845239046137], [54.38647599401408, 78.81316834097822], [54.89489573828321, 78.59350923687589], [55.3972596442302, 78.34967363463801], [55.89294176975501, 78.0818819029179], [56.38132310127728, 77.79037601006189], [56.86179219666286, 77.47541929082043], [57.33374582058782, 77.13729619247562], [57.79658957170505, 76.7763120006342], [58.24973850098674, 76.39279254495501], [58.69261772062432, 75.98708388509974], [59.12466300287774, 75.55955197721482], [59.545321368274685, 75.11058232127185], [59.954051662571985, 74.64057958961236], [60.3503251219019, 74.14996723706122], [60.733625925537865, 73.63918709299146], [61.10345173572674, 73.10869893573992], [61.459314224046835, 72.55898004979225], [61.800739583765235, 71.99052476617032], [62.127269027680796, 71.40384398647382], [62.438459270954354, 70.79946469104226], [62.7338829984421, 70.17792943171975], [63.013129316063434, 69.53979580972054], [63.27580418575093, 68.88563593910635], [63.521530843545385, 68.21603589640245], [63.74995020041658, 67.53159515689163], [63.96072122540671, 66.83292601813906], [64.15352131071153, 66.1206530113129], [64.32804661833163, 65.39541230087809], [64.48401240794503, 64.65785107325169], [64.57754692112395, 64.1596231815178]], [[62.165552315024435, 44.799493234609656], [61.818925070526774, 43.98502819263181], [61.4537222033689, 43.17699455723984], [61.07023036246362, 42.376080608667664], [60.66875596930847, 41.582966455736916], [60.24962493335912, 40.79832339188571], [59.81318234774089, 40.022813260172846], [59.35979216559447, 39.257087827877704], [58.88983685737078, 38.50178817130552], [58.40371704940935, 37.75754407139884], [57.901851144153156, 37.024973420744686], [57.38467492237035, 36.30468164255507], [56.85264112777201, 35.59726112218736], [Line truncated
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generates the knot icons' geometry; run via scripts/make-icons.sh."""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
NAVY = "#132441"
|
||||
CORAL = "#FF8A5B"
|
||||
|
||||
|
||||
def trefoil(cx, cy, size, gap):
|
||||
n = 1200
|
||||
pts = []
|
||||
for i in range(n):
|
||||
t = 2 * math.pi * i / n
|
||||
pts.append((math.sin(t) + 2 * math.sin(2 * t), -(math.cos(t) - 2 * math.cos(2 * t))))
|
||||
xs = [p[0] for p in pts]
|
||||
ys = [p[1] for p in pts]
|
||||
mx, my = (max(xs) + min(xs)) / 2, (max(ys) + min(ys)) / 2
|
||||
span = max(max(xs) - min(xs), max(ys) - min(ys))
|
||||
p = [(cx + (x - mx) / span * size, cy + (y - my) / span * size) for x, y in pts]
|
||||
|
||||
# Self-crossings: points far apart along the curve that land on each other.
|
||||
cands = []
|
||||
for i in range(n):
|
||||
for j in range(i + n // 6, n):
|
||||
if j - i > n - n // 6:
|
||||
continue
|
||||
d = (p[i][0] - p[j][0]) ** 2 + (p[i][1] - p[j][1]) ** 2
|
||||
if d < (size * 0.004) ** 2:
|
||||
cands.append((d, i, j))
|
||||
cands.sort()
|
||||
crossings = []
|
||||
for _, i, j in cands:
|
||||
far = lambda a, b: min(abs(a - b) % n, n - abs(a - b) % n) > 40
|
||||
if all(far(i, a) and far(j, b) for a, b in crossings):
|
||||
crossings.append((i, j))
|
||||
if len(crossings) == 3:
|
||||
break
|
||||
assert len(crossings) == 3, crossings
|
||||
|
||||
# Alternate crossings go under; hide points within `gap` (straight-line) of each.
|
||||
visits = sorted([i for i, _ in crossings] + [j for _, j in crossings])
|
||||
under = visits[1::2]
|
||||
hidden = set()
|
||||
for u in under:
|
||||
for d in range(-n // 10, n // 10 + 1):
|
||||
k = (u + d) % n
|
||||
if math.dist(p[k], p[u]) < gap:
|
||||
hidden.add(k)
|
||||
|
||||
strands, cur = [], []
|
||||
start = next(i for i in range(n) if i in hidden)
|
||||
for k in range(1, n + 1):
|
||||
i = (start + k) % n
|
||||
if i in hidden:
|
||||
if len(cur) > 1:
|
||||
strands.append(cur)
|
||||
cur = []
|
||||
else:
|
||||
cur.append(p[i])
|
||||
if len(cur) > 1:
|
||||
strands.append(cur)
|
||||
assert len(strands) == 3
|
||||
return [s[::3] + [s[-1]] for s in strands]
|
||||
|
||||
|
||||
def path(strand):
|
||||
return "M" + " L".join(f"{x:.2f},{y:.2f}" for x, y in strand)
|
||||
|
||||
|
||||
# Each gap is half the crossing strand's width, plus the round cap, plus clear space.
|
||||
ICON_STROKE, GLYPH_STROKE = 8.5, 1.9
|
||||
icon = trefoil(50, 51, 62, ICON_STROKE + 1.3)
|
||||
glyph = trefoil(9, 9.3, 14.2, GLYPH_STROKE + 0.6)
|
||||
|
||||
os.makedirs(os.path.join(ROOT, "assets"), exist_ok=True)
|
||||
json.dump({"icon": icon, "iconStroke": ICON_STROKE, "glyph": glyph, "glyphStroke": GLYPH_STROKE,
|
||||
"navy": NAVY, "coral": CORAL},
|
||||
open(os.path.join(ROOT, "scripts/icons/knot.json"), "w"))
|
||||
|
||||
strokes = "".join(f'<path d="{path(s)}"/>' for s in icon)
|
||||
open(os.path.join(ROOT, "assets/knotchy-icon.svg"), "w").write(
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
|
||||
f'<rect width="100" height="100" rx="22.5" fill="{NAVY}"/>'
|
||||
f'<g fill="none" stroke="{CORAL}" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round">{strokes}</g></svg>\n')
|
||||
open(os.path.join(ROOT, "assets/knotchy-mark.svg"), "w").write(
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="15 18 70 66">'
|
||||
f'<g fill="none" stroke="{CORAL}" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round">{strokes}</g></svg>\n')
|
||||
|
||||
points = ",\n".join(
|
||||
" [" + ", ".join(f"CGPoint(x: {x:.2f}, y: {y:.2f})" for x, y in s) + "]" for s in glyph)
|
||||
open(os.path.join(ROOT, "Sources/Knotchy/KnotGlyph.swift"), "w").write(f'''// Generated by scripts/icons/knot.py; run scripts/make-icons.sh to change it.
|
||||
import AppKit
|
||||
|
||||
enum KnotGlyph {{
|
||||
static let strands: [[CGPoint]] = [
|
||||
{points},
|
||||
]
|
||||
|
||||
static let menuBarImage: NSImage = {{
|
||||
let image = NSImage(size: NSSize(width: 18, height: 18), flipped: true) {{ _ in
|
||||
NSColor.black.setStroke()
|
||||
for strand in strands {{
|
||||
let path = NSBezierPath()
|
||||
path.move(to: strand[0])
|
||||
for point in strand.dropFirst() {{ path.line(to: point) }}
|
||||
path.lineWidth = 1.9
|
||||
path.lineCapStyle = .round
|
||||
path.lineJoinStyle = .round
|
||||
path.stroke()
|
||||
}}
|
||||
return true
|
||||
}}
|
||||
image.isTemplate = true
|
||||
image.accessibilityDescription = "Knotchy"
|
||||
return image
|
||||
}}()
|
||||
}}
|
||||
''')
|
||||
print("knot: 3 strands; wrote knot.json, assets/*.svg, Sources/Knotchy/KnotGlyph.swift")
|
||||
@@ -0,0 +1,183 @@
|
||||
// swift scripts/icons/render.swift <out-dir>; run via scripts/make-icons.sh.
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct Knot: Decodable {
|
||||
let icon: [[[Double]]]
|
||||
let iconStroke: Double
|
||||
}
|
||||
|
||||
let args = CommandLine.arguments
|
||||
let here = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||
let knot = try JSONDecoder().decode(Knot.self, from: Data(contentsOf: here.appendingPathComponent("knot.json")))
|
||||
let out = URL(fileURLWithPath: args.count > 1 ? args[1] : ".")
|
||||
|
||||
func color(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor {
|
||||
CGColor(srgbRed: CGFloat(hex >> 16 & 0xFF) / 255, green: CGFloat(hex >> 8 & 0xFF) / 255,
|
||||
blue: CGFloat(hex & 0xFF) / 255, alpha: alpha)
|
||||
}
|
||||
|
||||
/// Apple's icon shape is close to a superellipse with exponent 5.
|
||||
func squircle(_ r: CGRect) -> CGPath {
|
||||
let path = CGMutablePath()
|
||||
let steps = 720
|
||||
for i in 0...steps {
|
||||
let t = Double(i) / Double(steps) * 2 * .pi
|
||||
let c = cos(t), s = sin(t)
|
||||
let x = r.midX + r.width / 2 * CGFloat(copysign(pow(abs(c), 2.0 / 5), c))
|
||||
let y = r.midY + r.height / 2 * CGFloat(copysign(pow(abs(s), 2.0 / 5), s))
|
||||
if i == 0 { path.move(to: CGPoint(x: x, y: y)) } else { path.addLine(to: CGPoint(x: x, y: y)) }
|
||||
}
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
|
||||
func verticalGradient(_ ctx: CGContext, in rect: CGRect, top: CGColor, bottom: CGColor) {
|
||||
let gradient = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), colors: [top, bottom] as CFArray, locations: [0, 1])!
|
||||
ctx.drawLinearGradient(gradient, start: CGPoint(x: rect.midX, y: rect.minY), end: CGPoint(x: rect.midX, y: rect.maxY), options: [])
|
||||
}
|
||||
|
||||
/// The knot as drawn on the icon: `box` is the icon's body, and the knot keeps the icon's margins inside it.
|
||||
func drawKnot(_ ctx: CGContext, in box: CGRect) {
|
||||
let scale = box.width / 100
|
||||
let width = knot.iconStroke * scale
|
||||
for strand in knot.icon {
|
||||
let line = CGMutablePath()
|
||||
for (i, p) in strand.enumerated() {
|
||||
let point = CGPoint(x: box.minX + p[0] * scale, y: box.minY + p[1] * scale)
|
||||
if i == 0 { line.move(to: point) } else { line.addLine(to: point) }
|
||||
}
|
||||
let stroked = line.copy(strokingWithWidth: width, lineCap: .round, lineJoin: .round, miterLimit: 4)
|
||||
ctx.saveGState()
|
||||
ctx.addPath(stroked)
|
||||
ctx.clip()
|
||||
verticalGradient(ctx, in: box, top: color(0xFFA37A), bottom: color(0xFF7345))
|
||||
ctx.restoreGState()
|
||||
}
|
||||
}
|
||||
|
||||
func render(size: Int, body: CGRect, shadow: Bool, to url: URL) {
|
||||
let s = CGFloat(size)
|
||||
let ctx = CGContext(data: nil, width: size, height: size, bitsPerComponent: 8, bytesPerRow: 0,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
|
||||
ctx.translateBy(x: 0, y: s)
|
||||
ctx.scaleBy(x: 1, y: -1)
|
||||
ctx.setShouldAntialias(true)
|
||||
ctx.interpolationQuality = .high
|
||||
let shape = squircle(body)
|
||||
|
||||
if shadow {
|
||||
ctx.saveGState()
|
||||
ctx.setShadow(offset: CGSize(width: 0, height: s * 0.012), blur: s * 0.028, color: color(0x000000, 0.32))
|
||||
ctx.addPath(shape)
|
||||
ctx.setFillColor(color(0x132441))
|
||||
ctx.fillPath()
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
ctx.saveGState()
|
||||
ctx.addPath(shape)
|
||||
ctx.clip()
|
||||
verticalGradient(ctx, in: body, top: color(0x1C3563), bottom: color(0x0D1A33))
|
||||
drawKnot(ctx, in: body)
|
||||
ctx.restoreGState()
|
||||
|
||||
let image = ctx.makeImage()!
|
||||
let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)!
|
||||
CGImageDestinationAddImage(dest, image, nil)
|
||||
CGImageDestinationFinalize(dest)
|
||||
}
|
||||
|
||||
// Apple's grid: an 824-point body on a 1024-point canvas.
|
||||
let iconset = out.appendingPathComponent("AppIcon.iconset")
|
||||
try FileManager.default.createDirectory(at: iconset, withIntermediateDirectories: true)
|
||||
for (points, scale) in [(16, 1), (16, 2), (32, 1), (32, 2), (128, 1), (128, 2), (256, 1), (256, 2), (512, 1), (512, 2)] {
|
||||
let size = points * scale
|
||||
let inset = CGFloat(size) * 100 / 1024
|
||||
let body = CGRect(x: inset, y: inset, width: CGFloat(size) - 2 * inset, height: CGFloat(size) - 2 * inset)
|
||||
let name = scale == 1 ? "icon_\(points)x\(points).png" : "icon_\(points)x\(points)@2x.png"
|
||||
render(size: size, body: body, shadow: size >= 64, to: iconset.appendingPathComponent(name))
|
||||
}
|
||||
|
||||
// The Chrome Web Store wants 96 points of art with 16 of padding at 128.
|
||||
for (size, pad) in [(128, 16.0), (48, 2.0), (32, 1.0), (16, 0.0)] {
|
||||
let body = CGRect(x: pad, y: pad, width: Double(size) - 2 * pad, height: Double(size) - 2 * pad)
|
||||
render(size: size, body: body, shadow: false, to: out.appendingPathComponent("chrome-\(size).png"))
|
||||
}
|
||||
// The disk image window: Finder puts Knotchy at (180, 175) and Applications at (460, 175).
|
||||
// It's drawn taller than the 400-point window so a slightly taller window never shows an edge, and it's light
|
||||
// because Finder always draws the file names in black over a background picture, even in dark mode.
|
||||
func renderBackground(scale: Int, to url: URL) {
|
||||
let width = 640, height = 440
|
||||
let ctx = CGContext(data: nil, width: width * scale, height: height * scale, bitsPerComponent: 8, bytesPerRow: 0,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
|
||||
ctx.scaleBy(x: CGFloat(scale), y: CGFloat(scale))
|
||||
ctx.translateBy(x: 0, y: CGFloat(height))
|
||||
ctx.scaleBy(x: 1, y: -1)
|
||||
ctx.setFillColor(color(0xF5F5F7))
|
||||
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
|
||||
let arrow = CGMutablePath()
|
||||
arrow.move(to: CGPoint(x: 272, y: 175))
|
||||
arrow.addLine(to: CGPoint(x: 366, y: 175))
|
||||
arrow.move(to: CGPoint(x: 356, y: 165))
|
||||
arrow.addLine(to: CGPoint(x: 368, y: 175))
|
||||
arrow.addLine(to: CGPoint(x: 356, y: 185))
|
||||
ctx.addPath(arrow)
|
||||
ctx.setStrokeColor(color(0xFF8456))
|
||||
ctx.setLineWidth(3)
|
||||
ctx.setLineCap(.round)
|
||||
ctx.setLineJoin(.round)
|
||||
ctx.strokePath()
|
||||
|
||||
NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true)
|
||||
let caption = NSAttributedString(string: "Drag Knotchy to Applications", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 12, weight: .medium),
|
||||
.foregroundColor: NSColor(cgColor: color(0x86868B))!,
|
||||
])
|
||||
let size = caption.size()
|
||||
caption.draw(at: CGPoint(x: (CGFloat(width) - size.width) / 2, y: 300))
|
||||
NSGraphicsContext.current = nil
|
||||
|
||||
let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)!
|
||||
CGImageDestinationAddImage(dest, ctx.makeImage()!, [kCGImagePropertyDPIWidth: 72 * scale, kCGImagePropertyDPIHeight: 72 * scale] as CFDictionary)
|
||||
CGImageDestinationFinalize(dest)
|
||||
}
|
||||
renderBackground(scale: 1, to: out.appendingPathComponent("dmg-background.png"))
|
||||
renderBackground(scale: 2, to: out.appendingPathComponent("dmg-background@2x.png"))
|
||||
// Chrome Web Store promo images. The store takes 24-bit PNGs only, hence no alpha.
|
||||
func renderPromo(width: Int, height: Int, to url: URL) {
|
||||
let w = CGFloat(width), h = CGFloat(height), unit = h / 280
|
||||
let ctx = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)!
|
||||
ctx.translateBy(x: 0, y: h)
|
||||
ctx.scaleBy(x: 1, y: -1)
|
||||
verticalGradient(ctx, in: CGRect(x: 0, y: 0, width: w, height: h), top: color(0x1C3563), bottom: color(0x0D1A33))
|
||||
|
||||
let title = NSAttributedString(string: "Open in Knotchy", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 28 * unit, weight: .bold),
|
||||
.foregroundColor: NSColor.white,
|
||||
])
|
||||
let subtitle = NSAttributedString(string: "Open suspicious links in a\ndisposable, isolated browser.", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 14 * unit, weight: .medium),
|
||||
.foregroundColor: NSColor(white: 1, alpha: 0.72),
|
||||
])
|
||||
let knotSide = 140 * unit, gap = 14 * unit, lineGap = 8 * unit
|
||||
let left = (w - knotSide - gap - max(title.size().width, subtitle.size().width)) / 2
|
||||
drawKnot(ctx, in: CGRect(x: left, y: (h - knotSide) / 2, width: knotSide, height: knotSide))
|
||||
|
||||
let top = (h - title.size().height - lineGap - subtitle.size().height) / 2
|
||||
NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true)
|
||||
title.draw(at: CGPoint(x: left + knotSide + gap, y: top))
|
||||
subtitle.draw(at: CGPoint(x: left + knotSide + gap, y: top + title.size().height + lineGap))
|
||||
NSGraphicsContext.current = nil
|
||||
|
||||
let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)!
|
||||
CGImageDestinationAddImage(dest, ctx.makeImage()!, nil)
|
||||
CGImageDestinationFinalize(dest)
|
||||
}
|
||||
renderPromo(width: 440, height: 280, to: out.appendingPathComponent("promo-440x280.png"))
|
||||
renderPromo(width: 1400, height: 560, to: out.appendingPathComponent("marquee-1400x560.png"))
|
||||
print("rendered AppIcon.iconset, chrome-{16,32,48,128}.png, dmg-background{,@2x}.png and the store promos into \(out.path)")
|
||||
@@ -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/"
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Zips extensions/chrome for the Chrome Web Store: build/open-in-knotchy-<version>.zip.
|
||||
set -euo pipefail
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$root/extensions/chrome"
|
||||
version="$(plutil -extract version raw manifest.json)"
|
||||
out="$root/build/open-in-knotchy-$version.zip"
|
||||
mkdir -p "$root/build"
|
||||
rm -f "$out"
|
||||
zip -q -r -X "$out" manifest.json background.js icons -x '*.DS_Store'
|
||||
echo "$out"
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds a signed, notarized build/Knotchy-<version>.dmg for a GitHub release.
|
||||
# Needs a Developer ID Application certificate and notarization credentials saved once with
|
||||
# xcrun notarytool store-credentials knotchy --apple-id <email> --team-id <team id>
|
||||
set -euo pipefail
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$root"
|
||||
profile="${KNOTCHY_NOTARY_PROFILE:-knotchy}"
|
||||
identity="${KNOTCHY_SIGN_IDENTITY:-$(security find-identity -v -p codesigning | awk '/"Developer ID Application: / && !id { id = $2 } END { print id }')}"
|
||||
if [[ -z $identity ]]; then
|
||||
echo "A release needs a Developer ID Application certificate in the keychain." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -e /Volumes/Knotchy ]]; then
|
||||
echo "Eject the mounted Knotchy disk first; Finder finds the new one by name." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
notarize() {
|
||||
local result status
|
||||
result="$(xcrun notarytool submit "$1" --keychain-profile "$profile" --wait --output-format json)"
|
||||
status="$(plutil -extract status raw - <<<"$result")"
|
||||
if [[ $status != Accepted ]]; then
|
||||
xcrun notarytool log "$(plutil -extract id raw - <<<"$result")" --keychain-profile "$profile" >&2 || true
|
||||
echo "Notarization of $1 failed: $status" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
KNOTCHY_SIGN_IDENTITY="$identity" scripts/bundle-app.sh --release
|
||||
app="build/Knotchy.app"
|
||||
version="$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$app/Contents/Info.plist")"
|
||||
|
||||
# The app gets its own ticket so it still opens offline once it's copied out of the disk image.
|
||||
ditto -c -k --keepParent "$app" build/Knotchy.zip
|
||||
notarize build/Knotchy.zip
|
||||
rm build/Knotchy.zip
|
||||
xcrun stapler staple -q "$app"
|
||||
|
||||
dmg="build/Knotchy-$version.dmg"
|
||||
rw="build/Knotchy-rw.dmg"
|
||||
stage="build/dmg"
|
||||
rm -rf "$stage" "$dmg" "$rw"
|
||||
mkdir -p "$stage/.background"
|
||||
ditto "$app" "$stage/Knotchy.app"
|
||||
ln -s /Applications "$stage/Applications"
|
||||
cp assets/dmg-background.tiff "$stage/.background/background.tiff"
|
||||
hdiutil create -quiet -volname Knotchy -fs HFS+ -format UDRW -size "$(( $(du -sm "$stage" | cut -f1) + 20 ))m" \
|
||||
-srcfolder "$stage" "$rw"
|
||||
rm -rf "$stage"
|
||||
|
||||
mnt="$(hdiutil attach -readwrite -noverify -noautoopen "$rw" | awk -F'\t' '/\/Volumes\// { print $NF }')"
|
||||
trap 'hdiutil detach -quiet -force "$mnt" 2>/dev/null || true' EXIT
|
||||
# Finder saves the window layout into the disk's .DS_Store; macOS asks once to let this script control Finder.
|
||||
osascript - <<'EOF'
|
||||
tell application "Finder"
|
||||
tell disk "Knotchy"
|
||||
open
|
||||
set current view of container window to icon view
|
||||
set toolbar visible of container window to false
|
||||
set statusbar visible of container window to false
|
||||
set bounds of container window to {200, 120, 840, 548}
|
||||
set viewOptions to icon view options of container window
|
||||
set arrangement of viewOptions to not arranged
|
||||
set icon size of viewOptions to 128
|
||||
set text size of viewOptions to 13
|
||||
set background picture of viewOptions to file ".background:background.tiff"
|
||||
set position of item "Knotchy.app" of container window to {180, 175}
|
||||
set position of item "Applications" of container window to {460, 175}
|
||||
close
|
||||
open
|
||||
update without registering applications
|
||||
delay 1
|
||||
close
|
||||
end tell
|
||||
end tell
|
||||
EOF
|
||||
for _ in {1..20}; do
|
||||
[[ -f $mnt/.DS_Store ]] && break
|
||||
sleep 0.5
|
||||
done
|
||||
# Added after Finder is done, since laying out the window deletes the icon and clears the custom-icon flag.
|
||||
cp assets/AppIcon.icns "$mnt/.VolumeIcon.icns"
|
||||
xattr -wx com.apple.FinderInfo 0000000000000000040000000000000000000000000000000000000000000000 "$mnt"
|
||||
rm -rf "$mnt/.fseventsd"
|
||||
sync
|
||||
hdiutil detach -quiet "$mnt" || { sleep 2; hdiutil detach -quiet -force "$mnt"; }
|
||||
trap - EXIT
|
||||
hdiutil convert -quiet "$rw" -format UDZO -imagekey zlib-level=9 -o "$dmg"
|
||||
rm "$rw"
|
||||
codesign --sign "$identity" --timestamp "$dmg"
|
||||
notarize "$dmg"
|
||||
xcrun stapler staple -q "$dmg"
|
||||
|
||||
spctl --assess --type open --context context:primary-signature --verbose "$dmg"
|
||||
shasum -a 256 "$dmg"
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
me=$(ip -4 -o address show dev eth0 | awk '{ split($4, a, "/"); print a[1]; exit }')
|
||||
|
||||
cat > /tmp/tinyproxy.conf <<CONF
|
||||
Port 8888
|
||||
Listen 0.0.0.0
|
||||
Timeout 60
|
||||
LogLevel Connect
|
||||
LogFile "/tmp/tinyproxy.log"
|
||||
BasicAuth knotchy s3cr3t
|
||||
MaxClients 20
|
||||
CONF
|
||||
tinyproxy -c /tmp/tinyproxy.conf
|
||||
|
||||
microsocks -i 0.0.0.0 -p 1080 -u knotchy -P 's3cr3t' > /tmp/microsocks.log 2>&1 &
|
||||
|
||||
umask 077
|
||||
wg genkey > /tmp/server.key
|
||||
wg pubkey < /tmp/server.key > /tmp/server.pub
|
||||
wg genkey > /tmp/client.key
|
||||
wg pubkey < /tmp/client.key > /tmp/client.pub
|
||||
WG_I_PREFER_BUGGY_USERSPACE_TO_POLISHED_KMOD=1 wireguard-go wg0
|
||||
wg set wg0 listen-port 51820 private-key /tmp/server.key \
|
||||
peer "$(cat /tmp/client.pub)" allowed-ips 10.99.0.2/32
|
||||
ip address add 10.99.0.1/24 dev wg0
|
||||
ip link set dev wg0 up
|
||||
echo 1 > /proc/sys/net/ipv4/ip_forward
|
||||
nft -f - <<NFT
|
||||
table ip nat {
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat;
|
||||
oifname "eth0" masquerade
|
||||
}
|
||||
}
|
||||
NFT
|
||||
cat > /tmp/client.conf <<CONF
|
||||
[Interface]
|
||||
PrivateKey = $(cat /tmp/client.key)
|
||||
Address = 10.99.0.2/32
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = $(cat /tmp/server.pub)
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = $me:51820
|
||||
CONF
|
||||
echo "route-server ready on $me"
|
||||
exec sleep infinity
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/run-vm.sh NAME URL: starts a VM with the app's flags, prints the host socket.
|
||||
# Env: KNOTCHY_DEBUG, KNOTCHY_FIRST_VIEWER_TIMEOUT, KNOTCHY_ENV_FILE, KNOTCHY_SECRETS_DIR.
|
||||
# Name VMs knotchyctl-<8 hex> so the app's orphan sweep ignores them.
|
||||
set -euo pipefail
|
||||
name="${1:?usage: run-vm.sh NAME URL}"
|
||||
url="${2:?usage: run-vm.sh NAME URL}"
|
||||
if [[ ! "$name" =~ ^[a-z0-9][a-z0-9-]{0,40}$ ]]; then
|
||||
echo "run-vm.sh: invalid VM name '$name' (lowercase letters, digits, dashes)" >&2; exit 2
|
||||
fi
|
||||
if [[ "$name" =~ ^knotchy-[0-9a-f]{8}$ ]]; then
|
||||
echo "run-vm.sh: '$name' uses the app's naming; use knotchyctl-<8 hex>" >&2; exit 2
|
||||
fi
|
||||
|
||||
tmp="${TMPDIR:-/tmp}"
|
||||
run_dir="${tmp%/}/knotchy"
|
||||
mkdir -p -m 700 "$run_dir"
|
||||
if [[ ! -d "$run_dir" || -L "$run_dir" || ! -O "$run_dir" ]]; then
|
||||
echo "run-vm.sh: refusing unsafe socket directory $run_dir" >&2; exit 1
|
||||
fi
|
||||
chmod 700 "$run_dir"
|
||||
sock="$run_dir/$name.sock"
|
||||
rm -f "$sock"
|
||||
|
||||
# The Mac's networks may be public addresses, so the VM blocks them explicitly.
|
||||
block=$(python3 - <<'PY'
|
||||
import ipaddress, re, subprocess
|
||||
out = subprocess.run(["ifconfig"], capture_output=True, text=True).stdout
|
||||
nets = set()
|
||||
for ip, mask in re.findall(r"inet (\d+\.\d+\.\d+\.\d+)(?: --> \S+)? netmask 0x([0-9a-f]{8})", out):
|
||||
if ip.startswith("127."):
|
||||
continue
|
||||
net = ipaddress.ip_network(f"{ip}/{bin(int(mask, 16)).count('1')}", strict=False)
|
||||
if net.prefixlen >= 8:
|
||||
nets.add(str(net))
|
||||
print(" ".join(sorted(nets)))
|
||||
PY
|
||||
)
|
||||
if [[ -z "$block" ]]; then
|
||||
echo "run-vm.sh: warning: found no Mac IPv4 networks to block" >&2
|
||||
fi
|
||||
|
||||
container run -d --rm --name "$name" \
|
||||
--cpus 4 --memory 2g --shm-size 512m \
|
||||
--dns 1.1.1.1 \
|
||||
--cap-add NET_ADMIN \
|
||||
--masked-path NONE --read-only-path NONE \
|
||||
--publish-socket "$sock:/tmp/knotchy/knotchy.sock" \
|
||||
-e "KNOTCHY_URL=$url" \
|
||||
-e "KNOTCHY_SIZE=1024x640" \
|
||||
-e "KNOTCHY_BLOCK_CIDRS=$block" \
|
||||
-e "KNOTCHY_DEBUG=${KNOTCHY_DEBUG:-0}" \
|
||||
-e "KNOTCHY_FIRST_VIEWER_TIMEOUT=${KNOTCHY_FIRST_VIEWER_TIMEOUT:-60}" \
|
||||
${KNOTCHY_ENV_FILE:+--env-file "$KNOTCHY_ENV_FILE"} \
|
||||
${KNOTCHY_SECRETS_DIR:+--cap-add SYS_ADMIN --mount "type=bind,source=$KNOTCHY_SECRETS_DIR,target=/run/knotchy-secrets"} \
|
||||
local/knotchy-vm:latest >/dev/null
|
||||
echo "$sock"
|
||||
@@ -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
|
||||