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
+1 -1
View File
@@ -558,7 +558,7 @@ struct IdentityBanner: View {
let valid = !gitName.isEmpty && emailShape.test(email)
let row = touchOnly ? AnyLayout(VStackLayout(alignment: .leading, spacing: 6)) : AnyLayout(HStackLayout(spacing: 6))
row {
Text("Set the identity agents commit as: ")
Text("Name and email for the author line of agents' commits (not a git login or credentials):")
TextField("Git name", text: Binding(get: { gitName }, set: { name = $0 }))
.modifier(FieldStyle())
.frame(width: touchOnly ? nil : 220)
+7 -2
View File
@@ -444,9 +444,14 @@ struct ServerView: View {
}
.task {
await refresh()
var tick = 0
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2))
if let current = state, current.on, current.address == nil {
try? await Task.sleep(for: .seconds(1))
tick += 1
if tick % 5 == 0, !busy {
checks = await check()
}
if tick % 2 == 0, let current = state, current.on, current.address == nil {
state = await Server.state(loopback: loopback)
}
}
+73 -52
View File
@@ -298,26 +298,49 @@ final class ConnectForm {
}
#if os(macOS)
var installStep: Int?
var installSteps: [SetupStep] {
let names = ["Check the tools", "Install the server", "Sign the server in to Tailscale", "Wait for the server to answer", "Connect"]
return names.enumerated().map { i, name in
guard let installStep, i == installStep else {
return SetupStep(name: name, status: i < (installStep ?? 0) ? .done : .todo)
}
if i == 2, let service = Tailnet.shared.service {
return signInStep(name, hostname: service.hostname, state: service.state, authURL: URL(string: service.authUrl))
}
return SetupStep(name: name, status: .active)
}
}
func installHere() async -> URL? {
connecting = true
problem = nil
missing = []
defer {
installStep = nil
}
do {
installStep = 0
let checks = try await Server.checks()
if !checks.ready {
missing = checks.checks.filter { !$0.ok }
connecting = false
return nil
}
installStep = 1
try await Server.turnOn()
installStep = 2
let origin = try await waitForTailnet(URL(string: checks.loopback)!)
serverText = origin.absoluteString
installStep = 3
try await waitForServer(origin)
} catch {
connecting = false
problem = errorText(error)
return nil
}
installStep = 4
return await connect()
}
@@ -459,31 +482,6 @@ struct ConnectView: View {
#endif
}
struct ConnectingView: View {
let step: String
var problem: String?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text(step)
}
if let problem {
Text("Retrying: \(problem)")
.foregroundStyle(Palette.muted)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(maxWidth: 460)
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ServerFocus {
let store: Store?
let overlays: Overlays
@@ -514,40 +512,63 @@ struct ServerWindow: View {
@ViewBuilder
private var content: some View {
if Tailnet.shared.needsLogin {
TailnetLoginView()
} else if !Tailnet.shared.running {
ConnectingView(step: "Starting Tailscale…")
} else if let connection {
let step = "Connecting to \(serverName(connection.origin.absoluteString))…"
switch connection.phase {
case .starting:
ConnectingView(step: step)
case .failed(let problem):
ConnectingView(step: step, problem: problem)
case .running(let store):
#if os(macOS)
MainView()
.environment(store)
.environment(connection.overlays)
.id(ObjectIdentifier(store))
#else
PhoneView()
.environment(store)
.environment(connection.overlays)
.id(ObjectIdentifier(store))
#endif
}
} else if !Tailnet.shared.searched {
ConnectingView(step: "Looking for byre servers on the tailnet…")
} else {
let tailnet = Tailnet.shared
let ready = tailnet.running && !tailnet.needsLogin
#if os(macOS)
let installing = form.installStep != nil
#else
let installing = false
#endif
if installing {
#if os(macOS)
StepsCard(title: "Running byre on this Mac", detail: "byre sets up its server here, then connects to it.", steps: form.installSteps)
#endif
} else if ready, let connection, case .running(let store) = connection.phase {
#if os(macOS)
MainView()
.environment(store)
.environment(connection.overlays)
.id(ObjectIdentifier(store))
#else
PhoneView()
.environment(store)
.environment(connection.overlays)
.id(ObjectIdentifier(store))
#endif
} else if ready, connection == nil, tailnet.searched {
ConnectView(form: form) { connected in
form.serverText = ""
Servers.shared.current = connected.absoluteString
}
} else {
StepsCard(title: "Opening byre", detail: "This app reaches its servers through Tailscale.", steps: launchSteps)
}
}
private var launchSteps: [SetupStep] {
let tailnet = Tailnet.shared
let started = tailnet.state != "Starting" && tailnet.state != "NoState"
var steps = [SetupStep(name: "Start Tailscale", status: started ? .done : .active)]
steps.append(started ? signInStep("Sign this app in to Tailscale", hostname: tailnetNode.hostname, state: tailnet.state, authURL: tailnet.authURL) : SetupStep(name: "Sign this app in to Tailscale", status: .todo))
if let service = tailnet.service {
steps.append(signInStep("Sign this Mac's server in to Tailscale", hostname: service.hostname, state: service.state, authURL: URL(string: service.authUrl)))
}
let ready = tailnet.running && !tailnet.needsLogin
guard let connection else {
steps.append(SetupStep(name: "Find byre servers", status: ready ? .active : .todo))
steps.append(SetupStep(name: "Connect", status: .todo))
return steps
}
let name = serverName(connection.origin.absoluteString)
steps.append(SetupStep(name: "Find byre servers", status: .done, detail: "Found \(name)"))
var connect = SetupStep(name: "Connect to \(name)", status: ready ? .active : .todo)
if ready, case .failed(let problem) = connection.phase {
connect.detail = "Retrying: \(problem)"
}
steps.append(connect)
return steps
}
#if os(macOS)
private var framed: some View {
content
+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
-102
View File
@@ -3,9 +3,6 @@ import Network
import Observation
import SwiftUI
import TailscaleKit
#if os(iOS)
import SafariServices
#endif
func slug(_ text: String) -> String {
var out = ""
@@ -242,102 +239,3 @@ final class Tailnet {
}
}
struct TailnetLoginView: View {
var body: some View {
let tailnet = Tailnet.shared
VStack(alignment: .leading, spacing: 12) {
Text("Sign in to Tailscale")
.font(Fonts.font(17, weight: 700))
Text("byre reaches its servers through Tailscale. Sign each of these nodes in to the tailnet your byre servers are on.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom, 4)
NodeLoginRow(hostname: tailnetNode.hostname, role: "this app", state: tailnet.state, authURL: tailnet.authURL)
if let service = tailnet.service {
NodeLoginRow(hostname: service.hostname, role: "this Mac's server", state: service.state, authURL: URL(string: service.authUrl))
}
}
.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)
.onAppear {
Tailnet.shared.serviceWatchers += 1
}
.onDisappear {
Tailnet.shared.serviceWatchers -= 1
}
}
}
private struct NodeLoginRow: View {
let hostname: String
let role: String
let state: String
let authURL: URL?
@State private var signingIn = false
var body: some View {
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(hostname).font(Fonts.font(13, weight: 700))
Text(role).foregroundStyle(Palette.muted)
}
if state == "Running" {
Text("Signed in").foregroundStyle(Palette.ok)
} else {
Text(authURL == nil ? "Waiting for Tailscale (\(state))…" : "Waiting for the sign-in (\(state))…")
.font(Fonts.font(12))
.foregroundStyle(Palette.muted)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if state != "Running" {
Button("Sign in") {
#if os(macOS)
if let authURL {
openExternal(authURL)
}
#else
signingIn = true
#endif
}
.buttonStyle(WebButtonStyle(primary: true))
.disabled(authURL == nil)
.fixedSize()
}
}
.padding(12)
.background(Palette.bg, in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Palette.border))
#if os(iOS)
.sheet(isPresented: $signingIn) {
if let authURL {
SafariView(url: authURL)
.ignoresSafeArea()
}
}
.onChange(of: state) {
if state == "Running" {
signingIn = false
}
}
#endif
}
}
#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