Initial release

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

No files matched your search

+249
View File
@@ -0,0 +1,249 @@
import AppKit
import KnotchyCore
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
let backend: ContainerCLIBackend
let controller: SessionController
let services: ServicesProvider
let settings = AppSettings()
let vmStatus: VMStatus
let unsupported: Unsupported?
let setupFlow: SetupFlow
var isSupportedMac: Bool { unsupported == nil }
private(set) lazy var setupWindow = SetupWindowController(
unsupported.map(SetupWindowController.Content.unsupported) ?? .setup(setupFlow))
private(set) lazy var settingsWindow = SettingsWindowController(app: self)
private var notch: NotchWindowController?
private var copiedLinkHotKey: GlobalHotKey?
private var wantsCopiedLinkHotKey = false
private var signalSources: [DispatchSourceSignal] = []
private var quitting = false
override init() {
unsupported = Self.detectUnsupported()
let vmSources = Bundle.main.resourceURL?.appendingPathComponent("vm")
let hasSources = vmSources.map { FileManager.default.fileExists(atPath: $0.appendingPathComponent("Containerfile").path) } ?? false
backend = ContainerCLIBackend(vmSources: hasSources ? vmSources : nil)
controller = SessionController(
backend: backend,
connector: StreamClient(renderSize: SessionController.renderSize),
diskSpace: SystemDiskSpace(),
hostNetworks: { HostNetworks.current() },
requestQuit: { NSApp.terminate(nil) }
)
services = ServicesProvider(controller: controller)
vmStatus = VMStatus(backend: backend, controller: controller)
setupFlow = SetupFlow(host: SystemHost(), installer: SystemRuntimeInstaller(), driver: controller)
super.init()
controller.quitsWhenIdle = false
controller.showSetup = { [weak self] in self?.setupWindow.show() }
if isSupportedMac {
settings.onChange = { [weak self] in self?.applySettings() }
applySettings()
} else {
services.redirect = { [weak self] in self?.setupWindow.show() }
}
}
private static func detectUnsupported() -> Unsupported? {
#if DEBUG
if ProcessInfo.processInfo.environment["KNOTCHY_PRETEND"] == "no-virtualization" { return .noVirtualization }
#endif
return SystemHost.unsupported()
}
private func applySettings() {
settings.apply(to: controller)
backend.setNetworkRoute(settings.route)
guard settings.copiedLinkShortcut != wantsCopiedLinkHotKey else { return }
wantsCopiedLinkHotKey = settings.copiedLinkShortcut
if wantsCopiedLinkHotKey {
copiedLinkHotKey = GlobalHotKey.openCopiedLink { [services] in services.openCopiedLink() }
if copiedLinkHotKey == nil { ServicesLog.write("\(GlobalHotKey.openCopiedLinkLabel) is taken by another app") }
} else {
copiedLinkHotKey = nil
}
}
func applicationWillFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
// Registered before launch finishes, so a Services request that launched the app isn't lost.
NSApp.servicesProvider = services
NSUpdateDynamicServices()
}
func applicationDidFinishLaunching(_ notification: Notification) {
#if DEBUG
if let directory = DebugTour.argument(after: "--setup-snapshot") {
Task {
await DebugTour.snapshotSetup(app: self, to: URL(fileURLWithPath: directory))
NSApp.terminate(nil)
}
return
}
#endif
guard isSupportedMac else { return setupWindow.show() }
notch = NotchWindowController(controller: controller, routeLabel: { [backend] in backend.lastStartedRoute.label })
vmStatus.refresh()
// Route Ctrl-C, kill and logout through applicationShouldTerminate so the VMs get destroyed.
for sig in [SIGINT, SIGTERM, SIGHUP] {
signal(sig, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
source.setEventHandler { NSApp.terminate(nil) }
source.resume()
signalSources.append(source)
}
#if DEBUG
let all = CommandLine.arguments
if let index = all.firstIndex(of: "--tour"), index + 2 < all.count, let url = URL(string: all[index + 2]),
let notch {
let directory = URL(fileURLWithPath: all[index + 1])
Task { await DebugTour.run(controller: controller, snapshot: { notch.snapshot(to: directory, name: $0) }, url: url) }
return
}
if all.contains("--settings-timing") {
Task {
await DebugTour.timeNetworkSwitching(app: self)
NSApp.terminate(nil)
}
return
}
if let index = all.firstIndex(of: "--settings-snapshot"), index + 1 < all.count {
let directory = URL(fileURLWithPath: all[index + 1])
Task {
await DebugTour.snapshotSettings(app: self, to: directory)
NSApp.terminate(nil)
}
return
}
if let index = all.firstIndex(of: "--tour-timing"), index + 1 < all.count, let url = URL(string: all[index + 1]) {
Task { await DebugTour.run(controller: controller, snapshot: nil, url: url) }
return
}
#endif
Task { await openSetupIfNeeded() }
let arguments = CommandLine.arguments.dropFirst().filter { !$0.hasPrefix("-") }
if let first = arguments.first { controller.open(text: first) }
}
private func openSetupIfNeeded() async {
let runtime = await backend.runtimeStatus()
let completed = UserDefaults.standard.bool(forKey: SetupLaunchRule.completedKey)
if SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: completed, runtime: runtime) {
setupWindow.show()
}
}
// From the browser extensions: Chrome's Services menu never sees a link's address.
func application(_ application: NSApplication, open urls: [URL]) {
guard isSupportedMac else { return setupWindow.show() }
for url in urls where url.scheme == "knotchy" && url.host() == "open" {
let link = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "url" }?.value
ServicesLog.write("knotchy:// request, link \(link == nil ? "missing" : "present")")
if let link, let target = URL(string: link) {
controller.open(target)
} else {
controller.open(text: "")
}
}
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard !quitting else { return .terminateNow }
quitting = true
setupFlow.cancel()
if setupFlow.isDownloading { SystemRuntimeInstaller.removeLeftovers() }
// Not Now mid-build closes the window before the build ends.
if setupFlow.allRequiredDone { UserDefaults.standard.set(true, forKey: SetupLaunchRule.completedKey) }
// Synchronous on purpose: terminate: often runs inside a non-reentrant main-queue block; awaiting here deadlocked.
notch?.hide()
CATransaction.flush()
CLIRunner.stopBuilds()
controller.closeBackendChain()
let ids = Array(controller.liveIDs.snapshot)
if !ids.isEmpty { backend.destroyBlocking(ids: ids, deadline: .seconds(20)) }
return .terminateNow
}
}
@MainActor
final class ServicesProvider: NSObject {
private let controller: SessionController
/// In minimal mode, requests only bring the explanation window forward, so the calling app gets a normal reply.
var redirect: (@MainActor () -> Void)?
init(controller: SessionController) {
self.controller = controller
}
@objc func openLink(_ pasteboard: NSPasteboard, userData: String?, error: AutoreleasingUnsafeMutablePointer<NSString?>) {
open(from: pasteboard, service: "Open in Knotchy")
}
@objc func openCopiedLink(_ pasteboard: NSPasteboard, userData: String?, error: AutoreleasingUnsafeMutablePointer<NSString?>) {
open(from: .general, service: "Open Copied Link in Knotchy")
}
func openCopiedLink() {
open(from: .general, service: "Shortcut")
}
private func open(from pasteboard: NSPasteboard, service: String) {
if let redirect { return redirect() }
let types = pasteboard.types?.map(\.rawValue) ?? []
let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] ?? []
let url: URL?
let source: String
if let found = urls.first(where: { LinkIntake.validate($0) != nil }) {
(url, source) = (found, "url")
} else if let html = pasteboard.string(forType: .html), let found = LinkIntake.extractURL(fromHTML: html) {
// A selected hyperlink's text rarely holds the address; its HTML does.
(url, source) = (found, "html")
} else if let found = Self.firstLink(inRTF: pasteboard.data(forType: .rtf)) {
(url, source) = (found, "rtf")
} else if let text = pasteboard.string(forType: .string), let found = LinkIntake.extractURL(from: text) {
(url, source) = (found, "text")
} else {
(url, source) = (nil, "none")
}
ServicesLog.write("\(service): types \(types), link from \(source)")
if let url {
controller.open(url)
} else {
controller.open(text: "")
}
}
private static func firstLink(inRTF data: Data?) -> URL? {
guard let data, let text = NSAttributedString(rtf: data, documentAttributes: nil) else { return nil }
var found: URL?
text.enumerateAttribute(.link, in: NSRange(location: 0, length: text.length)) { value, _, stop in
let url = (value as? URL) ?? (value as? String).flatMap(URL.init(string:))
if let url, let valid = LinkIntake.validate(url) {
found = valid
stop.pointee = true
}
}
return found
}
}
// Pasteboard types and sources only; never log the links themselves.
enum ServicesLog {
static func write(_ line: String) {
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Logs/Knotchy.log")
let entry = "\(ISO8601DateFormatter().string(from: Date())) \(line)\n"
if let handle = try? FileHandle(forWritingTo: url) {
handle.seekToEndOfFile()
handle.write(Data(entry.utf8))
try? handle.close()
} else {
try? Data(entry.utf8).write(to: url)
}
}
}
+293
View File
@@ -0,0 +1,293 @@
import AppKit
import Carbon.HIToolbox
import KnotchyCore
import Observation
import ServiceManagement
@MainActor
@Observable
final class AppSettings {
static let autoDestroyChoices = [1, 2, 5, 10, 15, 30]
private let defaults = UserDefaults.standard
@ObservationIgnored var onChange: () -> Void = {}
var autoDestroyMinutes: Int {
didSet { save(autoDestroyMinutes, "autoDestroyMinutes") }
}
var opensLarge: Bool {
didSet { save(opensLarge, "opensLarge") }
}
var copiedLinkShortcut: Bool {
didSet { save(copiedLinkShortcut, "copiedLinkShortcut") }
}
// MARK: Network
enum NetworkMode: String, CaseIterable {
case direct, proxy, vpn
}
var networkMode: NetworkMode {
didSet { save(networkMode.rawValue, "networkMode") }
}
var proxyKind: ProxySettings.Kind {
didSet { save(proxyKind.rawValue, "proxyKind") }
}
var proxyHost: String {
didSet { save(proxyHost, "proxyHost") }
}
var proxyPort: Int {
didSet { save(proxyPort, "proxyPort") }
}
var proxyUsername: String {
didSet { save(proxyUsername, "proxyUsername") }
}
// Debounced: after an app update, every keychain write can raise a keychain prompt.
var proxyPassword: String {
didSet {
passwordSave?.cancel()
let password = proxyPassword
passwordSave = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled, let self else { return }
Keychain.write(password, for: "proxy-password")
self.defaults.set(!password.isEmpty, forKey: "proxyHasPassword")
}
onChange()
}
}
@ObservationIgnored private var passwordSave: Task<Void, Never>?
// In the keychain, not UserDefaults: WireGuard configs hold private keys.
private(set) var wireGuardConfigs: [String: String] {
didSet {
let json = (try? JSONEncoder().encode(wireGuardConfigs)).flatMap { String(data: $0, encoding: .utf8) } ?? ""
Keychain.write(wireGuardConfigs.isEmpty ? "" : json, for: "wireguard-configs")
onChange()
}
}
var wireGuardServer: String {
didSet { save(wireGuardServer, "wireGuardServer") }
}
var selectedWireGuard: WireGuardConfig? {
wireGuardConfigs[wireGuardServer].flatMap { try? WireGuardConfig.parse($0) }
}
var wireGuardNames: [String] {
wireGuardConfigs.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
// An unusable proxy or VPN must fail the start, never fall back to a direct connection.
var route: NetworkRoute {
switch networkMode {
case .direct:
return .direct
case .proxy:
let host = proxyHost.trimmingCharacters(in: .whitespaces)
guard !host.isEmpty, (1...65535).contains(proxyPort) else {
return .unusable(reason: "The proxy isn't set up. Add its server and port in Settings → Network, or switch to Direct.")
}
return .proxy(ProxySettings(kind: proxyKind, host: host, port: proxyPort,
username: proxyUsername, password: proxyPassword))
case .vpn:
guard let text = wireGuardConfigs[wireGuardServer] else {
return .unusable(reason: "No VPN server is chosen. Import a WireGuard config in Settings → Network, or switch to Direct.")
}
do {
var config = try WireGuardConfig.parse(text)
config.name = wireGuardServer
return .wireGuard(config)
} catch {
return .unusable(reason: "The WireGuard config “\(wireGuardServer)” \(error.message).")
}
}
}
/// A hint for Settings only; sessions enforce `ProxySettings.isLocal` on the resolved address.
var proxyIsLocal: Bool {
let host = proxyHost.trimmingCharacters(in: .whitespaces).lowercased()
if host == "localhost" || [".local", ".lan", ".home", ".internal"].contains(where: host.hasSuffix) { return true }
let octets = host.split(separator: ".", omittingEmptySubsequences: false).compactMap { Int($0) }
guard octets.count == 4 else { return false }
let (a, b) = (octets[0], octets[1])
return a == 10 || a == 127 || (a == 172 && (16...31).contains(b)) || (a == 192 && b == 168)
|| (a == 100 && (64...127).contains(b)) || (a == 169 && b == 254)
}
func importWireGuard(from urls: [URL]) async -> String? {
var found: [(String, String)] = []
var problems: [String] = []
for url in urls {
let files: [(String, String)]
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
if url.pathExtension.lowercased() == "zip" {
files = await Task.detached { Self.confFiles(inZip: url) }.value
if files.isEmpty { problems.append("\(url.lastPathComponent) has no .conf files") }
} else if size <= 64 * 1024, let text = try? String(contentsOf: url, encoding: .utf8) {
files = [(url.deletingPathExtension().lastPathComponent, text)]
} else {
problems.append("couldn't read \(url.lastPathComponent)")
continue
}
for (name, text) in files {
do {
_ = try WireGuardConfig.parse(text)
found.append((name, text))
} catch {
problems.append("“\(name)” \(error.message)")
}
}
}
guard !found.isEmpty else { return problems.first.map { "Nothing imported: \($0)." } ?? "Nothing imported." }
var configs = wireGuardConfigs
for (name, text) in found { configs[name] = text }
wireGuardConfigs = configs
wireGuardServer = found.map(\.0).sorted { $0.localizedStandardCompare($1) == .orderedAscending }[0]
networkMode = .vpn
return problems.isEmpty ? nil : "Imported \(found.count); skipped: \(problems.joined(separator: "; "))."
}
func removeWireGuardConfigs() {
wireGuardConfigs = [:]
wireGuardServer = ""
}
private nonisolated static func confFiles(inZip url: URL) -> [(String, String)] {
// Output past `limit` fails the read, so a zip bomb can't exhaust memory.
func unzip(_ arguments: [String], limit: Int) -> Data? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
guard (try? process.run()) != nil else { return nil }
let pid = process.processIdentifier
let watchdog = DispatchWorkItem { kill(pid, SIGKILL) }
DispatchQueue.global().asyncAfter(deadline: .now() + 10, execute: watchdog)
defer { watchdog.cancel() }
var data = Data()
while let chunk = try? pipe.fileHandleForReading.read(upToCount: 16 * 1024), !chunk.isEmpty {
data.append(chunk)
if data.count > limit {
process.terminate()
process.waitUntilExit()
return nil
}
}
process.waitUntilExit()
return process.terminationStatus == 0 ? data : nil
}
guard let listing = unzip(["-Z1", url.path], limit: 1024 * 1024).flatMap({ String(data: $0, encoding: .utf8) })
else { return [] }
let entries = listing.split(whereSeparator: \.isNewline).map(String.init)
.filter { $0.lowercased().hasSuffix(".conf") && !$0.hasPrefix("__MACOSX") }
.prefix(500)
return entries.compactMap { entry in
guard let data = unzip(["-p", url.path, entry], limit: 64 * 1024),
let text = String(data: data, encoding: .utf8) else { return nil }
return (URL(fileURLWithPath: entry).deletingPathExtension().lastPathComponent, text)
}
}
init() {
defaults.register(defaults: ["autoDestroyMinutes": 5, "opensLarge": false, "copiedLinkShortcut": true,
"networkMode": "direct", "proxyKind": "http", "proxyPort": 8080])
autoDestroyMinutes = defaults.integer(forKey: "autoDestroyMinutes")
opensLarge = defaults.bool(forKey: "opensLarge")
copiedLinkShortcut = defaults.bool(forKey: "copiedLinkShortcut")
networkMode = NetworkMode(rawValue: defaults.string(forKey: "networkMode") ?? "") ?? .direct
proxyKind = ProxySettings.Kind(rawValue: defaults.string(forKey: "proxyKind") ?? "") ?? .http
proxyHost = defaults.string(forKey: "proxyHost") ?? ""
proxyPort = defaults.integer(forKey: "proxyPort")
let username = defaults.string(forKey: "proxyUsername") ?? ""
let server = defaults.string(forKey: "wireGuardServer") ?? ""
proxyUsername = username
wireGuardServer = server
// Skip the keychain (and its possible prompt) unless a secret was saved.
proxyPassword = defaults.bool(forKey: "proxyHasPassword") || !username.isEmpty
? Keychain.read("proxy-password") ?? "" : ""
wireGuardConfigs = server.isEmpty ? [:] :
(Keychain.read("wireguard-configs")?.data(using: .utf8))
.flatMap { try? JSONDecoder().decode([String: String].self, from: $0) } ?? [:]
}
private func save(_ value: Any, _ key: String) {
defaults.set(value, forKey: key)
onChange()
}
func apply(to controller: SessionController) {
controller.idleDestroyDelay = .seconds(autoDestroyMinutes * 60)
controller.opensExpanded = opensLarge
}
}
@MainActor
enum LoginItem {
static var isEnabled: Bool {
SMAppService.mainApp.status == .enabled
}
static func set(_ enabled: Bool) -> String? {
do {
if enabled {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
return nil
} catch {
return error.localizedDescription
}
}
}
// Carbon, because its hot keys need no Accessibility permission.
@MainActor
final class GlobalHotKey {
static let openCopiedLinkLabel = "⌃⌥⌘L"
private var hotKey: EventHotKeyRef?
private var handler: EventHandlerRef?
private let action: () -> Void
static func openCopiedLink(_ action: @escaping () -> Void) -> GlobalHotKey? {
GlobalHotKey(keyCode: UInt32(kVK_ANSI_L), modifiers: UInt32(controlKey | optionKey | cmdKey), action: action)
}
private init?(keyCode: UInt32, modifiers: UInt32, action: @escaping () -> Void) {
self.action = action
var type = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
let installed = InstallEventHandler(GetApplicationEventTarget(), { _, _, userData in
guard let userData else { return OSStatus(eventNotHandledErr) }
let hotKey = Unmanaged<GlobalHotKey>.fromOpaque(userData).takeUnretainedValue()
MainActor.assumeIsolated { hotKey.action() }
return noErr
}, 1, &type, Unmanaged.passUnretained(self).toOpaque(), &handler)
guard installed == noErr else { return nil }
let id = EventHotKeyID(signature: OSType(0x4B4E_4F54), id: 1)
// Exclusive, so a shortcut another app owns fails instead of silently sharing.
guard RegisterEventHotKey(keyCode, modifiers, id, GetApplicationEventTarget(),
OptionBits(kEventHotKeyExclusive), &hotKey) == noErr else {
RemoveEventHandler(handler)
handler = nil // deinit also runs after a failed init; don't remove it twice.
return nil
}
}
isolated deinit {
if let hotKey { UnregisterEventHotKey(hotKey) }
if let handler { RemoveEventHandler(handler) }
}
}
+28
View File
@@ -0,0 +1,28 @@
import AppKit
enum ChromeExtension {
/// Set once the Chrome Web Store assigns the item ID; until then the setup window hides the row.
static let storeURL: URL? = nil
struct Browser: Equatable {
let name: String
let app: URL
}
// Chromium browsers all install from the Chrome Web Store.
private static let candidates = [("Chrome", "com.google.Chrome"), ("Edge", "com.microsoft.edgemac"),
("Brave", "com.brave.Browser"), ("Arc", "company.thebrowser.Browser")]
static func installedBrowser() -> Browser? {
guard storeURL != nil else { return nil }
for (name, id) in candidates {
if let app = NSWorkspace.shared.urlForApplication(withBundleIdentifier: id) { return Browser(name: name, app: app) }
}
return nil
}
static func open(in browser: Browser) {
guard let storeURL else { return }
NSWorkspace.shared.open([storeURL], withApplicationAt: browser.app, configuration: NSWorkspace.OpenConfiguration())
}
}
+164
View File
@@ -0,0 +1,164 @@
#if DEBUG
import AppKit
import KnotchyCore
import QuartzCore
import SwiftUI
// `Knotchy --tour <dir> <url>` saves PNGs of each state; `--tour-timing <url>` reports dropped frames.
// Checks the UI without screen recording permission; video doesn't appear in the snapshots.
@MainActor
enum DebugTour {
static func run(controller: SessionController, snapshot: ((String) -> Void)?, url: URL) async {
let meter = FrameMeter()
func wait(_ seconds: Double) async { try? await Task.sleep(for: .seconds(seconds)) }
func step(_ name: String, _ times: [Double], _ action: () -> Void) async {
if snapshot == nil { meter.start() }
action()
var elapsed = 0.0
for time in times {
await wait(time - elapsed)
elapsed = time
snapshot?("\(name)-\(Int(time * 1000))ms")
}
if snapshot == nil { print("\(name): \(meter.stop())") }
}
await step("01-no-link", [0.08, 0.2, 0.6, 1.0]) { controller.open(text: "no link here") }
await wait(2.5)
await step("02-opening", [0.06, 0.12, 0.2, 0.3, 0.45, 0.7, 1.2]) { controller.open(url) }
for _ in 0..<300 where controller.state != .live(.peek) { await wait(0.1) }
await wait(1)
await step("04-expand", [0.08, 0.16, 0.25, 0.4, 1.0]) { controller.expand() }
await step("05-shrink", [0.08, 0.16, 0.3, 1.0]) { controller.shrink() }
await step("06-fold", [0.06, 0.12, 0.2, 0.35, 1.0]) { controller.clickedOutside() }
await wait(0.5)
await step("07-unfold", [0.06, 0.12, 0.2, 0.35, 1.0]) { controller.pointerEntered() }
await step("08-destroy", [0.08, 0.2, 0.4, 0.8, 1.6, 2.2, 2.6]) { controller.destroy() }
await wait(1)
await step("09-reopen", [0.06, 0.12, 0.2, 0.3, 0.45, 0.7, 1.2]) { controller.open(url) }
for _ in 0..<300 where controller.state != .live(.peek) { await wait(0.1) }
await step("10-destroy", [0.3, 2.6]) { controller.destroy() }
await wait(1)
NSApp.terminate(nil)
}
static func argument(after flag: String) -> String? {
let all = CommandLine.arguments
guard let index = all.firstIndex(of: flag), index + 1 < all.count else { return nil }
return all[index + 1]
}
/// `Knotchy --setup-snapshot <dir>`; with `KNOTCHY_PRETEND=no-virtualization` it shows minimal mode.
static func snapshotSetup(app: AppDelegate, to directory: URL) async {
app.setupWindow.show()
try? await Task.sleep(for: .seconds(5))
guard let window = app.setupWindow.window, let view = window.contentView else { return }
print("setup: window \(window.frame.size)")
DebugSnapshot.write(view, to: directory.appendingPathComponent("setup.png"))
}
}
extension DebugTour {
static func snapshotSettings(app: AppDelegate, to directory: URL) async {
let settings = app.settings
let saved = (settings.networkMode, settings.proxyHost)
let cases: [(String, SettingsTab, AppSettings.NetworkMode)] = [
("general", .general, .direct), ("network-direct", .network, .direct),
("network-proxy", .network, .proxy), ("network-vpn", .network, .vpn), ("vm", .vm, .direct),
]
for (name, section, mode) in cases {
settings.networkMode = mode
if mode == .proxy { settings.proxyHost = "192.168.1.20" }
app.settingsWindow.show(section)
try? await Task.sleep(for: .seconds(0.8))
guard let window = app.settingsWindow.window, let view = window.contentView else { continue }
print("\(name): window \(window.frame.size)")
DebugSnapshot.write(view, to: directory.appendingPathComponent("settings-\(name).png"))
}
app.settingsWindow.window?.orderOut(nil)
(settings.networkMode, settings.proxyHost) = saved
}
static func timeNetworkSwitching(app: AppDelegate) async {
let settings = app.settings
let saved = settings.networkMode
settings.networkMode = .direct
app.settingsWindow.show(.general)
try? await Task.sleep(for: .seconds(1))
let meter = FrameMeter()
let steps: [(String, () -> Void)] = [
("section network", { app.settingsWindow.show(.network) }),
("mode proxy", { settings.networkMode = .proxy }),
("mode vpn", { settings.networkMode = .vpn }),
("mode direct", { settings.networkMode = .direct }),
("section vm", { app.settingsWindow.show(.vm) }),
("section general", { app.settingsWindow.show(.general) }),
("section network", { app.settingsWindow.show(.network) }),
]
for (name, step) in steps {
meter.start()
step()
try? await Task.sleep(for: .seconds(0.8))
let size = app.settingsWindow.window?.frame.size ?? .zero
print("\(name) (window \(Int(size.width))x\(Int(size.height))): \(meter.stop())")
}
app.settingsWindow.window?.orderOut(nil)
settings.networkMode = saved
}
}
enum DebugSnapshot {
@MainActor
static func write(_ view: NSView, to url: URL) {
guard let layer = view.layer, let window = view.window else { return }
let scale = window.backingScaleFactor
let size = view.bounds.size
guard let context = CGContext(data: nil, width: Int(size.width * scale), height: Int(size.height * scale),
bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpace(name: CGColorSpace.sRGB)!,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return }
context.setFillColor(CGColor(gray: 0.55, alpha: 1))
context.fill(CGRect(x: 0, y: 0, width: size.width * scale, height: size.height * scale))
context.scaleBy(x: scale, y: scale)
if view.isFlipped || !layer.isGeometryFlipped {
context.translateBy(x: 0, y: size.height)
context.scaleBy(x: 1, y: -1)
}
(layer.presentation() ?? layer).render(in: context)
guard let image = context.makeImage(),
let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else { return }
CGImageDestinationAddImage(destination, image, nil)
CGImageDestinationFinalize(destination)
}
}
// SwiftUI animates on the main thread, so a late display-link callback is a dropped frame.
@MainActor
final class FrameMeter: NSObject {
private var link: CADisplayLink?
private var stamps: [CFTimeInterval] = []
private var nominal: CFTimeInterval = 1.0 / 60
func start() {
stamps = []
link = (NSScreen.main ?? NSScreen.screens[0]).displayLink(target: self, selector: #selector(tick(_:)))
link?.add(to: .main, forMode: .common)
}
@objc private func tick(_ link: CADisplayLink) {
stamps.append(CACurrentMediaTime())
nominal = link.targetTimestamp - link.timestamp
}
func stop() -> String {
link?.invalidate()
link = nil
let intervals = zip(stamps.dropFirst(), stamps).map { $0 - $1 }
func ms(_ t: CFTimeInterval) -> String { String(format: "%.0f", t * 1000) }
// "duration@end", both in ms, end measured from the start.
let late = zip(intervals, stamps.dropFirst()).filter { $0.0 > nominal * 1.5 }
.map { "\(ms($0.0))@\(ms($0.1 - (stamps.first ?? 0)))" }
return "\(stamps.count) frames at \(ms(nominal)) ms, worst \(ms(intervals.max() ?? 0)) ms, "
+ "\(late.count) late \(late)"
}
}
#endif
+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>aculix.knotchy.app</string>
<key>CFBundleName</key>
<string>Knotchy</string>
<key>CFBundleDisplayName</key>
<string>Knotchy</string>
<key>CFBundleExecutable</key>
<string>Knotchy</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>26.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>© 2026 Aculix Technologies LLP. MIT License.</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>aculix.knotchy.open</string>
<key>CFBundleURLSchemes</key>
<array>
<string>knotchy</string>
</array>
</dict>
</array>
<key>NSServices</key>
<array>
<dict>
<key>NSMenuItem</key>
<dict>
<key>default</key>
<string>Open in Knotchy</string>
</dict>
<key>NSMessage</key>
<string>openLink</string>
<key>NSPortName</key>
<string>Knotchy</string>
<key>NSSendTypes</key>
<array>
<string>public.url</string>
<string>public.html</string>
<string>public.rtf</string>
<string>public.utf8-plain-text</string>
</array>
<key>NSRequiredContext</key>
<dict/>
</dict>
<dict>
<key>NSMenuItem</key>
<dict>
<key>default</key>
<string>Open Copied Link in Knotchy</string>
</dict>
<key>NSMessage</key>
<string>openCopiedLink</string>
<key>NSPortName</key>
<string>Knotchy</string>
<key>NSRequiredContext</key>
<dict/>
</dict>
</array>
</dict>
</plist>
+38
View File
@@ -0,0 +1,38 @@
import Foundation
import Security
enum Keychain {
private static let service = "aculix.knotchy.app"
static func read(_ account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func write(_ value: String, for account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
guard !value.isEmpty else {
SecItemDelete(query as CFDictionary)
return
}
let data = Data(value.utf8)
if SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) == errSecItemNotFound {
var item = query
item[kSecValueData as String] = data
item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked
SecItemAdd(item as CFDictionary, nil)
}
}
}
+29
View File
@@ -0,0 +1,29 @@
// Generated by scripts/icons/knot.py; run scripts/make-icons.sh to change it.
import AppKit
enum KnotGlyph {
static let strands: [[CGPoint]] = [
[CGPoint(x: 7.27, y: 5.99), CGPoint(x: 7.15, y: 6.15), CGPoint(x: 7.04, y: 6.32), CGPoint(x: 6.93, y: 6.49), CGPoint(x: 6.82, y: 6.67), CGPoint(x: 6.72, y: 6.84), CGPoint(x: 6.62, y: 7.02), CGPoint(x: 6.53, y: 7.20), CGPoint(x: 6.43, y: 7.39), CGPoint(x: 6.35, y: 7.57), CGPoint(x: 6.27, y: 7.76), CGPoint(x: 6.19, y: 7.94), CGPoint(x: 6.11, y: 8.13), CGPoint(x: 6.05, y: 8.32), CGPoint(x: 5.98, y: 8.51), CGPoint(x: 5.92, y: 8.70), CGPoint(x: 5.86, y: 8.89), CGPoint(x: 5.81, y: 9.09), CGPoint(x: 5.77, y: 9.28), CGPoint(x: 5.72, y: 9.47), CGPoint(x: 5.69, y: 9.66), CGPoint(x: 5.65, y: 9.85), CGPoint(x: 5.63, y: 10.05), CGPoint(x: 5.60, y: 10.24), CGPoint(x: 5.58, y: 10.43), CGPoint(x: 5.57, y: 10.62), CGPoint(x: 5.56, y: 10.81), CGPoint(x: 5.56, y: 10.99), CGPoint(x: 5.56, y: 11.18), CGPoint(x: 5.56, y: 11.36), CGPoint(x: 5.57, y: 11.55), CGPoint(x: 5.58, y: 11.73), CGPoint(x: 5.60, y: 11.91), CGPoint(x: 5.62, y: 12.08), CGPoint(x: 5.65, y: 12.26), CGPoint(x: 5.68, y: 12.43), CGPoint(x: 5.72, y: 12.60), CGPoint(x: 5.76, y: 12.76), CGPoint(x: 5.80, y: 12.93), CGPoint(x: 5.85, y: 13.09), CGPoint(x: 5.90, y: 13.24), CGPoint(x: 5.96, y: 13.40), CGPoint(x: 6.02, y: 13.55), CGPoint(x: 6.08, y: 13.69), CGPoint(x: 6.15, y: 13.83), CGPoint(x: 6.22, y: 13.97), CGPoint(x: 6.30, y: 14.11), CGPoint(x: 6.38, y: 14.24), CGPoint(x: 6.46, y: 14.36), CGPoint(x: 6.54, y: 14.49), CGPoint(x: 6.63, y: 14.60), CGPoint(x: 6.72, y: 14.71), CGPoint(x: 6.81, y: 14.82), CGPoint(x: 6.91, y: 14.92), CGPoint(x: 7.01, y: 15.02), CGPoint(x: 7.11, y: 15.12), CGPoint(x: 7.21, y: 15.20), CGPoint(x: 7.32, y: 15.29), CGPoint(x: 7.43, y: 15.36), CGPoint(x: 7.54, y: 15.44), CGPoint(x: 7.65, y: 15.50), CGPoint(x: 7.76, y: 15.56), CGPoint(x: 7.88, y: 15.62), CGPoint(x: 8.00, y: 15.67), CGPoint(x: 8.11, y: 15.71), CGPoint(x: 8.23, y: 15.75), CGPoint(x: 8.35, y: 15.79), CGPoint(x: 8.47, y: 15.82), CGPoint(x: 8.59, y: 15.84), CGPoint(x: 8.71, y: 15.85), CGPoint(x: 8.84, y: 15.86), CGPoint(x: 8.96, y: 15.87), CGPoint(x: 9.08, y: 15.87), CGPoint(x: 9.20, y: 15.86), CGPoint(x: 9.33, y: 15.85), CGPoint(x: 9.45, y: 15.83), CGPoint(x: 9.57, y: 15.81), CGPoint(x: 9.69, y: 15.78), CGPoint(x: 9.81, y: 15.74), CGPoint(x: 9.93, y: 15.70), CGPoint(x: 10.04, y: 15.65), CGPoint(x: 10.16, y: 15.60), CGPoint(x: 10.27, y: 15.54), CGPoint(x: 10.39, y: 15.48), CGPoint(x: 10.50, y: 15.41), CGPoint(x: 10.61, y: 15.34), CGPoint(x: 10.72, y: 15.26), CGPoint(x: 10.82, y: 15.17), CGPoint(x: 10.92, y: 15.09), CGPoint(x: 11.02, y: 14.99), CGPoint(x: 11.12, y: 14.89), CGPoint(x: 11.22, y: 14.79), CGPoint(x: 11.31, y: 14.68), CGPoint(x: 11.40, y: 14.56), CGPoint(x: 11.49, y: 14.45), CGPoint(x: 11.57, y: 14.32), CGPoint(x: 11.65, y: 14.19), CGPoint(x: 11.73, y: 14.06), CGPoint(x: 11.80, y: 13.93), CGPoint(x: 11.87, y: 13.79), CGPoint(x: 11.94, y: 13.64), CGPoint(x: 12.00, y: 13.50), CGPoint(x: 12.06, y: 13.35), CGPoint(x: 12.11, y: 13.19), CGPoint(x: 12.17, y: 13.03), CGPoint(x: 12.21, y: 12.87), CGPoint(x: 12.26, y: 12.71), CGPoint(x: 12.28, y: 12.60)],
[CGPoint(x: 11.68, y: 7.63), CGPoint(x: 11.59, y: 7.45), CGPoint(x: 11.51, y: 7.26), CGPoint(x: 11.41, y: 7.08), CGPoint(x: 11.31, y: 6.90), CGPoint(x: 11.21, y: 6.73), CGPoint(x: 11.11, y: 6.55), CGPoint(x: 11.00, y: 6.38), CGPoint(x: 10.89, y: 6.21), CGPoint(x: 10.77, y: 6.04), CGPoint(x: 10.65, y: 5.88), CGPoint(x: 10.53, y: 5.72), CGPoint(x: 10.40, y: 5.56), CGPoint(x: 10.27, y: 5.41), CGPoint(x: 10.14, y: 5.26), CGPoint(x: 10.00, y: 5.11), CGPoint(x: 9.87, y: 4.96), CGPoint(x: 9.73, y: 4.82), CGPoint(x: 9.58, y: 4.69), CGPoint(x: 9.44, y: 4.55), CGPoint(x: 9.29, y: 4.43), CGPoint(x: 9.14, y: 4.30), CGPoint(x: 8.99, y: 4.18), CGPoint(x: 8.83, y: 4.06), CGPoint(x: 8.68, y: 3.95), CGPoint(x: 8.52, y: 3.85), CGPoint(x: 8.36, y: 3.74), CGPoint(x: 8.20, y: 3.65), CGPoint(x: 8.04, y: 3.55), CGPoint(x: 7.88, y: 3.46), CGPoint(x: 7.72, y: 3.38), CGPoint(x: 7.55, y: 3.30), CGPoint(x: 7.39, y: 3.23), CGPoint(x: 7.22, y: 3.16), CGPoint(x: 7.06, y: 3.10), CGPoint(x: 6.90, y: 3.04), CGPoint(x: 6.73, y: 2.98), CGPoint(x: 6.57, y: 2.94), CGPoint(x: 6.40, y: 2.89), CGPoint(x: 6.24, y: 2.85), CGPoint(x: 6.08, y: 2.82), CGPoint(x: 5.92, y: 2.79), CGPoint(x: 5.76, y: 2.77), CGPoint(x: 5.60, y: 2.75), CGPoint(x: 5.44, y: 2.74), CGPoint(x: 5.29, y: 2.73), CGPoint(x: 5.13, y: 2.73), CGPoint(x: 4.98, y: 2.73), CGPoint(x: 4.83, y: 2.74), CGPoint(x: 4.69, y: 2.75), CGPoint(x: 4.54, y: 2.77), CGPoint(x: 4.40, y: 2.79), CGPoint(x: 4.26, y: 2.82), CGPoint(x: 4.12, y: 2.85), CGPoint(x: 3.99, y: 2.89), CGPoint(x: 3.85, y: 2.93), CGPoint(x: 3.73, y: 2.98), CGPoint(x: 3.60, y: 3.03), CGPoint(x: 3.48, y: 3.08), CGPoint(x: 3.36, y: 3.14), CGPoint(x: 3.25, y: 3.21), CGPoint(x: 3.14, y: 3.27), CGPoint(x: 3.03, y: 3.34), CGPoint(x: 2.93, y: 3.42), CGPoint(x: 2.83, y: 3.50), CGPoint(x: 2.74, y: 3.58), CGPoint(x: 2.65, y: 3.67), CGPoint(x: 2.57, y: 3.76), CGPoint(x: 2.49, y: 3.85), CGPoint(x: 2.41, y: 3.95), CGPoint(x: 2.34, y: 4.05), CGPoint(x: 2.28, y: 4.16), CGPoint(x: 2.22, y: 4.26), CGPoint(x: 2.16, y: 4.37), CGPoint(x: 2.11, y: 4.48), CGPoint(x: 2.07, y: 4.60), CGPoint(x: 2.03, y: 4.71), CGPoint(x: 1.99, y: 4.83), CGPoint(x: 1.96, y: 4.95), CGPoint(x: 1.94, y: 5.08), CGPoint(x: 1.92, y: 5.20), CGPoint(x: 1.91, y: 5.33), CGPoint(x: 1.90, y: 5.46), CGPoint(x: 1.90, y: 5.59), CGPoint(x: 1.90, y: 5.72), CGPoint(x: 1.91, y: 5.85), CGPoint(x: 1.93, y: 5.98), CGPoint(x: 1.95, y: 6.11), CGPoint(x: 1.97, y: 6.25), CGPoint(x: 2.01, y: 6.38), CGPoint(x: 2.04, y: 6.52), CGPoint(x: 2.09, y: 6.65), CGPoint(x: 2.13, y: 6.79), CGPoint(x: 2.19, y: 6.92), CGPoint(x: 2.25, y: 7.06), CGPoint(x: 2.31, y: 7.19), CGPoint(x: 2.38, y: 7.32), CGPoint(x: 2.46, y: 7.46), CGPoint(x: 2.54, y: 7.59), CGPoint(x: 2.62, y: 7.72), CGPoint(x: 2.72, y: 7.85), CGPoint(x: 2.81, y: 7.98), CGPoint(x: 2.91, y: 8.10), CGPoint(x: 3.02, y: 8.23), CGPoint(x: 3.13, y: 8.35), CGPoint(x: 3.25, y: 8.47), CGPoint(x: 3.37, y: 8.59), CGPoint(x: 3.45, y: 8.67)],
[CGPoint(x: 8.05, y: 10.63), CGPoint(x: 8.25, y: 10.65), CGPoint(x: 8.46, y: 10.66), CGPoint(x: 8.66, y: 10.67), CGPoint(x: 8.86, y: 10.68), CGPoint(x: 9.07, y: 10.68), CGPoint(x: 9.27, y: 10.67), CGPoint(x: 9.48, y: 10.67), CGPoint(x: 9.68, y: 10.65), CGPoint(x: 9.88, y: 10.64), CGPoint(x: 10.08, y: 10.62), CGPoint(x: 10.28, y: 10.59), CGPoint(x: 10.48, y: 10.56), CGPoint(x: 10.68, y: 10.52), CGPoint(x: 10.88, y: 10.49), CGPoint(x: 11.07, y: 10.44), CGPoint(x: 11.27, y: 10.39), CGPoint(x: 11.46, y: 10.34), CGPoint(x: 11.65, y: 10.29), CGPoint(x: 11.84, y: 10.23), CGPoint(x: 12.02, y: 10.16), CGPoint(x: 12.21, y: 10.10), CGPoint(x: 12.39, y: 10.02), CGPoint(x: 12.56, y: 9.95), CGPoint(x: 12.74, y: 9.87), CGPoint(x: 12.91, y: 9.79), CGPoint(x: 13.08, y: 9.70), CGPoint(x: 13.24, y: 9.61), CGPoint(x: 13.40, y: 9.52), CGPoint(x: 13.56, y: 9.42), CGPoint(x: 13.71, y: 9.32), CGPoint(x: 13.86, y: 9.22), CGPoint(x: 14.01, y: 9.12), CGPoint(x: 14.15, y: 9.01), CGPoint(x: 14.29, y: 8.90), CGPoint(x: 14.42, y: 8.78), CGPoint(x: 14.55, y: 8.67), CGPoint(x: 14.67, y: 8.55), CGPoint(x: 14.79, y: 8.43), CGPoint(x: 14.91, y: 8.31), CGPoint(x: 15.02, y: 8.19), CGPoint(x: 15.12, y: 8.06), CGPoint(x: 15.22, y: 7.93), CGPoint(x: 15.32, y: 7.80), CGPoint(x: 15.41, y: 7.68), CGPoint(x: 15.49, y: 7.54), CGPoint(x: 15.57, y: 7.41), CGPoint(x: 15.64, y: 7.28), CGPoint(x: 15.71, y: 7.15), CGPoint(x: 15.77, y: 7.01), CGPoint(x: 15.83, y: 6.88), CGPoint(x: 15.88, y: 6.74), CGPoint(x: 15.93, y: 6.61), CGPoint(x: 15.97, y: 6.47), CGPoint(x: 16.01, y: 6.34), CGPoint(x: 16.03, y: 6.20), CGPoint(x: 16.06, y: 6.07), CGPoint(x: 16.08, y: 5.94), CGPoint(x: 16.09, y: 5.80), CGPoint(x: 16.10, y: 5.67), CGPoint(x: 16.10, y: 5.54), CGPoint(x: 16.10, y: 5.41), CGPoint(x: 16.09, y: 5.29), CGPoint(x: 16.07, y: 5.16), CGPoint(x: 16.05, y: 5.04), CGPoint(x: 16.03, y: 4.91), CGPoint(x: 16.00, y: 4.79), CGPoint(x: 15.96, y: 4.67), CGPoint(x: 15.92, y: 4.56), CGPoint(x: 15.87, y: 4.45), CGPoint(x: 15.82, y: 4.33), CGPoint(x: 15.76, y: 4.23), CGPoint(x: 15.70, y: 4.12), CGPoint(x: 15.63, y: 4.02), CGPoint(x: 15.56, y: 3.92), CGPoint(x: 15.49, y: 3.82), CGPoint(x: 15.40, y: 3.73), CGPoint(x: 15.32, y: 3.64), CGPoint(x: 15.23, y: 3.55), CGPoint(x: 15.13, y: 3.47), CGPoint(x: 15.03, y: 3.39), CGPoint(x: 14.93, y: 3.32), CGPoint(x: 14.82, y: 3.25), CGPoint(x: 14.71, y: 3.18), CGPoint(x: 14.60, y: 3.12), CGPoint(x: 14.48, y: 3.06), CGPoint(x: 14.36, y: 3.01), CGPoint(x: 14.23, y: 2.96), CGPoint(x: 14.10, y: 2.92), CGPoint(x: 13.97, y: 2.88), CGPoint(x: 13.83, y: 2.84), CGPoint(x: 13.70, y: 2.81), CGPoint(x: 13.56, y: 2.79), CGPoint(x: 13.41, y: 2.76), CGPoint(x: 13.27, y: 2.75), CGPoint(x: 13.12, y: 2.74), CGPoint(x: 12.97, y: 2.73), CGPoint(x: 12.81, y: 2.73), CGPoint(x: 12.66, y: 2.74), CGPoint(x: 12.50, y: 2.74), CGPoint(x: 12.35, y: 2.76), CGPoint(x: 12.19, y: 2.78), CGPoint(x: 12.03, y: 2.80), CGPoint(x: 11.87, y: 2.83), CGPoint(x: 11.70, y: 2.87), CGPoint(x: 11.54, y: 2.91), CGPoint(x: 11.38, y: 2.95), CGPoint(x: 11.27, y: 2.98)],
]
static let menuBarImage: NSImage = {
let image = NSImage(size: NSSize(width: 18, height: 18), flipped: true) { _ in
NSColor.black.setStroke()
for strand in strands {
let path = NSBezierPath()
path.move(to: strand[0])
for point in strand.dropFirst() { path.line(to: point) }
path.lineWidth = 1.9
path.lineCapStyle = .round
path.lineJoinStyle = .round
path.stroke()
}
return true
}
image.isTemplate = true
image.accessibilityDescription = "Knotchy"
return image
}()
}
+15
View File
@@ -0,0 +1,15 @@
import KnotchyCore
import SwiftUI
@main
struct KnotchyApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var app
var body: some Scene {
MenuBarExtra(isInserted: .constant(app.isSupportedMac)) {
KnotchyMenu(app: app, settings: app.settings)
} label: {
Image(nsImage: KnotGlyph.menuBarImage)
}
}
}
+212
View File
@@ -0,0 +1,212 @@
import AppKit
import KnotchyCore
import Observation
import SwiftUI
struct KnotchyMenu: View {
let app: AppDelegate
@Bindable var settings: AppSettings
private var controller: SessionController { app.controller }
var body: some View {
if app.vmStatus.needsSetup {
Button("Set Up Knotchy…") { app.setupWindow.show() }
Divider()
}
if let session = SessionSummary(controller.state, url: controller.pageURL ?? controller.openedURL,
setupFolded: controller.setupFolded) {
Section(session.title) {
if session.canShow {
Button("Show Panel") { controller.showPanel() }
}
if session.canDestroy {
Button("Destroy VM") { controller.destroy() }
}
}
Divider()
}
Button("Open Copied Link") { app.services.openCopiedLink() }
.keyboardShortcut("l", modifiers: [.control, .option, .command])
Divider()
Picker("Auto-Destroy Folded Pages", selection: $settings.autoDestroyMinutes) {
ForEach(AppSettings.autoDestroyChoices, id: \.self) { minutes in
Text(minutes == 1 ? "After 1 Minute" : "After \(minutes) Minutes").tag(minutes)
}
}
Picker("Open Pages In", selection: $settings.opensLarge) {
Text("Small Panel").tag(false)
Text("Large Panel").tag(true)
}
Menu("Connection") {
Picker("Connection", selection: connection) {
Text("Direct").tag(Connection.direct)
if !settings.proxyHost.isEmpty {
Text("Proxy (\(settings.proxyHost):\(String(settings.proxyPort)))").tag(Connection.proxy)
}
if !settings.wireGuardNames.isEmpty {
Section("WireGuard VPN") {
ForEach(settings.wireGuardNames, id: \.self) { Text($0).tag(Connection.vpn($0)) }
}
}
}
.pickerStyle(.inline)
.labelsHidden()
Divider()
Button("Network Settings…") { showSettings(.network) }
}
Divider()
Button {
controller.rebuildImage()
} label: {
Text("Rebuild VM")
Text(app.vmStatus.summary)
}
.disabled(isSettingUp || app.vmStatus.image == .noContainerTool || app.vmStatus.needsSetup)
Divider()
if !app.vmStatus.needsSetup {
Button("Set Up Knotchy…") { app.setupWindow.show() }
}
Button("Settings…") { showSettings(nil) }
.keyboardShortcut(",")
Button("Quit Knotchy") { NSApp.terminate(nil) }
.keyboardShortcut("q")
}
private enum Connection: Hashable {
case direct, proxy, vpn(String)
}
private var connection: Binding<Connection> {
Binding {
switch settings.networkMode {
case .direct: .direct
case .proxy: .proxy
case .vpn: .vpn(settings.wireGuardServer)
}
} set: { choice in
switch choice {
case .direct:
settings.networkMode = .direct
case .proxy:
settings.networkMode = .proxy
case .vpn(let server):
settings.wireGuardServer = server
settings.networkMode = .vpn
}
}
}
private var isSettingUp: Bool {
if case .settingUp = controller.state { true } else { false }
}
private func showSettings(_ section: SettingsTab?) {
app.settingsWindow.show(section)
}
}
@MainActor
@Observable
final class VMStatus {
private(set) var image: ContainerCLIBackend.ImageStatus?
private(set) var runtime: RuntimeStatus?
@ObservationIgnored private let backend: ContainerCLIBackend
@ObservationIgnored private let controller: SessionController
init(backend: ContainerCLIBackend, controller: SessionController) {
self.backend = backend
self.controller = controller
watchSetup()
}
var needsSetup: Bool {
switch runtime {
case .missing?, .outdated?: true
default: false
}
}
var runtimeSummary: String {
switch runtime {
case nil: "Checking…"
case .missing: "Not installed"
case .outdated(let version): "Apple container \(version) (too old)"
case .current(let version?): "Apple container \(version)"
case .current(nil): "Apple container (version unknown)"
}
}
func refresh() {
Task {
let runtime = await backend.runtimeStatus()
if runtime != self.runtime { self.runtime = runtime }
let status = await backend.imageStatus()
if status != image { image = status }
}
}
var summary: String {
if needsSetup { return "Setup isn't finished" }
return switch image {
case nil: "Checking…"
case .noContainerTool: "Setup isn't finished"
case .missing: "Not built yet"
case .built(nil): "Built"
case .built(let date?): "Built " + RelativeDateTimeFormatter().localizedString(for: date, relativeTo: Date())
}
}
private func watchSetup() {
let wasSettingUp: Bool
if case .settingUp = controller.state { wasSettingUp = true } else { wasSettingUp = false }
withObservationTracking {
_ = controller.state
} onChange: { [weak self] in
Task { @MainActor in
guard let self else { return }
if wasSettingUp { self.refresh() }
self.watchSetup()
}
}
}
}
struct SessionSummary {
var title: String
var canShow: Bool
var canDestroy: Bool
init?(_ state: SessionState, url: URL?, setupFolded: Bool) {
let host = url?.host().map { " — \($0)" } ?? ""
switch state {
case .starting(_, let folded):
(title, canShow, canDestroy) = ("Opening\(host)", folded, true)
case .live(let mode):
(title, canShow, canDestroy) = ("Open\(host)", mode == .collapsed, true)
case .settingUp(let issue):
(title, canShow, canDestroy) = (Self.setupTitle(issue), setupFolded, false)
case .destroying:
(title, canShow, canDestroy) = ("Destroying the VM…", false, false)
default:
return nil
}
}
static func setupTitle(_ issue: SetupIssue) -> String {
switch issue {
case .cliMissing: "Installing the container runtime…"
case .cliOutdated: "Updating the container runtime…"
case .serviceStopped, .kernelMissing: "Starting the container runtime…"
default: "Building the VM…"
}
}
}
+196
View File
@@ -0,0 +1,196 @@
import SwiftUI
/// Flared top "shoulders" like the notch's own; `rect` includes them.
struct NotchShape: Shape {
var topRadius: CGFloat
var bottomRadius: CGFloat
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { AnimatablePair(topRadius, bottomRadius) }
set { (topRadius, bottomRadius) = (newValue.first, newValue.second) }
}
func path(in rect: CGRect) -> Path {
let top = max(0, min(topRadius, rect.width / 4, rect.height / 2))
let bottom = max(0, min(bottomRadius, (rect.width - 2 * top) / 2, rect.height - top))
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
path.addQuadCurve(to: CGPoint(x: rect.minX + top, y: rect.minY + top),
control: CGPoint(x: rect.minX + top, y: rect.minY))
path.addLine(to: CGPoint(x: rect.minX + top, y: rect.maxY - bottom))
path.addQuadCurve(to: CGPoint(x: rect.minX + top + bottom, y: rect.maxY),
control: CGPoint(x: rect.minX + top, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX - top - bottom, y: rect.maxY))
path.addQuadCurve(to: CGPoint(x: rect.maxX - top, y: rect.maxY - bottom),
control: CGPoint(x: rect.maxX - top, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX - top, y: rect.minY + top))
path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.minY),
control: CGPoint(x: rect.maxX - top, y: rect.minY))
path.closeSubpath()
return path
}
}
struct Morph: ViewModifier, Animatable {
var progress: CGFloat
var smallest: CGFloat = 0.55
var blur: CGFloat = 10
var clearUntil: CGFloat = 0.3
var opaqueFrom: CGFloat = 0.85
nonisolated var animatableData: CGFloat {
get { progress }
set { progress = newValue }
}
func body(content: Content) -> some View {
content
.scaleEffect(smallest + (1 - smallest) * progress, anchor: .top)
.blur(radius: max(0, 1 - progress) * blur)
.opacity(Double(min(1, max(0, (progress - clearUntil) / (opaqueFrom - clearUntil)))))
}
}
extension AnyTransition {
/// Old content is gone early so it barely overlaps the new.
static var island: AnyTransition {
.asymmetric(
insertion: .modifier(active: Morph(progress: 0), identity: Morph(progress: 1)),
removal: .modifier(active: Morph(progress: 0, smallest: 0.8, blur: 8, clearUntil: 0.6, opaqueFrom: 0.95),
identity: Morph(progress: 1, smallest: 0.8, blur: 8, clearUntil: 0.6, opaqueFrom: 0.95))
.animation(.spring(response: 0.2, dampingFraction: 1))
)
}
static var swap: AnyTransition {
.modifier(active: Morph(progress: 0, smallest: 0.92, blur: 6, clearUntil: 0.2, opaqueFrom: 0.8),
identity: Morph(progress: 1, smallest: 0.92, blur: 6, clearUntil: 0.2, opaqueFrom: 0.8))
.animation(.spring(response: 0.3, dampingFraction: 0.9))
}
}
enum Motion {
static let open = Animation.spring(response: 0.46, dampingFraction: 0.74)
static let resize = Animation.spring(response: 0.42, dampingFraction: 0.8)
static let fold = Animation.spring(response: 0.4, dampingFraction: 0.84)
static let small = Animation.spring(response: 0.4, dampingFraction: 0.7)
static let hide = Animation.spring(response: 0.36, dampingFraction: 0.9)
}
struct KnotMark: View {
var size: CGFloat
var color: Color = Palette.coral
var body: some View {
KnotShape()
.stroke(color, style: StrokeStyle(lineWidth: size * 1.9 / 18, lineCap: .round, lineJoin: .round))
.frame(width: size, height: size)
.accessibilityHidden(true)
}
}
/// `KnotGlyph.strands` are in an 18-point box.
struct KnotShape: Shape {
func path(in rect: CGRect) -> Path {
let scale = min(rect.width, rect.height) / 18
var path = Path()
for strand in KnotGlyph.strands {
path.move(to: CGPoint(x: rect.minX + strand[0].x * scale, y: rect.minY + strand[0].y * scale))
for point in strand.dropFirst() {
path.addLine(to: CGPoint(x: rect.minX + point.x * scale, y: rect.minY + point.y * scale))
}
}
return path
}
}
struct Breathing: ViewModifier {
func body(content: Content) -> some View {
content.phaseAnimator([1.0, 0.55]) { view, opacity in
view.opacity(opacity)
} animation: { _ in .easeInOut(duration: 1.6) }
}
}
enum Palette {
static let coral = Color(red: 1, green: 0.54, blue: 0.36)
static let green = Color(red: 0.25, green: 0.86, blue: 0.47)
static let red = Color(red: 1, green: 0.38, blue: 0.36)
static let amber = Color(red: 1, green: 0.72, blue: 0.2)
static let surface = LinearGradient(colors: [Color(white: 0.1), Color(white: 0.055)],
startPoint: .top, endPoint: .bottom)
}
struct IconButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
Styled(configuration: configuration)
}
struct Styled: View {
let configuration: Configuration
@State private var hovering = false
@Environment(\.isEnabled) private var isEnabled
var body: some View {
configuration.label
.foregroundStyle(.white.opacity(isEnabled ? (hovering ? 1 : 0.72) : 0.22))
.background(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(.white.opacity(configuration.isPressed ? 0.18 : (hovering && isEnabled ? 0.1 : 0)))
)
.contentShape(Rectangle())
.onHover { hovering = $0 }
.animation(.easeOut(duration: 0.12), value: hovering)
}
}
}
struct CapsuleButtonStyle: ButtonStyle {
enum Kind { case primary, secondary, destructive }
var kind: Kind = .secondary
var small = false
func makeBody(configuration: Configuration) -> some View {
Styled(configuration: configuration, kind: kind, small: small)
}
struct Styled: View {
let configuration: Configuration
let kind: Kind
let small: Bool
@State private var hovering = false
var body: some View {
configuration.label
.font(.system(size: small ? 11 : 12, weight: .semibold))
.lineLimit(1)
.padding(.horizontal, small ? 9 : 14)
.frame(height: small ? 20 : 28)
.foregroundStyle(foreground)
.background(Capsule().fill(fill))
.contentShape(Capsule())
.scaleEffect(configuration.isPressed ? 0.96 : 1)
.onHover { hovering = $0 }
.animation(.easeOut(duration: 0.12), value: hovering)
.animation(.easeOut(duration: 0.08), value: configuration.isPressed)
}
private var foreground: Color {
switch kind {
case .primary: .black
case .secondary: .white
case .destructive: Palette.red
}
}
private var fill: Color {
let boost = configuration.isPressed ? 0.1 : (hovering ? 0.06 : 0)
switch kind {
case .primary: return .white.opacity(0.92 + boost)
case .secondary: return .white.opacity(0.12 + boost)
case .destructive: return Palette.red.opacity(0.16 + boost * 1.5)
}
}
}
}
+603
View File
@@ -0,0 +1,603 @@
import KnotchyCore
import SwiftUI
struct NotchRootView: View {
let controller: SessionController
let chrome: NotchChrome
var body: some View {
let size = chrome.bodySize
ZStack(alignment: .top) {
switch chrome.content {
case .hidden:
Color.clear.frame(width: 0, height: 0)
case .compact:
CompactView(controller: controller, state: chrome.state, notchWidth: chrome.notchSize.width)
.frame(width: NotchChrome.bodySize(.compact, notch: chrome.notchSize, panel: chrome.panelSize,
hasNotch: chrome.hasNotch).width,
height: chrome.notchSize.height)
.transition(.island)
case .banner:
BannerView(controller: controller, state: chrome.state, notice: chrome.notice,
notchHeight: chrome.notchSize.height)
.frame(width: NotchChrome.bodySize(.banner, notch: chrome.notchSize, panel: chrome.panelSize,
hasNotch: chrome.hasNotch).width)
.transition(.island)
case .panel:
PanelView(controller: controller, chrome: chrome)
.frame(width: chrome.panelSize.width, height: chrome.panelSize.height)
.transition(.island)
}
}
.modifier(Morph(progress: chrome.reveal))
.frame(width: size.width, height: size.height, alignment: .top)
.padding(.horizontal, chrome.topRadius)
.clipShape(NotchShape(topRadius: chrome.topRadius, bottomRadius: chrome.bottomRadius))
.background {
// Shadow from the outline alone, not from everything inside it.
NotchShape(topRadius: chrome.topRadius, bottomRadius: chrome.bottomRadius)
.fill(.black)
.shadow(color: .black.opacity(chrome.presentation == .panel ? 0.5 : 0), radius: 14, y: 6)
}
.offset(x: chrome.offsetX)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.environment(\.colorScheme, .dark)
}
}
// MARK: - Compact
private struct CompactView: View {
let controller: SessionController
let state: SessionState
let notchWidth: CGFloat
var body: some View {
HStack(spacing: 0) {
leading.frame(maxWidth: .infinity)
Color.clear.frame(width: notchWidth)
trailing.frame(maxWidth: .infinity)
}
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.contentShape(Rectangle())
.contextMenu { PillMenu(controller: controller, state: state) }
}
@ViewBuilder private var leading: some View {
switch state {
case .starting, .settingUp:
ProgressView().controlSize(.small).scaleEffect(0.75)
default:
KnotMark(size: 15)
.modifier(Breathing())
.help("A page is open in a disposable VM")
}
}
@ViewBuilder private var trailing: some View {
switch state {
case .starting:
Text("Opening").foregroundStyle(.white.opacity(0.7))
case .settingUp(let issue):
Text(Self.setupWord(issue)).foregroundStyle(.white.opacity(0.7))
.help("Setting up the browser VM. Hover to see progress.")
default:
TimelineView(.periodic(from: .now, by: 1)) { _ in
let text = Self.format(controller.countdown)
Text(text)
.monospacedDigit()
.foregroundStyle(.white.opacity(0.7))
.contentTransition(.numericText(countsDown: true))
.animation(.snappy, value: text)
.help("Destroyed automatically when this reaches 0:00")
}
}
}
private static func setupWord(_ issue: SetupIssue) -> String {
switch issue {
case .imageMissing, .imageOutdated: "Building"
case .imageStale, .cliOutdated: "Updating"
case .cliMissing: "Installing"
default: "Starting"
}
}
private static func format(_ remaining: Duration?) -> String {
guard let remaining else { return "" }
let seconds = Int(remaining.components.seconds)
return String(format: "%d:%02d", seconds / 60, seconds % 60)
}
}
// MARK: - Banner
private struct BannerView: View {
let controller: SessionController
let state: SessionState
let notice: Notice?
let notchHeight: CGFloat
var body: some View {
VStack(spacing: 0) {
Color.clear.frame(height: notchHeight)
ZStack {
row.id(kind).transition(.swap)
}
.frame(maxWidth: .infinity)
.frame(height: NotchChrome.bannerRowHeight - 4)
}
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.contentShape(Rectangle())
.contextMenu { PillMenu(controller: controller, state: state) }
}
private enum Kind: Hashable { case noWebLink, destroying, destroyed, ended, stuck, other }
private var kind: Kind {
if notice == .noWebLink, state == .idle { return .noWebLink }
switch state {
case .destroying: return .destroying
case .destroyed: return .destroyed
case .failed(.ended): return .ended
case .failed(.destroyStuck): return .stuck
default: return .other
}
}
@ViewBuilder private var row: some View {
HStack(spacing: 7) {
switch kind {
case .noWebLink:
Image(systemName: "link").foregroundStyle(Palette.amber)
Text("No web link found")
case .destroying:
ProgressView().controlSize(.small).scaleEffect(0.7).frame(width: 16, height: 16)
Text("Destroying the VM…").foregroundStyle(.white.opacity(0.8))
case .destroyed:
BouncingSymbol(name: "checkmark.circle.fill", color: Palette.green)
Text("VM destroyed")
case .ended:
Image(systemName: "xmark.octagon.fill").foregroundStyle(Palette.red)
Text("Session ended")
case .stuck:
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Palette.amber)
Text("VM may still be running")
Button("Retry") { controller.retry() }
.buttonStyle(CapsuleButtonStyle(kind: .secondary, small: true))
.padding(.leading, 4)
case .other:
EmptyView()
}
}
}
}
private struct PillMenu: View {
let controller: SessionController
let state: SessionState
var body: some View {
if case .live = state { Button("Destroy Now") { controller.destroy() } }
Button("Quit Knotchy") { NSApp.terminate(nil) }
}
}
private struct BouncingSymbol: View {
let name: String
let color: Color
@State private var appeared = false
var body: some View {
Image(systemName: name)
.foregroundStyle(color)
.symbolEffect(.bounce, value: appeared)
.onAppear { appeared = true }
}
}
// MARK: - Panel
private struct PanelView: View {
let controller: SessionController
let chrome: NotchChrome
private var innerRadius: CGFloat { NotchChrome.bottomRadius(.panel) - NotchGeometry.margin }
var body: some View {
VStack(spacing: 0) {
TopStrip(controller: controller, state: chrome.state, notchWidth: chrome.notchSize.width,
routeLabel: chrome.routeLabel)
.frame(height: chrome.notchSize.height)
Toolbar(controller: controller, state: chrome.state)
.frame(height: NotchGeometry.toolbarHeight)
ZStack(alignment: .bottom) {
content
.id(ContentKind(chrome.state))
.transition(.swap)
if let stale = controller.staleImage, case .live = chrome.state {
StaleBanner(controller: controller, stale: stale)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.overlay(alignment: .top) {
if case .live = chrome.state { ProgressLine(progress: controller.progress) }
}
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 8, bottomLeadingRadius: innerRadius,
bottomTrailingRadius: innerRadius, topTrailingRadius: 8,
style: .continuous))
.padding([.horizontal, .bottom], NotchGeometry.margin)
}
.foregroundStyle(.white)
}
private enum ContentKind: Hashable {
case starting, live, failed, setup, settingUp, other
init(_ state: SessionState) {
switch state {
case .starting: self = .starting
case .live: self = .live
case .failed(.start): self = .failed
case .setupRequired: self = .setup
case .settingUp: self = .settingUp
default: self = .other
}
}
}
@ViewBuilder private var content: some View {
switch chrome.state {
case .live:
VideoSurface(controller: controller)
case .starting(let step, _):
StartingView(step: step, host: controller.openedURL?.host())
case .failed(.start(let message)):
MessageCard(symbol: "exclamationmark.triangle.fill", tint: Palette.amber,
title: "Couldn't open the link", message: message) {
Button("Dismiss") { controller.dismiss() }
.buttonStyle(CapsuleButtonStyle(kind: .secondary))
Button("Try Again") { controller.retry() }
.buttonStyle(CapsuleButtonStyle(kind: .primary))
.keyboardShortcut(.defaultAction)
}
case .setupRequired(let issue, let error):
MessageCard(symbol: Setup.symbol(issue), tint: error == nil ? .white : Palette.amber,
title: Setup.title(issue), message: error ?? Setup.text(issue)) {
Button("Not Now") { controller.dismiss() }
.buttonStyle(CapsuleButtonStyle(kind: .secondary))
if let action = Setup.action(issue) {
Button(action) { controller.performSetupAction() }
.buttonStyle(CapsuleButtonStyle(kind: .primary))
.keyboardShortcut(.defaultAction)
}
}
case .settingUp(let issue):
MessageCard(symbol: Setup.symbol(issue), tint: .white, title: Setup.progressTitle(issue),
message: Self.lastLine(controller.setupLog) ?? "Working…", monospaced: true) {
ProgressView().controlSize(.small)
Button("Hide") { controller.foldSetup() }
.buttonStyle(CapsuleButtonStyle(kind: .secondary))
.help("Keep going in the background; hover over the notch to check on it")
}
default:
Color.black
}
}
private static func lastLine(_ log: String?) -> String? {
log?.split(whereSeparator: \.isNewline).last.map(String.init)
}
}
private struct TopStrip: View {
let controller: SessionController
let state: SessionState
let notchWidth: CGFloat
let routeLabel: String?
var body: some View {
HStack(spacing: 0) {
HStack(spacing: 5) {
KnotMark(size: 13)
Text("Isolated").foregroundStyle(.white.opacity(0.75))
if let routeLabel {
Text("· via \(routeLabel)").foregroundStyle(.white.opacity(0.45)).lineLimit(1)
}
}
.font(.system(size: 11, weight: .semibold, design: .rounded))
.help("This page runs in a disposable Linux VM, cut off from your Mac and your network")
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 14)
Color.clear.frame(width: notchWidth)
Group {
if Self.canDestroy(state) {
Button { controller.destroy() } label: {
HStack(spacing: 4) {
Image(systemName: "power").font(.system(size: 9, weight: .bold))
Text("Destroy")
}
}
.buttonStyle(CapsuleButtonStyle(kind: .destructive, small: true))
.help("Destroy the VM and everything in it (⌘W)")
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.trailing, 10)
}
}
static func canDestroy(_ state: SessionState) -> Bool {
switch state {
case .starting, .live: true
default: false
}
}
}
private struct Toolbar: View {
let controller: SessionController
let state: SessionState
private var isLive: Bool {
if case .live = state { true } else { false }
}
var body: some View {
HStack(spacing: 2) {
IconButton(symbol: "chevron.left", help: "Back (⌘[)") { controller.goBack() }
IconButton(symbol: "chevron.right", help: "Forward (⌘])") { controller.goForward() }
IconButton(symbol: "arrow.clockwise", help: "Reload (⌘R)") { controller.reload() }
AddressField(url: controller.pageURL ?? controller.openedURL)
.padding(.horizontal, 6)
if state == .live(.expanded) {
IconButton(symbol: "arrow.down.right.and.arrow.up.left", help: "Make smaller (⇧⌘E)") { controller.shrink() }
} else {
IconButton(symbol: "arrow.up.left.and.arrow.down.right", help: "Make larger (⇧⌘E)") { controller.expand() }
}
}
.disabled(!isLive)
.padding(.horizontal, NotchGeometry.margin)
}
}
private struct IconButton: View {
let symbol: String
let help: String
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: symbol)
.font(.system(size: 12, weight: .semibold))
.frame(width: 28, height: 24)
}
.buttonStyle(IconButtonStyle())
.help(help)
}
}
private struct AddressField: View {
let url: URL?
var body: some View {
HStack(spacing: 6) {
Image(systemName: url?.scheme == "https" ? "lock.fill" : "globe")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.white.opacity(0.45))
Text(Self.display(url))
.lineLimit(1)
.truncationMode(.tail)
}
.font(.system(size: 12))
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 24)
.background(RoundedRectangle(cornerRadius: 7, style: .continuous).fill(.white.opacity(0.08)))
.help(url?.absoluteString ?? "")
}
static func display(_ url: URL?) -> AttributedString {
guard let url else { return AttributedString("") }
var host = AttributedString(url.host() ?? url.absoluteString)
host.foregroundColor = .white
var rest = url.path()
if let query = url.query() { rest += "?" + query }
if rest == "/" { rest = "" }
var tail = AttributedString(rest)
tail.foregroundColor = .white.opacity(0.4)
return host + tail
}
}
private struct ProgressLine: View {
let progress: Int?
@State private var shown: Int = 0
var body: some View {
GeometryReader { proxy in
Capsule()
.fill(LinearGradient(colors: [Color(red: 0.35, green: 0.7, blue: 1), Color(red: 0.2, green: 0.5, blue: 1)],
startPoint: .leading, endPoint: .trailing))
.frame(width: max(4, proxy.size.width * CGFloat(shown) / 100), height: 3)
}
.frame(height: 3)
.opacity(progress == nil || progress == 100 ? 0 : 1)
.animation(.easeOut(duration: 0.35), value: progress == nil || progress == 100)
.onChange(of: progress, initial: true) { _, value in
guard let value else { return }
withAnimation(value < shown ? nil : .easeOut(duration: 0.25)) { shown = value }
}
}
}
private struct StartingView: View {
let step: StartStep
let host: String?
var body: some View {
VStack(spacing: 18) {
ZStack {
Circle().fill(Palette.coral.opacity(0.14)).frame(width: 64, height: 64)
KnotMark(size: 32)
.modifier(Breathing())
}
VStack(spacing: 4) {
Text("Opening in a disposable VM").font(.system(size: 15, weight: .semibold))
if let host {
Text(host).font(.system(size: 12)).foregroundStyle(.white.opacity(0.5))
}
}
VStack(alignment: .leading, spacing: 9) {
StepRow(title: "Starting the VM", done: step != .booting, active: step == .booting)
StepRow(title: "Connecting to its browser", done: step == .loading, active: step == .connecting)
StepRow(title: "Loading the page", done: false, active: step == .loading)
}
.padding(.top, 2)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Palette.surface)
}
}
private struct StepRow: View {
let title: String
let done: Bool
let active: Bool
var body: some View {
HStack(spacing: 9) {
ZStack {
if done {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Palette.green)
.transition(.scale(scale: 0.4).combined(with: .opacity))
} else if active {
ProgressView().controlSize(.small).scaleEffect(0.75)
.transition(.opacity)
} else {
Image(systemName: "circle").foregroundStyle(.white.opacity(0.22))
}
}
.frame(width: 16, height: 16)
Text(title)
.foregroundStyle(.white.opacity(done || active ? 0.9 : 0.4))
}
.font(.system(size: 13))
.animation(.spring(duration: 0.35, bounce: 0.4), value: done)
.animation(.easeOut(duration: 0.2), value: active)
}
}
private struct MessageCard<Actions: View>: View {
let symbol: String
let tint: Color
let title: String
let message: String
var monospaced = false
@ViewBuilder let actions: Actions
var body: some View {
VStack(spacing: 14) {
ZStack {
Circle().fill(tint.opacity(0.14)).frame(width: 52, height: 52)
Image(systemName: symbol).font(.system(size: 21, weight: .semibold)).foregroundStyle(tint)
}
VStack(spacing: 6) {
Text(title).font(.system(size: 15, weight: .semibold))
Text(message)
.font(monospaced ? .system(size: 11, design: .monospaced) : .system(size: 12))
.foregroundStyle(.white.opacity(0.6))
.multilineTextAlignment(.center)
.lineLimit(5)
.frame(maxWidth: 400)
}
HStack(spacing: 8) { actions }
.padding(.top, 4)
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Palette.surface)
}
}
private struct StaleBanner: View {
let controller: SessionController
let stale: StaleImage
var body: some View {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Palette.amber)
Text(stale.builtDaysAgo.map { "The browser VM is \($0) days old." } ?? "The browser VM's age is unknown.")
.foregroundStyle(.white.opacity(0.85))
Spacer(minLength: 8)
Button("Later") { controller.dismissStaleImageWarning() }
.buttonStyle(CapsuleButtonStyle(kind: .secondary, small: true))
Button("Update Now") { controller.requestImageUpdate() }
.buttonStyle(CapsuleButtonStyle(kind: .primary, small: true))
}
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color(white: 0.1).opacity(0.94)))
.padding(8)
}
}
private enum Setup {
static func symbol(_ issue: SetupIssue) -> String {
switch issue {
case .cliMissing, .cliOutdated: "shippingbox"
case .serviceStopped, .kernelMissing: "power"
case .imageMissing, .imageOutdated: "square.and.arrow.down"
case .imageStale: "arrow.triangle.2.circlepath"
case .lowDisk: "internaldrive"
}
}
static func title(_ issue: SetupIssue) -> String {
switch issue {
case .cliMissing: "Knotchy needs Apple's container runtime"
case .cliOutdated: "Knotchy needs a newer container runtime"
case .serviceStopped, .kernelMissing: "The container service isn't running"
case .imageMissing, .imageOutdated: "The browser VM image needs to be built"
case .imageStale: "Updating the browser VM"
case .lowDisk: "Not enough disk space"
}
}
static func progressTitle(_ issue: SetupIssue) -> String {
switch issue {
case .cliMissing: "Installing the container runtime…"
case .cliOutdated: "Updating the container runtime…"
case .serviceStopped, .kernelMissing: "Starting the container runtime…"
default: title(issue)
}
}
static func text(_ issue: SetupIssue) -> String {
switch issue {
case .cliMissing: "Set it up in one step; the link opens when it's done."
case .cliOutdated: "Update it in one step; the link opens when it's done."
case .serviceStopped, .kernelMissing: "Start it to run the isolated browser."
case .imageMissing, .imageOutdated: "This takes about 3 minutes and downloads about 225 MB."
case .imageStale: "Rebuilding gets the latest WebKit security fixes."
case .lowDisk(let free): "Only \(free / 1_000_000_000) GB free; a session needs about 2 GB."
}
}
static func action(_ issue: SetupIssue) -> String? {
switch issue {
case .cliMissing, .cliOutdated: "Set Up…"
case .serviceStopped, .kernelMissing: "Start Service"
case .imageMissing, .imageOutdated: "Build Image"
case .imageStale: "Update Now"
case .lowDisk: nil
}
}
}
+418
View File
@@ -0,0 +1,418 @@
import AppKit
import KnotchyCore
import Observation
import SwiftUI
final class NotchPanel: NSPanel {
init() {
super.init(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
level = NSWindow.Level(rawValue: NSWindow.Level.mainMenu.rawValue + 3)
collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
isOpaque = false
backgroundColor = .clear
hasShadow = false
becomesKeyOnlyIfNeeded = true
isMovable = false
isReleasedWhenClosed = false
hidesOnDeactivate = false
}
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }
}
enum Presentation: Equatable {
case hidden
case compact
case banner
case panel
}
@MainActor
@Observable
final class NotchChrome {
static let earWidth: CGFloat = 64
static let bannerWidth: CGFloat = 320
static let bannerRowHeight: CGFloat = 34
var presentation: Presentation = .hidden
/// Lags `presentation` while retracting so the content shrinks into the notch with the shape.
var content: Presentation = .hidden
var reveal: CGFloat = 0
var state: SessionState = .idle
var notice: Notice?
var notchSize = CGSize(width: 200, height: 32)
var hasNotch = true
var panelSize = CGSize(width: 652, height: 472)
var panelOffsetX: CGFloat = 0
var routeLabel: String?
var bodySize: CGSize { Self.bodySize(presentation, notch: notchSize, panel: panelSize, hasNotch: hasNotch) }
var topRadius: CGFloat { Self.topRadius(presentation) }
var bottomRadius: CGFloat { Self.bottomRadius(presentation) }
var offsetX: CGFloat { presentation == .panel ? panelOffsetX : 0 }
static func bodySize(_ presentation: Presentation, notch: CGSize, panel: CGSize, hasNotch: Bool) -> CGSize {
switch presentation {
case .hidden: CGSize(width: notch.width, height: hasNotch ? notch.height : 0)
case .compact: CGSize(width: notch.width + 2 * earWidth, height: notch.height)
case .banner: CGSize(width: max(notch.width + 2 * earWidth, bannerWidth), height: notch.height + bannerRowHeight)
case .panel: panel
}
}
static func topRadius(_ presentation: Presentation) -> CGFloat {
switch presentation {
case .hidden: 0
case .compact: 6
case .banner: 8
case .panel: 10
}
}
static func bottomRadius(_ presentation: Presentation) -> CGFloat {
switch presentation {
case .hidden: 10
case .compact: 12
case .banner: 18
case .panel: 22
}
}
}
/// Tracks the shape, not the window, which is larger while the shape animates.
final class TrackingHostingView<Content: View>: NSHostingView<Content> {
var onEnter: () -> Void = {}
var onExit: () -> Void = {}
var trackingRect: CGRect = .zero {
didSet { if trackingRect != oldValue { updateTrackingAreas() } }
}
/// Remove only this one; NSHostingView's own areas drive SwiftUI hover effects.
private var shapeArea: NSTrackingArea?
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let shapeArea { removeTrackingArea(shapeArea) }
shapeArea = nil
guard !trackingRect.isEmpty else { return }
let area = NSTrackingArea(rect: trackingRect, options: [.mouseEnteredAndExited, .activeAlways], owner: self)
addTrackingArea(area)
shapeArea = area
}
override func mouseEntered(with event: NSEvent) {
if event.trackingArea === shapeArea { onEnter() } else { super.mouseEntered(with: event) }
}
override func mouseExited(with event: NSEvent) {
if event.trackingArea === shapeArea { onExit() } else { super.mouseExited(with: event) }
}
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
}
@MainActor
final class NotchWindowController {
private static let shadowPadding: CGFloat = 36
private let controller: SessionController
private let chrome = NotchChrome()
private let panel = NotchPanel()
private let host: TrackingHostingView<NotchRootView>
private var screen: NSScreen?
private var anchor = CGPoint.zero
private var clickMonitor: Any?
private var lastState: SessionState = .idle
/// A finished animation only tidies up if no newer one has started.
private var generation = 0
private var trackingExtent: Extent?
private let currentRouteLabel: () -> String?
private var sessionRouteLabel: String?
init(controller: SessionController, routeLabel: @escaping () -> String? = { nil }) {
self.controller = controller
currentRouteLabel = routeLabel
host = TrackingHostingView(rootView: NotchRootView(controller: controller, chrome: chrome))
host.sizingOptions = []
host.onEnter = { [weak self] in self?.pointerEntered() }
host.onExit = { [weak self] in self?.pointerExited() }
panel.contentView = host
// Global mouse monitors need no Accessibility permission.
clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { _ in
MainActor.assumeIsolated { controller.clickedOutside() }
}
NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification,
object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.screen = nil
self.panel.orderOut(nil)
self.update()
}
}
observe()
update()
}
func hide() {
panel.orderOut(nil)
}
/// So crossing the notch on the way to the menu bar doesn't open the folded pill.
private static let hoverIntent: Duration = .milliseconds(150)
private var pendingEnter: Task<Void, Never>?
private func pointerEntered() {
pendingEnter?.cancel()
guard chrome.presentation == .compact else {
controller.pointerEntered()
return
}
pendingEnter = Task { [weak self] in
try? await Task.sleep(for: Self.hoverIntent)
guard !Task.isCancelled else { return }
self?.controller.pointerEntered()
}
}
private func pointerExited() {
pendingEnter?.cancel()
pendingEnter = nil
controller.pointerExited()
}
/// A key non-activating panel keeps the keyboard until it leaves the screen; `resignKey()` only notifies.
private func relinquishKey() {
guard panel.isKeyWindow else { return }
panel.orderOut(nil)
panel.orderFrontRegardless()
}
private func observe() {
withObservationTracking {
_ = controller.state
_ = controller.notice
_ = controller.setupFolded
} onChange: { [weak self] in
Task { @MainActor in
self?.update()
self?.observe()
}
}
}
static func presentation(for state: SessionState, notice: Notice?, setupFolded: Bool) -> Presentation {
switch state {
case .settingUp where setupFolded:
.compact
case .idle:
notice == nil ? .hidden : .banner
case .starting(_, folded: true), .live(.collapsed):
.compact
case .destroying, .destroyed, .failed(.ended), .failed(.destroyStuck):
.banner
case .starting(_, folded: false), .live(.peek), .live(.expanded), .failed(.start), .setupRequired, .settingUp:
.panel
}
}
private func update() {
let state = controller.state
let notice = controller.notice
defer { lastState = state }
let target = Self.presentation(for: state, notice: notice, setupFolded: controller.setupFolded)
guard target != .hidden else {
retract()
return
}
if screen == nil || (state == .starting(.booting, folded: false) && lastState != state) {
let next = Self.screenUnderPointer()
if next != screen { panel.orderOut(nil) }
screen = next
}
guard let screen else { return }
// The route is chosen as the VM boots, so read it once connecting.
if case .starting(.booting, _) = state, lastState != state { sessionRouteLabel = nil }
if case .starting(.connecting, _) = state, lastState != state { sessionRouteLabel = currentRouteLabel() }
let routeLabel = sessionRouteLabel
let info = ScreenInfo(screen)
let large: Bool
switch state {
case .live(.expanded): large = true
case .starting(_, folded: false): large = controller.opensExpanded
default: large = false
}
let layout = NotchGeometry.layout(for: info, mode: large ? .expanded : .peek)
let notchSize = layout.notch.size
let panelSize = layout.panel.size
let panelOffsetX = layout.panel.midX - layout.notch.midX
anchor = CGPoint(x: layout.notch.midX, y: info.frame.maxY)
generation += 1
let current = generation
let apply = { [chrome] in
chrome.presentation = target
chrome.content = target
chrome.reveal = 1
chrome.state = state
chrome.notice = notice
chrome.notchSize = notchSize
chrome.panelSize = panelSize
chrome.panelOffsetX = panelOffsetX
chrome.hasNotch = info.hasNotch
chrome.routeLabel = routeLabel
}
let targetExtent = Self.extent(target, notch: notchSize, panel: panelSize, offset: panelOffsetX, hasNotch: info.hasNotch)
setTracking(targetExtent)
if !panel.isVisible {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
apply()
chrome.presentation = .hidden
chrome.reveal = 0
}
let hiddenExtent = Self.extent(.hidden, notch: notchSize, panel: panelSize, offset: 0, hasNotch: info.hasNotch)
fitWindow(hiddenExtent, targetExtent)
panel.orderFrontRegardless()
// Draw while invisible and start ~2 frames later, or the first draw (fonts, symbols) stalls the animation.
withTransaction(transaction) { host.layoutSubtreeIfNeeded() }
panel.displayIfNeeded()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(35)) { [weak self] in
guard let self, self.generation == current else { return }
self.animate(from: .hidden, to: target, apply: apply, generation: current, extent: targetExtent)
}
} else {
let from = chrome.presentation
let currentExtent = Self.extent(from, notch: chrome.notchSize, panel: chrome.panelSize,
offset: chrome.offsetX, hasNotch: chrome.hasNotch)
fitWindow(currentExtent, targetExtent)
animate(from: from, to: target, apply: apply, generation: current, extent: targetExtent)
}
// Only a click into the page (VideoView) makes the panel key; opening never does.
if target != .panel { relinquishKey() }
}
private func animate(from: Presentation, to target: Presentation, apply: @escaping () -> Void,
generation current: Int, extent: Extent) {
withAnimation(Self.animation(from: from, to: target), completionCriteria: .removed) {
apply()
} completion: { [weak self] in
guard let self, self.generation == current else { return }
self.fitWindow(extent)
}
}
private func retract() {
guard panel.isVisible else {
screen = nil
return
}
generation += 1
let current = generation
setTracking(nil)
relinquishKey()
withAnimation(Motion.hide, completionCriteria: .removed) {
chrome.presentation = .hidden
chrome.reveal = 0
} completion: { [weak self] in
guard let self, self.generation == current else { return }
self.panel.orderOut(nil)
self.screen = nil
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) { self.chrome.content = .hidden }
}
}
private static func animation(from: Presentation, to target: Presentation) -> Animation {
switch (from, target) {
case (.panel, .panel): Motion.resize
case (_, .panel): Motion.open
case (.panel, _): Motion.fold
default: Motion.small
}
}
// MARK: - Geometry
struct Extent {
/// Reach on the wider side of the notch's centre line.
var halfWidth: CGFloat
var height: CGFloat
var offset: CGFloat = 0
var width: CGFloat = 0
}
static func extent(_ presentation: Presentation, notch: CGSize, panel: CGSize, offset: CGFloat, hasNotch: Bool) -> Extent {
let body = NotchChrome.bodySize(presentation, notch: notch, panel: panel, hasNotch: hasNotch)
let width = body.width + 2 * NotchChrome.topRadius(presentation)
let shift = presentation == .panel ? offset : 0
return Extent(halfWidth: width / 2 + abs(shift), height: body.height, offset: shift, width: width)
}
private func fitWindow(_ extents: Extent...) {
let half = (extents.map(\.halfWidth).max() ?? 0) + Self.shadowPadding
let height = (extents.map(\.height).max() ?? 0) + Self.shadowPadding
let frame = CGRect(x: (anchor.x - half).rounded(), y: anchor.y - height.rounded(.up),
width: (2 * half).rounded(.up), height: height.rounded(.up))
if panel.frame != frame {
panel.setFrame(frame, display: false)
// Lay out outside any animation, or the shape animates across from the old window position.
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) { host.layoutSubtreeIfNeeded() }
}
refreshTracking()
}
private func setTracking(_ extent: Extent?) {
trackingExtent = extent
refreshTracking()
}
private func refreshTracking() {
guard let extent = trackingExtent else {
host.trackingRect = .zero
return
}
let bounds = host.bounds
let y = host.isFlipped ? bounds.minY : bounds.maxY - extent.height
host.trackingRect = CGRect(x: bounds.midX + extent.offset - extent.width / 2, y: y,
width: extent.width, height: extent.height)
}
#if DEBUG
func snapshot(to directory: URL, name: String) {
guard panel.isVisible else {
FileHandle.standardError.write(Data("snapshot \(name): window hidden\n".utf8))
return
}
DebugSnapshot.write(host, to: directory.appendingPathComponent("\(name).png"))
}
#endif
private static func screenUnderPointer() -> NSScreen? {
let pointer = NSEvent.mouseLocation
return NSScreen.screens.first { NSMouseInRect(pointer, $0.frame, false) } ?? NSScreen.main
}
}
extension ScreenInfo {
/// The only code that reads `NSScreen`.
@MainActor
init(_ screen: NSScreen) {
func global(_ rect: CGRect?) -> CGRect {
guard let rect, !rect.isEmpty else { return .zero }
// The auxiliary areas are documented in screen coordinates; make them global.
return screen.frame.contains(rect.origin) ? rect : rect.offsetBy(dx: screen.frame.minX, dy: screen.frame.minY)
}
self.init(frame: screen.frame, visibleFrame: screen.visibleFrame,
safeAreaTop: screen.safeAreaInsets.top,
auxLeft: global(screen.auxiliaryTopLeftArea), auxRight: global(screen.auxiliaryTopRightArea),
menuBarThickness: NSStatusBar.system.thickness)
}
}
+291
View File
@@ -0,0 +1,291 @@
import KnotchyCore
import SwiftUI
import UniformTypeIdentifiers
enum SettingsTab: String, CaseIterable, Identifiable {
case general, network, vm
var id: Self { self }
var title: String {
switch self {
case .general: "General"
case .network: "Network"
case .vm: "VM"
}
}
var symbol: String {
switch self {
case .general: "gearshape"
case .network: "network"
case .vm: "shippingbox"
}
}
}
@MainActor
@Observable
final class SettingsNavigation {
var section: SettingsTab? = .general
}
@MainActor
final class SettingsWindowController {
static let size = CGSize(width: 700, height: 470)
private let app: AppDelegate
private let navigation = SettingsNavigation()
private(set) var window: NSWindow?
init(app: AppDelegate) {
self.app = app
}
func show(_ section: SettingsTab? = nil) {
if let section { navigation.section = section }
let window = self.window ?? makeWindow()
self.window = window
// An accessory app's windows open behind the frontmost app unless it activates first.
NSApp.activate()
window.makeKeyAndOrderFront(nil)
}
private func makeWindow() -> NSWindow {
let host = NSHostingController(rootView: SettingsView(app: app, settings: app.settings, navigation: navigation))
host.sceneBridgingOptions = [.title, .toolbars]
let window = NSWindow(contentViewController: host)
window.styleMask = [.titled, .closable, .miniaturizable, .fullSizeContentView]
window.toolbarStyle = .unified
window.title = "Knotchy Settings"
window.isReleasedWhenClosed = false
window.setContentSize(Self.size)
window.center()
window.setFrameAutosaveName("KnotchySettings")
return window
}
}
struct SettingsView: View {
let app: AppDelegate
@Bindable var settings: AppSettings
@Bindable var navigation: SettingsNavigation
var body: some View {
NavigationSplitView {
List(SettingsTab.allCases, selection: $navigation.section) { section in
Label(section.title, systemImage: section.symbol)
}
.navigationSplitViewColumnWidth(180)
.toolbar(removing: .sidebarToggle)
} detail: {
Group {
switch navigation.section ?? .general {
case .general: GeneralSettings(settings: settings)
case .network: NetworkSettings(settings: settings)
case .vm: VMSettings(controller: app.controller, status: app.vmStatus)
}
}
.navigationTitle((navigation.section ?? .general).title)
}
.frame(width: SettingsWindowController.size.width, height: SettingsWindowController.size.height)
}
}
private struct GeneralSettings: View {
@Bindable var settings: AppSettings
@State private var opensAtLogin = LoginItem.isEnabled
@State private var loginError: String?
var body: some View {
Form {
Section {
Picker("Destroy folded pages", selection: $settings.autoDestroyMinutes) {
ForEach(AppSettings.autoDestroyChoices, id: \.self) { minutes in
Text(minutes == 1 ? "After 1 minute" : "After \(minutes) minutes").tag(minutes)
}
}
Picker("Open pages in", selection: $settings.opensLarge) {
Text("Small panel").tag(false)
Text("Large panel").tag(true)
}
} footer: {
Text("A page folded into the notch keeps running until it's destroyed.")
}
Section {
Toggle("Open copied link with \(GlobalHotKey.openCopiedLinkLabel)", isOn: $settings.copiedLinkShortcut)
} footer: {
Text("Copy a link anywhere (in Chrome: right-click › Copy Link Address), then press \(GlobalHotKey.openCopiedLinkLabel).")
}
Section {
Toggle("Open Knotchy at login", isOn: Binding {
opensAtLogin
} set: { enabled in
loginError = LoginItem.set(enabled)
opensAtLogin = LoginItem.isEnabled
})
} footer: {
if let loginError { Text(loginError).foregroundStyle(.red) }
}
}
.formStyle(.grouped)
.scrollDisabled(true)
.onAppear { opensAtLogin = LoginItem.isEnabled }
}
}
// Every mode's form stays laid out, inactive ones hidden, so switching crossfades instead of resizing the window.
private struct NetworkSettings: View {
@Bindable var settings: AppSettings
@State private var importing = false
@State private var importMessage: String?
var body: some View {
ZStack(alignment: .top) {
ForEach([AppSettings.NetworkMode.direct, .proxy, .vpn], id: \.self) { mode in
let active = settings.networkMode == mode
NetworkForm(settings: settings, mode: mode, importMessage: importMessage, importing: $importing)
.opacity(active ? 1 : 0)
.allowsHitTesting(active)
.accessibilityHidden(!active)
.animation(active ? .easeOut(duration: 0.2).delay(0.05) : .easeOut(duration: 0.1), value: active)
}
}
.fileImporter(isPresented: $importing,
allowedContentTypes: [UTType(filenameExtension: "conf") ?? .data, .zip],
allowsMultipleSelection: true) { result in
if case .success(let urls) = result {
Task { importMessage = await settings.importWireGuard(from: urls) }
}
}
}
}
private struct NetworkForm: View {
@Bindable var settings: AppSettings
let mode: AppSettings.NetworkMode
let importMessage: String?
@Binding var importing: Bool
var body: some View {
Form {
Section {
Picker("Connection", selection: $settings.networkMode) {
Text("Direct").tag(AppSettings.NetworkMode.direct)
Text("Proxy").tag(AppSettings.NetworkMode.proxy)
Text("WireGuard VPN").tag(AppSettings.NetworkMode.vpn)
}
.pickerStyle(.radioGroup)
} footer: {
Text(description)
}
details
.disabled(settings.networkMode != mode) // No tabbing into a hidden form.
}
.formStyle(.grouped)
.scrollDisabled(true)
}
private var description: String {
switch mode {
case .direct: "Pages go out through this Mac's internet connection, and can reach nothing else."
case .proxy: "Pages can only go out through the proxy. If it's down, they don't load."
case .vpn: "Pages can only go out through the VPN. If it's down, they don't load. The rest of your Mac isn't affected."
}
}
@ViewBuilder private var details: some View {
switch mode {
case .direct:
Section {
LabeledContent("Internet", value: "This Mac's connection")
LabeledContent("Blocked", value: "Your Mac, your network, IPv6")
LabeledContent("DNS", value: "1.1.1.1 (Cloudflare)")
} header: {
Text("Direct")
}
case .proxy:
Section {
Picker("Type", selection: $settings.proxyKind) {
Text("HTTP").tag(ProxySettings.Kind.http)
Text("SOCKS5").tag(ProxySettings.Kind.socks5)
}
TextField("Server", text: $settings.proxyHost, prompt: Text("proxy.example.com"))
TextField("Port", value: $settings.proxyPort, format: .number.grouping(.never), prompt: Text("8080"))
TextField("Username", text: $settings.proxyUsername, prompt: Text("Optional"))
SecureField("Password", text: $settings.proxyPassword, prompt: Text("Optional"))
} header: {
Text("Proxy")
} footer: {
if settings.proxyIsLocal {
Text("Proxies on this Mac or your network aren't allowed: pages could reach your network through them.")
.foregroundStyle(.orange)
} else {
Text("The password is kept in your keychain.")
}
}
case .vpn:
Section {
if settings.wireGuardNames.isEmpty {
LabeledContent("Server", value: "None")
LabeledContent("Endpoint", value: "—")
LabeledContent("DNS", value: "—")
LabeledContent("No configs imported") {
Button("Import…") { importing = true }
}
} else {
Picker("Server", selection: $settings.wireGuardServer) {
ForEach(settings.wireGuardNames, id: \.self) { Text($0).tag($0) }
}
let config = settings.selectedWireGuard
LabeledContent("Endpoint", value: config.map { "\($0.endpointHost):\(String($0.endpointPort))" } ?? "—")
LabeledContent("DNS", value: config.map { $0.dns.isEmpty ? "1.1.1.1" : $0.dns.joined(separator: ", ") } ?? "—")
LabeledContent(settings.wireGuardNames.count == 1 ? "1 config" : "\(settings.wireGuardNames.count) configs") {
HStack {
Button("Remove All") { settings.removeWireGuardConfigs() }
Button("Import…") { importing = true }
}
}
}
} header: {
Text("WireGuard")
} footer: {
if let importMessage {
Text(importMessage).foregroundStyle(.orange).lineLimit(2)
} else {
Text("Import the .conf files, or the .zip, from Mullvad, Proton VPN, IVPN or others.")
}
}
}
}
}
private struct VMSettings: View {
let controller: SessionController
let status: VMStatus
var body: some View {
Form {
Section {
LabeledContent("Container runtime", value: status.runtimeSummary)
LabeledContent("VM image", value: status.summary)
LabeledContent("Rebuild") {
Button(status.image == .missing ? "Build Now" : "Rebuild Now") { controller.rebuildImage() }
.disabled(isSettingUp || status.image == .noContainerTool)
}
} footer: {
Text("Rebuilding takes about 3 minutes and picks up the latest WebKit security fixes. Progress shows in the notch.")
}
}
.formStyle(.grouped)
.scrollDisabled(true)
.onAppear { status.refresh() }
}
private var isSettingUp: Bool {
if case .settingUp = controller.state { true } else { false }
}
}
+245
View File
@@ -0,0 +1,245 @@
import AppKit
import KnotchyCore
import SwiftUI
@MainActor
final class SetupWindowController: NSObject, NSWindowDelegate {
enum Content {
case setup(SetupFlow)
case unsupported(Unsupported)
}
private let content: Content
private(set) var window: NSWindow?
init(_ content: Content) {
self.content = content
}
func show() {
let window = self.window ?? makeWindow()
self.window = window
// An accessory app's windows open behind the frontmost app unless it activates first.
NSApp.activate()
window.makeKeyAndOrderFront(nil)
if case .setup(let flow) = content { Task { await flow.refresh() } }
}
private func makeWindow() -> NSWindow {
let root = switch content {
case .setup(let flow): AnyView(SetupView(flow: flow) { [weak self] in self?.window?.close() })
case .unsupported(let reason): AnyView(UnsupportedView(reason: reason))
}
let host = NSHostingController(rootView: root)
let window = NSWindow(contentViewController: host)
window.styleMask = [.titled, .closable, .fullSizeContentView]
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.title = "Welcome to Knotchy"
window.isReleasedWhenClosed = false
window.setContentSize(host.view.fittingSize)
window.center()
window.delegate = self
return window
}
func windowWillClose(_ notification: Notification) {
switch content {
case .unsupported:
NSApp.terminate(nil)
case .setup(let flow):
if flow.allRequiredDone { UserDefaults.standard.set(true, forKey: SetupLaunchRule.completedKey) }
}
}
}
struct SetupView: View {
let flow: SetupFlow
let close: () -> Void
@State private var browser = ChromeExtension.installedBrowser()
@State private var addedToBrowser = false
var body: some View {
VStack(alignment: .leading, spacing: 20) {
HStack(spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 56, height: 56)
VStack(alignment: .leading, spacing: 2) {
Text("Welcome to Knotchy").font(.title2.weight(.semibold))
Text("A few things to set up before your first link.").foregroundStyle(.secondary)
}
}
GroupBox {
VStack(spacing: 0) {
SetupRow(title: "This Mac", status: flow.mac) { Task { await flow.refresh() } }
Divider()
SetupRow(title: "Container runtime", status: flow.runtime) { flow.start() }
Divider()
SetupRow(title: "Browser VM", status: flow.vm) { flow.start() }
if let browser {
Divider()
BrowserRow(browser: browser, added: $addedToBrowser)
}
}
.padding(.horizontal, 6)
}
HStack {
Spacer()
if flow.allRequiredDone {
Button("Done", action: close)
.keyboardShortcut(.defaultAction)
} else {
Button("Not Now", action: close)
Button("Set Up") { flow.start() }
.keyboardShortcut(.defaultAction)
.disabled(!flow.canSetUp)
}
}
}
.padding(24)
.frame(width: 540)
}
}
private struct SetupRow: View {
let title: String
let status: SetupFlow.Status
let tryAgain: () -> Void
@State private var showsDetails = false
// Buttons sit on the title line and the detail reserves two lines, so no state changes the height.
var body: some View {
HStack(alignment: .top, spacing: 12) {
symbol.frame(width: 20, height: 22)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 8) {
Text(title)
Spacer(minLength: 8)
if case .failed(_, let details) = status {
if !details.isEmpty {
Button("Show Details") { showsDetails = true }
.popover(isPresented: $showsDetails, arrowEdge: .bottom) { DetailsView(lines: details) }
}
Button("Try Again", action: tryAgain)
}
}
.controlSize(.small)
.frame(minHeight: 22)
Text(detail)
.font(.callout)
.foregroundStyle(isFailed ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary))
.lineLimit(2, reservesSpace: true)
}
}
.padding(.vertical, 10)
}
private var detail: String {
switch status {
case .pending(let text), .running(let text, _), .done(let text), .failed(let text, _): text
}
}
private var isFailed: Bool {
if case .failed = status { true } else { false }
}
@ViewBuilder private var symbol: some View {
switch status {
case .pending:
Image(systemName: "circle.dashed").imageScale(.large).foregroundStyle(.tertiary)
case .running(_, let fraction?):
ProgressView(value: fraction).progressViewStyle(.circular).controlSize(.small)
case .running:
ProgressView().controlSize(.small)
case .done:
Image(systemName: "checkmark.circle.fill").imageScale(.large).foregroundStyle(.green)
case .failed:
Image(systemName: "exclamationmark.circle.fill").imageScale(.large).foregroundStyle(.red)
}
}
}
private struct BrowserRow: View {
let browser: ChromeExtension.Browser
@Binding var added: Bool
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "puzzlepiece.extension")
.imageScale(.large)
.foregroundStyle(.secondary)
.frame(width: 20, height: 22)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text("Open links from \(browser.name)")
Text("Optional").font(.caption).foregroundStyle(.tertiary)
Spacer(minLength: 8)
Button("Add to \(browser.name)") {
ChromeExtension.open(in: browser)
added = true
}
.controlSize(.small)
}
.frame(minHeight: 22)
Text(added ? "In \(browser.name), right-click a link and choose Open in Knotchy."
: "Safari and Mail already work from the right-click menu.")
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2, reservesSpace: true)
}
}
.padding(.vertical, 10)
}
}
private struct DetailsView: View {
let lines: [String]
var body: some View {
ScrollView {
Text(lines.joined(separator: "\n"))
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
}
.frame(width: 440, height: 220)
}
}
private struct UnsupportedView: View {
let reason: Unsupported
var body: some View {
VStack(spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 72, height: 72)
Text(title).font(.title2.weight(.semibold))
Text(message)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button("Quit") { NSApp.terminate(nil) }
.keyboardShortcut(.defaultAction)
.padding(.top, 6)
}
.padding(32)
.frame(width: 440)
}
private var title: String {
switch reason {
case .noVirtualization: "This Mac can't run virtual machines right now"
}
}
private var message: String {
switch reason {
case .noVirtualization:
"Knotchy opens every link in its own virtual machine. If macOS is itself running in a virtual machine, run Knotchy on the host Mac instead."
}
}
}
+234
View File
@@ -0,0 +1,234 @@
import AVFoundation
import AppKit
import CoreMedia
import KnotchyCore
import SwiftUI
import Synchronization
final class LatestFrame: Sendable {
private let frame = Mutex<DecodedFrame?>(nil)
func put(_ new: DecodedFrame) -> Bool {
frame.withLock { slot in
defer { slot = new }
return slot == nil
}
}
func take() -> DecodedFrame? {
frame.withLock { slot in
defer { slot = nil }
return slot
}
}
}
struct VideoSurface: NSViewRepresentable {
let controller: SessionController
func makeNSView(context: Context) -> VideoView {
let view = VideoView()
view.controller = controller
return view
}
func updateNSView(_ view: VideoView, context: Context) {
view.attach(controller.connection)
}
}
final class VideoView: NSView {
weak var controller: SessionController?
private var connection: (any StreamConnection)?
private let displayLayer = AVSampleBufferDisplayLayer()
private var scrollRemainder = CGPoint.zero
/// WebKit scrolls about 40 px per wheel tick.
private let scrollStep: CGFloat = 40
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
layer?.backgroundColor = NSColor.black.cgColor
displayLayer.videoGravity = .resizeAspect
displayLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
layer?.addSublayer(displayLayer)
}
required init?(coder: NSCoder) { fatalError("not used") }
override var isFlipped: Bool { true }
override var acceptsFirstResponder: Bool { true }
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
override func layout() {
super.layout()
CATransaction.begin()
CATransaction.setDisableActions(true)
displayLayer.frame = bounds
CATransaction.commit()
#if DEBUG
if ProcessInfo.processInfo.environment["KNOTCHY_LOG_LAYOUT"] != nil {
print(String(format: "%.3f video %.0fx%.0f", CACurrentMediaTime(), bounds.width, bounds.height))
}
#endif
}
func attach(_ connection: (any StreamConnection)?) {
guard self.connection !== connection else { return }
self.connection = connection
displayLayer.sampleBufferRenderer.flush()
let slot = LatestFrame()
connection?.setFrameHandler { [weak self] frame in
// Keep only the newest frame so a busy main thread drops frames instead of queueing multi-MB buffers.
guard slot.put(frame) else { return }
DispatchQueue.main.async {
guard let frame = slot.take() else { return }
MainActor.assumeIsolated { self?.show(frame.pixelBuffer) }
}
}
}
private func show(_ pixelBuffer: CVPixelBuffer) {
var format: CMVideoFormatDescription?
CMVideoFormatDescriptionCreateForImageBuffer(allocator: nil, imageBuffer: pixelBuffer, formatDescriptionOut: &format)
guard let format else { return }
var timing = CMSampleTimingInfo(duration: .invalid, presentationTimeStamp: .zero, decodeTimeStamp: .invalid)
var sample: CMSampleBuffer?
CMSampleBufferCreateReadyWithImageBuffer(allocator: nil, imageBuffer: pixelBuffer, formatDescription: format,
sampleTiming: &timing, sampleBufferOut: &sample)
guard let sample else { return }
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: true),
CFArrayGetCount(attachments) > 0 {
let dictionary = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self)
CFDictionarySetValue(dictionary,
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
}
let renderer = displayLayer.sampleBufferRenderer
if renderer.status == .failed { renderer.flush() }
renderer.enqueue(sample)
}
// MARK: - Mouse
override func updateTrackingAreas() {
super.updateTrackingAreas()
for area in trackingAreas where area.owner === self { removeTrackingArea(area) }
addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseMoved, .activeAlways, .inVisibleRect], owner: self))
}
private func videoPoint(_ event: NSEvent) -> (x: UInt16, y: UInt16)? {
NotchGeometry.videoPoint(convert(event.locationInWindow, from: nil), surfaceSize: bounds.size,
renderSize: SessionController.renderSize)
}
private func mods(_ event: NSEvent) -> Modifiers {
var mods: Modifiers = []
let flags = event.modifierFlags
if flags.contains(.shift) { mods.insert(.shift) }
// ⌘ maps to Ctrl so ⌘A/C/V/X/Z work like Linux shortcuts in the VM.
if flags.contains(.control) || flags.contains(.command) { mods.insert(.control) }
if flags.contains(.option) { mods.insert(.alt) }
let buttons = NSEvent.pressedMouseButtons
if buttons & 1 != 0 { mods.insert(.leftButton) }
if buttons & 2 != 0 { mods.insert(.rightButton) }
if buttons & 4 != 0 { mods.insert(.middleButton) }
return mods
}
private func sendMove(_ event: NSEvent) {
guard let point = videoPoint(event) else { return }
connection?.send(.pointerMove(x: point.x, y: point.y, mods: mods(event)))
}
private func sendButton(_ event: NSEvent, _ button: PointerButton, pressed: Bool) {
guard let point = videoPoint(event) else { return }
connection?.send(.pointerButton(button, pressed: pressed, x: point.x, y: point.y, mods: mods(event)))
}
override func mouseMoved(with event: NSEvent) { sendMove(event) }
override func mouseDragged(with event: NSEvent) { sendMove(event) }
override func rightMouseDragged(with event: NSEvent) { sendMove(event) }
override func otherMouseDragged(with event: NSEvent) { sendMove(event) }
override func mouseDown(with event: NSEvent) {
controller?.panelClicked()
window?.makeKey()
window?.makeFirstResponder(self)
sendButton(event, .left, pressed: true)
}
override func mouseUp(with event: NSEvent) { sendButton(event, .left, pressed: false) }
override func rightMouseDown(with event: NSEvent) { sendButton(event, .right, pressed: true) }
override func rightMouseUp(with event: NSEvent) { sendButton(event, .right, pressed: false) }
override func otherMouseDown(with event: NSEvent) { sendButton(event, .middle, pressed: true) }
override func otherMouseUp(with event: NSEvent) { sendButton(event, .middle, pressed: false) }
override func scrollWheel(with event: NSEvent) {
var dx = event.scrollingDeltaX
var dy = event.scrollingDeltaY
if event.hasPreciseScrollingDeltas {
scrollRemainder.x += dx
scrollRemainder.y += dy
dx = (scrollRemainder.x / scrollStep).rounded(.towardZero)
dy = (scrollRemainder.y / scrollStep).rounded(.towardZero)
scrollRemainder.x -= dx * scrollStep
scrollRemainder.y -= dy * scrollStep
} else {
dx = dx == 0 ? 0 : (dx > 0 ? max(1, dx.rounded()) : min(-1, dx.rounded()))
dy = dy == 0 ? 0 : (dy > 0 ? max(1, dy.rounded()) : min(-1, dy.rounded()))
}
guard dx != 0 || dy != 0, let point = videoPoint(event) else { return }
connection?.send(.scroll(ticksX: Int16(clamping: Int(dx)), ticksY: Int16(clamping: Int(dy)), x: point.x, y: point.y))
}
// MARK: - Keyboard
private static let specialKeys: [UInt16: String] = [
36: "Return", 76: "KP_Enter", 48: "Tab", 51: "BackSpace", 117: "Delete", 53: "Escape",
49: "space", 123: "Left", 124: "Right", 125: "Down", 126: "Up",
115: "Home", 119: "End", 116: "Page_Up", 121: "Page_Down",
122: "F1", 120: "F2", 99: "F3", 118: "F4", 96: "F5", 97: "F6",
98: "F7", 100: "F8", 101: "F9", 109: "F10", 103: "F11", 111: "F12",
]
private func keyName(_ event: NSEvent) -> String? {
if let name = Self.specialKeys[event.keyCode] { return name }
let flags = event.modifierFlags
let characters = flags.contains(.command) || flags.contains(.control)
? event.charactersIgnoringModifiers?.lowercased()
: event.characters
guard let scalars = characters?.unicodeScalars, scalars.count == 1, let scalar = scalars.first,
scalar.value >= 0x20, scalar.value != 0x7F, !(0xF700...0xF8FF).contains(scalar.value)
else { return nil }
return String(scalar)
}
private func sendKey(_ event: NSEvent, pressed: Bool) {
guard let name = keyName(event) else { return }
connection?.send(.key(pressed: pressed, mods: mods(event), name: name))
}
override func keyDown(with event: NSEvent) { sendKey(event, pressed: true) }
override func keyUp(with event: NSEvent) { sendKey(event, pressed: false) }
override func performKeyEquivalent(with event: NSEvent) -> Bool {
guard event.type == .keyDown, event.modifierFlags.contains(.command), let controller else {
return super.performKeyEquivalent(with: event)
}
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
let shift = event.modifierFlags.contains(.shift)
switch (key, shift) {
case ("w", false), (".", false): controller.destroy()
case ("e", true): controller.state == .live(.expanded) ? controller.shrink() : controller.expand()
case ("[", false): controller.goBack()
case ("]", false): controller.goForward()
case ("r", false): controller.reload()
case ("q", false): NSApp.terminate(nil)
default:
sendKey(event, pressed: true)
sendKey(event, pressed: false)
}
return true
}
}
+115
View File
@@ -0,0 +1,115 @@
import Foundation
import Synchronization
public struct CLIRunner: Sendable {
public struct Output: Sendable {
public var status: Int32
public var text: String
public var lastLine: String {
text.split(whereSeparator: \.isNewline).last.map(String.init) ?? "exit status \(status)"
}
}
public enum Failure: Error, Equatable {
case notInstalled
case timedOut([String])
case launchFailed(String)
}
// Fixed paths: apps launched from the Services menu get a minimal PATH.
public static let searchPaths = ["/usr/local/bin/container", "/opt/homebrew/bin/container"]
public let searchPaths: [String]
public init(searchPaths: [String] = CLIRunner.searchPaths) {
self.searchPaths = searchPaths
}
/// Looked up on every call, so a runtime installed while the app runs is found.
public var executable: String? {
searchPaths.first { FileManager.default.isExecutableFile(atPath: $0) }
}
public func run(_ args: [String], timeout: TimeInterval,
line: (@Sendable (String) -> Void)? = nil) async throws -> Output {
try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(with: Result { try self.runBlocking(args, timeout: timeout, line: line) })
}
}
}
static let outputLimit = 1024 * 1024
private static let builds = Mutex(Set<Int32>())
/// Builds would outlive the app. Other commands are left to the quit path, which waits for them.
public static func stopBuilds() {
for pid in builds.withLock({ $0 }) { kill(pid, SIGTERM) }
}
public func runBlocking(_ args: [String], timeout: TimeInterval,
line: (@Sendable (String) -> Void)? = nil) throws -> Output {
guard let executable else { throw Failure.notInstalled }
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
// Only the tail is kept: a noisy build can print for minutes.
let limit = Self.outputLimit
let collected = Mutex(Data())
let pending = Mutex(Data())
pipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
guard !chunk.isEmpty else { return }
collected.withLock { buffer in
buffer.append(chunk)
if buffer.count > limit { buffer.removeFirst(buffer.count - limit) }
}
guard let line else { return }
let lines: [String] = pending.withLock { buffer in
buffer.append(chunk)
var out: [String] = []
while let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) {
out.append(String(decoding: buffer[buffer.startIndex..<newline], as: UTF8.self))
buffer.removeSubrange(buffer.startIndex...newline)
}
if buffer.count > 16 * 1024 { // A line that never ends.
out.append(String(decoding: buffer.prefix(16 * 1024), as: UTF8.self))
buffer.removeAll()
}
return out
}
for text in lines where !text.trimmingCharacters(in: .whitespaces).isEmpty { line(text) }
}
let finished = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in finished.signal() }
do {
try process.run()
} catch {
pipe.fileHandleForReading.readabilityHandler = nil
throw Failure.launchFailed(error.localizedDescription)
}
let pid = process.processIdentifier
let isBuild = args.first == "build"
if isBuild { Self.builds.withLock { _ = $0.insert(pid) } }
defer { if isBuild { Self.builds.withLock { _ = $0.remove(pid) } } }
if finished.wait(timeout: .now() + timeout) == .timedOut {
process.terminate()
if finished.wait(timeout: .now() + 1) == .timedOut {
kill(process.processIdentifier, SIGKILL)
finished.wait()
}
pipe.fileHandleForReading.readabilityHandler = nil
throw Failure.timedOut(args)
}
pipe.fileHandleForReading.readabilityHandler = nil
let rest = pipe.fileHandleForReading.readDataToEndOfFile()
let data = collected.withLock { ($0 + rest).suffix(limit) }
return Output(status: process.terminationStatus, text: String(decoding: data, as: UTF8.self))
}
}
@@ -0,0 +1,311 @@
import Foundation
import Synchronization
public final class ContainerCLIBackend: VMBackend {
public static let image = "local/knotchy-vm:latest"
public static let staleAfterDays = 14
private let cli: CLIRunner
private let vmSources: URL?
private let runDirectory: URL
private let startsInFlight = Mutex(0)
private let route = Mutex(NetworkRoute.direct)
private let startedRoute = Mutex(NetworkRoute.direct)
public var lastStartedRoute: NetworkRoute { startedRoute.withLock { $0 } }
public func setNetworkRoute(_ newRoute: NetworkRoute) {
route.withLock { $0 = newRoute }
}
/// Test-only (knotchyctl --allow-local-proxy); the app never sets it.
private let localProxyAllowed = Mutex(false)
public func allowLocalProxy() {
localProxyAllowed.withLock { $0 = true }
}
public init(vmSources: URL?, cli: CLIRunner = CLIRunner()) {
self.vmSources = vmSources
self.cli = cli
runDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("knotchy", isDirectory: true)
}
// MARK: Preflight
public func preflight() async -> PreflightResult {
guard cli.executable != nil else { return .issue(.cliMissing) }
if case .outdated(let found) = await runtimeStatus() { return .issue(.cliOutdated(found: found)) }
guard let status = try? await cli.run(["system", "status"], timeout: 15), status.status == 0,
field("status", in: status.text) == "running"
else { return .issue(.serviceStopped) }
let appRoot = field("paths.appRoot", in: status.text)
?? NSHomeDirectory() + "/Library/Application Support/com.apple.container/"
let kernel = URL(fileURLWithPath: appRoot).appendingPathComponent("kernels/default.kernel-arm64")
guard FileManager.default.fileExists(atPath: kernel.path) else { return .issue(.kernelMissing) }
guard let inspect = try? await cli.run(["image", "inspect", Self.image], timeout: 15), inspect.status == 0
else { return .issue(.imageMissing) }
let labels = Self.labels(in: inspect.text)
let found = labels["aculix.knotchy.protocol"].flatMap(Int.init) ?? 0
guard found == WireProtocol.version else {
return .issue(.imageOutdated(found: found, need: WireProtocol.version))
}
prepareRunDirectory()
return .ready(appRoot: appRoot, staleImage: Self.staleness(built: labels["aculix.knotchy.built"]))
}
/// Resolved on the Mac: a proxy route gives the VM no DNS, and a VPN's DNS needs the tunnel up.
static func resolveIPv4(_ host: String) throws -> String {
var address = host
if !WireGuardConfig.isIPv4(host) {
var hints = addrinfo()
hints.ai_family = AF_INET
hints.ai_socktype = SOCK_STREAM
var result: UnsafeMutablePointer<addrinfo>?
guard getaddrinfo(host, nil, &hints, &result) == 0, let first = result, let sockaddr = first.pointee.ai_addr else {
throw VMBackendError.failed("Couldn't find the server “\(host)”. Check Settings → Network.")
}
defer { freeaddrinfo(result) }
var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
var inAddr = sockaddr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee.sin_addr }
guard inet_ntop(AF_INET, &inAddr, &buffer, socklen_t(buffer.count)) != nil else {
throw VMBackendError.failed("Couldn't find the server “\(host)”.")
}
address = String(cString: buffer)
}
if address.hasPrefix("127.") || address == "0.0.0.0" {
throw VMBackendError.failed("The VM can't reach this Mac's localhost (“\(host)”). A proxy on this Mac must listen on the Mac's network address instead.")
}
return address
}
public func imageStatus() async -> ImageStatus {
guard cli.executable != nil else { return .noContainerTool }
guard let inspect = try? await cli.run(["image", "inspect", Self.image], timeout: 15), inspect.status == 0
else { return .missing }
let built = Self.labels(in: inspect.text)["aculix.knotchy.built"].flatMap { ISO8601DateFormatter().date(from: $0) }
return .built(built)
}
public enum ImageStatus: Equatable, Sendable {
case noContainerTool
case missing
case built(Date?)
}
public func runtimeStatus() async -> RuntimeStatus {
guard cli.executable != nil else { return .missing }
let output = try? await cli.run(["--version"], timeout: 10)
return ContainerRuntime.status(versionOutput: output?.text ?? "")
}
public func stopService() async throws {
let result = try await cli.run(["system", "stop"], timeout: 60)
guard result.status == 0 else { throw VMBackendError.failed("Couldn't stop the container service: \(result.lastLine)") }
}
private func field(_ name: String, in text: String) -> String? {
for line in text.split(whereSeparator: \.isNewline) {
let parts = line.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
if parts.count == 2, parts[0] == name {
return parts[1].trimmingCharacters(in: .whitespaces)
}
}
return nil
}
static func labels(in json: String) -> [String: String] {
var found: [String: String] = [:]
func walk(_ value: Any) {
if let dict = value as? [String: Any] {
for (key, child) in dict {
if key.hasPrefix("aculix.knotchy."), let text = child as? String { found[key] = text }
walk(child)
}
} else if let array = value as? [Any] {
array.forEach(walk)
}
}
if let object = try? JSONSerialization.jsonObject(with: Data(json.utf8)) { walk(object) }
return found
}
static func staleness(built: String?, now: Date = Date()) -> StaleImage? {
let formatter = ISO8601DateFormatter()
guard let built, let date = formatter.date(from: built) else { return StaleImage(builtDaysAgo: nil) }
let days = Int(now.timeIntervalSince(date) / 86_400)
return days > staleAfterDays ? StaleImage(builtDaysAgo: days) : nil
}
private func prepareRunDirectory() {
let fm = FileManager.default
let path = runDirectory.path
var isDirectory: ObjCBool = false
let attributes = try? fm.attributesOfItem(atPath: path)
let isSymlink = (attributes?[.type] as? FileAttributeType) == .typeSymbolicLink
let owner = (attributes?[.ownerAccountID] as? NSNumber)?.uint32Value
let mode = (attributes?[.posixPermissions] as? NSNumber)?.intValue
if isSymlink || !fm.fileExists(atPath: path, isDirectory: &isDirectory) || !isDirectory.boolValue
|| owner != getuid() || mode != 0o700 {
try? fm.removeItem(atPath: path)
try? fm.createDirectory(atPath: path, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
}
// Secrets left by a crash; a VM's init deletes its own within a second of booting.
let stale = Date().addingTimeInterval(-120)
for name in (try? fm.contentsOfDirectory(atPath: path)) ?? [] where name.hasSuffix(".secrets") || name.hasSuffix(".env") {
let item = runDirectory.appendingPathComponent(name)
if let modified = (try? fm.attributesOfItem(atPath: item.path))?[.modificationDate] as? Date, modified < stale {
try? fm.removeItem(at: item)
}
}
}
// MARK: Start and destroy
public func start(id: String, url: URL, renderSize: PixelSize, blockCIDRs: [String]) async throws -> VMSession {
startsInFlight.withLock { $0 += 1 }
defer { startsInFlight.withLock { $0 -= 1 } }
prepareRunDirectory()
let socket = runDirectory.appendingPathComponent("\(id).sock").path
unlink(socket)
let chosenRoute = route.withLock { $0 }
let launch = try chosenRoute.launch(resolve: Self.resolveIPv4, hostNetworks: blockCIDRs,
allowLocalProxy: localProxyAllowed.withLock { $0 })
var args = [
"run", "-d", "--rm", "--name", id,
"--cpus", "4", "--memory", "2g", "--shm-size", "512m",
"--dns", "1.1.1.1",
"--cap-add", "NET_ADMIN",
// WebKit's bwrap can't mount procfs over a masked /proc, and init writes /proc/sys.
"--masked-path", "NONE", "--read-only-path", "NONE",
"--publish-socket", "\(socket):/tmp/knotchy/knotchy.sock",
"-e", "KNOTCHY_URL=\(url.absoluteString)",
"-e", "KNOTCHY_SIZE=\(renderSize)",
"-e", "KNOTCHY_BLOCK_CIDRS=\(blockCIDRs.joined(separator: " "))",
]
for setting in launch.environment { args += ["-e", setting] }
if !launch.secrets.isEmpty {
// Init reads, deletes and unmounts this (needs SYS_ADMIN), then drops all capabilities.
let share = secretsDirectory(id)
try? FileManager.default.removeItem(at: share)
guard (try? FileManager.default.createDirectory(at: share, withIntermediateDirectories: false,
attributes: [.posixPermissions: 0o700])) != nil,
FileManager.default.createFile(atPath: share.appendingPathComponent("route").path,
contents: Data((launch.secrets.joined(separator: "\n") + "\n").utf8),
attributes: [.posixPermissions: 0o600])
else {
try? FileManager.default.removeItem(at: share)
throw VMBackendError.failed("Couldn't pass the network settings to the VM.")
}
args += ["--cap-add", "SYS_ADMIN", "--mount", "type=bind,source=\(share.path),target=/run/knotchy-secrets"]
}
args.append(Self.image)
let output: CLIRunner.Output
do {
output = try await cli.run(args, timeout: 60)
} catch {
throw VMBackendError.failed("Starting the VM timed out.")
}
guard output.status == 0 else {
cleanUp(id)
throw VMBackendError.failed("Couldn't start the VM: \(output.lastLine)")
}
startedRoute.withLock { $0 = chosenRoute }
return VMSession(id: id, socketPath: socket)
}
public func destroy(id: String) async throws {
_ = try? await cli.run(["stop", "-t", "2", id], timeout: 10)
if await waitUntilGone(id, for: 5) { return cleanUp(id) }
_ = try? await cli.run(["delete", "--force", id], timeout: 10)
if await waitUntilGone(id, for: 5) { return cleanUp(id) }
throw VMBackendError.destroyStuck(id: id)
}
public func forceDestroy(id: String) async throws {
_ = try? await cli.run(["delete", "--force", id], timeout: 10)
if await waitUntilGone(id, for: 5) { return cleanUp(id) }
throw VMBackendError.destroyStuck(id: id)
}
public func sweepOrphans(keeping: Set<String>) async {
guard let listed = try? await cli.run(["ls", "-a", "-q"], timeout: 10) else { return }
for id in Self.ids(in: listed.text) where Self.isSessionID(id) && !keeping.contains(id) {
_ = try? await cli.run(["delete", "--force", id], timeout: 10)
cleanUp(id)
}
}
static func isSessionID(_ id: String) -> Bool {
id.count == 16 && id.hasPrefix("knotchy-") && id.dropFirst(8).allSatisfy(\.isHexDigit)
}
static func ids(in text: String) -> [String] {
text.split(whereSeparator: \.isNewline).map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
}
private func isListed(_ id: String) async -> Bool? {
guard let listed = try? await cli.run(["ls", "-a", "-q"], timeout: 10), listed.status == 0 else { return nil }
return Self.ids(in: listed.text).contains(id)
}
private func waitUntilGone(_ id: String, for seconds: Double) async -> Bool {
let deadline = Date().addingTimeInterval(seconds)
repeat {
if await isListed(id) == false { return true }
try? await Task.sleep(for: .milliseconds(100))
} while Date() < deadline
return false
}
private func cleanUp(_ id: String) {
unlink(runDirectory.appendingPathComponent("\(id).sock").path)
try? FileManager.default.removeItem(at: secretsDirectory(id))
}
private func secretsDirectory(_ id: String) -> URL {
runDirectory.appendingPathComponent("\(id).secrets", isDirectory: true)
}
// MARK: Setup actions
public func fix(_ issue: SetupIssue, output: @escaping @Sendable (String) -> Void) async throws {
switch issue {
case .serviceStopped, .kernelMissing:
let result = try await cli.run(["system", "start", "--enable-kernel-install"], timeout: 120, line: output)
guard result.status == 0 else { throw VMBackendError.failed("Couldn't start the service: \(result.lastLine)") }
case .imageMissing, .imageOutdated, .imageStale:
guard let vmSources else { throw VMBackendError.failed("The VM sources are missing from the app.") }
let now = Date()
let day = DateFormatter()
day.dateFormat = "yyyy-MM-dd"
let args = [
"build", "--cpus", "4", "--memory", "4g", "--dns", "1.1.1.1", "--pull",
"--build-arg", "APT_REFRESH=\(day.string(from: now))",
"--build-arg", "BUILD_DATE=\(ISO8601DateFormatter().string(from: now))",
"-f", vmSources.appendingPathComponent("Containerfile").path,
"-t", Self.image, vmSources.path,
]
let result = try await cli.run(args, timeout: 15 * 60, line: output)
guard result.status == 0 else { throw VMBackendError.failed("The image build failed: \(result.lastLine)") }
case .cliMissing, .cliOutdated, .lowDisk:
throw VMBackendError.failed("There's no automatic fix for this.")
}
}
// MARK: Quit path
public func destroyBlocking(ids: [String], deadline: Duration) {
let end = Date().addingTimeInterval(TimeInterval(deadline.components.seconds))
// Wait for in-flight starts so a VM created at the last moment is destroyed too.
while startsInFlight.withLock({ $0 }) > 0, Date() < end { usleep(50_000) }
for id in ids {
let left = end.timeIntervalSinceNow
guard left > 0.5 else { return }
_ = try? cli.runBlocking(["delete", "--force", id], timeout: min(10, left))
cleanUp(id)
}
}
}
@@ -0,0 +1,35 @@
import Darwin
import Foundation
public enum HostNetworks {
/// Blocked in the VM on top of the private ranges, for LANs and VPNs on public addresses.
public static func current() -> [String] {
var head: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&head) == 0, let first = head else { return [] }
defer { freeifaddrs(head) }
var networks = Set<String>()
for pointer in sequence(first: first, next: { $0.pointee.ifa_next }) {
let interface = pointer.pointee
guard let address = interface.ifa_addr, let mask = interface.ifa_netmask,
address.pointee.sa_family == UInt8(AF_INET),
interface.ifa_flags & UInt32(IFF_LOOPBACK) == 0 else { continue }
let ip = address.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { UInt32(bigEndian: $0.pointee.sin_addr.s_addr) }
let bits = mask.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { UInt32(bigEndian: $0.pointee.sin_addr.s_addr) }
let prefix = bits.nonzeroBitCount
guard prefix >= 8 else { continue } // Never let a bogus /0 block the whole internet.
let network = ip & bits
networks.insert("\(network >> 24).\((network >> 16) & 0xFF).\((network >> 8) & 0xFF).\(network & 0xFF)/\(prefix)")
}
return networks.sorted()
}
}
public struct SystemDiskSpace: DiskSpaceChecking {
public init() {}
@concurrent
public func bytesFree(atPath path: String) async -> Int64? {
let values = try? URL(fileURLWithPath: path).resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
return values?.volumeAvailableCapacityForImportantUsage
}
}
@@ -0,0 +1,245 @@
import Foundation
/// Proxy and VPN routes fail closed: the VM's firewall lets nothing else out.
public enum NetworkRoute: Equatable, Sendable {
case direct
case proxy(ProxySettings)
case wireGuard(WireGuardConfig)
/// Sessions fail to start rather than quietly browsing directly.
case unusable(reason: String)
}
public struct ProxySettings: Equatable, Sendable {
public enum Kind: String, CaseIterable, Sendable {
case http
case socks5
}
public var kind: Kind
public var host: String
public var port: Int
public var username: String
public var password: String
public init(kind: Kind, host: String, port: Int, username: String = "", password: String = "") {
self.kind = kind
self.host = host
self.port = port
self.username = username
self.password = password
}
public func url(address: String) -> String {
var userinfo = ""
if !username.isEmpty {
userinfo = Self.encode(username) + (password.isEmpty ? "" : ":" + Self.encode(password)) + "@"
}
return "\(kind.rawValue)://\(userinfo)\(address):\(port)"
}
/// Must match the VM firewall's list.
static let localRanges = ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
"172.16.0.0/12", "192.168.0.0/16", "198.18.0.0/15"]
/// A local proxy is refused: pages could reach the Mac and its network through it.
public static func isLocal(_ address: String, hostNetworks: [String]) -> Bool {
guard let ip = ipv4Value(address) else { return true }
return (localRanges + hostNetworks).contains { cidr in
let parts = cidr.split(separator: "/")
guard parts.count == 2, let network = ipv4Value(String(parts[0])), let bits = Int(parts[1]), (0...32).contains(bits)
else { return false }
let mask: UInt32 = bits == 0 ? 0 : ~UInt32(0) << (32 - bits)
return ip & mask == network & mask
}
}
static func ipv4Value(_ text: String) -> UInt32? {
guard WireGuardConfig.isIPv4(text) else { return nil }
return text.split(separator: ".").reduce(UInt32(0)) { $0 << 8 | UInt32($1)! }
}
static func encode(_ text: String) -> String {
let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
return text.addingPercentEncoding(withAllowedCharacters: unreserved) ?? ""
}
}
/// IPv4 only (the VM has IPv6 off). All traffic uses the tunnel, whatever AllowedIPs says.
public struct WireGuardConfig: Equatable, Sendable {
public var privateKey: String
public var addresses: [String]
/// Empty means 1.1.1.1 through the tunnel.
public var dns: [String]
public var mtu: Int?
public var peerPublicKey: String
public var presharedKey: String?
public var endpointHost: String
public var endpointPort: Int
public var persistentKeepalive: Int?
public var name: String?
public enum ParseError: Error, Equatable, Sendable {
case missing(String)
case invalid(String)
case multiplePeers
}
public static func parse(_ text: String) throws(ParseError) -> WireGuardConfig {
var section = ""
var interface: [String: String] = [:]
var peers: [[String: String]] = []
for rawLine in text.split(whereSeparator: \.isNewline) {
let line = rawLine.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)[0]
.trimmingCharacters(in: .whitespaces)
if line.isEmpty { continue }
if line.hasPrefix("[") {
section = line.lowercased()
if section == "[peer]" { peers.append([:]) }
continue
}
guard let equals = line.firstIndex(of: "=") else { throw .invalid(line) }
let key = line[..<equals].trimmingCharacters(in: .whitespaces).lowercased()
let value = line[line.index(after: equals)...].trimmingCharacters(in: .whitespaces)
switch section {
case "[interface]":
interface[key] = interface[key].map { $0 + "," + value } ?? value
case "[peer]":
peers[peers.count - 1][key] = value
default:
throw .invalid(line)
}
}
guard peers.count <= 1 else { throw .multiplePeers }
guard let peer = peers.first else { throw .missing("[Peer]") }
guard let privateKey = interface["privatekey"] else { throw .missing("PrivateKey") }
guard isKey(privateKey) else { throw .invalid("PrivateKey") }
guard let publicKey = peer["publickey"] else { throw .missing("PublicKey") }
guard isKey(publicKey) else { throw .invalid("PublicKey") }
let presharedKey = peer["presharedkey"]
if let presharedKey, !isKey(presharedKey) { throw .invalid("PresharedKey") }
let addresses = list(interface["address"]).compactMap(ipv4CIDR)
guard !addresses.isEmpty else { throw .missing("an IPv4 Address") }
let dns = list(interface["dns"]).filter(isIPv4)
let mtu = try interface["mtu"].map { (text: String) throws(ParseError) -> Int in
guard let mtu = Int(text), (576...9000).contains(mtu) else { throw .invalid("MTU") }
return mtu
}
let keepalive = try peer["persistentkeepalive"].flatMap { (text: String) throws(ParseError) -> Int? in
if text.lowercased() == "off" { return nil }
guard let seconds = Int(text), (0...65535).contains(seconds) else { throw .invalid("PersistentKeepalive") }
return seconds == 0 ? nil : seconds
}
guard let endpoint = peer["endpoint"] else { throw .missing("Endpoint") }
guard !endpoint.hasPrefix("["), let colon = endpoint.lastIndex(of: ":"),
let port = Int(endpoint[endpoint.index(after: colon)...]), (1...65535).contains(port)
else { throw .invalid("Endpoint (an IPv4 address or host name, and a port)") }
let host = String(endpoint[..<colon])
guard !host.isEmpty, host.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "." || $0 == "-" }) else {
throw .invalid("Endpoint")
}
return WireGuardConfig(privateKey: privateKey, addresses: addresses, dns: dns, mtu: mtu,
peerPublicKey: publicKey, presharedKey: presharedKey,
endpointHost: host, endpointPort: port, persistentKeepalive: keepalive)
}
public func setconf(endpointAddress address: String) -> String {
var lines = ["[Interface]", "PrivateKey = \(privateKey)", "", "[Peer]", "PublicKey = \(peerPublicKey)"]
if let presharedKey { lines.append("PresharedKey = \(presharedKey)") }
lines.append("Endpoint = \(address):\(endpointPort)")
lines.append("AllowedIPs = 0.0.0.0/0")
lines.append("PersistentKeepalive = \(persistentKeepalive ?? 25)")
return lines.joined(separator: "\n") + "\n"
}
// MARK: Validation
/// Curve25519: 32 bytes in base64.
static func isKey(_ text: String) -> Bool {
text.count == 44 && Data(base64Encoded: text)?.count == 32
}
static func list(_ text: String?) -> [String] {
(text ?? "").split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
}
static func isIPv4(_ text: String) -> Bool {
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
return parts.count == 4 && parts.allSatisfy { part in
!part.isEmpty && part.count <= 3 && part.allSatisfy(\.isASCII) && part.allSatisfy(\.isNumber)
&& Int(part).map { $0 <= 255 } == true
}
}
static func ipv4CIDR(_ text: String) -> String? {
let parts = text.split(separator: "/", omittingEmptySubsequences: false)
guard parts.count <= 2, isIPv4(String(parts[0])) else { return nil }
guard parts.count == 2 else { return text + "/32" }
guard let prefix = Int(parts[1]), (0...32).contains(prefix), parts[1].allSatisfy(\.isNumber) else { return nil }
return text
}
}
extension NetworkRoute {
public var label: String? {
switch self {
case .direct, .unusable: nil
case .proxy(let proxy): proxy.kind == .socks5 ? "SOCKS5 proxy" : "HTTP proxy"
case .wireGuard(let config): config.name ?? "VPN"
}
}
}
extension WireGuardConfig.ParseError {
/// Finishes "The WireGuard config …".
public var message: String {
switch self {
case .missing(let what): "has no \(what)"
case .invalid(let what): "has an invalid \(what)"
case .multiplePeers: "has more than one peer; Knotchy needs one server per config"
}
}
}
public struct RouteLaunch: Equatable, Sendable {
/// Always includes KNOTCHY_ROUTE, so a VM missing its proxy or VPN settings stops.
public var environment: [String]
/// Never passed with `-e`: `container` saves the environment to disk and shows it in `inspect`.
public var secrets: [String]
}
extension NetworkRoute {
public func launch(resolve: (String) throws -> String, hostNetworks: [String] = [],
allowLocalProxy: Bool = false) throws -> RouteLaunch {
switch self {
case .direct:
return RouteLaunch(environment: ["KNOTCHY_ROUTE=direct"], secrets: [])
case .unusable(let reason):
throw VMBackendError.failed(reason)
case .proxy(let proxy):
let address = try resolve(proxy.host)
if !allowLocalProxy, ProxySettings.isLocal(address, hostNetworks: hostNetworks) {
let which = proxy.host == address ? address : "\(proxy.host) is \(address)"
throw VMBackendError.failed(
"Knotchy won't use a proxy on this Mac or your local network (\(which)): pages could reach your network through it.")
}
return RouteLaunch(environment: ["KNOTCHY_ROUTE=proxy", "KNOTCHY_PROXY_ADDR=\(address):\(proxy.port)"],
secrets: ["KNOTCHY_PROXY_URL=\(proxy.url(address: address))"])
case .wireGuard(let config):
let address = try resolve(config.endpointHost)
return RouteLaunch(
environment: [
"KNOTCHY_ROUTE=wireguard",
"KNOTCHY_WG_ENDPOINT=\(address):\(config.endpointPort)",
"KNOTCHY_WG_ADDRESS=\(config.addresses.joined(separator: " "))",
"KNOTCHY_WG_DNS=\(config.dns.joined(separator: " "))",
"KNOTCHY_WG_MTU=\(config.mtu ?? 1420)",
],
secrets: ["KNOTCHY_WG_SETCONF=\(Data(config.setconf(endpointAddress: address).utf8).base64EncodedString())"]
)
}
}
}
@@ -0,0 +1,65 @@
import Foundation
/// The controller serializes all calls except `destroyBlocking`, which the quit path may make
/// from any thread while an async call is still running.
public protocol VMBackend: Sendable {
func preflight() async -> PreflightResult
/// Must register as in flight before its first suspension so `destroyBlocking` waits for it.
func start(id: String, url: URL, renderSize: PixelSize, blockCIDRs: [String]) async throws -> VMSession
/// Returns only once the VM is confirmed gone; otherwise throws `.destroyStuck`.
func destroy(id: String) async throws
func forceDestroy(id: String) async throws
func sweepOrphans(keeping: Set<String>) async
func fix(_ issue: SetupIssue, output: @escaping @Sendable (String) -> Void) async throws
/// Not serialized by the controller: `container --version` doesn't use the service.
func runtimeStatus() async -> RuntimeStatus
func stopService() async throws
/// Blocking. Waits for in-flight starts first so a VM created at the last moment is destroyed too.
func destroyBlocking(ids: [String], deadline: Duration)
}
public struct VMSession: Equatable, Sendable {
public let id: String
public let socketPath: String
public init(id: String, socketPath: String) {
self.id = id
self.socketPath = socketPath
}
}
public enum SetupIssue: Equatable, Sendable {
case cliMissing
case cliOutdated(found: String)
case serviceStopped
case kernelMissing
case imageMissing
case imageOutdated(found: Int, need: Int)
case imageStale(builtDaysAgo: Int?)
case lowDisk(bytesFree: Int64)
}
public enum PreflightResult: Equatable, Sendable {
case ready(appRoot: String, staleImage: StaleImage?)
/// Never `.imageStale` (that rides on `.ready`) or `.lowDisk` (the controller checks disk).
case issue(SetupIssue)
}
public struct StaleImage: Equatable, Sendable {
public var builtDaysAgo: Int?
public init(builtDaysAgo: Int?) {
self.builtDaysAgo = builtDaysAgo
}
}
public enum VMBackendError: Error, Equatable, Sendable {
case destroyStuck(id: String)
/// The message is shown to the user.
case failed(String)
}
public protocol DiskSpaceChecking: Sendable {
/// Async: the query can block for tens of milliseconds and stall the panel's opening animation.
func bytesFree(atPath path: String) async -> Int64?
}
@@ -0,0 +1,117 @@
import CoreGraphics
/// Rects are in global screen coordinates (AppKit's: origin bottom left, y up).
public struct ScreenInfo: Equatable, Sendable {
public var frame: CGRect
public var visibleFrame: CGRect
public var safeAreaTop: CGFloat
public var auxLeft: CGRect
public var auxRight: CGRect
public var menuBarThickness: CGFloat
public init(frame: CGRect, visibleFrame: CGRect, safeAreaTop: CGFloat,
auxLeft: CGRect, auxRight: CGRect, menuBarThickness: CGFloat) {
self.frame = frame
self.visibleFrame = visibleFrame
self.safeAreaTop = safeAreaTop
self.auxLeft = auxLeft
self.auxRight = auxRight
self.menuBarThickness = menuBarThickness
}
public var hasNotch: Bool {
safeAreaTop > 0 && !auxLeft.isEmpty && !auxRight.isEmpty
}
}
public enum PanelMode: Sendable {
case peek
case expanded
}
/// Global screen coordinates, y up; convert them for SwiftUI views and tracking areas.
public struct PanelLayout: Equatable, Sendable {
public var notch: CGRect
public var pill: CGRect
/// Full size for its mode so the shape never clips; a Peek↔Expanded animation needs the larger.
public var panel: CGRect
public var toolbar: CGRect
public var content: CGRect
public var contentScale: CGFloat
}
public enum NotchGeometry {
public static let toolbarHeight: CGFloat = 34
public static let margin: CGFloat = 6
public static let peekContent = CGSize(width: 640, height: 400)
public static let expandedContent = CGSize(width: 1024, height: 640)
public static let renderWidth: CGFloat = 1024
public static let virtualNotchWidth: CGFloat = 200
public static let minimumNotchHeight: CGFloat = 24
public static let notchWidthRange: ClosedRange<CGFloat> = 120...300
public static let pillEarWidth: CGFloat = 72
public static let screenInset: CGFloat = 8
public static let minimumContentHeight: CGFloat = 50
public static func layout(for screen: ScreenInfo, mode: PanelMode) -> PanelLayout {
let notch = notchRect(for: screen)
let height = notch.height
let base = mode == .peek ? peekContent : expandedContent
// Ignores a side Dock: the panel may cover its top.
let maxWidth = screen.frame.width - 2 * screenInset - 2 * margin
let maxHeight = (screen.frame.maxY - screen.visibleFrame.minY) - height - toolbarHeight - margin - screenInset
// Keep exactly 16:10 in whole points: the height is a multiple of 5, the width 8/5 of it.
let fittingHeight = min(base.height, maxHeight, maxWidth * 5 / 8)
let contentHeight = max(minimumContentHeight, (fittingHeight / 5).rounded(.down) * 5)
let contentWidth = contentHeight * 8 / 5
let panelWidth = contentWidth + 2 * margin
let panelHeight = height + toolbarHeight + contentHeight + margin
let centeredX = (notch.midX - panelWidth / 2).rounded()
let panelX = min(max(centeredX, screen.frame.minX), screen.frame.maxX - panelWidth)
let panel = CGRect(x: panelX, y: screen.frame.maxY - panelHeight, width: panelWidth, height: panelHeight)
return PanelLayout(
notch: notch,
pill: notch.insetBy(dx: -pillEarWidth, dy: 0),
panel: panel,
toolbar: CGRect(x: panel.minX + margin, y: panel.maxY - height - toolbarHeight,
width: contentWidth, height: toolbarHeight),
content: CGRect(x: panel.minX + margin, y: panel.minY + margin,
width: contentWidth, height: contentHeight),
contentScale: contentWidth / renderWidth
)
}
/// `point` is in a flipped view. Nil on degenerate input: drop the event rather than click at (0, 0).
public static func videoPoint(_ point: CGPoint, surfaceSize: CGSize, renderSize: PixelSize) -> (x: UInt16, y: UInt16)? {
let pixels = 1...65536
guard point.x.isFinite, point.y.isFinite, surfaceSize.width.isFinite, surfaceSize.height.isFinite,
surfaceSize.width > 0, surfaceSize.height > 0,
pixels.contains(renderSize.width), pixels.contains(renderSize.height)
else { return nil }
let video = CGSize(width: renderSize.width, height: renderSize.height)
let scale = min(surfaceSize.width / video.width, surfaceSize.height / video.height)
guard scale > 0 else { return nil }
let originX = (surfaceSize.width - video.width * scale) / 2
let originY = (surfaceSize.height - video.height * scale) / 2
let x = min(max((point.x - originX) / scale, 0), video.width - 1)
let y = min(max((point.y - originY) / scale, 0), video.height - 1)
return (UInt16(x), UInt16(y))
}
private static func notchRect(for screen: ScreenInfo) -> CGRect {
if screen.hasNotch {
let left = screen.auxLeft.maxX
let right = screen.auxRight.minX
let width = min(max(right - left, notchWidthRange.lowerBound), notchWidthRange.upperBound)
return CGRect(x: (left + right) / 2 - width / 2, y: screen.frame.maxY - screen.safeAreaTop,
width: width, height: screen.safeAreaTop)
}
// The minimum keeps a virtual notch visible when the menu bar auto-hides.
let height = max(screen.frame.maxY - screen.visibleFrame.maxY, screen.menuBarThickness, minimumNotchHeight)
return CGRect(x: (screen.frame.midX - virtualNotchWidth / 2).rounded(), y: screen.frame.maxY - height,
width: virtualNotchWidth, height: height)
}
}
+131
View File
@@ -0,0 +1,131 @@
import Foundation
/// Input is untrusted and scanned inside the synchronous Services call, so work is linear and capped.
public enum LinkIntake {
/// UTF-8 bytes. Links reach the VM in an env var and in NAVIGATE messages (64 KiB limit).
public static let maxURLBytes = 16 * 1024
public static let maxScannedCharacters = 1024 * 1024
/// The first http(s) URL in `text`, without surrounding prose punctuation, quotes or Markdown.
public static func extractURL(from text: String) -> URL? {
let startLimit = text.index(text.startIndex, offsetBy: maxScannedCharacters, limitedBy: text.endIndex)
?? text.endIndex
// One past the limit, so an over-long link is rejected rather than truncated.
let readLimit = text.index(startLimit, offsetBy: maxURLBytes + 1, limitedBy: text.endIndex) ?? text.endIndex
var from = text.startIndex
while from < startLimit,
let match = text.range(of: "http", options: .caseInsensitive, range: from..<startLimit) {
let end = text[match.lowerBound..<readLimit].firstIndex(where: endsURL) ?? readLimit
let token = text[match.lowerBound..<end]
let before = match.lowerBound > text.startIndex ? text[text.index(before: match.lowerBound)] : nil
var parts = [(token, afterParenthesis: before == "(")]
if let markdown = token.range(of: "](") {
parts = [(token[..<markdown.lowerBound], before == "("), (token[markdown.upperBound...], true)]
}
for (whole, afterParenthesis) in parts {
let part = afterParenthesis ? upToUnbalancedClosingParenthesis(whole) : whole
let candidate = trimmingTrailingPunctuation(part, stripping: before == "*" ? ".,;:!?\"'*" : ".,;:!?\"'")
if candidate.utf8.count <= maxURLBytes,
let url = URL(string: String(candidate)),
let valid = validate(url) {
return valid
}
}
from = end
}
return nil
}
/// Tries `href` targets first: a selected hyperlink's visible text rarely contains its address.
public static func extractURL(fromHTML html: String) -> URL? {
let text = String(html.prefix(maxScannedCharacters))
let range = NSRange(text.startIndex..., in: text)
for match in hrefPattern.matches(in: text, range: range) {
for group in 1...3 {
guard let found = Range(match.range(at: group), in: text) else { continue }
let target = decodingEntities(text[found]).trimmingCharacters(in: .whitespacesAndNewlines)
if target.utf8.count <= maxURLBytes, let url = URL(string: target), let valid = validate(url) {
return valid
}
}
}
return extractURL(from: text)
}
private static let hrefPattern = try! NSRegularExpression(
pattern: #"href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))"#, options: [.caseInsensitive])
private static func decodingEntities(_ value: Substring) -> String {
var result = String(value)
for (entity, character) in [("&quot;", "\""), ("&#34;", "\""), ("&#39;", "'"), ("&apos;", "'"),
("&lt;", "<"), ("&gt;", ">"), ("&#38;", "&"), ("&amp;", "&")] {
result = result.replacingOccurrences(of: entity, with: character, options: .caseInsensitive)
}
return result
}
public static func validate(_ url: URL) -> URL? {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https",
let host = url.host(percentEncoded: false), !host.isEmpty,
url.absoluteString.utf8.count <= maxURLBytes
else { return nil }
return url
}
/// Missed by the category check. IDNA would turn these full stops into dots in the host.
private static let cjkPunctuation: Set<Character> = ["。", ".", "。", "、", ",", "…", "!", "?", ":", ";"]
private static func endsURL(_ c: Character) -> Bool {
if c.isWhitespace || c == "<" || c == ">" || c == "\"" || c == "`" { return true }
guard let scalar = c.unicodeScalars.first, !scalar.isASCII else { return false }
switch scalar.properties.generalCategory {
case .initialPunctuation, .finalPunctuation, .openPunctuation, .closePunctuation:
return true
default:
return cjkPunctuation.contains(c)
}
}
private static func upToUnbalancedClosingParenthesis(_ part: Substring) -> Substring {
var depth = 0
for i in part.indices {
switch part[i] {
case "(":
depth += 1
case ")":
if depth == 0 { return part[..<i] }
depth -= 1
default:
break
}
}
return part
}
private static let openerOf: [Character: Character] = [")": "(", "]": "[", "}": "{"]
private static let closerOf: [Character: Character] = ["(": ")", "[": "]", "{": "}"]
private static func trimmingTrailingPunctuation(_ token: Substring, stripping punctuation: String) -> Substring {
// Closing minus opening brackets, keyed by the closing one.
var unbalanced: [Character: Int] = [:]
for c in token {
if openerOf[c] != nil {
unbalanced[c, default: 0] += 1
} else if let closer = closerOf[c] {
unbalanced[closer, default: 0] -= 1
}
}
var s = token
while let last = s.last {
if punctuation.contains(last) {
s.removeLast()
} else if openerOf[last] != nil, unbalanced[last, default: 0] > 0 {
unbalanced[last, default: 0] -= 1
s.removeLast()
} else {
break
}
}
return s
}
}
@@ -0,0 +1,49 @@
import Synchronization
/// Runs async jobs one at a time, in order, so backend operations never overlap.
@MainActor
final class BackendChain {
private var tail: Task<Void, Never>?
private(set) var isClosed = false
func enqueue(_ job: @escaping @MainActor () async -> Void) {
guard !isClosed else { return }
let previous = tail
tail = Task {
await previous?.value
guard !self.isClosed else { return }
await job()
}
}
func close() {
isClosed = true
}
func waitUntilIdle() async {
while let current = tail {
await current.value
if tail == current { return }
}
}
}
/// Every VM ID from its `start` being queued until its destroy is confirmed.
/// Lock-protected: the quit path reads it off the main actor.
public final class LiveIDs: Sendable {
private let ids = Mutex<Set<String>>([])
public init() {}
public var snapshot: Set<String> {
ids.withLock { $0 }
}
func insert(_ id: String) {
_ = ids.withLock { $0.insert(id) }
}
func remove(_ id: String) {
_ = ids.withLock { $0.remove(id) }
}
}
@@ -0,0 +1,956 @@
import Foundation
import Observation
/// The single-session state machine behind the notch UI. Inputs never block; backend calls run serially.
@MainActor
@Observable
public final class SessionController {
// MARK: - Timings and limits
public static let renderSize = PixelSize(width: 1024, height: 640)
public static let foldGrace: Duration = .milliseconds(400)
public static let neverEnteredFoldDelay: Duration = .seconds(30)
public static let defaultIdleDestroyDelay: Duration = .seconds(5 * 60)
public static let idleQuitDelay: Duration = .seconds(60)
public static let destroyedDisplay: Duration = .milliseconds(1500)
public static let endedDisplay: Duration = .seconds(3)
public static let destroyStuckDisplay: Duration = .seconds(30)
public static let noticeDisplay: Duration = .seconds(2)
public static let progressLinger: Duration = .seconds(2)
public static let minimumFreeBytes: Int64 = 2_000_000_000
public static let setupOutputLimit = 200
public static let stillBrokenMessage = "Setup finished, but the runtime still reports a problem."
// MARK: - Outputs
public private(set) var state: SessionState = .idle
// MARK: - Preferences
/// Takes effect the next time a session folds.
public var idleDestroyDelay: Duration = SessionController.defaultIdleDestroyDelay
public var opensExpanded = false
public var quitsWhenIdle = true
/// Opens the setup window; the app sets it.
@ObservationIgnored public var showSetup: @MainActor () -> Void = {}
public private(set) var setupFolded = false
@ObservationIgnored private var setupOpenedByHover = false
public private(set) var notice: Notice?
public private(set) var progress: Int?
public private(set) var openedURL: URL?
/// Reported by the VM, so untrusted: shown, never opened or retried.
public private(set) var pageURL: URL?
public private(set) var setupLog: String?
/// The latest setup action's last lines, for the setup window's details.
public private(set) var setupOutput: [String] = []
public private(set) var connection: (any StreamConnection)?
public private(set) var staleImage: StaleImage?
public private(set) var idleDeadline: ContinuousClock.Instant?
public var countdown: Duration? {
idleDeadline.map { max(.zero, $0 - clock.now) }
}
public nonisolated let liveIDs = LiveIDs()
// MARK: - Dependencies
private let backend: any VMBackend
private let connector: any StreamConnector
private let diskSpace: any DiskSpaceChecking
private let clock: any KnotchyClock
private let hostNetworks: () -> [String]
private let requestQuit: @MainActor () -> Void
private let makeSessionID: () -> String
// MARK: - Internal state
@MainActor
private final class Session {
let id: String
let url: URL
/// `backend.start` has been called, so a VM may exist.
var startBegan = false
var retired = false
var contentReady = false
var connection: (any StreamConnection)?
var tasks: [Task<Void, Never>] = []
init(id: String, url: URL) {
self.id = id
self.url = url
}
}
private enum TimerKey: Hashable, Sendable {
case foldGrace, neverEnteredFold, idleDestroy, transient, notice, progressHide, idleQuit
}
private enum RunMode: Equatable {
/// From the notch or the menu: stops at the next card, as before.
case link
/// From the setup window.
case window(continuing: Bool)
/// The setup window's check: starts the service, never builds.
case check
}
private struct SetupRun {
var mode: RunMode
/// The loop guard: each issue is fixed at most once per run.
var fixed: [SetupIssue]
var waiter: CheckedContinuation<SetupRunOutcome, Never>?
}
private let chain = BackendChain()
private let timers: DeadlineTimers<TimerKey>
// Invariants: at most one session isn't retired, and it is `current`; only `transition(to:)`
// writes `state` (and owns its timers); while `settingUp`, only the setup outcome changes it.
@ObservationIgnored private var current: Session?
@ObservationIgnored private var pendingURL: URL?
/// IDs whose destroy failed; they stay in `liveIDs`.
@ObservationIgnored private var pendingCleanup: [String] = []
@ObservationIgnored private var requestedImageUpdate: StaleImage?
@ObservationIgnored private var lastProgressAt: ContinuousClock.Instant?
@ObservationIgnored private var entered = false
@ObservationIgnored private var pointerInside = false
@ObservationIgnored private var focused = false
@ObservationIgnored private var run: SetupRun?
@ObservationIgnored private var settleWaiters: [CheckedContinuation<Void, Never>] = []
/// The issues started automatically for the link now waiting; each is tried once per request.
@ObservationIgnored private var autoFixed: (url: URL, issues: [SetupIssue])?
public init(
backend: any VMBackend,
connector: any StreamConnector,
diskSpace: any DiskSpaceChecking,
clock: any KnotchyClock = SystemClock(),
hostNetworks: @escaping () -> [String],
requestQuit: @escaping @MainActor () -> Void,
makeSessionID: @escaping () -> String = SessionController.randomSessionID
) {
self.backend = backend
self.connector = connector
self.diskSpace = diskSpace
self.clock = clock
self.hostNetworks = hostNetworks
self.requestQuit = requestQuit
self.makeSessionID = makeSessionID
timers = DeadlineTimers(clock: clock)
timers.onFire = { [weak self] key in self?.timerFired(key) }
enqueue { [backend] in await backend.sweepOrphans(keeping: []) }
timers.set(.idleQuit, after: Self.idleQuitDelay)
}
/// `knotchy-<8 hex>`: the only names the launch sweep deletes.
public nonisolated static func randomSessionID() -> String {
"knotchy-" + String(format: "%08x", UInt32.random(in: .min ... .max))
}
// MARK: - Links
/// A new link always wins. Anything but http/https shows "No web link found".
public func open(_ url: URL) {
guard let url = LinkIntake.validate(url) else { return show(.noWebLink) }
guard !chain.isClosed else { return }
autoFixed = nil
if case .settingUp = state {
pendingURL = url
setupFolded = false
} else {
startSession(url)
}
}
public func open(text: String) {
guard let url = LinkIntake.extractURL(from: text) else { return show(.noWebLink) }
open(url)
}
// MARK: - Toolbar
public func navigate(_ url: URL) {
guard let url = LinkIntake.validate(url) else { return show(.noWebLink) }
guard case .live = state, let connection else { return }
openedURL = url
pageURL = nil
connection.send(.navigate(url.absoluteString))
}
public func goBack() { runScript("history.back()") }
public func goForward() { runScript("history.forward()") }
public func reload() { runScript("location.reload()") }
private func runScript(_ javaScript: String) {
guard case .live = state else { return }
connection?.send(.script(javaScript))
}
// MARK: - Panel
public func expand() {
guard state == .live(.peek) else { return }
entered = true
focused = true
transition(to: .live(.expanded))
}
public func shrink() {
guard state == .live(.expanded) else { return }
transition(to: .live(.peek))
}
public func pointerEntered() {
pointerInside = true
timers.cancel(.foldGrace)
switch state {
case .settingUp:
if setupFolded { setupOpenedByHover = true }
setupFolded = false
case .starting(let step, folded: true):
unfold(to: .starting(step, folded: false))
case .live(.collapsed):
unfold(to: .live(.peek))
case .starting, .live:
entered = true
timers.cancel(.neverEnteredFold)
default:
break
}
}
public func pointerExited() {
pointerInside = false
if isPeekLike, entered, !focused {
timers.set(.foldGrace, after: Self.foldGrace)
}
if case .settingUp = state, setupOpenedByHover {
timers.set(.foldGrace, after: Self.foldGrace)
}
}
public func panelClicked() {
guard isOpenPanel else { return }
entered = true
focused = true
timers.cancel(.foldGrace)
timers.cancel(.neverEnteredFold)
}
public func clickedOutside() {
if isOpenPanel { fold() }
if case .settingUp = state { setupFolded = true }
}
public func foldSetup() {
if case .settingUp = state { setupFolded = true }
setupOpenedByHover = false
}
private var isPeekLike: Bool {
switch state {
case .starting(_, folded: false), .live(.peek): true
default: false
}
}
private var isOpenPanel: Bool {
isPeekLike || state == .live(.expanded)
}
private var isFolded: Bool {
if case .starting(_, folded: true) = state { true } else { false }
}
private func fold() {
focused = false
pointerInside = false
switch state {
case .starting(let step, folded: false):
transition(to: .starting(step, folded: true))
case .live(.peek), .live(.expanded):
transition(to: .live(.collapsed))
default:
break
}
}
private func unfold(to newState: SessionState) {
entered = true
focused = false
transition(to: newState)
}
// MARK: - Destroy, retry, dismiss
public func destroy() {
guard let session = current else { return }
switch state {
case .starting, .live:
transition(to: .destroying)
retire(session) { [weak self] gone in self?.destroyFinished(session, gone: gone) }
default:
break
}
}
public func retry() {
switch state {
case .failed(.start):
autoFixed = nil // A retry is a new request: it gets its own automatic start.
if let url = current?.url { startSession(url, pointerStays: true) }
case .failed(.destroyStuck):
guard let session = current else { return }
pendingCleanup.removeAll { $0 == session.id }
transition(to: .destroying)
enqueue { [weak self] in
await self?.runDestroy(session) { gone in self?.destroyFinished(session, gone: gone) }
}
default:
break
}
}
public func dismiss() {
switch state {
case .failed(.start):
enterIdle()
case .setupRequired:
pendingURL = nil
enterIdle()
default:
break
}
}
// MARK: - Setup
public func performSetupAction() {
guard case .setupRequired(let issue, _) = state else { return }
switch issue {
case .cliMissing, .cliOutdated:
showSetup()
case .lowDisk:
break
default:
startRun(issue, mode: .link, folded: false)
}
}
/// Rebuilds the image once this session ends.
public func requestImageUpdate() {
guard let stale = staleImage else { return }
staleImage = nil
requestedImageUpdate = stale
}
public func rebuildImage() {
switch state {
case .idle:
startRun(.imageStale(builtDaysAgo: nil), mode: .link, folded: false)
case .settingUp:
break
default:
staleImage = nil
requestedImageUpdate = StaleImage(builtDaysAgo: nil)
}
}
public func showPanel() {
switch state {
case .settingUp:
setupFolded = false
setupOpenedByHover = false
case .starting(let step, folded: true): unfold(to: .starting(step, folded: false))
case .live(.collapsed): unfold(to: .live(.peek))
default: break
}
}
public func dismissStaleImageWarning() {
staleImage = nil
}
/// Quit path: queued backend jobs never start after this. One already running may still call
/// the backend, which is safe because `destroyBlocking` then blocks the main thread until exit.
public func closeBackendChain() {
chain.close()
}
// MARK: - Setup window
/// Runs `issue`'s fix folded into the notch; when `continuing`, also each fixable issue preflight
/// finds next, each at most once. A waiting link opens only once everything is ready. nil when busy.
public func runSetup(_ issue: SetupIssue, continuing: Bool) async -> SetupRunOutcome? {
guard acceptsSetupWindow else { return nil }
return await withCheckedContinuation { continuation in
startRun(issue, mode: .window(continuing: continuing), folded: true, waiter: continuation)
}
}
/// Preflight for the setup window: starts a stopped service or missing kernel, never builds. nil when busy.
public func checkSetup() async -> SetupRunOutcome? {
guard acceptsSetupWindow else { return nil }
let preflight = await onChain { [backend] in await backend.preflight() }
guard acceptsSetupWindow else { return nil }
switch preflight {
case .ready:
// Fixed outside Knotchy while a card held a link: open it.
if case .setupRequired = state, let url = pendingURL { startSession(url, pointerStays: true) }
return .ready
case .issue(let issue) where Self.fixes(issue, in: .check):
return await withCheckedContinuation { continuation in
startRun(issue, mode: .check, folded: true, waiter: continuation)
}
case .issue(let issue):
return .needs(issue)
}
}
/// Around a runtime install: links wait, and an update stops the service first.
public func beginRuntimeInstall(replacing found: String?) async -> Result<Void, RuntimeHoldRefusal> {
guard acceptsSetupWindow else {
if case .settingUp = state { return .failure(.setupRunning) }
return .failure(.sessionOpen)
}
setupLog = nil
setupOutput = []
transition(to: .settingUp(found.map { .cliOutdated(found: $0) } ?? .cliMissing))
setupFolded = true
guard found != nil else { return .success(()) }
let stopped: String? = await onChain { [backend] in
do {
try await backend.stopService()
return nil
} catch {
return Self.message(for: error)
}
}
guard let stopped else { return .success(()) }
_ = await finishRuntimeInstall(.failed(stopped))
return .failure(.stopFailed(stopped))
}
/// After an install, starts the service and builds, like `runSetup(continuing: true)`.
public func finishRuntimeInstall(_ result: RuntimeInstallResult) async -> SetupRunOutcome {
guard case .settingUp(let issue) = state, Self.isRuntimeInstall(issue), run == nil else {
return .failed(.cliMissing, "No runtime install was in progress.")
}
switch result {
case .installed:
return await withCheckedContinuation { continuation in
startRun(.serviceStopped, mode: .window(continuing: true), folded: false, waiter: continuation)
}
case .cancelled:
endHold(issue, error: nil)
return .needs(issue)
case .failed(let message):
endHold(issue, error: message)
return .failed(issue, message)
}
}
private func endHold(_ issue: SetupIssue, error: String?) {
if pendingURL != nil { transition(to: .setupRequired(issue, error: error)) } else { enterIdle() }
}
private static func isRuntimeInstall(_ issue: SetupIssue) -> Bool {
switch issue {
case .cliMissing, .cliOutdated: true
default: false
}
}
public func waitWhileSettingUp() async {
guard case .settingUp = state else { return }
await withCheckedContinuation { settleWaiters.append($0) }
}
public func runtimeStatus() async -> RuntimeStatus {
await backend.runtimeStatus()
}
/// The states where no VM exists. A stuck destroy may still have one.
private var acceptsSetupWindow: Bool {
switch state {
case .idle, .setupRequired, .failed(.start), .failed(.ended), .destroyed: true
default: false
}
}
/// After `closeBackendChain()` the job never runs and this never returns; that only happens while quitting.
private func onChain<T: Sendable>(_ job: @escaping @MainActor () async -> T) async -> T {
await withCheckedContinuation { continuation in
enqueue { continuation.resume(returning: await job()) }
}
}
// MARK: - Session lifecycle
private func startSession(_ url: URL, pointerStays: Bool = false) {
let pointerOnPanel = pointerStays && pointerInside
let startsFolded: Bool
if case .settingUp = state { startsFolded = setupFolded } else { startsFolded = false }
if let old = current { retire(old) }
let session = Session(id: makeSessionID(), url: url)
current = session
pendingURL = nil
setupLog = nil
openedURL = url
pageURL = nil
connection = nil
staleImage = nil
progress = nil
lastProgressAt = nil
entered = pointerOnPanel
pointerInside = pointerOnPanel
focused = false
for key in [TimerKey.foldGrace, .neverEnteredFold, .progressHide] { timers.cancel(key) }
liveIDs.insert(session.id)
transition(to: .starting(.booting, folded: startsFolded))
enqueue { [weak self] in await self?.runStart(session) }
}
private func retire(_ session: Session, onGone: (@MainActor (Bool) -> Void)? = nil) {
guard !session.retired else { return }
session.retired = true
for task in session.tasks { task.cancel() }
session.connection?.close()
session.connection = nil
if current === session {
connection = nil
progress = nil
timers.cancel(.progressHide)
}
enqueue { [weak self] in await self?.runDestroy(session, onGone: onGone) }
}
private func runStart(_ session: Session) async {
guard !session.retired else { return }
let preflight = await backend.preflight()
guard !session.retired else { return }
switch preflight {
case .issue(let issue):
return needsSetup(issue, for: session)
case .ready(let appRoot, let stale):
let free = await diskSpace.bytesFree(atPath: appRoot)
guard !session.retired else { return }
if let free, free < Self.minimumFreeBytes {
return needsSetup(.lowDisk(bytesFree: free), for: session)
}
if requestedImageUpdate == nil { staleImage = stale }
}
session.startBegan = true
do {
let vm = try await backend.start(id: session.id, url: session.url,
renderSize: Self.renderSize, blockCIDRs: hostNetworks())
guard !session.retired else { return } // Superseded mid-start: its destroy job runs next.
transition(to: .starting(.connecting, folded: isFolded))
session.tasks.append(Task { [weak self] in await self?.connect(session, socketPath: vm.socketPath) })
} catch {
guard !session.retired else { return }
retire(session)
transition(to: .failed(.start(Self.message(for: error))))
}
}
/// No VM exists yet, so there is nothing to destroy. Starting the service needs no password, so it just happens.
private func needsSetup(_ issue: SetupIssue, for session: Session) {
session.retired = true
liveIDs.remove(session.id)
pendingURL = session.url
guard issue == .serviceStopped || issue == .kernelMissing else {
return transition(to: .setupRequired(issue, error: nil))
}
var tried = autoFixed?.url == session.url ? autoFixed?.issues ?? [] : []
guard !tried.contains(where: { Self.sameFix($0, issue) }) else {
return transition(to: .setupRequired(issue, error: Self.stillBrokenMessage))
}
tried.append(issue)
autoFixed = (session.url, tried)
startRun(issue, mode: .link, folded: false)
}
private func connect(_ session: Session, socketPath: String) async {
let outcome: Result<any StreamConnection, any Error>
do {
outcome = .success(try await connector.connect(socketPath: socketPath))
} catch {
outcome = .failure(error)
}
guard !session.retired else {
if case .success(let late) = outcome { late.close() }
return
}
switch outcome {
case .success(let stream):
session.connection = stream
connection = stream
transition(to: .starting(.loading, folded: isFolded))
session.tasks.append(Task { [weak self] in
for await event in stream.events { self?.handle(event, from: session) }
// A stream that finishes without `.ended` has still ended.
self?.handle(.ended(.closed), from: session)
})
case .failure(StreamError.versionMismatch(let found, let need)):
retire(session)
pendingURL = session.url
transition(to: .setupRequired(.imageOutdated(found: found, need: need), error: nil))
case .failure(let error):
retire(session)
transition(to: .failed(.start(Self.message(for: error))))
}
}
private func handle(_ event: StreamEvent, from session: Session) {
guard current === session, !session.retired else { return }
switch event {
case .hello:
break
case .progress(let percent):
progressChanged(percent)
case .pageURL(let url):
pageURL = url
case .contentReady:
contentReady(session)
case .ended(let reason):
let wasStarting = if case .starting = state { true } else { false }
retire(session)
transition(to: .failed(wasStarting ? .start(Self.message(for: reason)) : .ended(reason)))
}
}
private func contentReady(_ session: Session) {
guard case .starting(_, let folded) = state, !session.contentReady else { return }
session.contentReady = true
if folded {
transition(to: .live(.collapsed))
} else if opensExpanded {
transition(to: .live(.expanded))
} else {
transition(to: .live(.peek))
if !entered { timers.set(.neverEnteredFold, after: Self.neverEnteredFoldDelay) }
}
scheduleProgressHide()
}
private func runDestroy(_ session: Session, onGone: (@MainActor (Bool) -> Void)?) async {
guard session.startBegan else {
liveIDs.remove(session.id)
onGone?(true)
return
}
do {
try await backend.destroy(id: session.id)
pendingCleanup.removeAll { $0 == session.id }
liveIDs.remove(session.id)
onGone?(true)
} catch {
if !pendingCleanup.contains(session.id) { pendingCleanup.append(session.id) }
onGone?(false)
}
}
private func destroyFinished(_ session: Session, gone: Bool) {
guard current === session, state == .destroying else { return }
transition(to: gone ? .destroyed : .failed(.destroyStuck))
}
private func enterIdle() {
openedURL = nil
pageURL = nil
progress = nil
staleImage = nil
setupLog = nil
connection = nil
entered = false
pointerInside = false
focused = false
if let update = requestedImageUpdate {
requestedImageUpdate = nil
return startRun(.imageStale(builtDaysAgo: update.builtDaysAgo), mode: .link, folded: false)
}
transition(to: .idle)
}
// MARK: - Progress bar
private func progressChanged(_ percent: Int) {
lastProgressAt = clock.now
if percent >= 100 {
progress = nil
timers.cancel(.progressHide)
} else {
progress = percent
scheduleProgressHide()
}
}
/// Hides the bar 2 s after the last PROGRESS, so a load that never reports 100 doesn't leave it up.
private func scheduleProgressHide() {
guard current?.contentReady == true, progress != nil, let last = lastProgressAt else { return }
let hideAt = last + Self.progressLinger
if hideAt <= clock.now {
progress = nil
} else {
timers.set(.progressHide, at: hideAt)
}
}
// MARK: - Setup actions
private func runSetupAction(_ issue: SetupIssue, folded: Bool = false) {
setupLog = nil
setupOutput = []
transition(to: .settingUp(issue))
if folded { setupFolded = true }
let (lines, sink) = AsyncStream.makeStream(of: String.self)
let showLines = Task { [weak self] in
for await line in lines { self?.record(line) }
}
enqueue { [weak self, backend] in
let outcome: Result<Void, any Error>
do {
try await backend.fix(issue) { sink.yield($0) }
outcome = .success(())
} catch {
outcome = .failure(error)
}
sink.finish()
await showLines.value // Every line is shown before the outcome.
await self?.setupFinished(issue, outcome)
}
}
private func record(_ line: String) {
setupLog = line
setupOutput.append(line)
if setupOutput.count > Self.setupOutputLimit {
setupOutput.removeFirst(setupOutput.count - Self.setupOutputLimit)
}
}
private func startRun(_ issue: SetupIssue, mode: RunMode, folded: Bool,
waiter: CheckedContinuation<SetupRunOutcome, Never>? = nil) {
if run != nil { endRun(.needs(issue)) } // Never happens today; it keeps "every waiter resumes once" true.
run = SetupRun(mode: mode, fixed: [issue], waiter: waiter)
runSetupAction(issue, folded: folded)
}
private func endRun(_ outcome: SetupRunOutcome) {
let waiter = run?.waiter
run = nil
waiter?.resume(returning: outcome)
}
/// Link runs leave a card, as before; window runs leave one only for a waiting link.
private func stop(_ outcome: SetupRunOutcome, card: SessionState, mode: RunMode) {
endRun(outcome)
if mode == .link || pendingURL != nil { transition(to: card) } else { enterIdle() }
}
private static func fixes(_ issue: SetupIssue, in mode: RunMode) -> Bool {
switch (mode, issue) {
case (.window(continuing: true), .serviceStopped), (.window(continuing: true), .kernelMissing),
(.window(continuing: true), .imageMissing), (.window(continuing: true), .imageOutdated),
(.check, .serviceStopped), (.check, .kernelMissing):
true
default:
false
}
}
/// The backend runs one command for each group, so another issue from the same group means it didn't help.
private static func sameFix(_ a: SetupIssue, _ b: SetupIssue) -> Bool {
func group(_ issue: SetupIssue) -> Int? {
switch issue {
case .serviceStopped, .kernelMissing: 0
case .imageMissing, .imageOutdated, .imageStale: 1
default: nil
}
}
guard let first = group(a) else { return a == b }
return first == group(b)
}
private func setupFinished(_ issue: SetupIssue, _ outcome: Result<Void, any Error>) async {
guard state == .settingUp(issue) else { return }
let mode = run?.mode ?? .link
if case .failure(let error) = outcome {
let message = Self.message(for: error)
// A failed "Update now" rebuild never blocks a link.
if case .imageStale = issue, let url = pendingURL {
endRun(.failed(issue, message))
return startSession(url, pointerStays: true)
}
return stop(.failed(issue, message), card: .setupRequired(issue, error: message), mode: mode)
}
switch issue {
case .imageMissing, .imageOutdated, .imageStale: requestedImageUpdate = nil
default: break
}
if mode == .link, let url = pendingURL {
endRun(.ready)
return startSession(url, pointerStays: true)
}
let preflight = await backend.preflight()
guard state == .settingUp(issue) else { return }
switch preflight {
case .ready:
endRun(.ready)
if let url = pendingURL { return startSession(url, pointerStays: true) }
enterIdle()
case .issue(let next) where mode == .link:
endRun(.needs(next))
if let url = pendingURL { return startSession(url, pointerStays: true) }
transition(to: .setupRequired(next, error: nil))
case .issue(let next) where run?.fixed.contains(where: { Self.sameFix($0, next) }) == true:
stop(.failed(next, Self.stillBrokenMessage), card: .setupRequired(next, error: Self.stillBrokenMessage), mode: mode)
case .issue(let next) where Self.fixes(next, in: mode):
run?.fixed.append(next)
runSetupAction(next)
case .issue(let next):
stop(.needs(next), card: .setupRequired(next, error: nil), mode: mode)
}
}
// MARK: - Backend chain
/// After each job, one VM left by an earlier failed destroy gets a retry, taking turns.
private func enqueue(_ job: @escaping @MainActor () async -> Void) {
chain.enqueue { [weak self] in
let stuckBefore = self?.pendingCleanup ?? []
await job()
await self?.retryPendingCleanup(among: stuckBefore)
}
}
private func retryPendingCleanup(among candidates: [String]) async {
guard let id = candidates.first(where: pendingCleanup.contains) else { return }
do {
try await backend.forceDestroy(id: id)
pendingCleanup.removeAll { $0 == id }
liveIDs.remove(id)
} catch {
// Move it to the back so a VM stuck for good doesn't starve the others.
pendingCleanup.removeAll { $0 == id }
pendingCleanup.append(id)
}
}
// MARK: - State and timers
private func transition(to newState: SessionState) {
let oldState = state
guard newState != oldState else { return }
state = newState
if case .settingUp = newState {} else {
setupFolded = false
setupOpenedByHover = false
}
if !isPeekLike { timers.cancel(.foldGrace) }
if newState != .live(.peek) { timers.cancel(.neverEnteredFold) }
if newState == .live(.collapsed) {
timers.set(.idleDestroy, after: idleDestroyDelay)
idleDeadline = timers.deadline(of: .idleDestroy)
connection?.decodeEnabled = false
} else if oldState == .live(.collapsed) {
timers.cancel(.idleDestroy)
idleDeadline = nil
if case .live = newState { connection?.decodeEnabled = true }
}
switch newState {
case .destroyed: timers.set(.transient, after: Self.destroyedDisplay)
case .failed(.ended): timers.set(.transient, after: Self.endedDisplay)
case .failed(.destroyStuck): timers.set(.transient, after: Self.destroyStuckDisplay)
default: timers.cancel(.transient)
}
if newState == .idle {
timers.set(.idleQuit, after: Self.idleQuitDelay)
} else {
timers.cancel(.idleQuit)
}
if case .settingUp(let issue) = oldState {
if case .settingUp = newState {} else { settled(after: issue) }
}
}
/// So a waiting setup window never hangs, whatever ended the setup.
private func settled(after issue: SetupIssue) {
if run != nil { endRun(.needs(issue)) }
let waiters = settleWaiters
settleWaiters = []
for waiter in waiters { waiter.resume() }
}
private func show(_ newNotice: Notice) {
notice = newNotice
timers.set(.notice, after: Self.noticeDisplay)
}
private func timerFired(_ key: TimerKey) {
switch key {
case .foldGrace:
if isPeekLike, entered, !pointerInside, !focused { fold() }
if case .settingUp = state, setupOpenedByHover, !pointerInside {
setupOpenedByHover = false
setupFolded = true
}
case .neverEnteredFold:
if state == .live(.peek), !entered { fold() }
case .idleDestroy:
destroy()
case .transient:
enterIdle()
case .notice:
notice = nil
case .progressHide:
progress = nil
case .idleQuit:
if quitsWhenIdle { requestQuit() }
}
}
nonisolated static func message(for error: any Error) -> String {
switch error {
case VMBackendError.failed(let message): message
case StreamError.protocolViolation(let detail): "The VM sent a malformed message: \(detail)."
case StreamError.timedOut: "The VM didn't answer in time."
case StreamError.refused: "Couldn't connect to the VM."
default: String(describing: error)
}
}
nonisolated static func message(for reason: EndReason) -> String {
switch reason {
case .closed: "The VM closed the connection."
case .protocolViolation(let detail): "The VM sent a malformed message: \(detail)."
case .noFirstFrame: "The VM didn't send a first frame in time."
case .decodeErrors: "The video stream couldn't be decoded."
case .socketError(let detail): "The connection to the VM failed: \(detail)."
}
}
// MARK: - Test hooks
func fireDueTimers() {
timers.fireDue()
}
func waitForBackendIdle() async {
await chain.waitUntilIdle()
}
/// Call once the session is retired: a live session's events task runs until its stream ends.
func waitForSessionTasks() async {
for task in current?.tasks ?? [] { await task.value }
}
}
@@ -0,0 +1,36 @@
public enum SessionState: Equatable, Sendable {
case idle
case starting(StartStep, folded: Bool)
case live(LiveMode)
case destroying
case destroyed
case failed(SessionFailure)
case setupRequired(SetupIssue, error: String?)
case settingUp(SetupIssue)
}
public enum StartStep: Sendable {
case booting
/// Waiting for HELLO.
case connecting
/// Waiting for `ContentReadiness`.
case loading
}
public enum LiveMode: Sendable {
case peek
case expanded
case collapsed
}
public enum SessionFailure: Equatable, Sendable {
case start(String)
case ended(EndReason)
/// The VM stays on the pending-cleanup list.
case destroyStuck
}
/// A transient pill, shown whatever the state.
public enum Notice: Equatable, Sendable {
case noWebLink
}
@@ -0,0 +1,20 @@
/// How a setup run started by the setup window ended.
public enum SetupRunOutcome: Equatable, Sendable {
/// Preflight is ready; a waiting link may have started.
case ready
/// Stopped at an issue this kind of run doesn't fix.
case needs(SetupIssue)
case failed(SetupIssue, String)
}
public enum RuntimeHoldRefusal: Error, Equatable, Sendable {
case sessionOpen
case setupRunning
case stopFailed(String)
}
public enum RuntimeInstallResult: Equatable, Sendable {
case installed
case cancelled
case failed(String)
}
@@ -0,0 +1,55 @@
import Foundation
/// Apple's container runtime: the release Knotchy installs, and how an installed one is judged.
public enum ContainerRuntime {
public struct Release: Equatable, Sendable {
public let version: String
public let url: URL
public let sha256: String
public let size: Int64
public init(version: String, url: URL, sha256: String, size: Int64) {
self.version = version
self.url = url
self.sha256 = sha256
self.size = size
}
}
/// The only package Knotchy ever installs. Bump it only after testing a new release.
public static let pinned = Release(
version: "1.4.1",
url: URL(string: "https://github.com/apple/container/releases/download/1.4.1/container-1.4.1-installer-signed.pkg")!,
sha256: "c0d2716afefbb194c93fae662e9cae7cc186bcbcf746816608ec673dd648a6a4",
size: 117_773_865)
public static let minimumVersion = "1.4.0"
public static func version(in output: String) -> String? {
output.firstMatch(of: #/container CLI version (\d+(?:\.\d+)+)/#).map { String($0.1) }
}
/// Output Knotchy can't read counts as current, so a new format can never cause a downgrade.
public static func status(versionOutput: String?) -> RuntimeStatus {
guard let versionOutput else { return .missing }
guard let found = version(in: versionOutput) else { return .current(nil) }
return isOlder(found, than: minimumVersion) ? .outdated(found) : .current(found)
}
public static func isOlder(_ version: String, than other: String) -> Bool {
let lhs = version.split(separator: ".").map { Int($0) ?? 0 }
let rhs = other.split(separator: ".").map { Int($0) ?? 0 }
for index in 0..<max(lhs.count, rhs.count) {
let a = index < lhs.count ? lhs[index] : 0
let b = index < rhs.count ? rhs[index] : 0
if a != b { return a < b }
}
return false
}
}
public enum RuntimeStatus: Equatable, Sendable {
case missing
case outdated(String)
/// nil: installed, but its version couldn't be read.
case current(String?)
}
@@ -0,0 +1,44 @@
import Foundation
import Virtualization
/// Knotchy is built for Apple silicon only; macOS itself refuses to open it on an Intel Mac.
public enum Unsupported: Equatable, Sendable {
case noVirtualization
}
public struct HostFacts: Equatable, Sendable {
public var unsupported: Unsupported?
public var osVersion: String
public var bytesFree: Int64?
public init(unsupported: Unsupported?, osVersion: String, bytesFree: Int64?) {
self.unsupported = unsupported
self.osVersion = osVersion
self.bytesFree = bytesFree
}
}
public protocol HostChecking: Sendable {
func facts() async -> HostFacts
}
public struct SystemHost: HostChecking {
public init() {}
/// Synchronous, so the app can decide on minimal mode before it registers anything.
@MainActor
public static func unsupported() -> Unsupported? {
VZVirtualMachine.isSupported ? nil : .noVirtualization
}
public func facts() async -> HostFacts {
let unsupported = await Self.unsupported()
let version = ProcessInfo.processInfo.operatingSystemVersion
var os = "macOS \(version.majorVersion).\(version.minorVersion)"
if version.patchVersion > 0 { os += ".\(version.patchVersion)" }
// The runtime keeps its data under the home folder.
let values = try? URL(fileURLWithPath: NSHomeDirectory())
.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
return HostFacts(unsupported: unsupported, osVersion: os, bytesFree: values?.volumeAvailableCapacityForImportantUsage)
}
}
@@ -0,0 +1,209 @@
import AppKit
import CryptoKit
import Foundation
public struct DownloadedPackage: Equatable, Sendable {
public let directory: URL
/// Apple's own file name, which Installer shows.
public let name: String
public var file: URL { directory.appendingPathComponent(name) }
public init(directory: URL, name: String) {
self.directory = directory
self.name = name
}
}
public enum DownloadError: Error, Equatable, Sendable {
case failed(String)
/// Size or SHA-256 differ from the pinned release; the download is already deleted.
case mismatch
}
@MainActor
public protocol InstallerSession: AnyObject {
var hasQuit: Bool { get }
}
public enum InstallerOpening {
case opened(any InstallerSession)
/// Size or SHA-256 no longer match; the download is already deleted.
case changed
case failed(String)
}
public protocol RuntimeInstalling: Sendable {
func download(_ release: ContainerRuntime.Release,
progress: @escaping @Sendable (Int64) -> Void) async throws -> DownloadedPackage
/// Re-checks the size and hash off the main thread, then opens the package in Installer.
@MainActor func openInInstaller(_ package: DownloadedPackage, release: ContainerRuntime.Release) async -> InstallerOpening
func discard(_ package: DownloadedPackage)
}
public struct SystemRuntimeInstaller: RuntimeInstalling {
static let installerApp = URL(fileURLWithPath: "/System/Library/CoreServices/Installer.app")
public init() {}
public func download(_ release: ContainerRuntime.Release,
progress: @escaping @Sendable (Int64) -> Void) async throws -> DownloadedPackage {
Self.removeLeftovers()
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("knotchy-runtime-\(UUID().uuidString)", isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false,
attributes: [.posixPermissions: 0o700])
} catch {
throw DownloadError.failed(error.localizedDescription)
}
let package = DownloadedPackage(directory: directory, name: release.url.lastPathComponent)
do {
try await PackageDownload.fetch(release.url, to: package.file, progress: progress)
} catch {
discard(package)
throw DownloadError.failed(error.localizedDescription)
}
guard Self.matches(package.file, release) else {
discard(package)
throw DownloadError.mismatch
}
return package
}
public func discard(_ package: DownloadedPackage) {
try? FileManager.default.removeItem(at: package.directory)
}
/// Folders left by quitting mid-download or a crash.
public static func removeLeftovers() {
let temporary = URL(fileURLWithPath: NSTemporaryDirectory())
let names = (try? FileManager.default.contentsOfDirectory(atPath: temporary.path)) ?? []
for name in names where name.hasPrefix("knotchy-runtime-") {
try? FileManager.default.removeItem(at: temporary.appendingPathComponent(name))
}
}
@MainActor
public func openInInstaller(_ package: DownloadedPackage, release: ContainerRuntime.Release) async -> InstallerOpening {
let file = package.file
guard await Task.detached(operation: { Self.matches(file, release) }).value else {
discard(package)
return .changed
}
let configuration = NSWorkspace.OpenConfiguration()
configuration.activates = true
do {
let app = try await NSWorkspace.shared.open([file], withApplicationAt: Self.installerApp,
configuration: configuration)
return .opened(RunningInstaller(app: app))
} catch {
return .failed(error.localizedDescription)
}
}
static func matches(_ file: URL, _ release: ContainerRuntime.Release) -> Bool {
guard let size = (try? FileManager.default.attributesOfItem(atPath: file.path))?[.size] as? NSNumber,
size.int64Value == release.size,
let handle = try? FileHandle(forReadingFrom: file) else { return false }
defer { try? handle.close() }
var hash = SHA256()
while let chunk = try? handle.read(upToCount: 1 << 20), !chunk.isEmpty { hash.update(data: chunk) }
return hash.finalize().map { String(format: "%02x", $0) }.joined() == release.sha256
}
}
@MainActor
private final class RunningInstaller: InstallerSession {
let app: NSRunningApplication
init(app: NSRunningApplication) {
self.app = app
}
var hasQuit: Bool { app.isTerminated }
}
/// A data task streamed into the package file, so the partial download lives only in its own folder.
private final class PackageDownload: NSObject, URLSessionDataDelegate, @unchecked Sendable {
// Touched only on the session's serial delegate queue.
private var continuation: CheckedContinuation<Void, any Error>?
/// A completion that arrived before anyone waited: a task cancelled before it started.
private var outcome: Result<Void, any Error>?
private var handle: FileHandle?
private var written: Int64 = 0
private var writeError: (any Error)?
private let destination: URL
private let progress: @Sendable (Int64) -> Void
private init(destination: URL, progress: @escaping @Sendable (Int64) -> Void) {
self.destination = destination
self.progress = progress
}
static func fetch(_ url: URL, to destination: URL, progress: @escaping @Sendable (Int64) -> Void) async throws {
let delegate = PackageDownload(destination: destination, progress: progress)
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1
let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: queue)
defer { session.finishTasksAndInvalidate() }
// Created up front: cancelling before it exists would invalidate the session, and creating it then throws.
let task = session.dataTask(with: url)
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
queue.addOperation {
if let outcome = delegate.outcome {
continuation.resume(with: outcome)
} else {
delegate.continuation = continuation
task.resume()
}
}
}
} onCancel: {
task.cancel()
}
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void) {
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
writeError = URLError(.badServerResponse)
return completionHandler(.cancel)
}
do {
guard FileManager.default.createFile(atPath: destination.path, contents: nil) else {
throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: destination.path])
}
handle = try FileHandle(forWritingTo: destination)
} catch {
writeError = error
return completionHandler(.cancel)
}
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
guard let handle, writeError == nil else { return }
do {
try handle.write(contentsOf: data)
written += Int64(data.count)
progress(written)
} catch {
writeError = error
dataTask.cancel()
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) {
try? handle?.close()
handle = nil
// Ours comes first: cancelling after it adds a "cancelled" error.
let result: Result<Void, any Error> = if let failure = writeError ?? error { .failure(failure) } else { .success(()) }
if let waiting = continuation {
continuation = nil
waiting.resume(with: result)
} else {
outcome = result
}
}
}
@@ -0,0 +1,15 @@
/// What the setup flow needs from the session controller.
@MainActor
public protocol SetupDriving: AnyObject {
var state: SessionState { get }
var setupLog: String? { get }
var setupOutput: [String] { get }
func runtimeStatus() async -> RuntimeStatus
func checkSetup() async -> SetupRunOutcome?
func runSetup(_ issue: SetupIssue, continuing: Bool) async -> SetupRunOutcome?
func beginRuntimeInstall(replacing found: String?) async -> Result<Void, RuntimeHoldRefusal>
func finishRuntimeInstall(_ result: RuntimeInstallResult) async -> SetupRunOutcome
func waitWhileSettingUp() async
}
extension SessionController: SetupDriving {}
+360
View File
@@ -0,0 +1,360 @@
import Foundation
import Observation
/// The setup window's rows and the order of its steps. The window binds only to this.
@MainActor
@Observable
public final class SetupFlow {
public enum Status: Equatable, Sendable {
case pending(String)
case running(String, fraction: Double?)
case done(String)
case failed(String, details: [String])
}
public enum RuntimeAction: Equatable, Sendable {
case install
case update
}
public static let requiredFreeBytes: Int64 = 3_000_000_000
static let vmDetail = "About 3 minutes · downloads about 225 MB"
public private(set) var mac: Status = .pending("Checking…")
public private(set) var unsupported: Unsupported?
public private(set) var runtimeAction: RuntimeAction?
public private(set) var isRunning = false
/// Quitting now leaves a partial download behind.
@ObservationIgnored public private(set) var isDownloading = false
private var runtimeBase: Status = .pending("")
private var vmBase: Status = .pending(SetupFlow.vmDetail)
@ObservationIgnored private let host: any HostChecking
@ObservationIgnored private let installer: any RuntimeInstalling
@ObservationIgnored private let driver: any SetupDriving
@ObservationIgnored private let release: ContainerRuntime.Release
@ObservationIgnored private let retryDelay: Duration
@ObservationIgnored private var facts: HostFacts?
@ObservationIgnored private var runtimeStatus: RuntimeStatus?
public init(host: any HostChecking, installer: any RuntimeInstalling, driver: any SetupDriving,
release: ContainerRuntime.Release = ContainerRuntime.pinned, retryDelay: Duration = .milliseconds(500)) {
self.host = host
self.installer = installer
self.driver = driver
self.release = release
self.retryDelay = retryDelay
runtimeBase = .pending(Self.installDetail(release))
}
// MARK: - Rows
/// With the controller's live progress on top, so a setup the notch started shows here too.
public var runtime: Status {
if case .settingUp(let issue) = driver.state, issue == .serviceStopped || issue == .kernelMissing {
return .running("Starting the runtime…", fraction: nil)
}
return runtimeBase
}
public var vm: Status {
if case .settingUp(let issue) = driver.state, Self.isImageIssue(issue) {
return .running(driver.setupLog ?? "Starting the build…", fraction: nil)
}
return vmBase
}
public var allRequiredDone: Bool {
if case .done = mac, case .done = runtime, case .done = vm { return true }
return false
}
public var canSetUp: Bool {
guard !isRunning, unsupported == nil, !allRequiredDone else { return false }
if case .failed = mac { return false }
return true
}
// MARK: - Actions
@ObservationIgnored private var work: Task<Void, Never>?
/// For the Set Up and Try Again buttons: runs `setUp()` where quitting can cancel it.
public func start() {
guard !isRunning else { return }
work = Task { await setUp() }
}
/// Quitting: stops a download. An open Installer is left to finish.
public func cancel() {
work?.cancel()
}
/// Re-checks everything. Starts a stopped service, as a link would, but never installs or builds.
public func refresh() async {
guard !isRunning else { return }
isRunning = true
defer { isRunning = false }
await inspect()
}
/// The Set Up and Try Again buttons: runs whatever is left, in order.
public func setUp() async {
guard !isRunning else { return }
isRunning = true
defer { isRunning = false }
await inspect()
guard unsupported == nil else { return }
// The service didn't start: building would only try again.
if case .failed = runtimeBase { return }
if case .failed = mac { return }
if let action = runtimeAction {
await installRuntime(action)
} else if case .done = vmBase {
return
} else {
await buildVM()
}
updateMac()
}
// MARK: - Steps
private func inspect() async {
let facts = await host.facts()
self.facts = facts
unsupported = facts.unsupported
guard unsupported == nil else { return }
let status = await driver.runtimeStatus()
runtimeStatus = status
switch status {
case .missing:
runtimeAction = .install
runtimeBase = .pending(Self.installDetail(release))
vmBase = .pending(Self.vmDetail)
case .outdated(let found):
runtimeAction = .update
runtimeBase = .pending("container \(found) is too old · updates to \(release.version) with Apple's Installer")
vmBase = .pending(Self.vmDetail)
case .current(let version):
runtimeAction = nil
runtimeBase = .done(Self.installedText(version))
switch await driver.checkSetup() {
case .ready?: vmBase = .done("Ready")
case .needs(let issue)? where Self.isImageIssue(issue): vmBase = .pending(Self.vmDetail)
case .failed(let issue, let message)?: fail(issue, message)
case nil where Self.isBrowserOpen(driver.state): vmBase = .done("Ready") // A running VM has an image.
case .needs?, nil: break // Busy, or the runtime row's business: the live rows show it.
}
}
updateMac()
}
private func installRuntime(_ action: RuntimeAction) async {
switch driver.state {
case .starting, .live:
runtimeBase = .failed("Close the Knotchy browser first.", details: [])
return
case .destroying, .failed(.destroyStuck):
runtimeBase = .failed("Wait for the Knotchy browser to finish closing, then try again.", details: [])
return
default:
break
}
runtimeBase = .running(Self.downloadText(0, of: release.size), fraction: 0)
let package: DownloadedPackage
do {
package = try await download()
} catch DownloadError.mismatch {
runtimeBase = .failed("The download didn't match the expected package, so it wasn't installed.", details: [])
return
} catch {
runtimeBase = .failed("Couldn't download the runtime. Check your connection and try again.",
details: [String(describing: error)])
return
}
let found: String? = if case .outdated(let version) = runtimeStatus { version } else { nil }
let hold = await driver.beginRuntimeInstall(replacing: found)
if case .failure = hold { installer.discard(package) }
switch hold {
case .success:
break
case .failure(.sessionOpen):
runtimeBase = .failed("Close the Knotchy browser first.", details: [])
return
case .failure(.setupRunning):
runtimeBase = .failed("Wait for the current setup step to finish, then try again.", details: [])
return
case .failure(.stopFailed(let message)):
runtimeBase = .failed("Couldn't stop the runtime for the update.", details: [message])
return
}
runtimeBase = .running("Opening Installer…", fraction: nil)
let session: any InstallerSession
switch await installer.openInInstaller(package, release: release) {
case .opened(let opened):
session = opened
case .changed:
return await failInstall("The download changed after it was checked, so it wasn't opened.", details: [])
case .failed(let message):
installer.discard(package)
return await failInstall("Couldn't open Installer.", details: [message])
}
runtimeBase = .running("Finish the install in Installer…", fraction: nil)
let status = await waitForInstall(session)
// Installer reads the package until it quits.
Task { [installer] in
while !session.hasQuit { try? await Task.sleep(for: .seconds(2)) }
installer.discard(package)
}
// Quitting: Installer carries on by itself.
if Task.isCancelled { return }
guard case .current(let version)? = status else {
_ = await driver.finishRuntimeInstall(.cancelled)
runtimeBase = .pending("Installing was cancelled.")
return
}
runtimeStatus = status
runtimeAction = nil
runtimeBase = .done(Self.installedText(version))
apply(await driver.finishRuntimeInstall(.installed))
}
/// The current runtime once it appears, or nil when Installer quits without it.
private func waitForInstall(_ session: any InstallerSession) async -> RuntimeStatus? {
while !Task.isCancelled {
let status = await driver.runtimeStatus()
if case .current = status { return status }
if session.hasQuit {
// Installer may have finished between the two checks.
let last = await driver.runtimeStatus()
if case .current = last { return last }
return nil
}
try? await Task.sleep(for: retryDelay)
}
return nil
}
private func download() async throws -> DownloadedPackage {
isDownloading = true
defer { isDownloading = false }
return try await installer.download(release) { [weak self, release] bytes in
Task { @MainActor in self?.downloaded(bytes, of: release.size) }
}
}
private func failInstall(_ message: String, details: [String]) async {
_ = await driver.finishRuntimeInstall(.failed(message))
runtimeBase = .failed(message, details: details)
}
private func buildVM() async {
// A setup the notch started, or a session winding down, gets up to a minute to finish.
for _ in 0..<120 {
if Task.isCancelled { return }
guard let checked = await driver.checkSetup() else {
if case .settingUp = driver.state {
await driver.waitWhileSettingUp()
} else {
try? await Task.sleep(for: retryDelay)
}
continue
}
switch checked {
case .needs(let issue) where Self.isImageIssue(issue):
guard let result = await driver.runSetup(issue, continuing: true) else { continue }
return apply(result)
case .needs:
return await inspect()
default:
return apply(checked)
}
}
vmBase = .failed("Close the Knotchy browser first, then try again.", details: [])
}
private func apply(_ outcome: SetupRunOutcome) {
switch outcome {
case .ready:
vmBase = .done("Ready")
case .needs(let issue):
if Self.isImageIssue(issue) { vmBase = .pending(Self.vmDetail) }
case .failed(let issue, let message):
fail(issue, message)
}
}
private func fail(_ issue: SetupIssue, _ message: String) {
if Self.isImageIssue(issue) {
vmBase = .failed(message, details: driver.setupOutput)
} else {
runtimeBase = .failed(message, details: driver.setupOutput)
}
}
private func downloaded(_ bytes: Int64, of total: Int64) {
// Late callbacks mustn't overwrite what came after the download.
guard case .running(let text, _) = runtimeBase, text.hasPrefix("Downloading") else { return }
if bytes >= total {
runtimeBase = .running("Verifying…", fraction: nil)
} else {
runtimeBase = .running(Self.downloadText(bytes, of: total), fraction: Double(bytes) / Double(max(total, 1)))
}
}
private func updateMac() {
guard let facts else { return }
let free = facts.bytesFree.map { "\(Self.gigabytes($0)) GB free" }
if needsSpace, let bytes = facts.bytesFree, bytes < Self.requiredFreeBytes {
mac = .failed("Setting up needs about 3 GB free. \(Self.gigabytes(bytes)) GB is free.", details: [])
} else {
mac = .done([facts.osVersion, free].compactMap { $0 }.joined(separator: " · "))
}
}
/// Only the install and the build use space; starting the service doesn't.
private var needsSpace: Bool {
if runtimeAction != nil { return true }
if case .done = vmBase { return false }
return true
}
// MARK: - Text
static func installDetail(_ release: ContainerRuntime.Release) -> String {
"Apple's container \(release.version) · \(megabytes(release.size)) MB · installs with Apple's Installer"
}
static func installedText(_ version: String?) -> String {
version.map { "container \($0) installed" } ?? "container installed"
}
static func downloadText(_ bytes: Int64, of total: Int64) -> String {
"Downloading… \(megabytes(bytes)) of \(megabytes(total)) MB"
}
static func megabytes(_ bytes: Int64) -> Int {
Int((Double(bytes) / 1_000_000).rounded())
}
static func gigabytes(_ bytes: Int64) -> String {
bytes >= 10_000_000_000 ? "\(bytes / 1_000_000_000)" : String(format: "%.1f", Double(bytes) / 1_000_000_000)
}
static func isBrowserOpen(_ state: SessionState) -> Bool {
switch state {
case .starting, .live: true
default: false
}
}
static func isImageIssue(_ issue: SetupIssue) -> Bool {
switch issue {
case .imageMissing, .imageOutdated, .imageStale: true
default: false
}
}
}
@@ -0,0 +1,12 @@
/// When the setup window opens by itself at launch.
public enum SetupLaunchRule {
public static let completedKey = "setupCompleted"
public static func shouldOpen(hostSupported: Bool, setupCompleted: Bool, runtime: RuntimeStatus) -> Bool {
guard hostSupported, setupCompleted else { return true }
switch runtime {
case .missing, .outdated: return true
case .current: return false
}
}
}
@@ -0,0 +1,39 @@
/// Decides when Starting ends. Frames and progress before NAVIGATED come from the agent's
/// about:blank bootstrap and never count; `afterHello` is a backstop if NAVIGATED never arrives.
public struct ContentReadiness: Sendable {
public static let afterNavigated: Duration = .seconds(15)
public static let afterHello: Duration = .seconds(25)
public private(set) var isReady = false
private var helloAt: ContinuousClock.Instant?
private var navigatedAt: ContinuousClock.Instant?
public init() {}
public mutating func hello(at now: ContinuousClock.Instant) {
if helloAt == nil { helloAt = now }
}
public mutating func navigated(at now: ContinuousClock.Instant) {
if navigatedAt == nil { navigatedAt = now }
}
public mutating func frame(isUniform: Bool) {
if navigatedAt != nil, !isUniform { isReady = true }
}
public mutating func progress(_ percent: Int) {
if navigatedAt != nil, percent >= 100 { isReady = true }
}
public mutating func tick(now: ContinuousClock.Instant) {
if let deadline = nextDeadline, now >= deadline { isReady = true }
}
public var nextDeadline: ContinuousClock.Instant? {
guard !isReady else { return nil }
return [navigatedAt.map { $0 + Self.afterNavigated }, helloAt.map { $0 + Self.afterHello }]
.compactMap { $0 }
.min()
}
}
@@ -0,0 +1,22 @@
/// Tells a blank frame from one with page content.
public enum FrameSampler {
public static let grid = 48
public static let maxDimension = 1 << 15
public static let maxBytesPerRow = 1 << 20
/// Malformed input counts as uniform so it can never pose as content; the caps prevent overflow.
public static func isUniform(_ pixels: UnsafeRawBufferPointer, width: Int, height: Int, bytesPerRow: Int) -> Bool {
guard (1...maxDimension).contains(width), (1...maxDimension).contains(height),
bytesPerRow >= width * 4, bytesPerRow <= maxBytesPerRow,
pixels.count >= (height - 1) * bytesPerRow + width * 4
else { return true }
let first = pixels.loadUnaligned(fromByteOffset: 0, as: UInt32.self)
for gy in 0..<grid {
for gx in 0..<grid {
let offset = (gy * height / grid) * bytesPerRow + (gx * width / grid) * 4
if pixels.loadUnaligned(fromByteOffset: offset, as: UInt32.self) != first { return false }
}
}
return true
}
}
+88
View File
@@ -0,0 +1,88 @@
import Foundation
public enum NALUnitType {
public static let slice: UInt8 = 1
public static let idrSlice: UInt8 = 5
public static let sei: UInt8 = 6
public static let sps: UInt8 = 7
public static let pps: UInt8 = 8
public static let accessUnitDelimiter: UInt8 = 9
}
public enum AnnexB {
/// Splits an Annex-B stream into NAL units. Trailing zeros are trimmed: a NAL unit never ends in 0x00.
public static func nalUnits(in stream: Data) -> [Data] {
let bytes = [UInt8](stream)
var starts: [(code: Int, payload: Int)] = []
var i = 0
while i + 2 < bytes.count {
if bytes[i] == 0, bytes[i + 1] == 0, bytes[i + 2] == 1 {
starts.append((code: i, payload: i + 3))
i += 3
} else {
i += 1
}
}
return starts.indices.compactMap { k in
var end = k + 1 < starts.count ? starts[k + 1].code : bytes.count
while end > starts[k].payload, bytes[end - 1] == 0 { end -= 1 }
return end > starts[k].payload ? Data(bytes[starts[k].payload..<end]) : nil
}
}
}
public struct ParameterSets: Equatable, Sendable {
public var sps: Data
public var pps: Data
public init(sps: Data, pps: Data) {
self.sps = sps
self.pps = pps
}
}
public struct ParsedAccessUnit: Equatable, Sendable {
/// AVCC-framed slice NAL units; empty if the access unit has none, so don't decode it.
public var sample = Data()
public var isIDR = false
/// Set only on the unit that changes the SPS/PPS; if that unit is dropped, check `H264Parser.parameterSets`.
public var newParameterSets: ParameterSets?
}
public struct H264Parser: Sendable {
public private(set) var parameterSets: ParameterSets?
public init() {}
public mutating func parse(_ accessUnit: Data) -> ParsedAccessUnit {
var unit = ParsedAccessUnit()
var sps: Data?
var pps: Data?
for nal in AnnexB.nalUnits(in: accessUnit) {
let header = nal[nal.startIndex]
guard header & 0x80 == 0 else { continue } // forbidden_zero_bit
let type = header & 0x1F
switch type {
case NALUnitType.sps:
sps = nal
case NALUnitType.pps:
pps = nal
case NALUnitType.slice, NALUnitType.idrSlice:
if type == NALUnitType.idrSlice { unit.isIDR = true }
unit.sample.appendBE32(UInt32(nal.count))
unit.sample.append(nal)
default:
break
}
}
// SPS and PPS must come from the same access unit; x264 repeats both on every IDR.
if let sps, let pps {
let sets = ParameterSets(sps: sps, pps: pps)
if sets != parameterSets {
parameterSets = sets
unit.newParameterSets = sets
}
}
return unit
}
}
@@ -0,0 +1,78 @@
import Foundation
public struct Modifiers: OptionSet, Hashable, Sendable {
public let rawValue: UInt8
public init(rawValue: UInt8) {
self.rawValue = rawValue
}
public static let shift = Modifiers(rawValue: 1 << 0)
public static let control = Modifiers(rawValue: 1 << 1)
public static let alt = Modifiers(rawValue: 1 << 2)
public static let meta = Modifiers(rawValue: 1 << 3)
public static let leftButton = Modifiers(rawValue: 1 << 4)
public static let rightButton = Modifiers(rawValue: 1 << 5)
public static let middleButton = Modifiers(rawValue: 1 << 6)
}
public enum PointerButton: UInt8, Sendable {
case left = 1
case right = 2
case middle = 3
}
/// A viewer → agent message. Coordinates are render pixels from the top left; negative scroll is down/right.
public enum InputEvent: Equatable, Sendable {
case pointerMove(x: UInt16, y: UInt16, mods: Modifiers)
case pointerButton(PointerButton, pressed: Bool, x: UInt16, y: UInt16, mods: Modifiers)
case scroll(ticksX: Int16, ticksY: Int16, x: UInt16, y: UInt16)
case key(pressed: Bool, mods: Modifiers, name: String)
case navigate(String)
case script(String)
case keyframe
/// Nil if the agent can't use it: a payload over 64 KiB, an empty KEY name, or a NUL in a string.
public func encoded() -> Data? {
var payload = Data()
let type: UInt8
switch self {
case .pointerMove(let x, let y, let mods):
type = MessageType.pointerMove
payload.appendBE16(x)
payload.appendBE16(y)
payload.append(mods.rawValue)
case .pointerButton(let button, let pressed, let x, let y, let mods):
type = MessageType.pointerButton
payload.append(button.rawValue)
payload.append(pressed ? 1 : 0)
payload.appendBE16(x)
payload.appendBE16(y)
payload.append(mods.rawValue)
case .scroll(let ticksX, let ticksY, let x, let y):
type = MessageType.scroll
payload.appendBE16(UInt16(bitPattern: ticksX))
payload.appendBE16(UInt16(bitPattern: ticksY))
payload.appendBE16(x)
payload.appendBE16(y)
case .key(let pressed, let mods, let name):
guard !name.isEmpty, !name.utf8.contains(0) else { return nil }
type = MessageType.key
payload.append(pressed ? 1 : 0)
payload.append(mods.rawValue)
payload.append(contentsOf: Array(name.utf8))
case .navigate(let url):
guard !url.utf8.contains(0) else { return nil }
type = MessageType.navigate
payload.append(contentsOf: Array(url.utf8))
case .script(let javaScript):
guard !javaScript.utf8.contains(0) else { return nil }
type = MessageType.script
payload.append(contentsOf: Array(javaScript.utf8))
case .keyframe:
type = MessageType.keyframe
}
guard payload.count <= WireProtocol.maxOtherPayload else { return nil }
return framed(type, payload)
}
}
@@ -0,0 +1,348 @@
import CoreVideo
import Darwin
import Foundation
public struct StreamClient: StreamConnector {
public let renderSize: PixelSize
public init(renderSize: PixelSize) {
self.renderSize = renderSize
}
public func connect(socketPath: String) async throws -> any StreamConnection {
let renderSize = renderSize
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(with: Result { try Self.connectBlocking(path: socketPath, renderSize: renderSize) })
}
}
}
static func connectBlocking(path: String, renderSize: PixelSize) throws -> StreamSession {
let deadline = Date().addingTimeInterval(30)
var connectedOnce = false
while Date() < deadline {
if let fd = openSocket(path) {
connectedOnce = true
setReadTimeout(fd, seconds: 2)
var decoder = AgentMessageDecoder()
switch readHello(fd, into: &decoder) {
case .hello(let version, let size):
guard version == WireProtocol.version else {
Darwin.close(fd)
throw StreamError.versionMismatch(found: version, need: WireProtocol.version)
}
guard size == renderSize else {
Darwin.close(fd)
throw StreamError.protocolViolation("HELLO is \(size), asked for \(renderSize)")
}
setReadTimeout(fd, seconds: 0)
// A VM that stops reading input must not wedge the session's queue.
setSendTimeout(fd, seconds: 2)
return StreamSession(fd: fd, decoder: decoder, size: size)
case .violation(let detail):
Darwin.close(fd)
throw StreamError.protocolViolation(detail)
case .none:
Darwin.close(fd)
}
}
usleep(100_000)
}
throw connectedOnce ? StreamError.timedOut : StreamError.refused
}
private enum HelloOutcome {
case hello(version: Int, size: PixelSize)
case violation(String)
case none
}
private static func readHello(_ fd: Int32, into decoder: inout AgentMessageDecoder) -> HelloOutcome {
var buffer = [UInt8](repeating: 0, count: 64 * 1024)
while true {
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, $0.count) }
if n < 0, errno == EINTR { continue }
guard n > 0 else { return .none }
decoder.append(Data(buffer[0..<n]))
do {
if case .hello(let version, let size)? = try decoder.next() { return .hello(version: version, size: size) }
} catch {
return .violation(error.description)
}
}
}
private static func openSocket(_ path: String) -> Int32? {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return nil }
var address = sockaddr_un()
address.sun_family = sa_family_t(AF_UNIX)
let bytes = Array(path.utf8)
guard bytes.count < MemoryLayout.size(ofValue: address.sun_path) else {
Darwin.close(fd)
return nil
}
withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: bytes) }
let size = socklen_t(MemoryLayout<sockaddr_un>.size)
let result = withUnsafePointer(to: &address) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.connect(fd, $0, size) }
}
guard result == 0 else {
Darwin.close(fd)
return nil
}
var on: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout<Int32>.size))
return fd
}
static func setReadTimeout(_ fd: Int32, seconds: Int) {
var timeout = timeval.init(tv_sec: seconds, tv_usec: 0)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
}
static func setSendTimeout(_ fd: Int32, seconds: Int) {
var timeout = timeval.init(tv_sec: seconds, tv_usec: 0)
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
}
}
/// Its reader thread must never stop (the VM's watchdog needs a connected viewer); all else runs on `queue`.
final class StreamSession: StreamConnection, @unchecked Sendable {
static let firstFrameTimeout: TimeInterval = 20
static let decodeErrorLimit = 10
let events: AsyncStream<StreamEvent>
private let continuation: AsyncStream<StreamEvent>.Continuation
private let queue = DispatchQueue(label: "aculix.knotchy.stream")
private let fd: Int32
private var decoder: AgentMessageDecoder
private var parser = H264Parser()
private var video: VideoDecoder?
private var readiness = ContentReadiness()
private var announcedReady = false
private var enabled = true
private var waitingForIDR = false
private var frameHandler: (@Sendable (DecodedFrame) -> Void)?
private var readinessTimer: DispatchSourceTimer?
private var firstFrameTimer: DispatchSourceTimer?
private var pendingPageURL: URL?
private var pageURLTimer: DispatchSourceTimer?
private var decodeErrors: [Date] = []
private var ended = false
init(fd: Int32, decoder: AgentMessageDecoder, size: PixelSize) {
self.fd = fd
self.decoder = decoder
let (stream, continuation) = AsyncStream.makeStream(of: StreamEvent.self)
events = stream
self.continuation = continuation
continuation.yield(.hello(version: WireProtocol.version, size: size))
queue.async { self.begin() }
let reader = Thread { [self] in readLoop() }
reader.name = "knotchy stream reader"
reader.start()
}
// MARK: StreamConnection
var decodeEnabled: Bool {
get { queue.sync { enabled } }
set {
queue.async {
if newValue, !self.enabled {
self.waitingForIDR = true
self.write(.keyframe)
}
self.enabled = newValue
}
}
}
func setFrameHandler(_ handler: @escaping @Sendable (DecodedFrame) -> Void) {
queue.async { self.frameHandler = handler }
}
func send(_ input: InputEvent) {
queue.async { self.write(input) }
}
func close() {
queue.async { self.end(nil) }
}
// MARK: Reading
private func readLoop() {
var buffer = [UInt8](repeating: 0, count: 64 * 1024)
while true {
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, $0.count) }
if n < 0, errno == EINTR { continue }
guard n > 0 else { break }
let chunk = Data(buffer[0..<n])
queue.async { self.received(chunk) }
}
queue.async {
self.end(.closed)
Darwin.close(self.fd)
}
}
private func begin() {
readiness.hello(at: .now)
scheduleReadinessTick()
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + Self.firstFrameTimeout)
timer.setEventHandler { [weak self] in self?.end(.noFirstFrame) }
timer.resume()
firstFrameTimer = timer
received(Data()) // Messages that arrived together with HELLO.
}
private func received(_ chunk: Data) {
guard !ended else { return }
decoder.append(chunk)
do {
while let message = try decoder.next() { handle(message) }
} catch {
end(.protocolViolation(error.description))
}
}
private func handle(_ message: AgentMessage) {
switch message {
case .navigated:
readiness.navigated(at: .now)
scheduleReadinessTick()
case .progress(let percent):
readiness.progress(percent)
continuation.yield(.progress(percent))
announceIfReady()
case .video(_, let accessUnit):
firstFrameTimer?.cancel()
firstFrameTimer = nil
let unit = parser.parse(accessUnit)
if unit.newParameterSets != nil || video == nil, let sets = parser.parameterSets {
video = VideoDecoder(parameterSets: sets) { [weak self] frame, status in
guard let self else { return }
self.queue.async { self.decoded(frame, status: status) }
}
}
guard enabled, !unit.sample.isEmpty, let video else { return }
if waitingForIDR {
guard unit.isIDR else { return }
waitingForIDR = false
}
video.decode(unit.sample)
case .pageURL(let text):
guard let url = URL(string: text) else { return }
pendingPageURL = url
passOnPageURL()
case .hello, .unknown:
break
}
}
private func decoded(_ frame: DecodedFrame?, status: OSStatus) {
guard !ended else { return }
guard let frame else { return decodeFailed() }
if !readiness.isReady {
readiness.frame(isUniform: Self.isUniform(frame.pixelBuffer))
announceIfReady()
}
frameHandler?(frame)
}
private func decodeFailed() {
let now = Date()
decodeErrors = decodeErrors.filter { now.timeIntervalSince($0) < 60 } + [now]
if decodeErrors.count > Self.decodeErrorLimit { return end(.decodeErrors) }
waitingForIDR = true
write(.keyframe)
}
private static func isUniform(_ buffer: CVPixelBuffer) -> Bool {
CVPixelBufferLockBaseAddress(buffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) }
guard let base = CVPixelBufferGetBaseAddress(buffer) else { return true }
let bytes = UnsafeRawBufferPointer(start: base, count: CVPixelBufferGetDataSize(buffer))
return FrameSampler.isUniform(bytes, width: CVPixelBufferGetWidth(buffer),
height: CVPixelBufferGetHeight(buffer),
bytesPerRow: CVPixelBufferGetBytesPerRow(buffer))
}
// MARK: Readiness
private func announceIfReady() {
guard readiness.isReady, !announcedReady else { return }
announcedReady = true
readinessTimer?.cancel()
readinessTimer = nil
continuation.yield(.contentReady)
}
private func scheduleReadinessTick() {
readinessTimer?.cancel()
readinessTimer = nil
guard let deadline = readiness.nextDeadline else { return }
let wait = deadline - .now
let seconds = max(0, Double(wait.components.seconds) + Double(wait.components.attoseconds) / 1e18)
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + seconds)
timer.setEventHandler { [weak self] in
guard let self else { return }
self.readiness.tick(now: .now)
self.announceIfReady()
}
timer.resume()
readinessTimer = timer
}
/// At most 10 a second, always including the last: a page can change its address in a loop.
private func passOnPageURL() {
guard pageURLTimer == nil, let url = pendingPageURL else { return }
pendingPageURL = nil
continuation.yield(.pageURL(url))
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 0.1)
timer.setEventHandler { [weak self] in
guard let self, !self.ended else { return }
self.pageURLTimer = nil
self.passOnPageURL()
}
timer.resume()
pageURLTimer = timer
}
// MARK: Writing and ending
private func write(_ input: InputEvent) {
guard !ended, let data = input.encoded() else { return }
let stalled = data.withUnsafeBytes { raw -> Bool in
var offset = 0
while offset < raw.count {
let n = Darwin.write(fd, raw.baseAddress! + offset, raw.count - offset)
if n > 0 { offset += n } else if n < 0, errno == EINTR { continue } else {
return n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)
}
}
return false
}
if stalled { end(.socketError("The VM stopped taking input.")) }
}
private func end(_ reason: EndReason?) {
guard !ended else { return }
ended = true
shutdown(fd, SHUT_RDWR)
readinessTimer?.cancel()
firstFrameTimer?.cancel()
pageURLTimer?.cancel()
video = nil
frameHandler = nil
if let reason { continuation.yield(.ended(reason)) }
continuation.finish()
}
}
@@ -0,0 +1,50 @@
import CoreVideo
public protocol StreamConnector: Sendable {
/// Returns once a valid HELLO arrives; a version mismatch is reported before any other HELLO problem.
func connect(socketPath: String) async throws -> any StreamConnection
}
/// `@unchecked`: a decoded buffer is never written after VideoToolbox returns it.
public struct DecodedFrame: @unchecked Sendable {
public let pixelBuffer: CVPixelBuffer
public init(_ pixelBuffer: CVPixelBuffer) {
self.pixelBuffer = pixelBuffer
}
}
public protocol StreamConnection: AnyObject, Sendable {
/// Unbounded: reading starts only after `connect` returns, and early events must not be lost.
var events: AsyncStream<StreamEvent> { get }
var decodeEnabled: Bool { get set }
/// Called on the decode queue.
func setFrameHandler(_ handler: @escaping @Sendable (DecodedFrame) -> Void)
func send(_ input: InputEvent)
func close()
}
public enum StreamEvent: Equatable, Sendable {
case hello(version: Int, size: PixelSize)
case contentReady
case progress(Int)
/// Untrusted (from the VM): display only.
case pageURL(URL)
case ended(EndReason)
}
public enum EndReason: Equatable, Sendable {
/// EOF: the browser crashed, the firewall failed, or the VM's watchdog fired.
case closed
case protocolViolation(String)
case noFirstFrame
case decodeErrors
case socketError(String)
}
public enum StreamError: Error, Equatable, Sendable {
case versionMismatch(found: Int, need: Int)
case protocolViolation(String)
case timedOut
case refused
}
@@ -0,0 +1,74 @@
import CoreMedia
import CoreVideo
import Foundation
import VideoToolbox
final class VideoDecoder {
typealias Output = @Sendable (DecodedFrame?, OSStatus) -> Void
private let session: VTDecompressionSession
private let format: CMVideoFormatDescription
private let output: Output
init?(parameterSets: ParameterSets, output: @escaping Output) {
var description: CMFormatDescription?
let sps = [UInt8](parameterSets.sps)
let pps = [UInt8](parameterSets.pps)
let status = sps.withUnsafeBufferPointer { s in
pps.withUnsafeBufferPointer { p in
let pointers = [s.baseAddress!, p.baseAddress!]
return CMVideoFormatDescriptionCreateFromH264ParameterSets(
allocator: kCFAllocatorDefault, parameterSetCount: 2, parameterSetPointers: pointers,
parameterSetSizes: [sps.count, pps.count], nalUnitHeaderLength: 4, formatDescriptionOut: &description)
}
}
guard status == noErr, let description else { return nil }
let attributes: [CFString: Any] = [
kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA,
kCVPixelBufferIOSurfacePropertiesKey: [CFString: Any]() as CFDictionary,
]
var created: VTDecompressionSession?
let sessionStatus = VTDecompressionSessionCreate(
allocator: kCFAllocatorDefault, formatDescription: description, decoderSpecification: nil,
imageBufferAttributes: attributes as CFDictionary, outputCallback: nil, decompressionSessionOut: &created)
guard sessionStatus == noErr, let created else { return nil }
session = created
format = description
self.output = output
}
deinit {
VTDecompressionSessionInvalidate(session)
}
func decode(_ sample: Data) {
var block: CMBlockBuffer?
guard CMBlockBufferCreateWithMemoryBlock(
allocator: kCFAllocatorDefault, memoryBlock: nil, blockLength: sample.count,
blockAllocator: kCFAllocatorDefault, customBlockSource: nil, offsetToData: 0,
dataLength: sample.count, flags: 0, blockBufferOut: &block) == noErr, let block
else { return output(nil, -1) }
sample.withUnsafeBytes { bytes in
_ = CMBlockBufferReplaceDataBytes(with: bytes.baseAddress!, blockBuffer: block,
offsetIntoDestination: 0, dataLength: sample.count)
}
var sampleBuffer: CMSampleBuffer?
var size = sample.count
guard CMSampleBufferCreateReady(
allocator: kCFAllocatorDefault, dataBuffer: block, formatDescription: format,
sampleCount: 1, sampleTimingEntryCount: 0, sampleTimingArray: nil,
sampleSizeEntryCount: 1, sampleSizeArray: &size, sampleBufferOut: &sampleBuffer) == noErr,
let sampleBuffer
else { return output(nil, -1) }
let output = self.output
let status = VTDecompressionSessionDecodeFrame(session, sampleBuffer: sampleBuffer, flags: [],
infoFlagsOut: nil) { status, _, image, _, _ in
if status == noErr, let image {
output(DecodedFrame(image), noErr)
} else if status != noErr {
output(nil, status)
}
}
if status != noErr { output(nil, status) }
}
}
@@ -0,0 +1,214 @@
import Foundation
/// knotchy-agent ↔ viewer framing, both directions: `[u8 type][u32 big-endian payload length][payload]`.
public enum WireProtocol {
/// Must equal `ARG KNOTCHY_PROTOCOL` in vm/Containerfile. 2: proxy/VPN routes. 3: PAGE_URL.
public static let version = 3
public static let headerSize = 5
public static let maxVideoPayload = 2 * 1024 * 1024
/// Also the agent's cap on viewer messages; unknown agent messages up to this size are skipped.
public static let maxOtherPayload = 64 * 1024
public static let maxPageURL = 16 * 1024
}
enum MessageType {
static let hello: UInt8 = 0x01
static let video: UInt8 = 0x02
static let progress: UInt8 = 0x03
static let navigated: UInt8 = 0x04
static let pageURL: UInt8 = 0x05
static let pointerMove: UInt8 = 0x10
static let pointerButton: UInt8 = 0x11
static let scroll: UInt8 = 0x12
static let key: UInt8 = 0x13
static let navigate: UInt8 = 0x14
static let script: UInt8 = 0x15
static let keyframe: UInt8 = 0x16
}
public struct PixelSize: Hashable, Sendable, CustomStringConvertible {
public var width: Int
public var height: Int
public init(width: Int, height: Int) {
self.width = width
self.height = height
}
public var description: String { "\(width)x\(height)" }
}
public enum AgentMessage: Equatable, Sendable {
/// Version 0 means a pre-v1 image, whose HELLO has no version byte.
case hello(version: Int, size: PixelSize)
case video(size: PixelSize, accessUnit: Data)
case progress(Int)
case navigated
case pageURL(String)
case unknown(type: UInt8)
}
extension AgentMessage {
/// For tests and fake agents: out-of-range values trap, so build malformed frames by hand.
func encoded() -> Data {
var payload = Data()
let type: UInt8
switch self {
case .hello(let version, let size):
type = MessageType.hello
if version != 0 { payload.append(UInt8(version)) }
payload.appendBE16(UInt16(size.width))
payload.appendBE16(UInt16(size.height))
case .video(let size, let accessUnit):
type = MessageType.video
payload.appendBE16(UInt16(size.width))
payload.appendBE16(UInt16(size.height))
payload.append(accessUnit)
case .progress(let percent):
type = MessageType.progress
payload.append(UInt8(percent))
case .navigated:
type = MessageType.navigated
case .pageURL(let url):
type = MessageType.pageURL
payload.append(Data(url.utf8))
case .unknown(let unknownType):
type = unknownType
}
return framed(type, payload)
}
}
func framed(_ type: UInt8, _ payload: Data) -> Data {
var message = Data([type])
message.appendBE32(UInt32(payload.count))
message.append(payload)
return message
}
extension Data {
mutating func appendBE16(_ value: UInt16) {
append(UInt8(value >> 8))
append(UInt8(truncatingIfNeeded: value))
}
mutating func appendBE32(_ value: UInt32) {
appendBE16(UInt16(value >> 16))
appendBE16(UInt16(truncatingIfNeeded: value))
}
}
/// Strict incremental parser; the VM is untrusted, so any bad message is a final violation.
/// Call `next()` until nil after each `append`: that keeps the buffer to one message plus one read.
public struct AgentMessageDecoder: Sendable {
public struct ProtocolViolation: Error, Equatable, Sendable, CustomStringConvertible {
public let description: String
init(_ description: String) {
self.description = description
}
}
public private(set) var helloSize: PixelSize?
private var buffer: [UInt8] = []
private var start = 0
private var violation: ProtocolViolation?
public init() {}
public mutating func append(_ bytes: Data) {
guard violation == nil else { return }
if start > 0 {
buffer.removeFirst(start)
start = 0
}
buffer.append(contentsOf: bytes)
}
public var bufferedByteCount: Int { buffer.count - start }
/// Includes bytes already returned; for tests of the bound.
var storedByteCount: Int { buffer.count }
public mutating func next() throws(ProtocolViolation) -> AgentMessage? {
if let violation { throw violation }
do {
return try decodeNext()
} catch {
violation = error
buffer = []
start = 0
throw error
}
}
private mutating func decodeNext() throws(ProtocolViolation) -> AgentMessage? {
guard bufferedByteCount >= WireProtocol.headerSize else { return nil }
let type = buffer[start]
let length = Int(be32(at: start + 1))
try checkHeader(type: type, length: length)
guard bufferedByteCount - WireProtocol.headerSize >= length else { return nil }
let p = start + WireProtocol.headerSize
start = p + length
switch type {
case MessageType.hello:
let version = length == 5 ? Int(buffer[p]) : 0
let sizeAt = p + length - 4
let size = PixelSize(width: Int(be16(at: sizeAt)), height: Int(be16(at: sizeAt + 2)))
helloSize = size
return .hello(version: version, size: size)
case MessageType.video:
let size = PixelSize(width: Int(be16(at: p)), height: Int(be16(at: p + 2)))
guard size == helloSize else {
throw ProtocolViolation("VIDEO is \(size) but HELLO said \(helloSize.map(\.description) ?? "nothing")")
}
return .video(size: size, accessUnit: Data(buffer[(p + 4)..<(p + length)]))
case MessageType.progress:
let percent = Int(buffer[p])
guard percent <= 100 else { throw ProtocolViolation("PROGRESS \(percent) is over 100") }
return .progress(percent)
case MessageType.navigated:
return .navigated
case MessageType.pageURL:
guard let url = String(validating: buffer[p..<(p + length)], as: UTF8.self) else {
throw ProtocolViolation("PAGE_URL isn't UTF-8")
}
return .pageURL(url)
default:
return .unknown(type: type)
}
}
private func checkHeader(type: UInt8, length: Int) throws(ProtocolViolation) {
func name() -> String { String(format: "type 0x%02x", type) }
if helloSize == nil, type != MessageType.hello {
throw ProtocolViolation("\(name()) before HELLO")
}
let valid: Bool
switch type {
case MessageType.hello:
guard helloSize == nil else { throw ProtocolViolation("a second HELLO") }
valid = length == 5 || length == 4
case MessageType.video:
valid = (4...WireProtocol.maxVideoPayload).contains(length)
case MessageType.progress:
valid = length == 1
case MessageType.navigated:
valid = length == 0
case MessageType.pageURL:
valid = (1...WireProtocol.maxPageURL).contains(length)
default:
valid = length <= WireProtocol.maxOtherPayload
}
guard valid else { throw ProtocolViolation("\(name()) with a \(length)-byte payload") }
}
private func be16(at i: Int) -> UInt16 {
UInt16(buffer[i]) << 8 | UInt16(buffer[i + 1])
}
private func be32(at i: Int) -> UInt32 {
UInt32(be16(at: i)) << 16 | UInt32(be16(at: i + 2))
}
}
@@ -0,0 +1,67 @@
/// Tests call `fireDue()` after advancing a `TestClock`; firing is idempotent for a given `now`.
@MainActor
final class DeadlineTimers<Key: Hashable & Sendable> {
var onFire: @MainActor (Key) -> Void = { _ in }
private struct Entry {
var deadline: ContinuousClock.Instant
var order: Int
}
private let clock: any KnotchyClock
private var entries: [Key: Entry] = [:]
private var nextOrder = 0
private var driver: Task<Void, Never>?
init(clock: any KnotchyClock) {
self.clock = clock
}
isolated deinit {
driver?.cancel()
}
func set(_ key: Key, after duration: Duration) {
set(key, at: clock.now + duration)
}
func set(_ key: Key, at deadline: ContinuousClock.Instant) {
nextOrder += 1
entries[key] = Entry(deadline: deadline, order: nextOrder)
reschedule()
}
func cancel(_ key: Key) {
guard entries.removeValue(forKey: key) != nil else { return }
reschedule()
}
func deadline(of key: Key) -> ContinuousClock.Instant? {
entries[key]?.deadline
}
/// Keys `onFire` re-arms at or before `now` fire in this same pass; doing that every time loops forever.
func fireDue() {
let now = clock.now
while let (key, _) = entries
.filter({ $0.value.deadline <= now })
.min(by: { ($0.value.deadline, $0.value.order) < ($1.value.deadline, $1.value.order) }) {
entries[key] = nil
onFire(key)
}
reschedule()
}
private func reschedule() {
driver?.cancel()
guard let next = entries.values.map(\.deadline).min() else {
driver = nil
return
}
driver = Task { [weak self, clock] in
do { try await clock.sleep(until: next) } catch { return }
guard !Task.isCancelled else { return } // Replaced while waking up.
self?.fireDue()
}
}
}
@@ -0,0 +1,15 @@
/// `ContinuousClock` keeps counting while the Mac sleeps, so an expired idle countdown fires on wake.
public protocol KnotchyClock: Sendable {
var now: ContinuousClock.Instant { get }
func sleep(until deadline: ContinuousClock.Instant) async throws
}
public struct SystemClock: KnotchyClock {
public init() {}
public var now: ContinuousClock.Instant { .now }
public func sleep(until deadline: ContinuousClock.Instant) async throws {
try await Task.sleep(until: deadline, clock: .continuous)
}
}
+121
View File
@@ -0,0 +1,121 @@
import CoreImage
import Foundation
import ImageIO
import KnotchyCore
import Synchronization
import UniformTypeIdentifiers
// knotchyctl snapshot <url> <out.png> [--after <seconds>] [--click <render x>,<render y>]
// [--proxy <http|socks5>://[user:pass@]host:port] [--wireguard <file.conf>] [--allow-local-proxy]
let arguments = Array(CommandLine.arguments.dropFirst())
func fail(_ message: String) -> Never {
FileHandle.standardError.write(Data("knotchyctl: \(message)\n".utf8))
exit(1)
}
func vmSources() -> URL? {
let repo = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent()
.deletingLastPathComponent().appendingPathComponent("vm")
return FileManager.default.fileExists(atPath: repo.appendingPathComponent("Containerfile").path) ? repo : nil
}
func writePNG(_ buffer: CVPixelBuffer, to path: String) throws {
let image = CIImage(cvPixelBuffer: buffer)
guard let cgImage = CIContext().createCGImage(image, from: image.extent),
let destination = CGImageDestinationCreateWithURL(URL(fileURLWithPath: path) as CFURL,
UTType.png.identifier as CFString, 1, nil)
else { throw CocoaError(.fileWriteUnknown) }
CGImageDestinationAddImage(destination, cgImage, nil)
guard CGImageDestinationFinalize(destination) else { throw CocoaError(.fileWriteUnknown) }
}
// Thrown rather than fail()ed after the VM starts, so the VM still gets destroyed.
struct SnapshotError: Error, CustomStringConvertible {
let description: String
init(_ description: String) { self.description = description }
}
final class LatestFrame: Sendable {
private let frame = Mutex<DecodedFrame?>(nil)
func set(_ new: DecodedFrame) { frame.withLock { $0 = new } }
func get() -> DecodedFrame? { frame.withLock { $0 } }
}
func snapshot(url: URL, out: String, after: Double, click: (x: UInt16, y: UInt16)?, route: NetworkRoute) async throws {
let backend = ContainerCLIBackend(vmSources: vmSources())
backend.setNetworkRoute(route)
if arguments.contains("--allow-local-proxy") { backend.allowLocalProxy() }
// An outdated image would silently ignore the proxy or VPN route.
guard case .ready = await backend.preflight() else { fail("the VM image isn't ready; build it with scripts/build-vm.sh") }
let id = "knotchyctl-" + String(format: "%08x", UInt32.random(in: .min ... .max))
let started = ContinuousClock.now
let vm = try await backend.start(id: id, url: url, renderSize: SessionController.renderSize,
blockCIDRs: HostNetworks.current())
do {
let connection = try await StreamClient(renderSize: SessionController.renderSize).connect(socketPath: vm.socketPath)
let latest = LatestFrame()
connection.setFrameHandler { latest.set($0) }
var ready = false
for await event in connection.events {
if event == .contentReady { ready = true; break }
if case .ended(let reason) = event { throw CocoaError(.featureUnsupported, userInfo: ["reason": "\(reason)"]) }
}
guard ready else { throw SnapshotError("the stream ended before content was ready") }
print("content ready after \(ContinuousClock.now - started)")
let pages = Task {
for await event in connection.events {
if case .pageURL(let page) = event { print("page \(page.absoluteString)") }
}
}
defer { pages.cancel() }
if let click {
try await Task.sleep(for: .seconds(1))
connection.send(.pointerMove(x: click.x, y: click.y, mods: []))
connection.send(.pointerButton(.left, pressed: true, x: click.x, y: click.y, mods: .leftButton))
connection.send(.pointerButton(.left, pressed: false, x: click.x, y: click.y, mods: []))
}
try await Task.sleep(for: .seconds(after))
guard let frame = latest.get() else { throw SnapshotError("no frame decoded") }
try writePNG(frame.pixelBuffer, to: out)
print("wrote \(out)")
connection.close()
} catch {
try? await backend.destroy(id: id)
throw error
}
try await backend.destroy(id: id)
}
guard arguments.first == "snapshot", arguments.count >= 3, let url = URL(string: arguments[1]) else {
fail("usage: knotchyctl snapshot <url> <out.png> [--after <seconds>] [--click <x>,<y>] [--proxy <url>] [--wireguard <file>]")
}
var route = NetworkRoute.direct
if let index = arguments.firstIndex(of: "--proxy") {
guard let proxy = URLComponents(string: arguments[safe: index + 1] ?? ""), let host = proxy.host, let port = proxy.port,
let kind = ProxySettings.Kind(rawValue: proxy.scheme ?? "")
else { fail("--proxy needs http://[user:pass@]host:port or socks5://…") }
route = .proxy(ProxySettings(kind: kind, host: host, port: port,
username: proxy.user ?? "", password: proxy.password ?? ""))
}
if let index = arguments.firstIndex(of: "--wireguard") {
guard let text = try? String(contentsOfFile: arguments[safe: index + 1] ?? "", encoding: .utf8) else {
fail("--wireguard needs a readable .conf file")
}
do { route = .wireGuard(try WireGuardConfig.parse(text)) } catch { fail("the WireGuard config: \(error)") }
}
let after = arguments.firstIndex(of: "--after").flatMap { Double(arguments[safe: $0 + 1] ?? "") } ?? 3
let click: (x: UInt16, y: UInt16)? = arguments.firstIndex(of: "--click").flatMap { index in
let parts = (arguments[safe: index + 1] ?? "").split(separator: ",").compactMap { UInt16($0) }
return parts.count == 2 ? (parts[0], parts[1]) : nil
}
do {
try await snapshot(url: url, out: arguments[2], after: after, click: click, route: route)
} catch {
fail("\(error)")
}
extension Array {
subscript(safe index: Int) -> Element? { indices.contains(index) ? self[index] : nil }
}