Files
aculix 9fd654298a Fix first-run setup timing out on the kernel download
On a Mac without the Linux kernel, setup ran `container system start
--enable-kernel-install` with a 120 s limit. That command downloads a
697 MB archive without printing anything, so most connections hit the
limit and the window showed `timedOut([...])`.

- Start the service with --disable-kernel-install (10 min: a first
  start still pulls the 69 MB base filesystem).
- Download the kernel in its own step with progress, then hand the file
  to `container system kernel set --tar ... --digest ...`, which checks
  it against the runtime's digest.
- Opening the setup window no longer starts the download; Set Up does.
- Timeouts and runner errors read as sentences.
- A rebuild deletes the image it replaced, which frees about 2 GB each
  time, and deletes or stops the builder VM unless it was already
  running (it kept about 2.8 GB of memory).
- Setup asks for 10 GB free, measured on a real setup.
2026-09-29 16:25:50 +05:30

251 lines
11 KiB
Swift

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() }
if controller.state == .settingUp(.kernelMissing) { ContainerCLIBackend.removeKernelDownloads() }
// 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)
}
}
}