burrow/Apple/UI/BurrowView.swift
2026-03-31 13:40:13 -07:00

937 lines
32 KiB
Swift

import AuthenticationServices
import BurrowConfiguration
import Foundation
import SwiftUI
public struct BurrowView: View {
@State private var networkViewModel: NetworkViewModel
@State private var accountStore = NetworkAccountStore()
@State private var activeSheet: ConfigurationSheet?
@State private var didRunAutomation = false
public var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
Text("Burrow")
.font(.largeTitle)
.fontWeight(.bold)
if showsHeaderSubtitle {
Text("Networks and accounts")
.font(.headline)
.foregroundStyle(.secondary)
}
}
if showsToolbarAddMenu {
Spacer()
Menu {
Button("Add WireGuard Network") {
activeSheet = .wireGuard
}
Button("Save Tor Account") {
activeSheet = .tor
}
Button("Add Tailnet Account") {
activeSheet = .tailnet
}
} label: {
Image(systemName: "plus.circle.fill")
.font(.title)
.accessibilityLabel("Add")
}
}
}
.padding(.top)
if showsInlineQuickActions {
quickAddSection
}
VStack(alignment: .leading, spacing: 12) {
sectionHeader(
title: "Networks",
detail: showsInlineQuickActions
? nil
: "Stored daemon networks and their active account selectors"
)
if let connectionError = networkViewModel.connectionError {
Text(connectionError)
.font(.footnote)
.foregroundStyle(.secondary)
}
NetworkCarouselView(networks: networkViewModel.cards)
}
if showsAccountsSection {
VStack(alignment: .leading, spacing: 12) {
sectionHeader(
title: "Accounts",
detail: showsInlineQuickActions
? nil
: "Per-network identities and sign-in state"
)
if accountStore.accounts.isEmpty {
ContentUnavailableView(
"No Accounts Yet",
systemImage: "person.crop.circle.badge.plus",
description: Text("Save a Tor account or sign in to a Tailnet provider to keep network identities ready on this device.")
)
.frame(maxWidth: .infinity, minHeight: 180)
} else {
LazyVStack(spacing: 12) {
ForEach(accountStore.accounts) { account in
AccountRowView(
account: account,
hasSecret: accountStore.hasStoredSecret(for: account)
)
}
}
}
}
}
VStack(alignment: .leading, spacing: 8) {
sectionHeader(
title: "Tunnel",
detail: showsInlineQuickActions ? nil : "Current system extension state"
)
TunnelStatusView()
TunnelButton()
.padding(.bottom)
}
}
.padding()
}
}
.sheet(item: $activeSheet) { sheet in
ConfigurationSheetView(
sheet: sheet,
networkViewModel: networkViewModel,
accountStore: accountStore
)
}
.onAppear {
runAutomationIfNeeded()
}
}
public init() {
_networkViewModel = State(
initialValue: NetworkViewModel(
socketURLResult: Result { try Constants.socketURL }
)
)
}
private func runAutomationIfNeeded() {
guard !didRunAutomation, BurrowAutomationConfig.current?.action == .tailnetLogin else {
return
}
didRunAutomation = true
activeSheet = .tailnet
}
@ViewBuilder
private var quickAddSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionHeader(title: "Add", detail: nil)
VStack(spacing: 12) {
ForEach(ConfigurationSheet.allCases) { sheet in
QuickAddButton(sheet: sheet) {
activeSheet = sheet
}
}
}
}
}
@ViewBuilder
private func sectionHeader(title: String, detail: String?) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.title2.weight(.semibold))
if let detail, !detail.isEmpty {
Text(detail)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
private var showsInlineQuickActions: Bool {
#if os(iOS)
true
#else
false
#endif
}
private var showsToolbarAddMenu: Bool {
!showsInlineQuickActions
}
private var showsHeaderSubtitle: Bool {
!showsInlineQuickActions
}
private var showsAccountsSection: Bool {
#if os(iOS)
!accountStore.accounts.isEmpty
#else
true
#endif
}
}
private enum ConfigurationSheet: String, CaseIterable, Identifiable {
case wireGuard
case tor
case tailnet
var id: String { rawValue }
var kind: AccountNetworkKind {
switch self {
case .wireGuard: .wireGuard
case .tor: .tor
case .tailnet: .headscale
}
}
var iconName: String {
switch self {
case .wireGuard:
"wave.3.right"
case .tor:
"shield.lefthalf.filled.badge.checkmark"
case .tailnet:
"network.badge.shield.half.filled"
}
}
var quickActionTitle: String {
switch self {
case .wireGuard:
"WireGuard"
case .tor:
"Tor"
case .tailnet:
"Tailnet"
}
}
var quickActionSubtitle: String {
switch self {
case .wireGuard:
"Import a tunnel"
case .tor:
"Save an Arti profile"
case .tailnet:
"Sign in or save a control plane"
}
}
var quickActionColor: Color {
switch self {
case .wireGuard:
.blue
case .tor, .tailnet:
kind.accentColor
}
}
}
private struct QuickAddButton: View {
let sheet: ConfigurationSheet
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 14) {
Image(systemName: sheet.iconName)
.font(.title3.weight(.semibold))
.frame(width: 24)
VStack(alignment: .leading, spacing: 4) {
Text(sheet.quickActionTitle)
.font(.headline)
Text(sheet.quickActionSubtitle)
.font(.caption)
.opacity(0.88)
}
Spacer()
}
.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
}
.buttonStyle(.floating(color: sheet.quickActionColor, cornerRadius: 18))
}
}
private struct AccountDraft {
var title = ""
var accountName = ""
var identityName = ""
var wireGuardConfig = ""
var tailnetProvider: TailnetProvider = .tailscale
var authority = ""
var tailnet = ""
var hostname = ProcessInfo.processInfo.hostName
var username = ""
var secret = ""
var authMode: AccountAuthMode = .web
var torAddresses = "100.64.0.2/32"
var torDNS = "1.1.1.1, 1.0.0.1"
var torMTU = "1400"
var torListen = "127.0.0.1:9040"
init(sheet: ConfigurationSheet) {
switch sheet {
case .wireGuard:
break
case .tor:
title = "Default Tor"
accountName = "default"
identityName = "apple"
case .tailnet:
title = "Tailnet"
accountName = "default"
identityName = "apple"
authority = TailnetProvider.tailscale.defaultAuthority ?? ""
}
}
}
private struct ConfigurationSheetView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.webAuthenticationSession) private var webAuthenticationSession
let sheet: ConfigurationSheet
let networkViewModel: NetworkViewModel
let accountStore: NetworkAccountStore
@State private var draft: AccountDraft
@State private var isSubmitting = false
@State private var errorMessage: String?
@State private var loginSessionID: String?
@State private var loginStatus: TailnetLoginStatus?
@State private var pollingTask: Task<Void, Never>?
@State private var didRunAutomation = false
@State private var webAuthenticationTask: Task<Void, Never>?
init(
sheet: ConfigurationSheet,
networkViewModel: NetworkViewModel,
accountStore: NetworkAccountStore
) {
self.sheet = sheet
self.networkViewModel = networkViewModel
self.accountStore = accountStore
_draft = State(initialValue: AccountDraft(sheet: sheet))
}
var body: some View {
NavigationStack {
Form {
Section {
Text(sheet.kind.subtitle)
.font(.callout)
.foregroundStyle(.secondary)
if let availabilityNote = sheet.kind.availabilityNote {
Text(availabilityNote)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section("Identity") {
TextField("Title", text: $draft.title)
TextField("Account", text: $draft.accountName)
TextField("Identity", text: $draft.identityName)
if sheet == .tailnet {
TextField("Hostname", text: $draft.hostname)
.burrowLoginField()
.autocorrectionDisabled()
}
}
switch sheet {
case .wireGuard:
Section("WireGuard Configuration") {
TextEditor(text: $draft.wireGuardConfig)
.font(.body.monospaced())
.frame(minHeight: 220)
}
case .tor:
Section("Tor Preferences") {
TextField("Virtual Addresses", text: $draft.torAddresses)
TextField("DNS Resolvers", text: $draft.torDNS)
TextField("MTU", text: $draft.torMTU)
TextField("Transparent Listener", text: $draft.torListen)
}
case .tailnet:
tailnetSections
}
if let errorMessage {
Section {
Text(errorMessage)
.foregroundStyle(.red)
}
}
}
.navigationTitle(sheet.kind.title)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(confirmationTitle) {
submit()
}
.disabled(isSubmitting || submissionDisabled)
}
}
}
.frame(minWidth: 520, minHeight: 620)
.onAppear {
runAutomationIfNeeded()
}
.onDisappear {
pollingTask?.cancel()
webAuthenticationTask?.cancel()
webAuthenticationTask = nil
}
}
@ViewBuilder
private var tailnetSections: some View {
Section("Tailnet Provider") {
Picker("Provider", selection: $draft.tailnetProvider) {
ForEach(TailnetProvider.allCases) { provider in
Text(provider.title).tag(provider)
}
}
Text(draft.tailnetProvider.subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Tailnet") {
if draft.tailnetProvider.requiresControlURL {
TextField("Server URL", text: $draft.authority)
.burrowLoginField()
.autocorrectionDisabled()
}
TextField("Tailnet", text: $draft.tailnet)
.burrowLoginField()
.autocorrectionDisabled()
if draft.tailnetProvider.usesWebLogin {
Text("Sign-in is brokered by `burrow auth-server` on the host and opens the real Tailscale login page in an in-app authentication session.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
TextField("Username", text: $draft.username)
.burrowLoginField()
.autocorrectionDisabled()
Picker("Authentication", selection: $draft.authMode) {
ForEach([AccountAuthMode.none, .password, .preauthKey]) { mode in
Text(mode.title).tag(mode)
}
}
if draft.authMode != .none {
SecureField(
draft.authMode == .password ? "Password" : "Preauth Key",
text: $draft.secret
)
}
}
}
if draft.tailnetProvider.usesWebLogin {
Section("Tailscale Sign-In") {
if let loginStatus {
labeledValue("State", loginStatus.backendState)
if let tailnetName = loginStatus.tailnetName {
labeledValue("Tailnet", tailnetName)
}
if let dnsName = loginStatus.selfDNSName {
labeledValue("Device", dnsName)
}
if !loginStatus.tailscaleIPs.isEmpty {
labeledValue("Addresses", loginStatus.tailscaleIPs.joined(separator: ", "))
}
if let authURL = loginStatus.authURL {
labeledValue("Login URL", authURL)
Button("Resume Sign-In") {
if let url = URL(string: authURL) {
openLoginURL(url)
}
}
}
if !loginStatus.health.isEmpty {
Text(loginStatus.health.joined(separator: ""))
.font(.footnote)
.foregroundStyle(.secondary)
}
} else {
Text("Start sign-in to launch a local Tailscale bridge and fetch the real browser login URL.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
private var confirmationTitle: String {
switch sheet {
case .wireGuard:
return "Add Network"
case .tor:
return "Save Account"
case .tailnet:
if draft.tailnetProvider.usesWebLogin {
return loginStatus?.running == true ? "Save Account" : "Start Sign-In"
}
return "Save Account"
}
}
private var submissionDisabled: Bool {
switch sheet {
case .wireGuard:
return draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
case .tor:
return normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil
case .tailnet:
if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil {
return true
}
if draft.tailnetProvider.usesWebLogin {
return false
}
if draft.tailnetProvider.requiresControlURL && normalizedOptional(draft.authority) == nil {
return true
}
if draft.authMode != .none && normalizedOptional(draft.secret) == nil {
return true
}
return false
}
}
private func submit() {
isSubmitting = true
errorMessage = nil
Task { @MainActor in
defer { isSubmitting = false }
do {
switch sheet {
case .wireGuard:
try await submitWireGuard()
dismiss()
case .tor:
try submitTor()
dismiss()
case .tailnet:
try await submitTailnet()
}
} catch {
errorMessage = error.localizedDescription
}
}
}
private func submitWireGuard() async throws {
let networkID = try await networkViewModel.addWireGuardNetwork(
configText: draft.wireGuardConfig
)
let title = titleOrFallback("WireGuard \(networkID)")
let record = NetworkAccountRecord(
id: UUID(),
kind: .wireGuard,
title: title,
authority: nil,
provider: nil,
accountName: normalized(draft.accountName, fallback: "default"),
identityName: normalized(draft.identityName, fallback: "network-\(networkID)"),
hostname: nil,
username: nil,
tailnet: nil,
authMode: .none,
note: "Linked to daemon network #\(networkID).",
createdAt: .now,
updatedAt: .now
)
try accountStore.upsert(record, secret: nil)
}
private func submitTor() throws {
let title = titleOrFallback("Tor \(normalized(draft.identityName, fallback: "apple"))")
let note = [
"Addresses: \(csvSummary(draft.torAddresses))",
"DNS: \(csvSummary(draft.torDNS))",
"MTU: \(normalized(draft.torMTU, fallback: "1400"))",
"Listen: \(normalized(draft.torListen, fallback: "127.0.0.1:9040"))",
].joined(separator: "")
let record = NetworkAccountRecord(
id: UUID(),
kind: .tor,
title: title,
authority: "arti://local",
provider: nil,
accountName: normalized(draft.accountName, fallback: "default"),
identityName: normalized(draft.identityName, fallback: "apple"),
hostname: nil,
username: nil,
tailnet: nil,
authMode: .none,
note: note,
createdAt: .now,
updatedAt: .now
)
try accountStore.upsert(record, secret: nil)
}
private func submitTailnet() async throws {
if draft.tailnetProvider.usesWebLogin {
if loginStatus?.running == true {
webAuthenticationTask?.cancel()
webAuthenticationTask = nil
try await saveTailnetAccount(secret: nil, username: nil)
dismiss()
} else {
try await startTailscaleLogin()
}
return
}
let secret = draft.authMode == .none ? nil : draft.secret
let username = normalizedOptional(draft.username)
try await saveTailnetAccount(secret: secret, username: username)
dismiss()
}
private func startTailscaleLogin() async throws {
let response = try await TailnetBridgeClient.startLogin(
TailnetLoginStartRequest(
accountName: normalized(draft.accountName, fallback: "default"),
identityName: normalized(draft.identityName, fallback: "apple"),
hostname: normalizedOptional(draft.hostname),
controlURL: draft.tailnetProvider.defaultAuthority
)
)
loginSessionID = response.sessionID
loginStatus = response.status
if let authURL = response.status.authURL, let url = URL(string: authURL) {
openLoginURL(url)
}
startPollingTailscaleLogin()
}
private func runAutomationIfNeeded() {
guard !didRunAutomation,
sheet == .tailnet,
let automation = BurrowAutomationConfig.current,
automation.action == .tailnetLogin
else {
return
}
didRunAutomation = true
draft.tailnetProvider = .tailscale
draft.title = automation.title ?? draft.title
draft.accountName = automation.accountName ?? draft.accountName
draft.identityName = automation.identityName ?? draft.identityName
draft.hostname = automation.hostname ?? draft.hostname
Task { @MainActor in
do {
try await startTailscaleLogin()
} catch {
errorMessage = error.localizedDescription
}
}
}
private func startPollingTailscaleLogin() {
pollingTask?.cancel()
guard let loginSessionID else { return }
pollingTask = Task { @MainActor in
while !Task.isCancelled {
do {
let status = try await TailnetBridgeClient.status(sessionID: loginSessionID)
let previousAuthURL = loginStatus?.authURL
loginStatus = status
if previousAuthURL == nil,
let authURL = status.authURL,
let url = URL(string: authURL)
{
openLoginURL(url)
}
if status.running {
webAuthenticationTask?.cancel()
webAuthenticationTask = nil
return
}
} catch {
errorMessage = error.localizedDescription
return
}
try? await Task.sleep(for: .seconds(2))
}
}
}
private func openLoginURL(_ url: URL) {
webAuthenticationTask?.cancel()
webAuthenticationTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(300))
do {
_ = try await webAuthenticationSession.authenticate(
using: url,
callbackURLScheme: "burrow",
preferredBrowserSession: .shared
)
} catch is CancellationError {
return
} catch let error as ASWebAuthenticationSessionError
where error.code == .canceledLogin
{
return
} catch {
errorMessage = error.localizedDescription
}
webAuthenticationTask = nil
}
}
private func saveTailnetAccount(secret: String?, username: String?) async throws {
let provider = draft.tailnetProvider
let title = titleOrFallback(
hostnameFallback(
from: provider.usesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority,
fallback: provider.title
)
)
let payload = TailnetNetworkPayload(
provider: provider,
authority: normalizedOptional(provider.defaultAuthority ?? draft.authority),
account: normalized(draft.accountName, fallback: "default"),
identity: normalized(draft.identityName, fallback: "apple"),
tailnet: normalizedOptional(loginStatus?.tailnetName ?? draft.tailnet),
hostname: normalizedOptional(draft.hostname)
)
var noteParts: [String] = [
provider.title,
provider.usesWebLogin
? "State: \(loginStatus?.backendState ?? "NeedsLogin")"
: "Auth: \(draft.authMode.title)",
]
if let dnsName = loginStatus?.selfDNSName {
noteParts.append("Device: \(dnsName)")
}
if let magicDNSSuffix = loginStatus?.magicDNSSuffix {
noteParts.append("MagicDNS: \(magicDNSSuffix)")
}
do {
let networkID = try await networkViewModel.addTailnetNetwork(payload: payload)
noteParts.append("Linked to daemon network #\(networkID)")
} catch {
noteParts.append("Daemon network add pending")
}
let record = NetworkAccountRecord(
id: UUID(),
kind: .headscale,
title: title,
authority: payload.authority,
provider: provider,
accountName: payload.account,
identityName: payload.identity,
hostname: payload.hostname,
username: username,
tailnet: payload.tailnet,
authMode: provider.usesWebLogin ? .web : draft.authMode,
note: noteParts.joined(separator: ""),
createdAt: .now,
updatedAt: .now
)
try accountStore.upsert(record, secret: secret)
}
private func normalized(_ value: String, fallback: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? fallback : trimmed
}
private func normalizedOptional(_ value: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func titleOrFallback(_ fallback: String) -> String {
normalized(draft.title, fallback: fallback)
}
private func csvSummary(_ value: String) -> String {
value
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ", ")
}
private func hostnameFallback(from value: String, fallback: String) -> String {
guard let url = URL(string: value), let host = url.host, !host.isEmpty else {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? fallback : trimmed
}
return host
}
@ViewBuilder
private func labeledValue(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.body.monospaced())
}
}
}
private struct AccountRowView: View {
let account: NetworkAccountRecord
let hasSecret: Bool
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(account.title)
.font(.headline)
HStack(spacing: 8) {
Text(account.kind.title)
if let provider = account.provider {
Text(provider.title)
}
}
.font(.subheadline)
.foregroundStyle(account.kind.accentColor)
}
Spacer()
if hasSecret {
Label("Credential stored", systemImage: "key.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if let authority = account.authority {
labeledValue("Authority", authority)
}
labeledValue("Account", account.accountName)
labeledValue("Identity", account.identityName)
if let hostname = account.hostname {
labeledValue("Hostname", hostname)
}
if let username = account.username {
labeledValue("Username", username)
}
if let tailnet = account.tailnet {
labeledValue("Tailnet", tailnet)
}
if let note = account.note {
Text(note)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.thinMaterial)
)
}
@ViewBuilder
private func labeledValue(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.body.monospaced())
}
}
}
private extension View {
@ViewBuilder
func burrowLoginField() -> some View {
#if os(iOS)
textInputAutocapitalization(.never)
#else
self
#endif
}
}
private struct BurrowAutomationConfig {
enum Action: String {
case tailnetLogin = "tailnet-login"
}
let action: Action
let title: String?
let accountName: String?
let identityName: String?
let hostname: String?
static let current: BurrowAutomationConfig? = {
let environment = ProcessInfo.processInfo.environment
guard let rawAction = environment["BURROW_UI_AUTOMATION"],
let action = Action(rawValue: rawAction)
else {
return nil
}
return BurrowAutomationConfig(
action: action,
title: environment["BURROW_UI_AUTOMATION_TITLE"],
accountName: environment["BURROW_UI_AUTOMATION_ACCOUNT"],
identityName: environment["BURROW_UI_AUTOMATION_IDENTITY"],
hostname: environment["BURROW_UI_AUTOMATION_HOSTNAME"]
)
}()
}
#if DEBUG
struct NetworkView_Previews: PreviewProvider {
static var previews: some View {
BurrowView()
.environment(\.tunnel, PreviewTunnel())
}
}
#endif