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