Show startup as one step checklist and re-check tools on the Server tab
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user