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,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user