mirror of
https://github.com/aculix/knotchy.git
synced 2026-10-27 07:15:53 +00:00
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.
214 lines
6.8 KiB
Swift
214 lines
6.8 KiB
Swift
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: "Starting the container runtime…"
|
|
case .kernelMissing: "Downloading the Linux kernel…"
|
|
default: "Building the VM…"
|
|
}
|
|
}
|
|
}
|