mirror of
https://github.com/aculix/knotchy.git
synced 2026-10-27 07:15:53 +00:00
Initial release
This commit is contained in:
commit
4b67728cce
108 files changed
+13310
No files matched your search
@@ -0,0 +1,249 @@
|
||||
import AppKit
|
||||
import KnotchyCore
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let backend: ContainerCLIBackend
|
||||
let controller: SessionController
|
||||
let services: ServicesProvider
|
||||
let settings = AppSettings()
|
||||
let vmStatus: VMStatus
|
||||
let unsupported: Unsupported?
|
||||
let setupFlow: SetupFlow
|
||||
var isSupportedMac: Bool { unsupported == nil }
|
||||
private(set) lazy var setupWindow = SetupWindowController(
|
||||
unsupported.map(SetupWindowController.Content.unsupported) ?? .setup(setupFlow))
|
||||
private(set) lazy var settingsWindow = SettingsWindowController(app: self)
|
||||
private var notch: NotchWindowController?
|
||||
private var copiedLinkHotKey: GlobalHotKey?
|
||||
private var wantsCopiedLinkHotKey = false
|
||||
private var signalSources: [DispatchSourceSignal] = []
|
||||
private var quitting = false
|
||||
|
||||
override init() {
|
||||
unsupported = Self.detectUnsupported()
|
||||
let vmSources = Bundle.main.resourceURL?.appendingPathComponent("vm")
|
||||
let hasSources = vmSources.map { FileManager.default.fileExists(atPath: $0.appendingPathComponent("Containerfile").path) } ?? false
|
||||
backend = ContainerCLIBackend(vmSources: hasSources ? vmSources : nil)
|
||||
controller = SessionController(
|
||||
backend: backend,
|
||||
connector: StreamClient(renderSize: SessionController.renderSize),
|
||||
diskSpace: SystemDiskSpace(),
|
||||
hostNetworks: { HostNetworks.current() },
|
||||
requestQuit: { NSApp.terminate(nil) }
|
||||
)
|
||||
services = ServicesProvider(controller: controller)
|
||||
vmStatus = VMStatus(backend: backend, controller: controller)
|
||||
setupFlow = SetupFlow(host: SystemHost(), installer: SystemRuntimeInstaller(), driver: controller)
|
||||
super.init()
|
||||
controller.quitsWhenIdle = false
|
||||
controller.showSetup = { [weak self] in self?.setupWindow.show() }
|
||||
if isSupportedMac {
|
||||
settings.onChange = { [weak self] in self?.applySettings() }
|
||||
applySettings()
|
||||
} else {
|
||||
services.redirect = { [weak self] in self?.setupWindow.show() }
|
||||
}
|
||||
}
|
||||
|
||||
private static func detectUnsupported() -> Unsupported? {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.environment["KNOTCHY_PRETEND"] == "no-virtualization" { return .noVirtualization }
|
||||
#endif
|
||||
return SystemHost.unsupported()
|
||||
}
|
||||
|
||||
private func applySettings() {
|
||||
settings.apply(to: controller)
|
||||
backend.setNetworkRoute(settings.route)
|
||||
guard settings.copiedLinkShortcut != wantsCopiedLinkHotKey else { return }
|
||||
wantsCopiedLinkHotKey = settings.copiedLinkShortcut
|
||||
if wantsCopiedLinkHotKey {
|
||||
copiedLinkHotKey = GlobalHotKey.openCopiedLink { [services] in services.openCopiedLink() }
|
||||
if copiedLinkHotKey == nil { ServicesLog.write("\(GlobalHotKey.openCopiedLinkLabel) is taken by another app") }
|
||||
} else {
|
||||
copiedLinkHotKey = nil
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillFinishLaunching(_ notification: Notification) {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
// Registered before launch finishes, so a Services request that launched the app isn't lost.
|
||||
NSApp.servicesProvider = services
|
||||
NSUpdateDynamicServices()
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
#if DEBUG
|
||||
if let directory = DebugTour.argument(after: "--setup-snapshot") {
|
||||
Task {
|
||||
await DebugTour.snapshotSetup(app: self, to: URL(fileURLWithPath: directory))
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
#endif
|
||||
guard isSupportedMac else { return setupWindow.show() }
|
||||
notch = NotchWindowController(controller: controller, routeLabel: { [backend] in backend.lastStartedRoute.label })
|
||||
vmStatus.refresh()
|
||||
|
||||
// Route Ctrl-C, kill and logout through applicationShouldTerminate so the VMs get destroyed.
|
||||
for sig in [SIGINT, SIGTERM, SIGHUP] {
|
||||
signal(sig, SIG_IGN)
|
||||
let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
|
||||
source.setEventHandler { NSApp.terminate(nil) }
|
||||
source.resume()
|
||||
signalSources.append(source)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let all = CommandLine.arguments
|
||||
if let index = all.firstIndex(of: "--tour"), index + 2 < all.count, let url = URL(string: all[index + 2]),
|
||||
let notch {
|
||||
let directory = URL(fileURLWithPath: all[index + 1])
|
||||
Task { await DebugTour.run(controller: controller, snapshot: { notch.snapshot(to: directory, name: $0) }, url: url) }
|
||||
return
|
||||
}
|
||||
if all.contains("--settings-timing") {
|
||||
Task {
|
||||
await DebugTour.timeNetworkSwitching(app: self)
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
if let index = all.firstIndex(of: "--settings-snapshot"), index + 1 < all.count {
|
||||
let directory = URL(fileURLWithPath: all[index + 1])
|
||||
Task {
|
||||
await DebugTour.snapshotSettings(app: self, to: directory)
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
if let index = all.firstIndex(of: "--tour-timing"), index + 1 < all.count, let url = URL(string: all[index + 1]) {
|
||||
Task { await DebugTour.run(controller: controller, snapshot: nil, url: url) }
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
Task { await openSetupIfNeeded() }
|
||||
let arguments = CommandLine.arguments.dropFirst().filter { !$0.hasPrefix("-") }
|
||||
if let first = arguments.first { controller.open(text: first) }
|
||||
}
|
||||
|
||||
private func openSetupIfNeeded() async {
|
||||
let runtime = await backend.runtimeStatus()
|
||||
let completed = UserDefaults.standard.bool(forKey: SetupLaunchRule.completedKey)
|
||||
if SetupLaunchRule.shouldOpen(hostSupported: true, setupCompleted: completed, runtime: runtime) {
|
||||
setupWindow.show()
|
||||
}
|
||||
}
|
||||
|
||||
// From the browser extensions: Chrome's Services menu never sees a link's address.
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
guard isSupportedMac else { return setupWindow.show() }
|
||||
for url in urls where url.scheme == "knotchy" && url.host() == "open" {
|
||||
let link = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?.first { $0.name == "url" }?.value
|
||||
ServicesLog.write("knotchy:// request, link \(link == nil ? "missing" : "present")")
|
||||
if let link, let target = URL(string: link) {
|
||||
controller.open(target)
|
||||
} else {
|
||||
controller.open(text: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
guard !quitting else { return .terminateNow }
|
||||
quitting = true
|
||||
setupFlow.cancel()
|
||||
if setupFlow.isDownloading { SystemRuntimeInstaller.removeLeftovers() }
|
||||
// Not Now mid-build closes the window before the build ends.
|
||||
if setupFlow.allRequiredDone { UserDefaults.standard.set(true, forKey: SetupLaunchRule.completedKey) }
|
||||
// Synchronous on purpose: terminate: often runs inside a non-reentrant main-queue block; awaiting here deadlocked.
|
||||
notch?.hide()
|
||||
CATransaction.flush()
|
||||
CLIRunner.stopBuilds()
|
||||
controller.closeBackendChain()
|
||||
let ids = Array(controller.liveIDs.snapshot)
|
||||
if !ids.isEmpty { backend.destroyBlocking(ids: ids, deadline: .seconds(20)) }
|
||||
return .terminateNow
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ServicesProvider: NSObject {
|
||||
private let controller: SessionController
|
||||
/// In minimal mode, requests only bring the explanation window forward, so the calling app gets a normal reply.
|
||||
var redirect: (@MainActor () -> Void)?
|
||||
|
||||
init(controller: SessionController) {
|
||||
self.controller = controller
|
||||
}
|
||||
|
||||
@objc func openLink(_ pasteboard: NSPasteboard, userData: String?, error: AutoreleasingUnsafeMutablePointer<NSString?>) {
|
||||
open(from: pasteboard, service: "Open in Knotchy")
|
||||
}
|
||||
|
||||
@objc func openCopiedLink(_ pasteboard: NSPasteboard, userData: String?, error: AutoreleasingUnsafeMutablePointer<NSString?>) {
|
||||
open(from: .general, service: "Open Copied Link in Knotchy")
|
||||
}
|
||||
|
||||
func openCopiedLink() {
|
||||
open(from: .general, service: "Shortcut")
|
||||
}
|
||||
|
||||
private func open(from pasteboard: NSPasteboard, service: String) {
|
||||
if let redirect { return redirect() }
|
||||
let types = pasteboard.types?.map(\.rawValue) ?? []
|
||||
let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] ?? []
|
||||
let url: URL?
|
||||
let source: String
|
||||
if let found = urls.first(where: { LinkIntake.validate($0) != nil }) {
|
||||
(url, source) = (found, "url")
|
||||
} else if let html = pasteboard.string(forType: .html), let found = LinkIntake.extractURL(fromHTML: html) {
|
||||
// A selected hyperlink's text rarely holds the address; its HTML does.
|
||||
(url, source) = (found, "html")
|
||||
} else if let found = Self.firstLink(inRTF: pasteboard.data(forType: .rtf)) {
|
||||
(url, source) = (found, "rtf")
|
||||
} else if let text = pasteboard.string(forType: .string), let found = LinkIntake.extractURL(from: text) {
|
||||
(url, source) = (found, "text")
|
||||
} else {
|
||||
(url, source) = (nil, "none")
|
||||
}
|
||||
ServicesLog.write("\(service): types \(types), link from \(source)")
|
||||
if let url {
|
||||
controller.open(url)
|
||||
} else {
|
||||
controller.open(text: "")
|
||||
}
|
||||
}
|
||||
|
||||
private static func firstLink(inRTF data: Data?) -> URL? {
|
||||
guard let data, let text = NSAttributedString(rtf: data, documentAttributes: nil) else { return nil }
|
||||
var found: URL?
|
||||
text.enumerateAttribute(.link, in: NSRange(location: 0, length: text.length)) { value, _, stop in
|
||||
let url = (value as? URL) ?? (value as? String).flatMap(URL.init(string:))
|
||||
if let url, let valid = LinkIntake.validate(url) {
|
||||
found = valid
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
// Pasteboard types and sources only; never log the links themselves.
|
||||
enum ServicesLog {
|
||||
static func write(_ line: String) {
|
||||
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Logs/Knotchy.log")
|
||||
let entry = "\(ISO8601DateFormatter().string(from: Date())) \(line)\n"
|
||||
if let handle = try? FileHandle(forWritingTo: url) {
|
||||
handle.seekToEndOfFile()
|
||||
handle.write(Data(entry.utf8))
|
||||
try? handle.close()
|
||||
} else {
|
||||
try? Data(entry.utf8).write(to: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import AppKit
|
||||
import Carbon.HIToolbox
|
||||
import KnotchyCore
|
||||
import Observation
|
||||
import ServiceManagement
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppSettings {
|
||||
static let autoDestroyChoices = [1, 2, 5, 10, 15, 30]
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
@ObservationIgnored var onChange: () -> Void = {}
|
||||
|
||||
var autoDestroyMinutes: Int {
|
||||
didSet { save(autoDestroyMinutes, "autoDestroyMinutes") }
|
||||
}
|
||||
|
||||
var opensLarge: Bool {
|
||||
didSet { save(opensLarge, "opensLarge") }
|
||||
}
|
||||
|
||||
var copiedLinkShortcut: Bool {
|
||||
didSet { save(copiedLinkShortcut, "copiedLinkShortcut") }
|
||||
}
|
||||
|
||||
// MARK: Network
|
||||
|
||||
enum NetworkMode: String, CaseIterable {
|
||||
case direct, proxy, vpn
|
||||
}
|
||||
|
||||
var networkMode: NetworkMode {
|
||||
didSet { save(networkMode.rawValue, "networkMode") }
|
||||
}
|
||||
|
||||
var proxyKind: ProxySettings.Kind {
|
||||
didSet { save(proxyKind.rawValue, "proxyKind") }
|
||||
}
|
||||
|
||||
var proxyHost: String {
|
||||
didSet { save(proxyHost, "proxyHost") }
|
||||
}
|
||||
|
||||
var proxyPort: Int {
|
||||
didSet { save(proxyPort, "proxyPort") }
|
||||
}
|
||||
|
||||
var proxyUsername: String {
|
||||
didSet { save(proxyUsername, "proxyUsername") }
|
||||
}
|
||||
|
||||
// Debounced: after an app update, every keychain write can raise a keychain prompt.
|
||||
var proxyPassword: String {
|
||||
didSet {
|
||||
passwordSave?.cancel()
|
||||
let password = proxyPassword
|
||||
passwordSave = Task { [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
Keychain.write(password, for: "proxy-password")
|
||||
self.defaults.set(!password.isEmpty, forKey: "proxyHasPassword")
|
||||
}
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
@ObservationIgnored private var passwordSave: Task<Void, Never>?
|
||||
|
||||
// In the keychain, not UserDefaults: WireGuard configs hold private keys.
|
||||
private(set) var wireGuardConfigs: [String: String] {
|
||||
didSet {
|
||||
let json = (try? JSONEncoder().encode(wireGuardConfigs)).flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||
Keychain.write(wireGuardConfigs.isEmpty ? "" : json, for: "wireguard-configs")
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
|
||||
var wireGuardServer: String {
|
||||
didSet { save(wireGuardServer, "wireGuardServer") }
|
||||
}
|
||||
|
||||
var selectedWireGuard: WireGuardConfig? {
|
||||
wireGuardConfigs[wireGuardServer].flatMap { try? WireGuardConfig.parse($0) }
|
||||
}
|
||||
|
||||
var wireGuardNames: [String] {
|
||||
wireGuardConfigs.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
// An unusable proxy or VPN must fail the start, never fall back to a direct connection.
|
||||
var route: NetworkRoute {
|
||||
switch networkMode {
|
||||
case .direct:
|
||||
return .direct
|
||||
case .proxy:
|
||||
let host = proxyHost.trimmingCharacters(in: .whitespaces)
|
||||
guard !host.isEmpty, (1...65535).contains(proxyPort) else {
|
||||
return .unusable(reason: "The proxy isn't set up. Add its server and port in Settings → Network, or switch to Direct.")
|
||||
}
|
||||
return .proxy(ProxySettings(kind: proxyKind, host: host, port: proxyPort,
|
||||
username: proxyUsername, password: proxyPassword))
|
||||
case .vpn:
|
||||
guard let text = wireGuardConfigs[wireGuardServer] else {
|
||||
return .unusable(reason: "No VPN server is chosen. Import a WireGuard config in Settings → Network, or switch to Direct.")
|
||||
}
|
||||
do {
|
||||
var config = try WireGuardConfig.parse(text)
|
||||
config.name = wireGuardServer
|
||||
return .wireGuard(config)
|
||||
} catch {
|
||||
return .unusable(reason: "The WireGuard config “\(wireGuardServer)” \(error.message).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A hint for Settings only; sessions enforce `ProxySettings.isLocal` on the resolved address.
|
||||
var proxyIsLocal: Bool {
|
||||
let host = proxyHost.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
if host == "localhost" || [".local", ".lan", ".home", ".internal"].contains(where: host.hasSuffix) { return true }
|
||||
let octets = host.split(separator: ".", omittingEmptySubsequences: false).compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let (a, b) = (octets[0], octets[1])
|
||||
return a == 10 || a == 127 || (a == 172 && (16...31).contains(b)) || (a == 192 && b == 168)
|
||||
|| (a == 100 && (64...127).contains(b)) || (a == 169 && b == 254)
|
||||
}
|
||||
|
||||
func importWireGuard(from urls: [URL]) async -> String? {
|
||||
var found: [(String, String)] = []
|
||||
var problems: [String] = []
|
||||
for url in urls {
|
||||
let files: [(String, String)]
|
||||
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
|
||||
if url.pathExtension.lowercased() == "zip" {
|
||||
files = await Task.detached { Self.confFiles(inZip: url) }.value
|
||||
if files.isEmpty { problems.append("\(url.lastPathComponent) has no .conf files") }
|
||||
} else if size <= 64 * 1024, let text = try? String(contentsOf: url, encoding: .utf8) {
|
||||
files = [(url.deletingPathExtension().lastPathComponent, text)]
|
||||
} else {
|
||||
problems.append("couldn't read \(url.lastPathComponent)")
|
||||
continue
|
||||
}
|
||||
for (name, text) in files {
|
||||
do {
|
||||
_ = try WireGuardConfig.parse(text)
|
||||
found.append((name, text))
|
||||
} catch {
|
||||
problems.append("“\(name)” \(error.message)")
|
||||
}
|
||||
}
|
||||
}
|
||||
guard !found.isEmpty else { return problems.first.map { "Nothing imported: \($0)." } ?? "Nothing imported." }
|
||||
var configs = wireGuardConfigs
|
||||
for (name, text) in found { configs[name] = text }
|
||||
wireGuardConfigs = configs
|
||||
wireGuardServer = found.map(\.0).sorted { $0.localizedStandardCompare($1) == .orderedAscending }[0]
|
||||
networkMode = .vpn
|
||||
return problems.isEmpty ? nil : "Imported \(found.count); skipped: \(problems.joined(separator: "; "))."
|
||||
}
|
||||
|
||||
func removeWireGuardConfigs() {
|
||||
wireGuardConfigs = [:]
|
||||
wireGuardServer = ""
|
||||
}
|
||||
|
||||
private nonisolated static func confFiles(inZip url: URL) -> [(String, String)] {
|
||||
// Output past `limit` fails the read, so a zip bomb can't exhaust memory.
|
||||
func unzip(_ arguments: [String], limit: Int) -> Data? {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
|
||||
process.arguments = arguments
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = FileHandle.nullDevice
|
||||
guard (try? process.run()) != nil else { return nil }
|
||||
let pid = process.processIdentifier
|
||||
let watchdog = DispatchWorkItem { kill(pid, SIGKILL) }
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 10, execute: watchdog)
|
||||
defer { watchdog.cancel() }
|
||||
var data = Data()
|
||||
while let chunk = try? pipe.fileHandleForReading.read(upToCount: 16 * 1024), !chunk.isEmpty {
|
||||
data.append(chunk)
|
||||
if data.count > limit {
|
||||
process.terminate()
|
||||
process.waitUntilExit()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0 ? data : nil
|
||||
}
|
||||
guard let listing = unzip(["-Z1", url.path], limit: 1024 * 1024).flatMap({ String(data: $0, encoding: .utf8) })
|
||||
else { return [] }
|
||||
let entries = listing.split(whereSeparator: \.isNewline).map(String.init)
|
||||
.filter { $0.lowercased().hasSuffix(".conf") && !$0.hasPrefix("__MACOSX") }
|
||||
.prefix(500)
|
||||
return entries.compactMap { entry in
|
||||
guard let data = unzip(["-p", url.path, entry], limit: 64 * 1024),
|
||||
let text = String(data: data, encoding: .utf8) else { return nil }
|
||||
return (URL(fileURLWithPath: entry).deletingPathExtension().lastPathComponent, text)
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
defaults.register(defaults: ["autoDestroyMinutes": 5, "opensLarge": false, "copiedLinkShortcut": true,
|
||||
"networkMode": "direct", "proxyKind": "http", "proxyPort": 8080])
|
||||
autoDestroyMinutes = defaults.integer(forKey: "autoDestroyMinutes")
|
||||
opensLarge = defaults.bool(forKey: "opensLarge")
|
||||
copiedLinkShortcut = defaults.bool(forKey: "copiedLinkShortcut")
|
||||
networkMode = NetworkMode(rawValue: defaults.string(forKey: "networkMode") ?? "") ?? .direct
|
||||
proxyKind = ProxySettings.Kind(rawValue: defaults.string(forKey: "proxyKind") ?? "") ?? .http
|
||||
proxyHost = defaults.string(forKey: "proxyHost") ?? ""
|
||||
proxyPort = defaults.integer(forKey: "proxyPort")
|
||||
let username = defaults.string(forKey: "proxyUsername") ?? ""
|
||||
let server = defaults.string(forKey: "wireGuardServer") ?? ""
|
||||
proxyUsername = username
|
||||
wireGuardServer = server
|
||||
// Skip the keychain (and its possible prompt) unless a secret was saved.
|
||||
proxyPassword = defaults.bool(forKey: "proxyHasPassword") || !username.isEmpty
|
||||
? Keychain.read("proxy-password") ?? "" : ""
|
||||
wireGuardConfigs = server.isEmpty ? [:] :
|
||||
(Keychain.read("wireguard-configs")?.data(using: .utf8))
|
||||
.flatMap { try? JSONDecoder().decode([String: String].self, from: $0) } ?? [:]
|
||||
}
|
||||
|
||||
private func save(_ value: Any, _ key: String) {
|
||||
defaults.set(value, forKey: key)
|
||||
onChange()
|
||||
}
|
||||
|
||||
func apply(to controller: SessionController) {
|
||||
controller.idleDestroyDelay = .seconds(autoDestroyMinutes * 60)
|
||||
controller.opensExpanded = opensLarge
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum LoginItem {
|
||||
static var isEnabled: Bool {
|
||||
SMAppService.mainApp.status == .enabled
|
||||
}
|
||||
|
||||
static func set(_ enabled: Bool) -> String? {
|
||||
do {
|
||||
if enabled {
|
||||
try SMAppService.mainApp.register()
|
||||
} else {
|
||||
try SMAppService.mainApp.unregister()
|
||||
}
|
||||
return nil
|
||||
} catch {
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Carbon, because its hot keys need no Accessibility permission.
|
||||
@MainActor
|
||||
final class GlobalHotKey {
|
||||
static let openCopiedLinkLabel = "⌃⌥⌘L"
|
||||
|
||||
private var hotKey: EventHotKeyRef?
|
||||
private var handler: EventHandlerRef?
|
||||
private let action: () -> Void
|
||||
|
||||
static func openCopiedLink(_ action: @escaping () -> Void) -> GlobalHotKey? {
|
||||
GlobalHotKey(keyCode: UInt32(kVK_ANSI_L), modifiers: UInt32(controlKey | optionKey | cmdKey), action: action)
|
||||
}
|
||||
|
||||
private init?(keyCode: UInt32, modifiers: UInt32, action: @escaping () -> Void) {
|
||||
self.action = action
|
||||
var type = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
|
||||
let installed = InstallEventHandler(GetApplicationEventTarget(), { _, _, userData in
|
||||
guard let userData else { return OSStatus(eventNotHandledErr) }
|
||||
let hotKey = Unmanaged<GlobalHotKey>.fromOpaque(userData).takeUnretainedValue()
|
||||
MainActor.assumeIsolated { hotKey.action() }
|
||||
return noErr
|
||||
}, 1, &type, Unmanaged.passUnretained(self).toOpaque(), &handler)
|
||||
guard installed == noErr else { return nil }
|
||||
let id = EventHotKeyID(signature: OSType(0x4B4E_4F54), id: 1)
|
||||
// Exclusive, so a shortcut another app owns fails instead of silently sharing.
|
||||
guard RegisterEventHotKey(keyCode, modifiers, id, GetApplicationEventTarget(),
|
||||
OptionBits(kEventHotKeyExclusive), &hotKey) == noErr else {
|
||||
RemoveEventHandler(handler)
|
||||
handler = nil // deinit also runs after a failed init; don't remove it twice.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
isolated deinit {
|
||||
if let hotKey { UnregisterEventHotKey(hotKey) }
|
||||
if let handler { RemoveEventHandler(handler) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import AppKit
|
||||
|
||||
enum ChromeExtension {
|
||||
/// Set once the Chrome Web Store assigns the item ID; until then the setup window hides the row.
|
||||
static let storeURL: URL? = nil
|
||||
|
||||
struct Browser: Equatable {
|
||||
let name: String
|
||||
let app: URL
|
||||
}
|
||||
|
||||
// Chromium browsers all install from the Chrome Web Store.
|
||||
private static let candidates = [("Chrome", "com.google.Chrome"), ("Edge", "com.microsoft.edgemac"),
|
||||
("Brave", "com.brave.Browser"), ("Arc", "company.thebrowser.Browser")]
|
||||
|
||||
static func installedBrowser() -> Browser? {
|
||||
guard storeURL != nil else { return nil }
|
||||
for (name, id) in candidates {
|
||||
if let app = NSWorkspace.shared.urlForApplication(withBundleIdentifier: id) { return Browser(name: name, app: app) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func open(in browser: Browser) {
|
||||
guard let storeURL else { return }
|
||||
NSWorkspace.shared.open([storeURL], withApplicationAt: browser.app, configuration: NSWorkspace.OpenConfiguration())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
#if DEBUG
|
||||
import AppKit
|
||||
import KnotchyCore
|
||||
import QuartzCore
|
||||
import SwiftUI
|
||||
|
||||
// `Knotchy --tour <dir> <url>` saves PNGs of each state; `--tour-timing <url>` reports dropped frames.
|
||||
// Checks the UI without screen recording permission; video doesn't appear in the snapshots.
|
||||
@MainActor
|
||||
enum DebugTour {
|
||||
static func run(controller: SessionController, snapshot: ((String) -> Void)?, url: URL) async {
|
||||
let meter = FrameMeter()
|
||||
func wait(_ seconds: Double) async { try? await Task.sleep(for: .seconds(seconds)) }
|
||||
func step(_ name: String, _ times: [Double], _ action: () -> Void) async {
|
||||
if snapshot == nil { meter.start() }
|
||||
action()
|
||||
var elapsed = 0.0
|
||||
for time in times {
|
||||
await wait(time - elapsed)
|
||||
elapsed = time
|
||||
snapshot?("\(name)-\(Int(time * 1000))ms")
|
||||
}
|
||||
if snapshot == nil { print("\(name): \(meter.stop())") }
|
||||
}
|
||||
|
||||
await step("01-no-link", [0.08, 0.2, 0.6, 1.0]) { controller.open(text: "no link here") }
|
||||
await wait(2.5)
|
||||
await step("02-opening", [0.06, 0.12, 0.2, 0.3, 0.45, 0.7, 1.2]) { controller.open(url) }
|
||||
for _ in 0..<300 where controller.state != .live(.peek) { await wait(0.1) }
|
||||
await wait(1)
|
||||
await step("04-expand", [0.08, 0.16, 0.25, 0.4, 1.0]) { controller.expand() }
|
||||
await step("05-shrink", [0.08, 0.16, 0.3, 1.0]) { controller.shrink() }
|
||||
await step("06-fold", [0.06, 0.12, 0.2, 0.35, 1.0]) { controller.clickedOutside() }
|
||||
await wait(0.5)
|
||||
await step("07-unfold", [0.06, 0.12, 0.2, 0.35, 1.0]) { controller.pointerEntered() }
|
||||
await step("08-destroy", [0.08, 0.2, 0.4, 0.8, 1.6, 2.2, 2.6]) { controller.destroy() }
|
||||
await wait(1)
|
||||
await step("09-reopen", [0.06, 0.12, 0.2, 0.3, 0.45, 0.7, 1.2]) { controller.open(url) }
|
||||
for _ in 0..<300 where controller.state != .live(.peek) { await wait(0.1) }
|
||||
await step("10-destroy", [0.3, 2.6]) { controller.destroy() }
|
||||
await wait(1)
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
|
||||
static func argument(after flag: String) -> String? {
|
||||
let all = CommandLine.arguments
|
||||
guard let index = all.firstIndex(of: flag), index + 1 < all.count else { return nil }
|
||||
return all[index + 1]
|
||||
}
|
||||
|
||||
/// `Knotchy --setup-snapshot <dir>`; with `KNOTCHY_PRETEND=no-virtualization` it shows minimal mode.
|
||||
static func snapshotSetup(app: AppDelegate, to directory: URL) async {
|
||||
app.setupWindow.show()
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
guard let window = app.setupWindow.window, let view = window.contentView else { return }
|
||||
print("setup: window \(window.frame.size)")
|
||||
DebugSnapshot.write(view, to: directory.appendingPathComponent("setup.png"))
|
||||
}
|
||||
}
|
||||
|
||||
extension DebugTour {
|
||||
static func snapshotSettings(app: AppDelegate, to directory: URL) async {
|
||||
let settings = app.settings
|
||||
let saved = (settings.networkMode, settings.proxyHost)
|
||||
let cases: [(String, SettingsTab, AppSettings.NetworkMode)] = [
|
||||
("general", .general, .direct), ("network-direct", .network, .direct),
|
||||
("network-proxy", .network, .proxy), ("network-vpn", .network, .vpn), ("vm", .vm, .direct),
|
||||
]
|
||||
for (name, section, mode) in cases {
|
||||
settings.networkMode = mode
|
||||
if mode == .proxy { settings.proxyHost = "192.168.1.20" }
|
||||
app.settingsWindow.show(section)
|
||||
try? await Task.sleep(for: .seconds(0.8))
|
||||
guard let window = app.settingsWindow.window, let view = window.contentView else { continue }
|
||||
print("\(name): window \(window.frame.size)")
|
||||
DebugSnapshot.write(view, to: directory.appendingPathComponent("settings-\(name).png"))
|
||||
}
|
||||
app.settingsWindow.window?.orderOut(nil)
|
||||
(settings.networkMode, settings.proxyHost) = saved
|
||||
}
|
||||
|
||||
static func timeNetworkSwitching(app: AppDelegate) async {
|
||||
let settings = app.settings
|
||||
let saved = settings.networkMode
|
||||
settings.networkMode = .direct
|
||||
app.settingsWindow.show(.general)
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
let meter = FrameMeter()
|
||||
let steps: [(String, () -> Void)] = [
|
||||
("section network", { app.settingsWindow.show(.network) }),
|
||||
("mode proxy", { settings.networkMode = .proxy }),
|
||||
("mode vpn", { settings.networkMode = .vpn }),
|
||||
("mode direct", { settings.networkMode = .direct }),
|
||||
("section vm", { app.settingsWindow.show(.vm) }),
|
||||
("section general", { app.settingsWindow.show(.general) }),
|
||||
("section network", { app.settingsWindow.show(.network) }),
|
||||
]
|
||||
for (name, step) in steps {
|
||||
meter.start()
|
||||
step()
|
||||
try? await Task.sleep(for: .seconds(0.8))
|
||||
let size = app.settingsWindow.window?.frame.size ?? .zero
|
||||
print("\(name) (window \(Int(size.width))x\(Int(size.height))): \(meter.stop())")
|
||||
}
|
||||
app.settingsWindow.window?.orderOut(nil)
|
||||
settings.networkMode = saved
|
||||
}
|
||||
}
|
||||
|
||||
enum DebugSnapshot {
|
||||
@MainActor
|
||||
static func write(_ view: NSView, to url: URL) {
|
||||
guard let layer = view.layer, let window = view.window else { return }
|
||||
let scale = window.backingScaleFactor
|
||||
let size = view.bounds.size
|
||||
guard let context = CGContext(data: nil, width: Int(size.width * scale), height: Int(size.height * scale),
|
||||
bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpace(name: CGColorSpace.sRGB)!,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return }
|
||||
context.setFillColor(CGColor(gray: 0.55, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: size.width * scale, height: size.height * scale))
|
||||
context.scaleBy(x: scale, y: scale)
|
||||
if view.isFlipped || !layer.isGeometryFlipped {
|
||||
context.translateBy(x: 0, y: size.height)
|
||||
context.scaleBy(x: 1, y: -1)
|
||||
}
|
||||
(layer.presentation() ?? layer).render(in: context)
|
||||
guard let image = context.makeImage(),
|
||||
let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else { return }
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
CGImageDestinationFinalize(destination)
|
||||
}
|
||||
}
|
||||
|
||||
// SwiftUI animates on the main thread, so a late display-link callback is a dropped frame.
|
||||
@MainActor
|
||||
final class FrameMeter: NSObject {
|
||||
private var link: CADisplayLink?
|
||||
private var stamps: [CFTimeInterval] = []
|
||||
private var nominal: CFTimeInterval = 1.0 / 60
|
||||
|
||||
func start() {
|
||||
stamps = []
|
||||
link = (NSScreen.main ?? NSScreen.screens[0]).displayLink(target: self, selector: #selector(tick(_:)))
|
||||
link?.add(to: .main, forMode: .common)
|
||||
}
|
||||
|
||||
@objc private func tick(_ link: CADisplayLink) {
|
||||
stamps.append(CACurrentMediaTime())
|
||||
nominal = link.targetTimestamp - link.timestamp
|
||||
}
|
||||
|
||||
func stop() -> String {
|
||||
link?.invalidate()
|
||||
link = nil
|
||||
let intervals = zip(stamps.dropFirst(), stamps).map { $0 - $1 }
|
||||
func ms(_ t: CFTimeInterval) -> String { String(format: "%.0f", t * 1000) }
|
||||
// "duration@end", both in ms, end measured from the start.
|
||||
let late = zip(intervals, stamps.dropFirst()).filter { $0.0 > nominal * 1.5 }
|
||||
.map { "\(ms($0.0))@\(ms($0.1 - (stamps.first ?? 0)))" }
|
||||
return "\(stamps.count) frames at \(ms(nominal)) ms, worst \(ms(intervals.max() ?? 0)) ms, "
|
||||
+ "\(late.count) late \(late)"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>aculix.knotchy.app</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Knotchy</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Knotchy</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Knotchy</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>26.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2026 Aculix Technologies LLP. MIT License.</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>aculix.knotchy.open</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>knotchy</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSServices</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSMenuItem</key>
|
||||
<dict>
|
||||
<key>default</key>
|
||||
<string>Open in Knotchy</string>
|
||||
</dict>
|
||||
<key>NSMessage</key>
|
||||
<string>openLink</string>
|
||||
<key>NSPortName</key>
|
||||
<string>Knotchy</string>
|
||||
<key>NSSendTypes</key>
|
||||
<array>
|
||||
<string>public.url</string>
|
||||
<string>public.html</string>
|
||||
<string>public.rtf</string>
|
||||
<string>public.utf8-plain-text</string>
|
||||
</array>
|
||||
<key>NSRequiredContext</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSMenuItem</key>
|
||||
<dict>
|
||||
<key>default</key>
|
||||
<string>Open Copied Link in Knotchy</string>
|
||||
</dict>
|
||||
<key>NSMessage</key>
|
||||
<string>openCopiedLink</string>
|
||||
<key>NSPortName</key>
|
||||
<string>Knotchy</string>
|
||||
<key>NSRequiredContext</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum Keychain {
|
||||
private static let service = "aculix.knotchy.app"
|
||||
|
||||
static func read(_ account: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var result: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func write(_ value: String, for account: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
guard !value.isEmpty else {
|
||||
SecItemDelete(query as CFDictionary)
|
||||
return
|
||||
}
|
||||
let data = Data(value.utf8)
|
||||
if SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) == errSecItemNotFound {
|
||||
var item = query
|
||||
item[kSecValueData as String] = data
|
||||
item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked
|
||||
SecItemAdd(item as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Generated by scripts/icons/knot.py; run scripts/make-icons.sh to change it.
|
||||
import AppKit
|
||||
|
||||
enum KnotGlyph {
|
||||
static let strands: [[CGPoint]] = [
|
||||
[CGPoint(x: 7.27, y: 5.99), CGPoint(x: 7.15, y: 6.15), CGPoint(x: 7.04, y: 6.32), CGPoint(x: 6.93, y: 6.49), CGPoint(x: 6.82, y: 6.67), CGPoint(x: 6.72, y: 6.84), CGPoint(x: 6.62, y: 7.02), CGPoint(x: 6.53, y: 7.20), CGPoint(x: 6.43, y: 7.39), CGPoint(x: 6.35, y: 7.57), CGPoint(x: 6.27, y: 7.76), CGPoint(x: 6.19, y: 7.94), CGPoint(x: 6.11, y: 8.13), CGPoint(x: 6.05, y: 8.32), CGPoint(x: 5.98, y: 8.51), CGPoint(x: 5.92, y: 8.70), CGPoint(x: 5.86, y: 8.89), CGPoint(x: 5.81, y: 9.09), CGPoint(x: 5.77, y: 9.28), CGPoint(x: 5.72, y: 9.47), CGPoint(x: 5.69, y: 9.66), CGPoint(x: 5.65, y: 9.85), CGPoint(x: 5.63, y: 10.05), CGPoint(x: 5.60, y: 10.24), CGPoint(x: 5.58, y: 10.43), CGPoint(x: 5.57, y: 10.62), CGPoint(x: 5.56, y: 10.81), CGPoint(x: 5.56, y: 10.99), CGPoint(x: 5.56, y: 11.18), CGPoint(x: 5.56, y: 11.36), CGPoint(x: 5.57, y: 11.55), CGPoint(x: 5.58, y: 11.73), CGPoint(x: 5.60, y: 11.91), CGPoint(x: 5.62, y: 12.08), CGPoint(x: 5.65, y: 12.26), CGPoint(x: 5.68, y: 12.43), CGPoint(x: 5.72, y: 12.60), CGPoint(x: 5.76, y: 12.76), CGPoint(x: 5.80, y: 12.93), CGPoint(x: 5.85, y: 13.09), CGPoint(x: 5.90, y: 13.24), CGPoint(x: 5.96, y: 13.40), CGPoint(x: 6.02, y: 13.55), CGPoint(x: 6.08, y: 13.69), CGPoint(x: 6.15, y: 13.83), CGPoint(x: 6.22, y: 13.97), CGPoint(x: 6.30, y: 14.11), CGPoint(x: 6.38, y: 14.24), CGPoint(x: 6.46, y: 14.36), CGPoint(x: 6.54, y: 14.49), CGPoint(x: 6.63, y: 14.60), CGPoint(x: 6.72, y: 14.71), CGPoint(x: 6.81, y: 14.82), CGPoint(x: 6.91, y: 14.92), CGPoint(x: 7.01, y: 15.02), CGPoint(x: 7.11, y: 15.12), CGPoint(x: 7.21, y: 15.20), CGPoint(x: 7.32, y: 15.29), CGPoint(x: 7.43, y: 15.36), CGPoint(x: 7.54, y: 15.44), CGPoint(x: 7.65, y: 15.50), CGPoint(x: 7.76, y: 15.56), CGPoint(x: 7.88, y: 15.62), CGPoint(x: 8.00, y: 15.67), CGPoint(x: 8.11, y: 15.71), CGPoint(x: 8.23, y: 15.75), CGPoint(x: 8.35, y: 15.79), CGPoint(x: 8.47, y: 15.82), CGPoint(x: 8.59, y: 15.84), CGPoint(x: 8.71, y: 15.85), CGPoint(x: 8.84, y: 15.86), CGPoint(x: 8.96, y: 15.87), CGPoint(x: 9.08, y: 15.87), CGPoint(x: 9.20, y: 15.86), CGPoint(x: 9.33, y: 15.85), CGPoint(x: 9.45, y: 15.83), CGPoint(x: 9.57, y: 15.81), CGPoint(x: 9.69, y: 15.78), CGPoint(x: 9.81, y: 15.74), CGPoint(x: 9.93, y: 15.70), CGPoint(x: 10.04, y: 15.65), CGPoint(x: 10.16, y: 15.60), CGPoint(x: 10.27, y: 15.54), CGPoint(x: 10.39, y: 15.48), CGPoint(x: 10.50, y: 15.41), CGPoint(x: 10.61, y: 15.34), CGPoint(x: 10.72, y: 15.26), CGPoint(x: 10.82, y: 15.17), CGPoint(x: 10.92, y: 15.09), CGPoint(x: 11.02, y: 14.99), CGPoint(x: 11.12, y: 14.89), CGPoint(x: 11.22, y: 14.79), CGPoint(x: 11.31, y: 14.68), CGPoint(x: 11.40, y: 14.56), CGPoint(x: 11.49, y: 14.45), CGPoint(x: 11.57, y: 14.32), CGPoint(x: 11.65, y: 14.19), CGPoint(x: 11.73, y: 14.06), CGPoint(x: 11.80, y: 13.93), CGPoint(x: 11.87, y: 13.79), CGPoint(x: 11.94, y: 13.64), CGPoint(x: 12.00, y: 13.50), CGPoint(x: 12.06, y: 13.35), CGPoint(x: 12.11, y: 13.19), CGPoint(x: 12.17, y: 13.03), CGPoint(x: 12.21, y: 12.87), CGPoint(x: 12.26, y: 12.71), CGPoint(x: 12.28, y: 12.60)],
|
||||
[CGPoint(x: 11.68, y: 7.63), CGPoint(x: 11.59, y: 7.45), CGPoint(x: 11.51, y: 7.26), CGPoint(x: 11.41, y: 7.08), CGPoint(x: 11.31, y: 6.90), CGPoint(x: 11.21, y: 6.73), CGPoint(x: 11.11, y: 6.55), CGPoint(x: 11.00, y: 6.38), CGPoint(x: 10.89, y: 6.21), CGPoint(x: 10.77, y: 6.04), CGPoint(x: 10.65, y: 5.88), CGPoint(x: 10.53, y: 5.72), CGPoint(x: 10.40, y: 5.56), CGPoint(x: 10.27, y: 5.41), CGPoint(x: 10.14, y: 5.26), CGPoint(x: 10.00, y: 5.11), CGPoint(x: 9.87, y: 4.96), CGPoint(x: 9.73, y: 4.82), CGPoint(x: 9.58, y: 4.69), CGPoint(x: 9.44, y: 4.55), CGPoint(x: 9.29, y: 4.43), CGPoint(x: 9.14, y: 4.30), CGPoint(x: 8.99, y: 4.18), CGPoint(x: 8.83, y: 4.06), CGPoint(x: 8.68, y: 3.95), CGPoint(x: 8.52, y: 3.85), CGPoint(x: 8.36, y: 3.74), CGPoint(x: 8.20, y: 3.65), CGPoint(x: 8.04, y: 3.55), CGPoint(x: 7.88, y: 3.46), CGPoint(x: 7.72, y: 3.38), CGPoint(x: 7.55, y: 3.30), CGPoint(x: 7.39, y: 3.23), CGPoint(x: 7.22, y: 3.16), CGPoint(x: 7.06, y: 3.10), CGPoint(x: 6.90, y: 3.04), CGPoint(x: 6.73, y: 2.98), CGPoint(x: 6.57, y: 2.94), CGPoint(x: 6.40, y: 2.89), CGPoint(x: 6.24, y: 2.85), CGPoint(x: 6.08, y: 2.82), CGPoint(x: 5.92, y: 2.79), CGPoint(x: 5.76, y: 2.77), CGPoint(x: 5.60, y: 2.75), CGPoint(x: 5.44, y: 2.74), CGPoint(x: 5.29, y: 2.73), CGPoint(x: 5.13, y: 2.73), CGPoint(x: 4.98, y: 2.73), CGPoint(x: 4.83, y: 2.74), CGPoint(x: 4.69, y: 2.75), CGPoint(x: 4.54, y: 2.77), CGPoint(x: 4.40, y: 2.79), CGPoint(x: 4.26, y: 2.82), CGPoint(x: 4.12, y: 2.85), CGPoint(x: 3.99, y: 2.89), CGPoint(x: 3.85, y: 2.93), CGPoint(x: 3.73, y: 2.98), CGPoint(x: 3.60, y: 3.03), CGPoint(x: 3.48, y: 3.08), CGPoint(x: 3.36, y: 3.14), CGPoint(x: 3.25, y: 3.21), CGPoint(x: 3.14, y: 3.27), CGPoint(x: 3.03, y: 3.34), CGPoint(x: 2.93, y: 3.42), CGPoint(x: 2.83, y: 3.50), CGPoint(x: 2.74, y: 3.58), CGPoint(x: 2.65, y: 3.67), CGPoint(x: 2.57, y: 3.76), CGPoint(x: 2.49, y: 3.85), CGPoint(x: 2.41, y: 3.95), CGPoint(x: 2.34, y: 4.05), CGPoint(x: 2.28, y: 4.16), CGPoint(x: 2.22, y: 4.26), CGPoint(x: 2.16, y: 4.37), CGPoint(x: 2.11, y: 4.48), CGPoint(x: 2.07, y: 4.60), CGPoint(x: 2.03, y: 4.71), CGPoint(x: 1.99, y: 4.83), CGPoint(x: 1.96, y: 4.95), CGPoint(x: 1.94, y: 5.08), CGPoint(x: 1.92, y: 5.20), CGPoint(x: 1.91, y: 5.33), CGPoint(x: 1.90, y: 5.46), CGPoint(x: 1.90, y: 5.59), CGPoint(x: 1.90, y: 5.72), CGPoint(x: 1.91, y: 5.85), CGPoint(x: 1.93, y: 5.98), CGPoint(x: 1.95, y: 6.11), CGPoint(x: 1.97, y: 6.25), CGPoint(x: 2.01, y: 6.38), CGPoint(x: 2.04, y: 6.52), CGPoint(x: 2.09, y: 6.65), CGPoint(x: 2.13, y: 6.79), CGPoint(x: 2.19, y: 6.92), CGPoint(x: 2.25, y: 7.06), CGPoint(x: 2.31, y: 7.19), CGPoint(x: 2.38, y: 7.32), CGPoint(x: 2.46, y: 7.46), CGPoint(x: 2.54, y: 7.59), CGPoint(x: 2.62, y: 7.72), CGPoint(x: 2.72, y: 7.85), CGPoint(x: 2.81, y: 7.98), CGPoint(x: 2.91, y: 8.10), CGPoint(x: 3.02, y: 8.23), CGPoint(x: 3.13, y: 8.35), CGPoint(x: 3.25, y: 8.47), CGPoint(x: 3.37, y: 8.59), CGPoint(x: 3.45, y: 8.67)],
|
||||
[CGPoint(x: 8.05, y: 10.63), CGPoint(x: 8.25, y: 10.65), CGPoint(x: 8.46, y: 10.66), CGPoint(x: 8.66, y: 10.67), CGPoint(x: 8.86, y: 10.68), CGPoint(x: 9.07, y: 10.68), CGPoint(x: 9.27, y: 10.67), CGPoint(x: 9.48, y: 10.67), CGPoint(x: 9.68, y: 10.65), CGPoint(x: 9.88, y: 10.64), CGPoint(x: 10.08, y: 10.62), CGPoint(x: 10.28, y: 10.59), CGPoint(x: 10.48, y: 10.56), CGPoint(x: 10.68, y: 10.52), CGPoint(x: 10.88, y: 10.49), CGPoint(x: 11.07, y: 10.44), CGPoint(x: 11.27, y: 10.39), CGPoint(x: 11.46, y: 10.34), CGPoint(x: 11.65, y: 10.29), CGPoint(x: 11.84, y: 10.23), CGPoint(x: 12.02, y: 10.16), CGPoint(x: 12.21, y: 10.10), CGPoint(x: 12.39, y: 10.02), CGPoint(x: 12.56, y: 9.95), CGPoint(x: 12.74, y: 9.87), CGPoint(x: 12.91, y: 9.79), CGPoint(x: 13.08, y: 9.70), CGPoint(x: 13.24, y: 9.61), CGPoint(x: 13.40, y: 9.52), CGPoint(x: 13.56, y: 9.42), CGPoint(x: 13.71, y: 9.32), CGPoint(x: 13.86, y: 9.22), CGPoint(x: 14.01, y: 9.12), CGPoint(x: 14.15, y: 9.01), CGPoint(x: 14.29, y: 8.90), CGPoint(x: 14.42, y: 8.78), CGPoint(x: 14.55, y: 8.67), CGPoint(x: 14.67, y: 8.55), CGPoint(x: 14.79, y: 8.43), CGPoint(x: 14.91, y: 8.31), CGPoint(x: 15.02, y: 8.19), CGPoint(x: 15.12, y: 8.06), CGPoint(x: 15.22, y: 7.93), CGPoint(x: 15.32, y: 7.80), CGPoint(x: 15.41, y: 7.68), CGPoint(x: 15.49, y: 7.54), CGPoint(x: 15.57, y: 7.41), CGPoint(x: 15.64, y: 7.28), CGPoint(x: 15.71, y: 7.15), CGPoint(x: 15.77, y: 7.01), CGPoint(x: 15.83, y: 6.88), CGPoint(x: 15.88, y: 6.74), CGPoint(x: 15.93, y: 6.61), CGPoint(x: 15.97, y: 6.47), CGPoint(x: 16.01, y: 6.34), CGPoint(x: 16.03, y: 6.20), CGPoint(x: 16.06, y: 6.07), CGPoint(x: 16.08, y: 5.94), CGPoint(x: 16.09, y: 5.80), CGPoint(x: 16.10, y: 5.67), CGPoint(x: 16.10, y: 5.54), CGPoint(x: 16.10, y: 5.41), CGPoint(x: 16.09, y: 5.29), CGPoint(x: 16.07, y: 5.16), CGPoint(x: 16.05, y: 5.04), CGPoint(x: 16.03, y: 4.91), CGPoint(x: 16.00, y: 4.79), CGPoint(x: 15.96, y: 4.67), CGPoint(x: 15.92, y: 4.56), CGPoint(x: 15.87, y: 4.45), CGPoint(x: 15.82, y: 4.33), CGPoint(x: 15.76, y: 4.23), CGPoint(x: 15.70, y: 4.12), CGPoint(x: 15.63, y: 4.02), CGPoint(x: 15.56, y: 3.92), CGPoint(x: 15.49, y: 3.82), CGPoint(x: 15.40, y: 3.73), CGPoint(x: 15.32, y: 3.64), CGPoint(x: 15.23, y: 3.55), CGPoint(x: 15.13, y: 3.47), CGPoint(x: 15.03, y: 3.39), CGPoint(x: 14.93, y: 3.32), CGPoint(x: 14.82, y: 3.25), CGPoint(x: 14.71, y: 3.18), CGPoint(x: 14.60, y: 3.12), CGPoint(x: 14.48, y: 3.06), CGPoint(x: 14.36, y: 3.01), CGPoint(x: 14.23, y: 2.96), CGPoint(x: 14.10, y: 2.92), CGPoint(x: 13.97, y: 2.88), CGPoint(x: 13.83, y: 2.84), CGPoint(x: 13.70, y: 2.81), CGPoint(x: 13.56, y: 2.79), CGPoint(x: 13.41, y: 2.76), CGPoint(x: 13.27, y: 2.75), CGPoint(x: 13.12, y: 2.74), CGPoint(x: 12.97, y: 2.73), CGPoint(x: 12.81, y: 2.73), CGPoint(x: 12.66, y: 2.74), CGPoint(x: 12.50, y: 2.74), CGPoint(x: 12.35, y: 2.76), CGPoint(x: 12.19, y: 2.78), CGPoint(x: 12.03, y: 2.80), CGPoint(x: 11.87, y: 2.83), CGPoint(x: 11.70, y: 2.87), CGPoint(x: 11.54, y: 2.91), CGPoint(x: 11.38, y: 2.95), CGPoint(x: 11.27, y: 2.98)],
|
||||
]
|
||||
|
||||
static let menuBarImage: NSImage = {
|
||||
let image = NSImage(size: NSSize(width: 18, height: 18), flipped: true) { _ in
|
||||
NSColor.black.setStroke()
|
||||
for strand in strands {
|
||||
let path = NSBezierPath()
|
||||
path.move(to: strand[0])
|
||||
for point in strand.dropFirst() { path.line(to: point) }
|
||||
path.lineWidth = 1.9
|
||||
path.lineCapStyle = .round
|
||||
path.lineJoinStyle = .round
|
||||
path.stroke()
|
||||
}
|
||||
return true
|
||||
}
|
||||
image.isTemplate = true
|
||||
image.accessibilityDescription = "Knotchy"
|
||||
return image
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import KnotchyCore
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct KnotchyApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var app
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra(isInserted: .constant(app.isSupportedMac)) {
|
||||
KnotchyMenu(app: app, settings: app.settings)
|
||||
} label: {
|
||||
Image(nsImage: KnotGlyph.menuBarImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import AppKit
|
||||
import KnotchyCore
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct KnotchyMenu: View {
|
||||
let app: AppDelegate
|
||||
@Bindable var settings: AppSettings
|
||||
|
||||
private var controller: SessionController { app.controller }
|
||||
|
||||
var body: some View {
|
||||
if app.vmStatus.needsSetup {
|
||||
Button("Set Up Knotchy…") { app.setupWindow.show() }
|
||||
Divider()
|
||||
}
|
||||
|
||||
if let session = SessionSummary(controller.state, url: controller.pageURL ?? controller.openedURL,
|
||||
setupFolded: controller.setupFolded) {
|
||||
Section(session.title) {
|
||||
if session.canShow {
|
||||
Button("Show Panel") { controller.showPanel() }
|
||||
}
|
||||
if session.canDestroy {
|
||||
Button("Destroy VM") { controller.destroy() }
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
|
||||
Button("Open Copied Link") { app.services.openCopiedLink() }
|
||||
.keyboardShortcut("l", modifiers: [.control, .option, .command])
|
||||
|
||||
Divider()
|
||||
|
||||
Picker("Auto-Destroy Folded Pages", selection: $settings.autoDestroyMinutes) {
|
||||
ForEach(AppSettings.autoDestroyChoices, id: \.self) { minutes in
|
||||
Text(minutes == 1 ? "After 1 Minute" : "After \(minutes) Minutes").tag(minutes)
|
||||
}
|
||||
}
|
||||
Picker("Open Pages In", selection: $settings.opensLarge) {
|
||||
Text("Small Panel").tag(false)
|
||||
Text("Large Panel").tag(true)
|
||||
}
|
||||
Menu("Connection") {
|
||||
Picker("Connection", selection: connection) {
|
||||
Text("Direct").tag(Connection.direct)
|
||||
if !settings.proxyHost.isEmpty {
|
||||
Text("Proxy (\(settings.proxyHost):\(String(settings.proxyPort)))").tag(Connection.proxy)
|
||||
}
|
||||
if !settings.wireGuardNames.isEmpty {
|
||||
Section("WireGuard VPN") {
|
||||
ForEach(settings.wireGuardNames, id: \.self) { Text($0).tag(Connection.vpn($0)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
.labelsHidden()
|
||||
Divider()
|
||||
Button("Network Settings…") { showSettings(.network) }
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
controller.rebuildImage()
|
||||
} label: {
|
||||
Text("Rebuild VM")
|
||||
Text(app.vmStatus.summary)
|
||||
}
|
||||
.disabled(isSettingUp || app.vmStatus.image == .noContainerTool || app.vmStatus.needsSetup)
|
||||
|
||||
Divider()
|
||||
|
||||
if !app.vmStatus.needsSetup {
|
||||
Button("Set Up Knotchy…") { app.setupWindow.show() }
|
||||
}
|
||||
Button("Settings…") { showSettings(nil) }
|
||||
.keyboardShortcut(",")
|
||||
Button("Quit Knotchy") { NSApp.terminate(nil) }
|
||||
.keyboardShortcut("q")
|
||||
}
|
||||
|
||||
private enum Connection: Hashable {
|
||||
case direct, proxy, vpn(String)
|
||||
}
|
||||
|
||||
private var connection: Binding<Connection> {
|
||||
Binding {
|
||||
switch settings.networkMode {
|
||||
case .direct: .direct
|
||||
case .proxy: .proxy
|
||||
case .vpn: .vpn(settings.wireGuardServer)
|
||||
}
|
||||
} set: { choice in
|
||||
switch choice {
|
||||
case .direct:
|
||||
settings.networkMode = .direct
|
||||
case .proxy:
|
||||
settings.networkMode = .proxy
|
||||
case .vpn(let server):
|
||||
settings.wireGuardServer = server
|
||||
settings.networkMode = .vpn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isSettingUp: Bool {
|
||||
if case .settingUp = controller.state { true } else { false }
|
||||
}
|
||||
|
||||
private func showSettings(_ section: SettingsTab?) {
|
||||
app.settingsWindow.show(section)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class VMStatus {
|
||||
private(set) var image: ContainerCLIBackend.ImageStatus?
|
||||
private(set) var runtime: RuntimeStatus?
|
||||
@ObservationIgnored private let backend: ContainerCLIBackend
|
||||
@ObservationIgnored private let controller: SessionController
|
||||
|
||||
init(backend: ContainerCLIBackend, controller: SessionController) {
|
||||
self.backend = backend
|
||||
self.controller = controller
|
||||
watchSetup()
|
||||
}
|
||||
|
||||
var needsSetup: Bool {
|
||||
switch runtime {
|
||||
case .missing?, .outdated?: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
var runtimeSummary: String {
|
||||
switch runtime {
|
||||
case nil: "Checking…"
|
||||
case .missing: "Not installed"
|
||||
case .outdated(let version): "Apple container \(version) (too old)"
|
||||
case .current(let version?): "Apple container \(version)"
|
||||
case .current(nil): "Apple container (version unknown)"
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
Task {
|
||||
let runtime = await backend.runtimeStatus()
|
||||
if runtime != self.runtime { self.runtime = runtime }
|
||||
let status = await backend.imageStatus()
|
||||
if status != image { image = status }
|
||||
}
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
if needsSetup { return "Setup isn't finished" }
|
||||
return switch image {
|
||||
case nil: "Checking…"
|
||||
case .noContainerTool: "Setup isn't finished"
|
||||
case .missing: "Not built yet"
|
||||
case .built(nil): "Built"
|
||||
case .built(let date?): "Built " + RelativeDateTimeFormatter().localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
private func watchSetup() {
|
||||
let wasSettingUp: Bool
|
||||
if case .settingUp = controller.state { wasSettingUp = true } else { wasSettingUp = false }
|
||||
withObservationTracking {
|
||||
_ = controller.state
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
if wasSettingUp { self.refresh() }
|
||||
self.watchSetup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionSummary {
|
||||
var title: String
|
||||
var canShow: Bool
|
||||
var canDestroy: Bool
|
||||
|
||||
init?(_ state: SessionState, url: URL?, setupFolded: Bool) {
|
||||
let host = url?.host().map { " — \($0)" } ?? ""
|
||||
switch state {
|
||||
case .starting(_, let folded):
|
||||
(title, canShow, canDestroy) = ("Opening\(host)", folded, true)
|
||||
case .live(let mode):
|
||||
(title, canShow, canDestroy) = ("Open\(host)", mode == .collapsed, true)
|
||||
case .settingUp(let issue):
|
||||
(title, canShow, canDestroy) = (Self.setupTitle(issue), setupFolded, false)
|
||||
case .destroying:
|
||||
(title, canShow, canDestroy) = ("Destroying the VM…", false, false)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func setupTitle(_ issue: SetupIssue) -> String {
|
||||
switch issue {
|
||||
case .cliMissing: "Installing the container runtime…"
|
||||
case .cliOutdated: "Updating the container runtime…"
|
||||
case .serviceStopped, .kernelMissing: "Starting the container runtime…"
|
||||
default: "Building the VM…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Flared top "shoulders" like the notch's own; `rect` includes them.
|
||||
struct NotchShape: Shape {
|
||||
var topRadius: CGFloat
|
||||
var bottomRadius: CGFloat
|
||||
|
||||
var animatableData: AnimatablePair<CGFloat, CGFloat> {
|
||||
get { AnimatablePair(topRadius, bottomRadius) }
|
||||
set { (topRadius, bottomRadius) = (newValue.first, newValue.second) }
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let top = max(0, min(topRadius, rect.width / 4, rect.height / 2))
|
||||
let bottom = max(0, min(bottomRadius, (rect.width - 2 * top) / 2, rect.height - top))
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
path.addQuadCurve(to: CGPoint(x: rect.minX + top, y: rect.minY + top),
|
||||
control: CGPoint(x: rect.minX + top, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.minX + top, y: rect.maxY - bottom))
|
||||
path.addQuadCurve(to: CGPoint(x: rect.minX + top + bottom, y: rect.maxY),
|
||||
control: CGPoint(x: rect.minX + top, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX - top - bottom, y: rect.maxY))
|
||||
path.addQuadCurve(to: CGPoint(x: rect.maxX - top, y: rect.maxY - bottom),
|
||||
control: CGPoint(x: rect.maxX - top, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX - top, y: rect.minY + top))
|
||||
path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.minY),
|
||||
control: CGPoint(x: rect.maxX - top, y: rect.minY))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
struct Morph: ViewModifier, Animatable {
|
||||
var progress: CGFloat
|
||||
var smallest: CGFloat = 0.55
|
||||
var blur: CGFloat = 10
|
||||
var clearUntil: CGFloat = 0.3
|
||||
var opaqueFrom: CGFloat = 0.85
|
||||
|
||||
nonisolated var animatableData: CGFloat {
|
||||
get { progress }
|
||||
set { progress = newValue }
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.scaleEffect(smallest + (1 - smallest) * progress, anchor: .top)
|
||||
.blur(radius: max(0, 1 - progress) * blur)
|
||||
.opacity(Double(min(1, max(0, (progress - clearUntil) / (opaqueFrom - clearUntil)))))
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyTransition {
|
||||
/// Old content is gone early so it barely overlaps the new.
|
||||
static var island: AnyTransition {
|
||||
.asymmetric(
|
||||
insertion: .modifier(active: Morph(progress: 0), identity: Morph(progress: 1)),
|
||||
removal: .modifier(active: Morph(progress: 0, smallest: 0.8, blur: 8, clearUntil: 0.6, opaqueFrom: 0.95),
|
||||
identity: Morph(progress: 1, smallest: 0.8, blur: 8, clearUntil: 0.6, opaqueFrom: 0.95))
|
||||
.animation(.spring(response: 0.2, dampingFraction: 1))
|
||||
)
|
||||
}
|
||||
|
||||
static var swap: AnyTransition {
|
||||
.modifier(active: Morph(progress: 0, smallest: 0.92, blur: 6, clearUntil: 0.2, opaqueFrom: 0.8),
|
||||
identity: Morph(progress: 1, smallest: 0.92, blur: 6, clearUntil: 0.2, opaqueFrom: 0.8))
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.9))
|
||||
}
|
||||
}
|
||||
|
||||
enum Motion {
|
||||
static let open = Animation.spring(response: 0.46, dampingFraction: 0.74)
|
||||
static let resize = Animation.spring(response: 0.42, dampingFraction: 0.8)
|
||||
static let fold = Animation.spring(response: 0.4, dampingFraction: 0.84)
|
||||
static let small = Animation.spring(response: 0.4, dampingFraction: 0.7)
|
||||
static let hide = Animation.spring(response: 0.36, dampingFraction: 0.9)
|
||||
}
|
||||
|
||||
struct KnotMark: View {
|
||||
var size: CGFloat
|
||||
var color: Color = Palette.coral
|
||||
|
||||
var body: some View {
|
||||
KnotShape()
|
||||
.stroke(color, style: StrokeStyle(lineWidth: size * 1.9 / 18, lineCap: .round, lineJoin: .round))
|
||||
.frame(width: size, height: size)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// `KnotGlyph.strands` are in an 18-point box.
|
||||
struct KnotShape: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let scale = min(rect.width, rect.height) / 18
|
||||
var path = Path()
|
||||
for strand in KnotGlyph.strands {
|
||||
path.move(to: CGPoint(x: rect.minX + strand[0].x * scale, y: rect.minY + strand[0].y * scale))
|
||||
for point in strand.dropFirst() {
|
||||
path.addLine(to: CGPoint(x: rect.minX + point.x * scale, y: rect.minY + point.y * scale))
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
struct Breathing: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content.phaseAnimator([1.0, 0.55]) { view, opacity in
|
||||
view.opacity(opacity)
|
||||
} animation: { _ in .easeInOut(duration: 1.6) }
|
||||
}
|
||||
}
|
||||
|
||||
enum Palette {
|
||||
static let coral = Color(red: 1, green: 0.54, blue: 0.36)
|
||||
static let green = Color(red: 0.25, green: 0.86, blue: 0.47)
|
||||
static let red = Color(red: 1, green: 0.38, blue: 0.36)
|
||||
static let amber = Color(red: 1, green: 0.72, blue: 0.2)
|
||||
static let surface = LinearGradient(colors: [Color(white: 0.1), Color(white: 0.055)],
|
||||
startPoint: .top, endPoint: .bottom)
|
||||
}
|
||||
|
||||
struct IconButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
Styled(configuration: configuration)
|
||||
}
|
||||
|
||||
struct Styled: View {
|
||||
let configuration: Configuration
|
||||
@State private var hovering = false
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
var body: some View {
|
||||
configuration.label
|
||||
.foregroundStyle(.white.opacity(isEnabled ? (hovering ? 1 : 0.72) : 0.22))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||
.fill(.white.opacity(configuration.isPressed ? 0.18 : (hovering && isEnabled ? 0.1 : 0)))
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onHover { hovering = $0 }
|
||||
.animation(.easeOut(duration: 0.12), value: hovering)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CapsuleButtonStyle: ButtonStyle {
|
||||
enum Kind { case primary, secondary, destructive }
|
||||
|
||||
var kind: Kind = .secondary
|
||||
var small = false
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
Styled(configuration: configuration, kind: kind, small: small)
|
||||
}
|
||||
|
||||
struct Styled: View {
|
||||
let configuration: Configuration
|
||||
let kind: Kind
|
||||
let small: Bool
|
||||
@State private var hovering = false
|
||||
|
||||
var body: some View {
|
||||
configuration.label
|
||||
.font(.system(size: small ? 11 : 12, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, small ? 9 : 14)
|
||||
.frame(height: small ? 20 : 28)
|
||||
.foregroundStyle(foreground)
|
||||
.background(Capsule().fill(fill))
|
||||
.contentShape(Capsule())
|
||||
.scaleEffect(configuration.isPressed ? 0.96 : 1)
|
||||
.onHover { hovering = $0 }
|
||||
.animation(.easeOut(duration: 0.12), value: hovering)
|
||||
.animation(.easeOut(duration: 0.08), value: configuration.isPressed)
|
||||
}
|
||||
|
||||
private var foreground: Color {
|
||||
switch kind {
|
||||
case .primary: .black
|
||||
case .secondary: .white
|
||||
case .destructive: Palette.red
|
||||
}
|
||||
}
|
||||
|
||||
private var fill: Color {
|
||||
let boost = configuration.isPressed ? 0.1 : (hovering ? 0.06 : 0)
|
||||
switch kind {
|
||||
case .primary: return .white.opacity(0.92 + boost)
|
||||
case .secondary: return .white.opacity(0.12 + boost)
|
||||
case .destructive: return Palette.red.opacity(0.16 + boost * 1.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
import KnotchyCore
|
||||
import SwiftUI
|
||||
|
||||
struct NotchRootView: View {
|
||||
let controller: SessionController
|
||||
let chrome: NotchChrome
|
||||
|
||||
var body: some View {
|
||||
let size = chrome.bodySize
|
||||
ZStack(alignment: .top) {
|
||||
switch chrome.content {
|
||||
case .hidden:
|
||||
Color.clear.frame(width: 0, height: 0)
|
||||
case .compact:
|
||||
CompactView(controller: controller, state: chrome.state, notchWidth: chrome.notchSize.width)
|
||||
.frame(width: NotchChrome.bodySize(.compact, notch: chrome.notchSize, panel: chrome.panelSize,
|
||||
hasNotch: chrome.hasNotch).width,
|
||||
height: chrome.notchSize.height)
|
||||
.transition(.island)
|
||||
case .banner:
|
||||
BannerView(controller: controller, state: chrome.state, notice: chrome.notice,
|
||||
notchHeight: chrome.notchSize.height)
|
||||
.frame(width: NotchChrome.bodySize(.banner, notch: chrome.notchSize, panel: chrome.panelSize,
|
||||
hasNotch: chrome.hasNotch).width)
|
||||
.transition(.island)
|
||||
case .panel:
|
||||
PanelView(controller: controller, chrome: chrome)
|
||||
.frame(width: chrome.panelSize.width, height: chrome.panelSize.height)
|
||||
.transition(.island)
|
||||
}
|
||||
}
|
||||
.modifier(Morph(progress: chrome.reveal))
|
||||
.frame(width: size.width, height: size.height, alignment: .top)
|
||||
.padding(.horizontal, chrome.topRadius)
|
||||
.clipShape(NotchShape(topRadius: chrome.topRadius, bottomRadius: chrome.bottomRadius))
|
||||
.background {
|
||||
// Shadow from the outline alone, not from everything inside it.
|
||||
NotchShape(topRadius: chrome.topRadius, bottomRadius: chrome.bottomRadius)
|
||||
.fill(.black)
|
||||
.shadow(color: .black.opacity(chrome.presentation == .panel ? 0.5 : 0), radius: 14, y: 6)
|
||||
}
|
||||
.offset(x: chrome.offsetX)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compact
|
||||
|
||||
private struct CompactView: View {
|
||||
let controller: SessionController
|
||||
let state: SessionState
|
||||
let notchWidth: CGFloat
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
leading.frame(maxWidth: .infinity)
|
||||
Color.clear.frame(width: notchWidth)
|
||||
trailing.frame(maxWidth: .infinity)
|
||||
}
|
||||
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.contentShape(Rectangle())
|
||||
.contextMenu { PillMenu(controller: controller, state: state) }
|
||||
}
|
||||
|
||||
@ViewBuilder private var leading: some View {
|
||||
switch state {
|
||||
case .starting, .settingUp:
|
||||
ProgressView().controlSize(.small).scaleEffect(0.75)
|
||||
default:
|
||||
KnotMark(size: 15)
|
||||
.modifier(Breathing())
|
||||
.help("A page is open in a disposable VM")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var trailing: some View {
|
||||
switch state {
|
||||
case .starting:
|
||||
Text("Opening").foregroundStyle(.white.opacity(0.7))
|
||||
case .settingUp(let issue):
|
||||
Text(Self.setupWord(issue)).foregroundStyle(.white.opacity(0.7))
|
||||
.help("Setting up the browser VM. Hover to see progress.")
|
||||
default:
|
||||
TimelineView(.periodic(from: .now, by: 1)) { _ in
|
||||
let text = Self.format(controller.countdown)
|
||||
Text(text)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
.animation(.snappy, value: text)
|
||||
.help("Destroyed automatically when this reaches 0:00")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func setupWord(_ issue: SetupIssue) -> String {
|
||||
switch issue {
|
||||
case .imageMissing, .imageOutdated: "Building"
|
||||
case .imageStale, .cliOutdated: "Updating"
|
||||
case .cliMissing: "Installing"
|
||||
default: "Starting"
|
||||
}
|
||||
}
|
||||
|
||||
private static func format(_ remaining: Duration?) -> String {
|
||||
guard let remaining else { return "" }
|
||||
let seconds = Int(remaining.components.seconds)
|
||||
return String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Banner
|
||||
|
||||
private struct BannerView: View {
|
||||
let controller: SessionController
|
||||
let state: SessionState
|
||||
let notice: Notice?
|
||||
let notchHeight: CGFloat
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Color.clear.frame(height: notchHeight)
|
||||
ZStack {
|
||||
row.id(kind).transition(.swap)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: NotchChrome.bannerRowHeight - 4)
|
||||
}
|
||||
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.contentShape(Rectangle())
|
||||
.contextMenu { PillMenu(controller: controller, state: state) }
|
||||
}
|
||||
|
||||
private enum Kind: Hashable { case noWebLink, destroying, destroyed, ended, stuck, other }
|
||||
|
||||
private var kind: Kind {
|
||||
if notice == .noWebLink, state == .idle { return .noWebLink }
|
||||
switch state {
|
||||
case .destroying: return .destroying
|
||||
case .destroyed: return .destroyed
|
||||
case .failed(.ended): return .ended
|
||||
case .failed(.destroyStuck): return .stuck
|
||||
default: return .other
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var row: some View {
|
||||
HStack(spacing: 7) {
|
||||
switch kind {
|
||||
case .noWebLink:
|
||||
Image(systemName: "link").foregroundStyle(Palette.amber)
|
||||
Text("No web link found")
|
||||
case .destroying:
|
||||
ProgressView().controlSize(.small).scaleEffect(0.7).frame(width: 16, height: 16)
|
||||
Text("Destroying the VM…").foregroundStyle(.white.opacity(0.8))
|
||||
case .destroyed:
|
||||
BouncingSymbol(name: "checkmark.circle.fill", color: Palette.green)
|
||||
Text("VM destroyed")
|
||||
case .ended:
|
||||
Image(systemName: "xmark.octagon.fill").foregroundStyle(Palette.red)
|
||||
Text("Session ended")
|
||||
case .stuck:
|
||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Palette.amber)
|
||||
Text("VM may still be running")
|
||||
Button("Retry") { controller.retry() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .secondary, small: true))
|
||||
.padding(.leading, 4)
|
||||
case .other:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PillMenu: View {
|
||||
let controller: SessionController
|
||||
let state: SessionState
|
||||
|
||||
var body: some View {
|
||||
if case .live = state { Button("Destroy Now") { controller.destroy() } }
|
||||
Button("Quit Knotchy") { NSApp.terminate(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct BouncingSymbol: View {
|
||||
let name: String
|
||||
let color: Color
|
||||
@State private var appeared = false
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: name)
|
||||
.foregroundStyle(color)
|
||||
.symbolEffect(.bounce, value: appeared)
|
||||
.onAppear { appeared = true }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Panel
|
||||
|
||||
private struct PanelView: View {
|
||||
let controller: SessionController
|
||||
let chrome: NotchChrome
|
||||
|
||||
private var innerRadius: CGFloat { NotchChrome.bottomRadius(.panel) - NotchGeometry.margin }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
TopStrip(controller: controller, state: chrome.state, notchWidth: chrome.notchSize.width,
|
||||
routeLabel: chrome.routeLabel)
|
||||
.frame(height: chrome.notchSize.height)
|
||||
Toolbar(controller: controller, state: chrome.state)
|
||||
.frame(height: NotchGeometry.toolbarHeight)
|
||||
ZStack(alignment: .bottom) {
|
||||
content
|
||||
.id(ContentKind(chrome.state))
|
||||
.transition(.swap)
|
||||
if let stale = controller.staleImage, case .live = chrome.state {
|
||||
StaleBanner(controller: controller, stale: stale)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if case .live = chrome.state { ProgressLine(progress: controller.progress) }
|
||||
}
|
||||
.clipShape(UnevenRoundedRectangle(topLeadingRadius: 8, bottomLeadingRadius: innerRadius,
|
||||
bottomTrailingRadius: innerRadius, topTrailingRadius: 8,
|
||||
style: .continuous))
|
||||
.padding([.horizontal, .bottom], NotchGeometry.margin)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
private enum ContentKind: Hashable {
|
||||
case starting, live, failed, setup, settingUp, other
|
||||
|
||||
init(_ state: SessionState) {
|
||||
switch state {
|
||||
case .starting: self = .starting
|
||||
case .live: self = .live
|
||||
case .failed(.start): self = .failed
|
||||
case .setupRequired: self = .setup
|
||||
case .settingUp: self = .settingUp
|
||||
default: self = .other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
switch chrome.state {
|
||||
case .live:
|
||||
VideoSurface(controller: controller)
|
||||
case .starting(let step, _):
|
||||
StartingView(step: step, host: controller.openedURL?.host())
|
||||
case .failed(.start(let message)):
|
||||
MessageCard(symbol: "exclamationmark.triangle.fill", tint: Palette.amber,
|
||||
title: "Couldn't open the link", message: message) {
|
||||
Button("Dismiss") { controller.dismiss() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .secondary))
|
||||
Button("Try Again") { controller.retry() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .primary))
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
case .setupRequired(let issue, let error):
|
||||
MessageCard(symbol: Setup.symbol(issue), tint: error == nil ? .white : Palette.amber,
|
||||
title: Setup.title(issue), message: error ?? Setup.text(issue)) {
|
||||
Button("Not Now") { controller.dismiss() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .secondary))
|
||||
if let action = Setup.action(issue) {
|
||||
Button(action) { controller.performSetupAction() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .primary))
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
case .settingUp(let issue):
|
||||
MessageCard(symbol: Setup.symbol(issue), tint: .white, title: Setup.progressTitle(issue),
|
||||
message: Self.lastLine(controller.setupLog) ?? "Working…", monospaced: true) {
|
||||
ProgressView().controlSize(.small)
|
||||
Button("Hide") { controller.foldSetup() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .secondary))
|
||||
.help("Keep going in the background; hover over the notch to check on it")
|
||||
}
|
||||
default:
|
||||
Color.black
|
||||
}
|
||||
}
|
||||
|
||||
private static func lastLine(_ log: String?) -> String? {
|
||||
log?.split(whereSeparator: \.isNewline).last.map(String.init)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TopStrip: View {
|
||||
let controller: SessionController
|
||||
let state: SessionState
|
||||
let notchWidth: CGFloat
|
||||
let routeLabel: String?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
HStack(spacing: 5) {
|
||||
KnotMark(size: 13)
|
||||
Text("Isolated").foregroundStyle(.white.opacity(0.75))
|
||||
if let routeLabel {
|
||||
Text("· via \(routeLabel)").foregroundStyle(.white.opacity(0.45)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
.help("This page runs in a disposable Linux VM, cut off from your Mac and your network")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.leading, 14)
|
||||
|
||||
Color.clear.frame(width: notchWidth)
|
||||
|
||||
Group {
|
||||
if Self.canDestroy(state) {
|
||||
Button { controller.destroy() } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "power").font(.system(size: 9, weight: .bold))
|
||||
Text("Destroy")
|
||||
}
|
||||
}
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .destructive, small: true))
|
||||
.help("Destroy the VM and everything in it (⌘W)")
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.padding(.trailing, 10)
|
||||
}
|
||||
}
|
||||
|
||||
static func canDestroy(_ state: SessionState) -> Bool {
|
||||
switch state {
|
||||
case .starting, .live: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Toolbar: View {
|
||||
let controller: SessionController
|
||||
let state: SessionState
|
||||
|
||||
private var isLive: Bool {
|
||||
if case .live = state { true } else { false }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
IconButton(symbol: "chevron.left", help: "Back (⌘[)") { controller.goBack() }
|
||||
IconButton(symbol: "chevron.right", help: "Forward (⌘])") { controller.goForward() }
|
||||
IconButton(symbol: "arrow.clockwise", help: "Reload (⌘R)") { controller.reload() }
|
||||
AddressField(url: controller.pageURL ?? controller.openedURL)
|
||||
.padding(.horizontal, 6)
|
||||
if state == .live(.expanded) {
|
||||
IconButton(symbol: "arrow.down.right.and.arrow.up.left", help: "Make smaller (⇧⌘E)") { controller.shrink() }
|
||||
} else {
|
||||
IconButton(symbol: "arrow.up.left.and.arrow.down.right", help: "Make larger (⇧⌘E)") { controller.expand() }
|
||||
}
|
||||
}
|
||||
.disabled(!isLive)
|
||||
.padding(.horizontal, NotchGeometry.margin)
|
||||
}
|
||||
}
|
||||
|
||||
private struct IconButton: View {
|
||||
let symbol: String
|
||||
let help: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.frame(width: 28, height: 24)
|
||||
}
|
||||
.buttonStyle(IconButtonStyle())
|
||||
.help(help)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AddressField: View {
|
||||
let url: URL?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: url?.scheme == "https" ? "lock.fill" : "globe")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
Text(Self.display(url))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.padding(.horizontal, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: 24)
|
||||
.background(RoundedRectangle(cornerRadius: 7, style: .continuous).fill(.white.opacity(0.08)))
|
||||
.help(url?.absoluteString ?? "")
|
||||
}
|
||||
|
||||
static func display(_ url: URL?) -> AttributedString {
|
||||
guard let url else { return AttributedString("") }
|
||||
var host = AttributedString(url.host() ?? url.absoluteString)
|
||||
host.foregroundColor = .white
|
||||
var rest = url.path()
|
||||
if let query = url.query() { rest += "?" + query }
|
||||
if rest == "/" { rest = "" }
|
||||
var tail = AttributedString(rest)
|
||||
tail.foregroundColor = .white.opacity(0.4)
|
||||
return host + tail
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProgressLine: View {
|
||||
let progress: Int?
|
||||
@State private var shown: Int = 0
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
Capsule()
|
||||
.fill(LinearGradient(colors: [Color(red: 0.35, green: 0.7, blue: 1), Color(red: 0.2, green: 0.5, blue: 1)],
|
||||
startPoint: .leading, endPoint: .trailing))
|
||||
.frame(width: max(4, proxy.size.width * CGFloat(shown) / 100), height: 3)
|
||||
}
|
||||
.frame(height: 3)
|
||||
.opacity(progress == nil || progress == 100 ? 0 : 1)
|
||||
.animation(.easeOut(duration: 0.35), value: progress == nil || progress == 100)
|
||||
.onChange(of: progress, initial: true) { _, value in
|
||||
guard let value else { return }
|
||||
withAnimation(value < shown ? nil : .easeOut(duration: 0.25)) { shown = value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StartingView: View {
|
||||
let step: StartStep
|
||||
let host: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
ZStack {
|
||||
Circle().fill(Palette.coral.opacity(0.14)).frame(width: 64, height: 64)
|
||||
KnotMark(size: 32)
|
||||
.modifier(Breathing())
|
||||
}
|
||||
VStack(spacing: 4) {
|
||||
Text("Opening in a disposable VM").font(.system(size: 15, weight: .semibold))
|
||||
if let host {
|
||||
Text(host).font(.system(size: 12)).foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
StepRow(title: "Starting the VM", done: step != .booting, active: step == .booting)
|
||||
StepRow(title: "Connecting to its browser", done: step == .loading, active: step == .connecting)
|
||||
StepRow(title: "Loading the page", done: false, active: step == .loading)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Palette.surface)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StepRow: View {
|
||||
let title: String
|
||||
let done: Bool
|
||||
let active: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 9) {
|
||||
ZStack {
|
||||
if done {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Palette.green)
|
||||
.transition(.scale(scale: 0.4).combined(with: .opacity))
|
||||
} else if active {
|
||||
ProgressView().controlSize(.small).scaleEffect(0.75)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
Image(systemName: "circle").foregroundStyle(.white.opacity(0.22))
|
||||
}
|
||||
}
|
||||
.frame(width: 16, height: 16)
|
||||
Text(title)
|
||||
.foregroundStyle(.white.opacity(done || active ? 0.9 : 0.4))
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
.animation(.spring(duration: 0.35, bounce: 0.4), value: done)
|
||||
.animation(.easeOut(duration: 0.2), value: active)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessageCard<Actions: View>: View {
|
||||
let symbol: String
|
||||
let tint: Color
|
||||
let title: String
|
||||
let message: String
|
||||
var monospaced = false
|
||||
@ViewBuilder let actions: Actions
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle().fill(tint.opacity(0.14)).frame(width: 52, height: 52)
|
||||
Image(systemName: symbol).font(.system(size: 21, weight: .semibold)).foregroundStyle(tint)
|
||||
}
|
||||
VStack(spacing: 6) {
|
||||
Text(title).font(.system(size: 15, weight: .semibold))
|
||||
Text(message)
|
||||
.font(monospaced ? .system(size: 11, design: .monospaced) : .system(size: 12))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(5)
|
||||
.frame(maxWidth: 400)
|
||||
}
|
||||
HStack(spacing: 8) { actions }
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Palette.surface)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StaleBanner: View {
|
||||
let controller: SessionController
|
||||
let stale: StaleImage
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(Palette.amber)
|
||||
Text(stale.builtDaysAgo.map { "The browser VM is \($0) days old." } ?? "The browser VM's age is unknown.")
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
Spacer(minLength: 8)
|
||||
Button("Later") { controller.dismissStaleImageWarning() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .secondary, small: true))
|
||||
Button("Update Now") { controller.requestImageUpdate() }
|
||||
.buttonStyle(CapsuleButtonStyle(kind: .primary, small: true))
|
||||
}
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color(white: 0.1).opacity(0.94)))
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
private enum Setup {
|
||||
static func symbol(_ issue: SetupIssue) -> String {
|
||||
switch issue {
|
||||
case .cliMissing, .cliOutdated: "shippingbox"
|
||||
case .serviceStopped, .kernelMissing: "power"
|
||||
case .imageMissing, .imageOutdated: "square.and.arrow.down"
|
||||
case .imageStale: "arrow.triangle.2.circlepath"
|
||||
case .lowDisk: "internaldrive"
|
||||
}
|
||||
}
|
||||
|
||||
static func title(_ issue: SetupIssue) -> String {
|
||||
switch issue {
|
||||
case .cliMissing: "Knotchy needs Apple's container runtime"
|
||||
case .cliOutdated: "Knotchy needs a newer container runtime"
|
||||
case .serviceStopped, .kernelMissing: "The container service isn't running"
|
||||
case .imageMissing, .imageOutdated: "The browser VM image needs to be built"
|
||||
case .imageStale: "Updating the browser VM"
|
||||
case .lowDisk: "Not enough disk space"
|
||||
}
|
||||
}
|
||||
|
||||
static func progressTitle(_ issue: SetupIssue) -> String {
|
||||
switch issue {
|
||||
case .cliMissing: "Installing the container runtime…"
|
||||
case .cliOutdated: "Updating the container runtime…"
|
||||
case .serviceStopped, .kernelMissing: "Starting the container runtime…"
|
||||
default: title(issue)
|
||||
}
|
||||
}
|
||||
|
||||
static func text(_ issue: SetupIssue) -> String {
|
||||
switch issue {
|
||||
case .cliMissing: "Set it up in one step; the link opens when it's done."
|
||||
case .cliOutdated: "Update it in one step; the link opens when it's done."
|
||||
case .serviceStopped, .kernelMissing: "Start it to run the isolated browser."
|
||||
case .imageMissing, .imageOutdated: "This takes about 3 minutes and downloads about 225 MB."
|
||||
case .imageStale: "Rebuilding gets the latest WebKit security fixes."
|
||||
case .lowDisk(let free): "Only \(free / 1_000_000_000) GB free; a session needs about 2 GB."
|
||||
}
|
||||
}
|
||||
|
||||
static func action(_ issue: SetupIssue) -> String? {
|
||||
switch issue {
|
||||
case .cliMissing, .cliOutdated: "Set Up…"
|
||||
case .serviceStopped, .kernelMissing: "Start Service"
|
||||
case .imageMissing, .imageOutdated: "Build Image"
|
||||
case .imageStale: "Update Now"
|
||||
case .lowDisk: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import AppKit
|
||||
import KnotchyCore
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
final class NotchPanel: NSPanel {
|
||||
init() {
|
||||
super.init(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
|
||||
level = NSWindow.Level(rawValue: NSWindow.Level.mainMenu.rawValue + 3)
|
||||
collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
|
||||
isOpaque = false
|
||||
backgroundColor = .clear
|
||||
hasShadow = false
|
||||
becomesKeyOnlyIfNeeded = true
|
||||
isMovable = false
|
||||
isReleasedWhenClosed = false
|
||||
hidesOnDeactivate = false
|
||||
}
|
||||
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { false }
|
||||
}
|
||||
|
||||
enum Presentation: Equatable {
|
||||
case hidden
|
||||
case compact
|
||||
case banner
|
||||
case panel
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class NotchChrome {
|
||||
static let earWidth: CGFloat = 64
|
||||
static let bannerWidth: CGFloat = 320
|
||||
static let bannerRowHeight: CGFloat = 34
|
||||
|
||||
var presentation: Presentation = .hidden
|
||||
/// Lags `presentation` while retracting so the content shrinks into the notch with the shape.
|
||||
var content: Presentation = .hidden
|
||||
var reveal: CGFloat = 0
|
||||
var state: SessionState = .idle
|
||||
var notice: Notice?
|
||||
var notchSize = CGSize(width: 200, height: 32)
|
||||
var hasNotch = true
|
||||
var panelSize = CGSize(width: 652, height: 472)
|
||||
var panelOffsetX: CGFloat = 0
|
||||
var routeLabel: String?
|
||||
|
||||
var bodySize: CGSize { Self.bodySize(presentation, notch: notchSize, panel: panelSize, hasNotch: hasNotch) }
|
||||
var topRadius: CGFloat { Self.topRadius(presentation) }
|
||||
var bottomRadius: CGFloat { Self.bottomRadius(presentation) }
|
||||
var offsetX: CGFloat { presentation == .panel ? panelOffsetX : 0 }
|
||||
|
||||
static func bodySize(_ presentation: Presentation, notch: CGSize, panel: CGSize, hasNotch: Bool) -> CGSize {
|
||||
switch presentation {
|
||||
case .hidden: CGSize(width: notch.width, height: hasNotch ? notch.height : 0)
|
||||
case .compact: CGSize(width: notch.width + 2 * earWidth, height: notch.height)
|
||||
case .banner: CGSize(width: max(notch.width + 2 * earWidth, bannerWidth), height: notch.height + bannerRowHeight)
|
||||
case .panel: panel
|
||||
}
|
||||
}
|
||||
|
||||
static func topRadius(_ presentation: Presentation) -> CGFloat {
|
||||
switch presentation {
|
||||
case .hidden: 0
|
||||
case .compact: 6
|
||||
case .banner: 8
|
||||
case .panel: 10
|
||||
}
|
||||
}
|
||||
|
||||
static func bottomRadius(_ presentation: Presentation) -> CGFloat {
|
||||
switch presentation {
|
||||
case .hidden: 10
|
||||
case .compact: 12
|
||||
case .banner: 18
|
||||
case .panel: 22
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks the shape, not the window, which is larger while the shape animates.
|
||||
final class TrackingHostingView<Content: View>: NSHostingView<Content> {
|
||||
var onEnter: () -> Void = {}
|
||||
var onExit: () -> Void = {}
|
||||
var trackingRect: CGRect = .zero {
|
||||
didSet { if trackingRect != oldValue { updateTrackingAreas() } }
|
||||
}
|
||||
/// Remove only this one; NSHostingView's own areas drive SwiftUI hover effects.
|
||||
private var shapeArea: NSTrackingArea?
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
if let shapeArea { removeTrackingArea(shapeArea) }
|
||||
shapeArea = nil
|
||||
guard !trackingRect.isEmpty else { return }
|
||||
let area = NSTrackingArea(rect: trackingRect, options: [.mouseEnteredAndExited, .activeAlways], owner: self)
|
||||
addTrackingArea(area)
|
||||
shapeArea = area
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
if event.trackingArea === shapeArea { onEnter() } else { super.mouseEntered(with: event) }
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
if event.trackingArea === shapeArea { onExit() } else { super.mouseExited(with: event) }
|
||||
}
|
||||
|
||||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class NotchWindowController {
|
||||
private static let shadowPadding: CGFloat = 36
|
||||
|
||||
private let controller: SessionController
|
||||
private let chrome = NotchChrome()
|
||||
private let panel = NotchPanel()
|
||||
private let host: TrackingHostingView<NotchRootView>
|
||||
private var screen: NSScreen?
|
||||
private var anchor = CGPoint.zero
|
||||
private var clickMonitor: Any?
|
||||
private var lastState: SessionState = .idle
|
||||
/// A finished animation only tidies up if no newer one has started.
|
||||
private var generation = 0
|
||||
private var trackingExtent: Extent?
|
||||
private let currentRouteLabel: () -> String?
|
||||
private var sessionRouteLabel: String?
|
||||
|
||||
init(controller: SessionController, routeLabel: @escaping () -> String? = { nil }) {
|
||||
self.controller = controller
|
||||
currentRouteLabel = routeLabel
|
||||
host = TrackingHostingView(rootView: NotchRootView(controller: controller, chrome: chrome))
|
||||
host.sizingOptions = []
|
||||
host.onEnter = { [weak self] in self?.pointerEntered() }
|
||||
host.onExit = { [weak self] in self?.pointerExited() }
|
||||
panel.contentView = host
|
||||
// Global mouse monitors need no Accessibility permission.
|
||||
clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { _ in
|
||||
MainActor.assumeIsolated { controller.clickedOutside() }
|
||||
}
|
||||
NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification,
|
||||
object: nil, queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self else { return }
|
||||
self.screen = nil
|
||||
self.panel.orderOut(nil)
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
observe()
|
||||
update()
|
||||
}
|
||||
|
||||
func hide() {
|
||||
panel.orderOut(nil)
|
||||
}
|
||||
|
||||
/// So crossing the notch on the way to the menu bar doesn't open the folded pill.
|
||||
private static let hoverIntent: Duration = .milliseconds(150)
|
||||
private var pendingEnter: Task<Void, Never>?
|
||||
|
||||
private func pointerEntered() {
|
||||
pendingEnter?.cancel()
|
||||
guard chrome.presentation == .compact else {
|
||||
controller.pointerEntered()
|
||||
return
|
||||
}
|
||||
pendingEnter = Task { [weak self] in
|
||||
try? await Task.sleep(for: Self.hoverIntent)
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.controller.pointerEntered()
|
||||
}
|
||||
}
|
||||
|
||||
private func pointerExited() {
|
||||
pendingEnter?.cancel()
|
||||
pendingEnter = nil
|
||||
controller.pointerExited()
|
||||
}
|
||||
|
||||
/// A key non-activating panel keeps the keyboard until it leaves the screen; `resignKey()` only notifies.
|
||||
private func relinquishKey() {
|
||||
guard panel.isKeyWindow else { return }
|
||||
panel.orderOut(nil)
|
||||
panel.orderFrontRegardless()
|
||||
}
|
||||
|
||||
private func observe() {
|
||||
withObservationTracking {
|
||||
_ = controller.state
|
||||
_ = controller.notice
|
||||
_ = controller.setupFolded
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.update()
|
||||
self?.observe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func presentation(for state: SessionState, notice: Notice?, setupFolded: Bool) -> Presentation {
|
||||
switch state {
|
||||
case .settingUp where setupFolded:
|
||||
.compact
|
||||
case .idle:
|
||||
notice == nil ? .hidden : .banner
|
||||
case .starting(_, folded: true), .live(.collapsed):
|
||||
.compact
|
||||
case .destroying, .destroyed, .failed(.ended), .failed(.destroyStuck):
|
||||
.banner
|
||||
case .starting(_, folded: false), .live(.peek), .live(.expanded), .failed(.start), .setupRequired, .settingUp:
|
||||
.panel
|
||||
}
|
||||
}
|
||||
|
||||
private func update() {
|
||||
let state = controller.state
|
||||
let notice = controller.notice
|
||||
defer { lastState = state }
|
||||
let target = Self.presentation(for: state, notice: notice, setupFolded: controller.setupFolded)
|
||||
guard target != .hidden else {
|
||||
retract()
|
||||
return
|
||||
}
|
||||
|
||||
if screen == nil || (state == .starting(.booting, folded: false) && lastState != state) {
|
||||
let next = Self.screenUnderPointer()
|
||||
if next != screen { panel.orderOut(nil) }
|
||||
screen = next
|
||||
}
|
||||
guard let screen else { return }
|
||||
// The route is chosen as the VM boots, so read it once connecting.
|
||||
if case .starting(.booting, _) = state, lastState != state { sessionRouteLabel = nil }
|
||||
if case .starting(.connecting, _) = state, lastState != state { sessionRouteLabel = currentRouteLabel() }
|
||||
let routeLabel = sessionRouteLabel
|
||||
let info = ScreenInfo(screen)
|
||||
let large: Bool
|
||||
switch state {
|
||||
case .live(.expanded): large = true
|
||||
case .starting(_, folded: false): large = controller.opensExpanded
|
||||
default: large = false
|
||||
}
|
||||
let layout = NotchGeometry.layout(for: info, mode: large ? .expanded : .peek)
|
||||
let notchSize = layout.notch.size
|
||||
let panelSize = layout.panel.size
|
||||
let panelOffsetX = layout.panel.midX - layout.notch.midX
|
||||
anchor = CGPoint(x: layout.notch.midX, y: info.frame.maxY)
|
||||
|
||||
generation += 1
|
||||
let current = generation
|
||||
let apply = { [chrome] in
|
||||
chrome.presentation = target
|
||||
chrome.content = target
|
||||
chrome.reveal = 1
|
||||
chrome.state = state
|
||||
chrome.notice = notice
|
||||
chrome.notchSize = notchSize
|
||||
chrome.panelSize = panelSize
|
||||
chrome.panelOffsetX = panelOffsetX
|
||||
chrome.hasNotch = info.hasNotch
|
||||
chrome.routeLabel = routeLabel
|
||||
}
|
||||
let targetExtent = Self.extent(target, notch: notchSize, panel: panelSize, offset: panelOffsetX, hasNotch: info.hasNotch)
|
||||
setTracking(targetExtent)
|
||||
|
||||
if !panel.isVisible {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
apply()
|
||||
chrome.presentation = .hidden
|
||||
chrome.reveal = 0
|
||||
}
|
||||
let hiddenExtent = Self.extent(.hidden, notch: notchSize, panel: panelSize, offset: 0, hasNotch: info.hasNotch)
|
||||
fitWindow(hiddenExtent, targetExtent)
|
||||
panel.orderFrontRegardless()
|
||||
// Draw while invisible and start ~2 frames later, or the first draw (fonts, symbols) stalls the animation.
|
||||
withTransaction(transaction) { host.layoutSubtreeIfNeeded() }
|
||||
panel.displayIfNeeded()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(35)) { [weak self] in
|
||||
guard let self, self.generation == current else { return }
|
||||
self.animate(from: .hidden, to: target, apply: apply, generation: current, extent: targetExtent)
|
||||
}
|
||||
} else {
|
||||
let from = chrome.presentation
|
||||
let currentExtent = Self.extent(from, notch: chrome.notchSize, panel: chrome.panelSize,
|
||||
offset: chrome.offsetX, hasNotch: chrome.hasNotch)
|
||||
fitWindow(currentExtent, targetExtent)
|
||||
animate(from: from, to: target, apply: apply, generation: current, extent: targetExtent)
|
||||
}
|
||||
|
||||
// Only a click into the page (VideoView) makes the panel key; opening never does.
|
||||
if target != .panel { relinquishKey() }
|
||||
}
|
||||
|
||||
private func animate(from: Presentation, to target: Presentation, apply: @escaping () -> Void,
|
||||
generation current: Int, extent: Extent) {
|
||||
withAnimation(Self.animation(from: from, to: target), completionCriteria: .removed) {
|
||||
apply()
|
||||
} completion: { [weak self] in
|
||||
guard let self, self.generation == current else { return }
|
||||
self.fitWindow(extent)
|
||||
}
|
||||
}
|
||||
|
||||
private func retract() {
|
||||
guard panel.isVisible else {
|
||||
screen = nil
|
||||
return
|
||||
}
|
||||
generation += 1
|
||||
let current = generation
|
||||
setTracking(nil)
|
||||
relinquishKey()
|
||||
withAnimation(Motion.hide, completionCriteria: .removed) {
|
||||
chrome.presentation = .hidden
|
||||
chrome.reveal = 0
|
||||
} completion: { [weak self] in
|
||||
guard let self, self.generation == current else { return }
|
||||
self.panel.orderOut(nil)
|
||||
self.screen = nil
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) { self.chrome.content = .hidden }
|
||||
}
|
||||
}
|
||||
|
||||
private static func animation(from: Presentation, to target: Presentation) -> Animation {
|
||||
switch (from, target) {
|
||||
case (.panel, .panel): Motion.resize
|
||||
case (_, .panel): Motion.open
|
||||
case (.panel, _): Motion.fold
|
||||
default: Motion.small
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geometry
|
||||
|
||||
struct Extent {
|
||||
/// Reach on the wider side of the notch's centre line.
|
||||
var halfWidth: CGFloat
|
||||
var height: CGFloat
|
||||
var offset: CGFloat = 0
|
||||
var width: CGFloat = 0
|
||||
}
|
||||
|
||||
static func extent(_ presentation: Presentation, notch: CGSize, panel: CGSize, offset: CGFloat, hasNotch: Bool) -> Extent {
|
||||
let body = NotchChrome.bodySize(presentation, notch: notch, panel: panel, hasNotch: hasNotch)
|
||||
let width = body.width + 2 * NotchChrome.topRadius(presentation)
|
||||
let shift = presentation == .panel ? offset : 0
|
||||
return Extent(halfWidth: width / 2 + abs(shift), height: body.height, offset: shift, width: width)
|
||||
}
|
||||
|
||||
private func fitWindow(_ extents: Extent...) {
|
||||
let half = (extents.map(\.halfWidth).max() ?? 0) + Self.shadowPadding
|
||||
let height = (extents.map(\.height).max() ?? 0) + Self.shadowPadding
|
||||
let frame = CGRect(x: (anchor.x - half).rounded(), y: anchor.y - height.rounded(.up),
|
||||
width: (2 * half).rounded(.up), height: height.rounded(.up))
|
||||
if panel.frame != frame {
|
||||
panel.setFrame(frame, display: false)
|
||||
// Lay out outside any animation, or the shape animates across from the old window position.
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) { host.layoutSubtreeIfNeeded() }
|
||||
}
|
||||
refreshTracking()
|
||||
}
|
||||
|
||||
private func setTracking(_ extent: Extent?) {
|
||||
trackingExtent = extent
|
||||
refreshTracking()
|
||||
}
|
||||
|
||||
private func refreshTracking() {
|
||||
guard let extent = trackingExtent else {
|
||||
host.trackingRect = .zero
|
||||
return
|
||||
}
|
||||
let bounds = host.bounds
|
||||
let y = host.isFlipped ? bounds.minY : bounds.maxY - extent.height
|
||||
host.trackingRect = CGRect(x: bounds.midX + extent.offset - extent.width / 2, y: y,
|
||||
width: extent.width, height: extent.height)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func snapshot(to directory: URL, name: String) {
|
||||
guard panel.isVisible else {
|
||||
FileHandle.standardError.write(Data("snapshot \(name): window hidden\n".utf8))
|
||||
return
|
||||
}
|
||||
DebugSnapshot.write(host, to: directory.appendingPathComponent("\(name).png"))
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func screenUnderPointer() -> NSScreen? {
|
||||
let pointer = NSEvent.mouseLocation
|
||||
return NSScreen.screens.first { NSMouseInRect(pointer, $0.frame, false) } ?? NSScreen.main
|
||||
}
|
||||
}
|
||||
|
||||
extension ScreenInfo {
|
||||
/// The only code that reads `NSScreen`.
|
||||
@MainActor
|
||||
init(_ screen: NSScreen) {
|
||||
func global(_ rect: CGRect?) -> CGRect {
|
||||
guard let rect, !rect.isEmpty else { return .zero }
|
||||
// The auxiliary areas are documented in screen coordinates; make them global.
|
||||
return screen.frame.contains(rect.origin) ? rect : rect.offsetBy(dx: screen.frame.minX, dy: screen.frame.minY)
|
||||
}
|
||||
self.init(frame: screen.frame, visibleFrame: screen.visibleFrame,
|
||||
safeAreaTop: screen.safeAreaInsets.top,
|
||||
auxLeft: global(screen.auxiliaryTopLeftArea), auxRight: global(screen.auxiliaryTopRightArea),
|
||||
menuBarThickness: NSStatusBar.system.thickness)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import KnotchyCore
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum SettingsTab: String, CaseIterable, Identifiable {
|
||||
case general, network, vm
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .general: "General"
|
||||
case .network: "Network"
|
||||
case .vm: "VM"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .general: "gearshape"
|
||||
case .network: "network"
|
||||
case .vm: "shippingbox"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SettingsNavigation {
|
||||
var section: SettingsTab? = .general
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SettingsWindowController {
|
||||
static let size = CGSize(width: 700, height: 470)
|
||||
|
||||
private let app: AppDelegate
|
||||
private let navigation = SettingsNavigation()
|
||||
private(set) var window: NSWindow?
|
||||
|
||||
init(app: AppDelegate) {
|
||||
self.app = app
|
||||
}
|
||||
|
||||
func show(_ section: SettingsTab? = nil) {
|
||||
if let section { navigation.section = section }
|
||||
let window = self.window ?? makeWindow()
|
||||
self.window = window
|
||||
// An accessory app's windows open behind the frontmost app unless it activates first.
|
||||
NSApp.activate()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
private func makeWindow() -> NSWindow {
|
||||
let host = NSHostingController(rootView: SettingsView(app: app, settings: app.settings, navigation: navigation))
|
||||
host.sceneBridgingOptions = [.title, .toolbars]
|
||||
let window = NSWindow(contentViewController: host)
|
||||
window.styleMask = [.titled, .closable, .miniaturizable, .fullSizeContentView]
|
||||
window.toolbarStyle = .unified
|
||||
window.title = "Knotchy Settings"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.setContentSize(Self.size)
|
||||
window.center()
|
||||
window.setFrameAutosaveName("KnotchySettings")
|
||||
return window
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
let app: AppDelegate
|
||||
@Bindable var settings: AppSettings
|
||||
@Bindable var navigation: SettingsNavigation
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(SettingsTab.allCases, selection: $navigation.section) { section in
|
||||
Label(section.title, systemImage: section.symbol)
|
||||
}
|
||||
.navigationSplitViewColumnWidth(180)
|
||||
.toolbar(removing: .sidebarToggle)
|
||||
} detail: {
|
||||
Group {
|
||||
switch navigation.section ?? .general {
|
||||
case .general: GeneralSettings(settings: settings)
|
||||
case .network: NetworkSettings(settings: settings)
|
||||
case .vm: VMSettings(controller: app.controller, status: app.vmStatus)
|
||||
}
|
||||
}
|
||||
.navigationTitle((navigation.section ?? .general).title)
|
||||
}
|
||||
.frame(width: SettingsWindowController.size.width, height: SettingsWindowController.size.height)
|
||||
}
|
||||
}
|
||||
|
||||
private struct GeneralSettings: View {
|
||||
@Bindable var settings: AppSettings
|
||||
@State private var opensAtLogin = LoginItem.isEnabled
|
||||
@State private var loginError: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Destroy folded pages", selection: $settings.autoDestroyMinutes) {
|
||||
ForEach(AppSettings.autoDestroyChoices, id: \.self) { minutes in
|
||||
Text(minutes == 1 ? "After 1 minute" : "After \(minutes) minutes").tag(minutes)
|
||||
}
|
||||
}
|
||||
Picker("Open pages in", selection: $settings.opensLarge) {
|
||||
Text("Small panel").tag(false)
|
||||
Text("Large panel").tag(true)
|
||||
}
|
||||
} footer: {
|
||||
Text("A page folded into the notch keeps running until it's destroyed.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Open copied link with \(GlobalHotKey.openCopiedLinkLabel)", isOn: $settings.copiedLinkShortcut)
|
||||
} footer: {
|
||||
Text("Copy a link anywhere (in Chrome: right-click › Copy Link Address), then press \(GlobalHotKey.openCopiedLinkLabel).")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Open Knotchy at login", isOn: Binding {
|
||||
opensAtLogin
|
||||
} set: { enabled in
|
||||
loginError = LoginItem.set(enabled)
|
||||
opensAtLogin = LoginItem.isEnabled
|
||||
})
|
||||
} footer: {
|
||||
if let loginError { Text(loginError).foregroundStyle(.red) }
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollDisabled(true)
|
||||
.onAppear { opensAtLogin = LoginItem.isEnabled }
|
||||
}
|
||||
}
|
||||
|
||||
// Every mode's form stays laid out, inactive ones hidden, so switching crossfades instead of resizing the window.
|
||||
private struct NetworkSettings: View {
|
||||
@Bindable var settings: AppSettings
|
||||
@State private var importing = false
|
||||
@State private var importMessage: String?
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
ForEach([AppSettings.NetworkMode.direct, .proxy, .vpn], id: \.self) { mode in
|
||||
let active = settings.networkMode == mode
|
||||
NetworkForm(settings: settings, mode: mode, importMessage: importMessage, importing: $importing)
|
||||
.opacity(active ? 1 : 0)
|
||||
.allowsHitTesting(active)
|
||||
.accessibilityHidden(!active)
|
||||
.animation(active ? .easeOut(duration: 0.2).delay(0.05) : .easeOut(duration: 0.1), value: active)
|
||||
}
|
||||
}
|
||||
.fileImporter(isPresented: $importing,
|
||||
allowedContentTypes: [UTType(filenameExtension: "conf") ?? .data, .zip],
|
||||
allowsMultipleSelection: true) { result in
|
||||
if case .success(let urls) = result {
|
||||
Task { importMessage = await settings.importWireGuard(from: urls) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NetworkForm: View {
|
||||
@Bindable var settings: AppSettings
|
||||
let mode: AppSettings.NetworkMode
|
||||
let importMessage: String?
|
||||
@Binding var importing: Bool
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Connection", selection: $settings.networkMode) {
|
||||
Text("Direct").tag(AppSettings.NetworkMode.direct)
|
||||
Text("Proxy").tag(AppSettings.NetworkMode.proxy)
|
||||
Text("WireGuard VPN").tag(AppSettings.NetworkMode.vpn)
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
} footer: {
|
||||
Text(description)
|
||||
}
|
||||
|
||||
details
|
||||
.disabled(settings.networkMode != mode) // No tabbing into a hidden form.
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollDisabled(true)
|
||||
}
|
||||
|
||||
private var description: String {
|
||||
switch mode {
|
||||
case .direct: "Pages go out through this Mac's internet connection, and can reach nothing else."
|
||||
case .proxy: "Pages can only go out through the proxy. If it's down, they don't load."
|
||||
case .vpn: "Pages can only go out through the VPN. If it's down, they don't load. The rest of your Mac isn't affected."
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var details: some View {
|
||||
switch mode {
|
||||
case .direct:
|
||||
Section {
|
||||
LabeledContent("Internet", value: "This Mac's connection")
|
||||
LabeledContent("Blocked", value: "Your Mac, your network, IPv6")
|
||||
LabeledContent("DNS", value: "1.1.1.1 (Cloudflare)")
|
||||
} header: {
|
||||
Text("Direct")
|
||||
}
|
||||
case .proxy:
|
||||
Section {
|
||||
Picker("Type", selection: $settings.proxyKind) {
|
||||
Text("HTTP").tag(ProxySettings.Kind.http)
|
||||
Text("SOCKS5").tag(ProxySettings.Kind.socks5)
|
||||
}
|
||||
TextField("Server", text: $settings.proxyHost, prompt: Text("proxy.example.com"))
|
||||
TextField("Port", value: $settings.proxyPort, format: .number.grouping(.never), prompt: Text("8080"))
|
||||
TextField("Username", text: $settings.proxyUsername, prompt: Text("Optional"))
|
||||
SecureField("Password", text: $settings.proxyPassword, prompt: Text("Optional"))
|
||||
} header: {
|
||||
Text("Proxy")
|
||||
} footer: {
|
||||
if settings.proxyIsLocal {
|
||||
Text("Proxies on this Mac or your network aren't allowed: pages could reach your network through them.")
|
||||
.foregroundStyle(.orange)
|
||||
} else {
|
||||
Text("The password is kept in your keychain.")
|
||||
}
|
||||
}
|
||||
case .vpn:
|
||||
Section {
|
||||
if settings.wireGuardNames.isEmpty {
|
||||
LabeledContent("Server", value: "None")
|
||||
LabeledContent("Endpoint", value: "—")
|
||||
LabeledContent("DNS", value: "—")
|
||||
LabeledContent("No configs imported") {
|
||||
Button("Import…") { importing = true }
|
||||
}
|
||||
} else {
|
||||
Picker("Server", selection: $settings.wireGuardServer) {
|
||||
ForEach(settings.wireGuardNames, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
let config = settings.selectedWireGuard
|
||||
LabeledContent("Endpoint", value: config.map { "\($0.endpointHost):\(String($0.endpointPort))" } ?? "—")
|
||||
LabeledContent("DNS", value: config.map { $0.dns.isEmpty ? "1.1.1.1" : $0.dns.joined(separator: ", ") } ?? "—")
|
||||
LabeledContent(settings.wireGuardNames.count == 1 ? "1 config" : "\(settings.wireGuardNames.count) configs") {
|
||||
HStack {
|
||||
Button("Remove All") { settings.removeWireGuardConfigs() }
|
||||
Button("Import…") { importing = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("WireGuard")
|
||||
} footer: {
|
||||
if let importMessage {
|
||||
Text(importMessage).foregroundStyle(.orange).lineLimit(2)
|
||||
} else {
|
||||
Text("Import the .conf files, or the .zip, from Mullvad, Proton VPN, IVPN or others.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct VMSettings: View {
|
||||
let controller: SessionController
|
||||
let status: VMStatus
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Container runtime", value: status.runtimeSummary)
|
||||
LabeledContent("VM image", value: status.summary)
|
||||
LabeledContent("Rebuild") {
|
||||
Button(status.image == .missing ? "Build Now" : "Rebuild Now") { controller.rebuildImage() }
|
||||
.disabled(isSettingUp || status.image == .noContainerTool)
|
||||
}
|
||||
} footer: {
|
||||
Text("Rebuilding takes about 3 minutes and picks up the latest WebKit security fixes. Progress shows in the notch.")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollDisabled(true)
|
||||
.onAppear { status.refresh() }
|
||||
}
|
||||
|
||||
private var isSettingUp: Bool {
|
||||
if case .settingUp = controller.state { true } else { false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import AppKit
|
||||
import KnotchyCore
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class SetupWindowController: NSObject, NSWindowDelegate {
|
||||
enum Content {
|
||||
case setup(SetupFlow)
|
||||
case unsupported(Unsupported)
|
||||
}
|
||||
|
||||
private let content: Content
|
||||
private(set) var window: NSWindow?
|
||||
|
||||
init(_ content: Content) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
func show() {
|
||||
let window = self.window ?? makeWindow()
|
||||
self.window = window
|
||||
// An accessory app's windows open behind the frontmost app unless it activates first.
|
||||
NSApp.activate()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
if case .setup(let flow) = content { Task { await flow.refresh() } }
|
||||
}
|
||||
|
||||
private func makeWindow() -> NSWindow {
|
||||
let root = switch content {
|
||||
case .setup(let flow): AnyView(SetupView(flow: flow) { [weak self] in self?.window?.close() })
|
||||
case .unsupported(let reason): AnyView(UnsupportedView(reason: reason))
|
||||
}
|
||||
let host = NSHostingController(rootView: root)
|
||||
let window = NSWindow(contentViewController: host)
|
||||
window.styleMask = [.titled, .closable, .fullSizeContentView]
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.titleVisibility = .hidden
|
||||
window.title = "Welcome to Knotchy"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.setContentSize(host.view.fittingSize)
|
||||
window.center()
|
||||
window.delegate = self
|
||||
return window
|
||||
}
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
switch content {
|
||||
case .unsupported:
|
||||
NSApp.terminate(nil)
|
||||
case .setup(let flow):
|
||||
if flow.allRequiredDone { UserDefaults.standard.set(true, forKey: SetupLaunchRule.completedKey) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SetupView: View {
|
||||
let flow: SetupFlow
|
||||
let close: () -> Void
|
||||
@State private var browser = ChromeExtension.installedBrowser()
|
||||
@State private var addedToBrowser = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(spacing: 14) {
|
||||
Image(nsImage: NSApp.applicationIconImage)
|
||||
.resizable()
|
||||
.frame(width: 56, height: 56)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Welcome to Knotchy").font(.title2.weight(.semibold))
|
||||
Text("A few things to set up before your first link.").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
GroupBox {
|
||||
VStack(spacing: 0) {
|
||||
SetupRow(title: "This Mac", status: flow.mac) { Task { await flow.refresh() } }
|
||||
Divider()
|
||||
SetupRow(title: "Container runtime", status: flow.runtime) { flow.start() }
|
||||
Divider()
|
||||
SetupRow(title: "Browser VM", status: flow.vm) { flow.start() }
|
||||
if let browser {
|
||||
Divider()
|
||||
BrowserRow(browser: browser, added: $addedToBrowser)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
if flow.allRequiredDone {
|
||||
Button("Done", action: close)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
} else {
|
||||
Button("Not Now", action: close)
|
||||
Button("Set Up") { flow.start() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(!flow.canSetUp)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 540)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SetupRow: View {
|
||||
let title: String
|
||||
let status: SetupFlow.Status
|
||||
let tryAgain: () -> Void
|
||||
@State private var showsDetails = false
|
||||
|
||||
// Buttons sit on the title line and the detail reserves two lines, so no state changes the height.
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
symbol.frame(width: 20, height: 22)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
Spacer(minLength: 8)
|
||||
if case .failed(_, let details) = status {
|
||||
if !details.isEmpty {
|
||||
Button("Show Details") { showsDetails = true }
|
||||
.popover(isPresented: $showsDetails, arrowEdge: .bottom) { DetailsView(lines: details) }
|
||||
}
|
||||
Button("Try Again", action: tryAgain)
|
||||
}
|
||||
}
|
||||
.controlSize(.small)
|
||||
.frame(minHeight: 22)
|
||||
Text(detail)
|
||||
.font(.callout)
|
||||
.foregroundStyle(isFailed ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private var detail: String {
|
||||
switch status {
|
||||
case .pending(let text), .running(let text, _), .done(let text), .failed(let text, _): text
|
||||
}
|
||||
}
|
||||
|
||||
private var isFailed: Bool {
|
||||
if case .failed = status { true } else { false }
|
||||
}
|
||||
|
||||
@ViewBuilder private var symbol: some View {
|
||||
switch status {
|
||||
case .pending:
|
||||
Image(systemName: "circle.dashed").imageScale(.large).foregroundStyle(.tertiary)
|
||||
case .running(_, let fraction?):
|
||||
ProgressView(value: fraction).progressViewStyle(.circular).controlSize(.small)
|
||||
case .running:
|
||||
ProgressView().controlSize(.small)
|
||||
case .done:
|
||||
Image(systemName: "checkmark.circle.fill").imageScale(.large).foregroundStyle(.green)
|
||||
case .failed:
|
||||
Image(systemName: "exclamationmark.circle.fill").imageScale(.large).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BrowserRow: View {
|
||||
let browser: ChromeExtension.Browser
|
||||
@Binding var added: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "puzzlepiece.extension")
|
||||
.imageScale(.large)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20, height: 22)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text("Open links from \(browser.name)")
|
||||
Text("Optional").font(.caption).foregroundStyle(.tertiary)
|
||||
Spacer(minLength: 8)
|
||||
Button("Add to \(browser.name)") {
|
||||
ChromeExtension.open(in: browser)
|
||||
added = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.frame(minHeight: 22)
|
||||
Text(added ? "In \(browser.name), right-click a link and choose Open in Knotchy."
|
||||
: "Safari and Mail already work from the right-click menu.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DetailsView: View {
|
||||
let lines: [String]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
Text(lines.joined(separator: "\n"))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
}
|
||||
.frame(width: 440, height: 220)
|
||||
}
|
||||
}
|
||||
|
||||
private struct UnsupportedView: View {
|
||||
let reason: Unsupported
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(nsImage: NSApp.applicationIconImage)
|
||||
.resizable()
|
||||
.frame(width: 72, height: 72)
|
||||
Text(title).font(.title2.weight(.semibold))
|
||||
Text(message)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Button("Quit") { NSApp.terminate(nil) }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(width: 440)
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch reason {
|
||||
case .noVirtualization: "This Mac can't run virtual machines right now"
|
||||
}
|
||||
}
|
||||
|
||||
private var message: String {
|
||||
switch reason {
|
||||
case .noVirtualization:
|
||||
"Knotchy opens every link in its own virtual machine. If macOS is itself running in a virtual machine, run Knotchy on the host Mac instead."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import AVFoundation
|
||||
import AppKit
|
||||
import CoreMedia
|
||||
import KnotchyCore
|
||||
import SwiftUI
|
||||
import Synchronization
|
||||
|
||||
final class LatestFrame: Sendable {
|
||||
private let frame = Mutex<DecodedFrame?>(nil)
|
||||
|
||||
func put(_ new: DecodedFrame) -> Bool {
|
||||
frame.withLock { slot in
|
||||
defer { slot = new }
|
||||
return slot == nil
|
||||
}
|
||||
}
|
||||
|
||||
func take() -> DecodedFrame? {
|
||||
frame.withLock { slot in
|
||||
defer { slot = nil }
|
||||
return slot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VideoSurface: NSViewRepresentable {
|
||||
let controller: SessionController
|
||||
|
||||
func makeNSView(context: Context) -> VideoView {
|
||||
let view = VideoView()
|
||||
view.controller = controller
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: VideoView, context: Context) {
|
||||
view.attach(controller.connection)
|
||||
}
|
||||
}
|
||||
|
||||
final class VideoView: NSView {
|
||||
weak var controller: SessionController?
|
||||
private var connection: (any StreamConnection)?
|
||||
private let displayLayer = AVSampleBufferDisplayLayer()
|
||||
private var scrollRemainder = CGPoint.zero
|
||||
/// WebKit scrolls about 40 px per wheel tick.
|
||||
private let scrollStep: CGFloat = 40
|
||||
|
||||
override init(frame: NSRect) {
|
||||
super.init(frame: frame)
|
||||
wantsLayer = true
|
||||
layer?.backgroundColor = NSColor.black.cgColor
|
||||
displayLayer.videoGravity = .resizeAspect
|
||||
displayLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
|
||||
layer?.addSublayer(displayLayer)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("not used") }
|
||||
|
||||
override var isFlipped: Bool { true }
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
displayLayer.frame = bounds
|
||||
CATransaction.commit()
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.environment["KNOTCHY_LOG_LAYOUT"] != nil {
|
||||
print(String(format: "%.3f video %.0fx%.0f", CACurrentMediaTime(), bounds.width, bounds.height))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func attach(_ connection: (any StreamConnection)?) {
|
||||
guard self.connection !== connection else { return }
|
||||
self.connection = connection
|
||||
displayLayer.sampleBufferRenderer.flush()
|
||||
let slot = LatestFrame()
|
||||
connection?.setFrameHandler { [weak self] frame in
|
||||
// Keep only the newest frame so a busy main thread drops frames instead of queueing multi-MB buffers.
|
||||
guard slot.put(frame) else { return }
|
||||
DispatchQueue.main.async {
|
||||
guard let frame = slot.take() else { return }
|
||||
MainActor.assumeIsolated { self?.show(frame.pixelBuffer) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func show(_ pixelBuffer: CVPixelBuffer) {
|
||||
var format: CMVideoFormatDescription?
|
||||
CMVideoFormatDescriptionCreateForImageBuffer(allocator: nil, imageBuffer: pixelBuffer, formatDescriptionOut: &format)
|
||||
guard let format else { return }
|
||||
var timing = CMSampleTimingInfo(duration: .invalid, presentationTimeStamp: .zero, decodeTimeStamp: .invalid)
|
||||
var sample: CMSampleBuffer?
|
||||
CMSampleBufferCreateReadyWithImageBuffer(allocator: nil, imageBuffer: pixelBuffer, formatDescription: format,
|
||||
sampleTiming: &timing, sampleBufferOut: &sample)
|
||||
guard let sample else { return }
|
||||
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: true),
|
||||
CFArrayGetCount(attachments) > 0 {
|
||||
let dictionary = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self)
|
||||
CFDictionarySetValue(dictionary,
|
||||
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
|
||||
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
|
||||
}
|
||||
let renderer = displayLayer.sampleBufferRenderer
|
||||
if renderer.status == .failed { renderer.flush() }
|
||||
renderer.enqueue(sample)
|
||||
}
|
||||
|
||||
// MARK: - Mouse
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
for area in trackingAreas where area.owner === self { removeTrackingArea(area) }
|
||||
addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseMoved, .activeAlways, .inVisibleRect], owner: self))
|
||||
}
|
||||
|
||||
private func videoPoint(_ event: NSEvent) -> (x: UInt16, y: UInt16)? {
|
||||
NotchGeometry.videoPoint(convert(event.locationInWindow, from: nil), surfaceSize: bounds.size,
|
||||
renderSize: SessionController.renderSize)
|
||||
}
|
||||
|
||||
private func mods(_ event: NSEvent) -> Modifiers {
|
||||
var mods: Modifiers = []
|
||||
let flags = event.modifierFlags
|
||||
if flags.contains(.shift) { mods.insert(.shift) }
|
||||
// ⌘ maps to Ctrl so ⌘A/C/V/X/Z work like Linux shortcuts in the VM.
|
||||
if flags.contains(.control) || flags.contains(.command) { mods.insert(.control) }
|
||||
if flags.contains(.option) { mods.insert(.alt) }
|
||||
let buttons = NSEvent.pressedMouseButtons
|
||||
if buttons & 1 != 0 { mods.insert(.leftButton) }
|
||||
if buttons & 2 != 0 { mods.insert(.rightButton) }
|
||||
if buttons & 4 != 0 { mods.insert(.middleButton) }
|
||||
return mods
|
||||
}
|
||||
|
||||
private func sendMove(_ event: NSEvent) {
|
||||
guard let point = videoPoint(event) else { return }
|
||||
connection?.send(.pointerMove(x: point.x, y: point.y, mods: mods(event)))
|
||||
}
|
||||
|
||||
private func sendButton(_ event: NSEvent, _ button: PointerButton, pressed: Bool) {
|
||||
guard let point = videoPoint(event) else { return }
|
||||
connection?.send(.pointerButton(button, pressed: pressed, x: point.x, y: point.y, mods: mods(event)))
|
||||
}
|
||||
|
||||
override func mouseMoved(with event: NSEvent) { sendMove(event) }
|
||||
override func mouseDragged(with event: NSEvent) { sendMove(event) }
|
||||
override func rightMouseDragged(with event: NSEvent) { sendMove(event) }
|
||||
override func otherMouseDragged(with event: NSEvent) { sendMove(event) }
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
controller?.panelClicked()
|
||||
window?.makeKey()
|
||||
window?.makeFirstResponder(self)
|
||||
sendButton(event, .left, pressed: true)
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) { sendButton(event, .left, pressed: false) }
|
||||
override func rightMouseDown(with event: NSEvent) { sendButton(event, .right, pressed: true) }
|
||||
override func rightMouseUp(with event: NSEvent) { sendButton(event, .right, pressed: false) }
|
||||
override func otherMouseDown(with event: NSEvent) { sendButton(event, .middle, pressed: true) }
|
||||
override func otherMouseUp(with event: NSEvent) { sendButton(event, .middle, pressed: false) }
|
||||
|
||||
override func scrollWheel(with event: NSEvent) {
|
||||
var dx = event.scrollingDeltaX
|
||||
var dy = event.scrollingDeltaY
|
||||
if event.hasPreciseScrollingDeltas {
|
||||
scrollRemainder.x += dx
|
||||
scrollRemainder.y += dy
|
||||
dx = (scrollRemainder.x / scrollStep).rounded(.towardZero)
|
||||
dy = (scrollRemainder.y / scrollStep).rounded(.towardZero)
|
||||
scrollRemainder.x -= dx * scrollStep
|
||||
scrollRemainder.y -= dy * scrollStep
|
||||
} else {
|
||||
dx = dx == 0 ? 0 : (dx > 0 ? max(1, dx.rounded()) : min(-1, dx.rounded()))
|
||||
dy = dy == 0 ? 0 : (dy > 0 ? max(1, dy.rounded()) : min(-1, dy.rounded()))
|
||||
}
|
||||
guard dx != 0 || dy != 0, let point = videoPoint(event) else { return }
|
||||
connection?.send(.scroll(ticksX: Int16(clamping: Int(dx)), ticksY: Int16(clamping: Int(dy)), x: point.x, y: point.y))
|
||||
}
|
||||
|
||||
// MARK: - Keyboard
|
||||
|
||||
private static let specialKeys: [UInt16: String] = [
|
||||
36: "Return", 76: "KP_Enter", 48: "Tab", 51: "BackSpace", 117: "Delete", 53: "Escape",
|
||||
49: "space", 123: "Left", 124: "Right", 125: "Down", 126: "Up",
|
||||
115: "Home", 119: "End", 116: "Page_Up", 121: "Page_Down",
|
||||
122: "F1", 120: "F2", 99: "F3", 118: "F4", 96: "F5", 97: "F6",
|
||||
98: "F7", 100: "F8", 101: "F9", 109: "F10", 103: "F11", 111: "F12",
|
||||
]
|
||||
|
||||
private func keyName(_ event: NSEvent) -> String? {
|
||||
if let name = Self.specialKeys[event.keyCode] { return name }
|
||||
let flags = event.modifierFlags
|
||||
let characters = flags.contains(.command) || flags.contains(.control)
|
||||
? event.charactersIgnoringModifiers?.lowercased()
|
||||
: event.characters
|
||||
guard let scalars = characters?.unicodeScalars, scalars.count == 1, let scalar = scalars.first,
|
||||
scalar.value >= 0x20, scalar.value != 0x7F, !(0xF700...0xF8FF).contains(scalar.value)
|
||||
else { return nil }
|
||||
return String(scalar)
|
||||
}
|
||||
|
||||
private func sendKey(_ event: NSEvent, pressed: Bool) {
|
||||
guard let name = keyName(event) else { return }
|
||||
connection?.send(.key(pressed: pressed, mods: mods(event), name: name))
|
||||
}
|
||||
|
||||
override func keyDown(with event: NSEvent) { sendKey(event, pressed: true) }
|
||||
override func keyUp(with event: NSEvent) { sendKey(event, pressed: false) }
|
||||
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
guard event.type == .keyDown, event.modifierFlags.contains(.command), let controller else {
|
||||
return super.performKeyEquivalent(with: event)
|
||||
}
|
||||
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
|
||||
let shift = event.modifierFlags.contains(.shift)
|
||||
switch (key, shift) {
|
||||
case ("w", false), (".", false): controller.destroy()
|
||||
case ("e", true): controller.state == .live(.expanded) ? controller.shrink() : controller.expand()
|
||||
case ("[", false): controller.goBack()
|
||||
case ("]", false): controller.goForward()
|
||||
case ("r", false): controller.reload()
|
||||
case ("q", false): NSApp.terminate(nil)
|
||||
default:
|
||||
sendKey(event, pressed: true)
|
||||
sendKey(event, pressed: false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user