Show startup as one step checklist and re-check tools on the Server tab

This commit is contained in:
Ian Gulliver
2026-10-23 05:42:30 -07:00
parent 522c903344
commit dfb114ea64
9 changed files with 242 additions and 162 deletions
+153
View File
@@ -0,0 +1,153 @@
import SwiftUI
#if os(iOS)
import SafariServices
#endif
struct SetupStep: Identifiable {
enum Status {
case done, active, todo
}
let name: String
var status: Status
var detail: String?
var signIn = false
var signInURL: URL?
var id: String {
name
}
}
func signInStep(_ name: String, hostname: String, state: String, authURL: URL?) -> SetupStep {
if state == "Running" {
return SetupStep(name: name, status: .done)
}
if state == "NeedsMachineAuth" {
return SetupStep(name: name, status: .active, detail: "\(hostname) is waiting for a tailnet admin to approve it")
}
if state == "NeedsLogin" {
return SetupStep(name: name, status: .active, detail: authURL == nil ? "Waiting for Tailscale's sign-in link…" : "\(hostname) is waiting for you", signIn: true, signInURL: authURL)
}
return SetupStep(name: name, status: .active, detail: "Waiting for Tailscale (\(state))…")
}
struct StepsCard: View {
let title: String
let detail: String
let steps: [SetupStep]
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(Fonts.font(17, weight: 700))
Text(detail)
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
.padding(.bottom, 10)
ForEach(Array(steps.enumerated()), id: \.element.id) { i, step in
if i > 0 {
Rectangle().fill(Palette.border).frame(height: 1)
}
StepRow(step: step)
}
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
.frame(maxWidth: 460)
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
#if os(macOS)
.onAppear {
Tailnet.shared.serviceWatchers += 1
}
.onDisappear {
Tailnet.shared.serviceWatchers -= 1
}
#endif
}
}
private struct StepRow: View {
let step: SetupStep
@State private var signingIn = false
var body: some View {
HStack(alignment: .center, spacing: 10) {
icon
.frame(width: 18, height: 18)
VStack(alignment: .leading, spacing: 2) {
Text(step.name)
.foregroundStyle(step.status == .todo ? Palette.muted : Palette.text)
if let detail = step.detail {
Text(detail)
.font(Fonts.font(12))
.foregroundStyle(Palette.muted)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if step.signIn {
Button("Sign in") {
#if os(macOS)
if let url = step.signInURL {
openExternal(url)
}
#else
signingIn = true
#endif
}
.buttonStyle(WebButtonStyle(primary: true))
.disabled(step.signInURL == nil)
.fixedSize()
}
}
.padding(.vertical, 8)
#if os(iOS)
.sheet(isPresented: $signingIn) {
if let url = step.signInURL {
SafariView(url: url)
.ignoresSafeArea()
}
}
.onChange(of: step.signIn) {
if !step.signIn {
signingIn = false
}
}
#endif
}
@ViewBuilder
private var icon: some View {
switch step.status {
case .done:
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Palette.ok)
case .active:
ProgressView()
.controlSize(.small)
case .todo:
Image(systemName: "circle")
.font(.system(size: 8))
.foregroundStyle(Palette.muted)
}
}
}
#if os(iOS)
private struct SafariView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> SFSafariViewController {
SFSafariViewController(url: url)
}
func updateUIViewController(_ controller: SFSafariViewController, context: Context) {}
}
#endif