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
}
}