mirror of
https://github.com/aculix/knotchy.git
synced 2026-10-27 07:15:53 +00:00
246 lines
8.5 KiB
Swift
246 lines
8.5 KiB
Swift
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."
|
|
}
|
|
}
|
|
}
|