burrow/Apple/UI/BurrowView.swift
JettChenT d1638726ca Add proxy subscription runtime support
Add daemon RPCs, Apple and GTK import flows, packet proxy runtime support, diagnostics, and BEPs for proxy subscription handling.

Redact subscription URL secrets from fetch errors before they reach logs or UI surfaces.
2026-06-01 15:23:17 +08:00

2404 lines
82 KiB
Swift

import BurrowConfiguration
import Foundation
import GRPC
import SwiftUI
#if canImport(AuthenticationServices)
import AuthenticationServices
#endif
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
public struct BurrowView: View {
@State private var networkViewModel: NetworkViewModel
@State private var accountStore = NetworkAccountStore()
@State private var activeSheet: ConfigurationSheet?
@State private var didRunAutomation = false
@State private var expandedProxySubscriptionID: Int32?
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("Import Proxy Subscription") {
activeSheet = .proxySubscription
}
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)
.lineLimit(4)
.truncationMode(.middle)
}
NetworkCarouselView(
networks: networkViewModel.cards,
selectedNetworkID: expandedProxySubscriptionID
) { network in
guard networkViewModel.proxySubscriptions.contains(where: { $0.id == network.id }) else {
return
}
withAnimation(.snappy) {
expandedProxySubscriptionID = expandedProxySubscriptionID == network.id
? nil
: network.id
}
}
}
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 Tailnet 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
)
}
.sheet(isPresented: proxySubscriptionSheetBinding) {
ProxySubscriptionManagerView(
networks: networkViewModel.proxySubscriptions,
networkViewModel: networkViewModel,
selectedNetworkID: $expandedProxySubscriptionID
)
.frame(width: 820, height: 600)
.padding(20)
}
.onAppear {
runAutomationIfNeeded()
}
}
public init() {
_networkViewModel = State(
initialValue: NetworkViewModel(
socketURLResult: Result { try Constants.socketURL }
)
)
}
private func runAutomationIfNeeded() {
guard !didRunAutomation,
let automation = BurrowAutomationConfig.current,
automation.action == .tailnetLogin || automation.action == .tailnetProbe
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
}
}
}
}
}
private var proxySubscriptionSheetBinding: Binding<Bool> {
Binding(
get: { expandedProxySubscriptionID != nil },
set: { isPresented in
guard !isPresented else { return }
expandedProxySubscriptionID = nil
}
)
}
@ViewBuilder
private func sectionHeader(title: String, detail: String?) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.title2.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
if let detail, !detail.isEmpty {
Text(detail)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(3)
.truncationMode(.tail)
}
}
}
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
!accountStore.accounts.isEmpty || networkViewModel.networks.isEmpty
#endif
}
}
private enum ConfigurationSheet: String, CaseIterable, Identifiable {
case wireGuard
case proxySubscription
case tor
case tailnet
var id: String { rawValue }
var kind: AccountNetworkKind {
switch self {
case .wireGuard: .wireGuard
case .proxySubscription: .proxySubscription
case .tor: .tor
case .tailnet: .tailnet
}
}
var iconName: String {
switch self {
case .wireGuard:
"wave.3.right"
case .proxySubscription:
"link.badge.plus"
case .tor:
"shield.lefthalf.filled.badge.checkmark"
case .tailnet:
"network.badge.shield.half.filled"
}
}
var quickActionTitle: String {
switch self {
case .wireGuard:
"WireGuard"
case .proxySubscription:
"Proxy Subscription"
case .tor:
"Tor"
case .tailnet:
"Tailnet"
}
}
var quickActionSubtitle: String {
switch self {
case .wireGuard:
"Import a tunnel"
case .proxySubscription:
"Import Trojan or Shadowsocks"
case .tor:
"Save an Arti profile"
case .tailnet:
"Sign in or save a control plane"
}
}
var quickActionColor: Color {
switch self {
case .wireGuard:
.blue
case .proxySubscription:
.teal
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)
}
.accessibilityIdentifier("quick-add-\(sheet.rawValue)")
.buttonStyle(.floating(color: sheet.quickActionColor, cornerRadius: 18))
}
}
private struct AccountDraft {
var title = ""
var accountName = ""
var identityName = ""
var wireGuardConfig = ""
var proxySubscriptionURL = ""
var proxySubscriptionName = ""
var discoveryEmail = ""
var authority = ""
var tailnet = ""
var hostname = ProcessInfo.processInfo.hostName
var username = ""
var secret = ""
var authMode: AccountAuthMode = .none
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 .proxySubscription:
title = "Proxy Subscription"
case .tor:
title = "Default Tor"
accountName = "default"
identityName = "apple"
case .tailnet:
title = "Tailnet"
accountName = "default"
identityName = "apple"
authority = TailnetProvider.tailscale.defaultAuthority ?? ""
authMode = .web
}
}
}
private struct ConfigurationSheetView: View {
@Environment(\.dismiss) private var dismiss
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 discoveryStatus: TailnetDiscoveryResponse?
@State private var discoveryError: String?
@State private var isDiscoveringTailnet = false
@State private var authorityProbeStatus: TailnetAuthorityProbeStatus?
@State private var authorityProbeError: String?
@State private var isProbingAuthority = false
@State private var tailnetLoginStatus: TailnetLoginStatus?
@State private var tailnetLoginError: String?
@State private var tailnetLoginSessionID: String?
@State private var isStartingTailnetLogin = false
@State private var proxySubscriptionPreview: ProxySubscriptionPreview?
@State private var proxySubscriptionPreviewError: String?
@State private var isPreviewingProxySubscription = false
@State private var selectedProxySubscriptionOrdinal: Int32?
@State private var proxySubscriptionNodeFilter = ""
@State private var tailnetPresentedAuthURL: URL?
@State private var preserveTailnetLoginSession = false
@State private var usesCustomTailnetAuthority = false
@State private var showsAdvancedTailnetSettings = false
@State private var browserAuthenticator = TailnetBrowserAuthenticator()
@State private var tailnetLoginPollTask: Task<Void, Never>?
@State private var tailnetDiscoveryTask: Task<Void, Never>?
@State private var tailnetProbeTask: Task<Void, Never>?
@State private var didRunAutomation = false
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 {
sheetSummaryCard
}
.listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0))
.listRowBackground(Color.clear)
if showsIdentitySection {
Section("Identity") {
identityFields
}
}
configurationSections
if let errorMessage {
Section {
feedbackText(errorMessage, color: .red)
}
}
}
.navigationTitle(sheet.kind.title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
Task { @MainActor in
await cancelTailnetLoginIfNeeded()
dismiss()
}
}
}
#if os(iOS)
ToolbarItem(placement: .topBarTrailing) {
Menu {
sheetMenuActions
} label: {
Image(systemName: "ellipsis.circle")
}
.accessibilityLabel("More")
}
#else
ToolbarItem(placement: .primaryAction) {
Menu {
sheetMenuActions
} label: {
Image(systemName: "ellipsis.circle")
}
.accessibilityLabel("More")
}
#endif
if !showsBottomActionButton {
ToolbarItem(placement: .confirmationAction) {
Button(confirmationTitle) {
submit()
}
.disabled(isSubmitting || submissionDisabled)
}
}
}
}
#if os(macOS)
.frame(width: 520)
.frame(minHeight: 620)
#endif
.safeAreaInset(edge: .bottom) {
if showsBottomActionButton {
bottomActionBar
}
}
.onAppear {
runAutomationIfNeeded()
}
.onChange(of: draft.authority) { _, _ in
resetAuthorityProbe()
if sheet == .tailnet, usesCustomTailnetAuthority {
scheduleTailnetAuthorityProbe()
}
}
.onChange(of: draft.discoveryEmail) { _, _ in
resetTailnetDiscoveryFeedback()
if sheet == .tailnet, !usesCustomTailnetAuthority {
scheduleTailnetDiscovery()
}
}
.onChange(of: draft.authMode) { _, newMode in
guard newMode != .web else { return }
Task { @MainActor in
await cancelTailnetLoginIfNeeded()
}
}
.onChange(of: draft.proxySubscriptionURL) { _, _ in
proxySubscriptionPreview = nil
proxySubscriptionPreviewError = nil
selectedProxySubscriptionOrdinal = nil
proxySubscriptionNodeFilter = ""
}
.onDisappear {
tailnetLoginPollTask?.cancel()
tailnetDiscoveryTask?.cancel()
tailnetProbeTask?.cancel()
browserAuthenticator.cancel()
if !preserveTailnetLoginSession {
Task { @MainActor in
await cancelTailnetLoginIfNeeded()
}
}
}
}
@ViewBuilder
private var configurationSections: some View {
switch sheet {
case .wireGuard:
wireGuardConfigurationSection
case .proxySubscription:
proxySubscriptionSections
case .tor:
torPreferenceSection
case .tailnet:
tailnetSections
}
}
private var wireGuardConfigurationSection: some View {
Section("WireGuard Configuration") {
TextEditor(text: $draft.wireGuardConfig)
.font(.body.monospaced())
.frame(minHeight: wireGuardEditorHeight)
.contextMenu {
wireGuardContextActions
}
}
}
@ViewBuilder
private var proxySubscriptionSections: some View {
Section("Subscription") {
TextField("Subscription URL", text: $draft.proxySubscriptionURL)
.autocorrectionDisabled()
TextField("Name", text: $draft.proxySubscriptionName)
}
Section("Preview") {
proxySubscriptionPreviewButton
if let proxySubscriptionPreview {
proxySubscriptionPreviewView(proxySubscriptionPreview)
} else if let proxySubscriptionPreviewError {
feedbackText(proxySubscriptionPreviewError, color: .red)
}
}
}
private var proxySubscriptionPreviewButton: some View {
Button {
previewProxySubscription()
} label: {
Label(
isPreviewingProxySubscription ? "Previewing" : "Preview",
systemImage: isPreviewingProxySubscription ? "hourglass" : "eye"
)
}
.disabled(isPreviewingProxySubscription || normalizedOptional(draft.proxySubscriptionURL) == nil)
}
private var torPreferenceSection: some View {
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)
}
}
@ViewBuilder
private var identityFields: some View {
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()
}
}
@ViewBuilder
private var tailnetSections: some View {
Section("Connection") {
TextField("Email address", text: $draft.discoveryEmail)
.burrowEmailField()
.burrowLoginField()
.autocorrectionDisabled()
.accessibilityIdentifier("tailnet-discovery-email")
.submitLabel(.continue)
.onSubmit {
if !usesCustomTailnetAuthority {
scheduleTailnetDiscovery(immediate: true)
}
}
tailnetServerCard
if showsAdvancedTailnetSettings {
if usesCustomTailnetAuthority {
TextField("Server URL", text: $draft.authority)
.burrowLoginField()
.autocorrectionDisabled()
.accessibilityIdentifier("tailnet-authority")
} else {
TextField("Tailnet", text: $draft.tailnet)
.burrowLoginField()
.autocorrectionDisabled()
.accessibilityIdentifier("tailnet-name")
}
}
}
Section("Authentication") {
if showsAdvancedTailnetSettings {
Picker("Authentication", selection: $draft.authMode) {
ForEach(availableTailnetAuthModes) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.menu)
}
if draft.authMode == .web {
Button {
startTailnetLogin()
} label: {
Label {
Text(isStartingTailnetLogin ? "Starting Sign-In" : tailnetSignInActionTitle)
} icon: {
Image(systemName: isStartingTailnetLogin ? "hourglass" : "person.badge.key")
}
}
.buttonStyle(.borderless)
.disabled(isStartingTailnetLogin || tailnetLoginActionDisabled)
.accessibilityIdentifier("tailnet-start-sign-in")
if let tailnetLoginStatus {
tailnetLoginCard(status: tailnetLoginStatus, failure: nil)
} else if let tailnetLoginError {
tailnetLoginCard(status: nil, failure: tailnetLoginError)
}
} else {
TextField("Username", text: $draft.username)
.burrowLoginField()
.autocorrectionDisabled()
if draft.authMode != .none {
SecureField(
draft.authMode == .password ? "Password" : "Preauth Key",
text: $draft.secret
)
}
}
Text(tailnetAuthenticationFootnote)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private var sheetSummaryCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
Image(systemName: sheet.iconName)
.font(.title3.weight(.semibold))
.foregroundStyle(sheetAccentColor)
.frame(width: 28, height: 28)
.background(
Circle()
.fill(sheetAccentColor.opacity(0.14))
)
VStack(alignment: .leading, spacing: 3) {
Text(summaryTitle)
.font(.headline)
Text(sheet.kind.subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
}
if let availabilityNote = sheet.kind.availabilityNote {
Text(availabilityNote)
.font(.footnote)
.foregroundStyle(.secondary)
}
if sheet == .tailnet {
labeledValue("Server", tailnetServerDisplayLabel)
if let connectionSummary = tailnetConnectionSummary {
Text(connectionSummary)
.font(.footnote.weight(.medium))
.foregroundStyle(tailnetConnectionSummaryColor)
}
if tailnetLoginStatus?.running == true {
HStack(spacing: 8) {
summaryBadge("Signed In")
}
}
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(.thinMaterial)
)
}
private var tailnetServerCard: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(usesCustomTailnetAuthority ? "Custom Server" : "Server")
.font(.subheadline.weight(.medium))
Text(tailnetServerDisplayLabel)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
Spacer()
if isDiscoveringTailnet || isProbingAuthority {
ProgressView()
.controlSize(.small)
} else if let summary = tailnetConnectionSummary {
Text(summary)
.font(.caption.weight(.medium))
.foregroundStyle(tailnetConnectionSummaryColor)
}
}
if let detail = tailnetServerDetail {
Text(detail)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.thinMaterial)
)
.accessibilityIdentifier("tailnet-server-card")
}
private func tailnetAuthorityProbeCard(
status: TailnetAuthorityProbeStatus?,
failure: String?
) -> some View {
VStack(alignment: .leading, spacing: 6) {
if let status {
Text(status.summary)
.font(.subheadline.weight(.medium))
Text(status.detail ?? "HTTP \(status.statusCode) from \(status.authority)")
.font(.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
} else if let failure {
Text("Connection failed")
.font(.subheadline.weight(.medium))
.foregroundStyle(.red)
Text(failure)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.thinMaterial)
)
.accessibilityIdentifier("tailnet-authority-probe-card")
}
private func tailnetDiscoveryCard(
status: TailnetDiscoveryResponse?,
failure: String?
) -> some View {
VStack(alignment: .leading, spacing: 6) {
if let status {
Text("Discovered Tailnet Server")
.font(.subheadline.weight(.medium))
Text(status.authority)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
Text(status.provider == .tailscale ? "Managed authority" : "Custom authority")
.font(.footnote)
.foregroundStyle(.secondary)
if let oidcIssuer = status.oidcIssuer {
Text("OIDC: \(oidcIssuer)")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(3)
.textSelection(.enabled)
}
} else if let failure {
Text("Discovery failed")
.font(.subheadline.weight(.medium))
.foregroundStyle(.red)
Text(failure)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.thinMaterial)
)
.accessibilityIdentifier("tailnet-discovery-card")
}
private func tailnetLoginCard(
status: TailnetLoginStatus?,
failure: String?
) -> some View {
VStack(alignment: .leading, spacing: 6) {
if let status {
Text(status.running ? "Signed In" : status.needsLogin ? "Browser Sign-In Required" : "Checking Sign-In")
.font(.subheadline.weight(.medium))
if let tailnetName = status.tailnetName, !tailnetName.isEmpty {
Text("Tailnet: \(tailnetName)")
.font(.footnote)
.foregroundStyle(.secondary)
}
if let selfDNSName = status.selfDNSName, !selfDNSName.isEmpty {
Text(selfDNSName)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if !status.tailnetIPs.isEmpty {
Text(status.tailnetIPs.joined(separator: ", "))
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if !status.health.isEmpty {
Text(status.health.joined(separator: ""))
.font(.footnote)
.foregroundStyle(.secondary)
}
} else if let failure {
Text("Sign-In failed")
.font(.subheadline.weight(.medium))
.foregroundStyle(.red)
Text(failure)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.thinMaterial)
)
.accessibilityIdentifier("tailnet-login-card")
}
private func summaryBadge(_ label: String) -> some View {
Text(label)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
Capsule()
.fill(.white.opacity(0.5))
)
}
private func proxySubscriptionPreviewView(_ preview: ProxySubscriptionPreview) -> some View {
VStack(alignment: .leading, spacing: 8) {
labeledValue("Format", preview.detectedFormat)
labeledValue("Compatible", "\(preview.compatibleCount)")
labeledValue("Runtime Supported", "\(preview.nodes.filter { $0.runtimeSupported }.count)")
labeledValue("Rejected", "\(preview.rejectedCount)")
let selectableNodes = supportedProxySubscriptionNodes(in: preview)
if !selectableNodes.isEmpty {
TextField("Filter nodes", text: $proxySubscriptionNodeFilter)
.autocorrectionDisabled()
let filteredNodes = filteredProxySubscriptionNodes(selectableNodes)
proxySubscriptionNodeList(nodes: filteredNodes, preview: preview)
if !proxySubscriptionNodeFilter.isEmpty && filteredNodes.isEmpty {
feedbackText("No nodes match the current filter.", color: .orange)
}
} else if !preview.nodes.isEmpty {
Text("No previewed nodes are supported by the packet runtime.")
.font(.caption)
.foregroundStyle(.orange)
}
ForEach(preview.warnings, id: \.self) { warning in
feedbackText(warning, color: .orange)
}
}
}
private func proxySubscriptionNodeList(
nodes: [ProxySubscriptionNodePreview],
preview: ProxySubscriptionPreview
) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 6) {
ForEach(nodes) { node in
proxySubscriptionNodeRow(
node: node,
isSelected: selectedProxySubscriptionNode(in: preview)?.ordinal == node.ordinal
)
}
}
.padding(.vertical, 2)
}
.frame(maxHeight: 260)
}
private func proxySubscriptionNodeRow(
node: ProxySubscriptionNodePreview,
isSelected: Bool
) -> some View {
Button {
selectedProxySubscriptionOrdinal = node.ordinal
} label: {
HStack(spacing: 8) {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
.imageScale(.small)
.frame(width: 16)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(node.name)
.font(.footnote.weight(isSelected ? .semibold : .regular))
.lineLimit(1)
.truncationMode(.tail)
Text(node.proxyProtocol.uppercased())
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(
Capsule()
.fill(.secondary.opacity(0.12))
)
}
Text("\(node.server):\(node.port)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isSelected ? Color.accentColor.opacity(0.14) : Color.secondary.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isSelected ? Color.accentColor.opacity(0.45) : Color.clear, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
private func supportedProxySubscriptionNodes(
in preview: ProxySubscriptionPreview
) -> [ProxySubscriptionNodePreview] {
preview.nodes.filter { $0.runtimeSupported }
}
private func filteredProxySubscriptionNodes(
_ nodes: [ProxySubscriptionNodePreview]
) -> [ProxySubscriptionNodePreview] {
let query = proxySubscriptionNodeFilter.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return nodes }
return nodes.filter { node in
node.name.localizedCaseInsensitiveContains(query)
|| node.server.localizedCaseInsensitiveContains(query)
|| node.proxyProtocol.localizedCaseInsensitiveContains(query)
}
}
private func selectedProxySubscriptionNode(in preview: ProxySubscriptionPreview) -> ProxySubscriptionNodePreview? {
let ordinal = selectedProxySubscriptionOrdinal
?? preview.nodes.first(where: { $0.runtimeSupported })?.ordinal
return preview.nodes.first { $0.ordinal == ordinal }
}
private func feedbackText(_ message: String, color: Color) -> some View {
Text(message)
.font(.caption)
.foregroundStyle(color)
.lineLimit(6)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
@ViewBuilder
private var bottomActionBar: some View {
VStack(spacing: 0) {
Divider()
.overlay(.white.opacity(0.3))
Button(confirmationTitle) {
submit()
}
.buttonStyle(.floating(color: sheetAccentColor, cornerRadius: 18))
.disabled(isSubmitting || submissionDisabled)
.padding(.horizontal)
.padding(.top, 12)
.padding(.bottom, 8)
}
.background(.ultraThinMaterial)
}
@ViewBuilder
private var sheetMenuActions: some View {
Button("Use Suggested Identity") {
applySuggestedIdentity()
}
switch sheet {
case .wireGuard:
Button("Paste Configuration") {
pasteWireGuardConfiguration()
}
.disabled(clipboardString?.isEmpty ?? true)
Button("Clear Configuration", role: .destructive) {
draft.wireGuardConfig = ""
}
.disabled(draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
case .proxySubscription:
Button("Preview Subscription") {
previewProxySubscription()
}
.disabled(normalizedOptional(draft.proxySubscriptionURL) == nil)
case .tor:
Menu("Presets") {
Button("Recommended Tor Defaults") {
applyTorDefaults()
}
Button("Restore Suggested Identity") {
applySuggestedIdentity()
}
}
case .tailnet:
Button(usesCustomTailnetAuthority ? "Use Automatic Server" : "Edit Custom Server") {
toggleTailnetAuthorityMode()
}
Button(showsAdvancedTailnetSettings ? "Hide Advanced Settings" : "Show Advanced Settings") {
showsAdvancedTailnetSettings.toggle()
}
if showsAdvancedTailnetSettings, availableTailnetAuthModes.count > 1 {
Menu("Authentication") {
ForEach(availableTailnetAuthModes) { mode in
Button(mode.title) {
draft.authMode = mode
if mode == .none {
draft.secret = ""
}
}
}
}
}
Button("Refresh Server Lookup") {
scheduleTailnetDiscovery(immediate: true)
}
.disabled(usesCustomTailnetAuthority || normalizedOptional(draft.discoveryEmail) == nil)
}
}
@ViewBuilder
private var wireGuardContextActions: some View {
Button("Paste Configuration") {
pasteWireGuardConfiguration()
}
.disabled(clipboardString?.isEmpty ?? true)
Button("Clear", role: .destructive) {
draft.wireGuardConfig = ""
}
.disabled(draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
private var sheetAccentColor: Color {
switch sheet {
case .wireGuard:
.blue
case .proxySubscription:
.teal
case .tor, .tailnet:
sheet.kind.accentColor
}
}
private var summaryTitle: String {
switch sheet {
case .wireGuard:
"Import WireGuard"
case .proxySubscription:
"Import Proxy Subscription"
case .tor:
"Configure Tor"
case .tailnet:
"Connect Tailnet"
}
}
private var showsBottomActionButton: Bool {
#if os(iOS)
return true
#else
return false
#endif
}
private var showsIdentitySection: Bool {
switch sheet {
case .wireGuard, .tor:
return true
case .proxySubscription:
return false
case .tailnet:
return showsAdvancedTailnetSettings
}
}
private var wireGuardEditorHeight: CGFloat {
#if os(iOS)
180
#else
220
#endif
}
private var confirmationTitle: String {
switch sheet {
case .wireGuard:
return "Add Network"
case .proxySubscription:
return "Import"
case .tor:
return "Save Account"
case .tailnet:
return "Save Account"
}
}
private var tailnetLoginActionDisabled: Bool {
switch sheet {
case .tailnet:
if usesCustomTailnetAuthority {
return normalizedOptional(draft.authority) == nil
}
return false
case .wireGuard, .tor, .proxySubscription:
return true
}
}
private var submissionDisabled: Bool {
switch sheet {
case .wireGuard:
return draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
case .proxySubscription:
return normalizedOptional(draft.proxySubscriptionURL) == nil
|| proxySubscriptionPreview?.nodes.contains(where: { $0.runtimeSupported }) != true
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 normalizedOptional(draft.authority) == nil {
return true
}
if draft.authMode == .web {
return tailnetLoginStatus?.running != true
}
if draft.authMode != .none && normalizedOptional(draft.secret) == nil {
return true
}
return false
}
}
private var tailnetServerDisplayLabel: String {
if usesCustomTailnetAuthority {
return normalizedOptional(draft.authority)
?? "Enter a custom Tailnet server"
}
return TailnetProvider.tailscale.defaultAuthority ?? "Tailscale managed"
}
private var tailnetServerDetail: String? {
if usesCustomTailnetAuthority {
if let discovery = discoveryStatus {
return "Discovered from \(discovery.domain)."
}
if let discoveryError {
return discoveryError
}
return "Use a custom Tailnet authority when your domain does not advertise one."
}
return "Continue with Tailscale, or open advanced settings to use a custom server."
}
private var tailnetConnectionSummary: String? {
if isDiscoveringTailnet {
return "Finding server"
}
if isProbingAuthority {
return "Checking"
}
if let authorityProbeStatus {
return authorityProbeStatus.summary
}
if authorityProbeError != nil {
return "Unavailable"
}
return nil
}
private var tailnetConnectionSummaryColor: Color {
if authorityProbeError != nil {
return .red
}
return .secondary
}
private func submit() {
isSubmitting = true
errorMessage = nil
Task { @MainActor in
defer { isSubmitting = false }
do {
switch sheet {
case .wireGuard:
try await submitWireGuard()
dismiss()
case .proxySubscription:
try await submitProxySubscription()
dismiss()
case .tor:
try submitTor()
dismiss()
case .tailnet:
try await submitTailnet()
}
} catch {
errorMessage = displayError(error)
}
}
}
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 submitProxySubscription() async throws {
let url = normalized(draft.proxySubscriptionURL, fallback: "")
let name = normalized(draft.proxySubscriptionName, fallback: "Proxy Subscription")
let selectedOrdinal = selectedProxySubscriptionOrdinal
?? proxySubscriptionPreview?.nodes.first(where: { $0.runtimeSupported })?.ordinal
_ = try await networkViewModel.importProxySubscription(
url: url,
name: name,
selectedOrdinal: selectedOrdinal
)
}
private func previewProxySubscription() {
guard let url = normalizedOptional(draft.proxySubscriptionURL) else {
proxySubscriptionPreviewError = "Enter a subscription URL before previewing."
return
}
isPreviewingProxySubscription = true
proxySubscriptionPreviewError = nil
Task { @MainActor in
defer { isPreviewingProxySubscription = false }
do {
let preview = try await networkViewModel.previewProxySubscription(url: url)
proxySubscriptionPreview = preview
selectedProxySubscriptionOrdinal = preview.nodes.first(where: { $0.runtimeSupported })?.ordinal
proxySubscriptionNodeFilter = ""
if draft.proxySubscriptionName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
draft.proxySubscriptionName = preview.suggestedName
}
} catch {
proxySubscriptionPreview = nil
selectedProxySubscriptionOrdinal = nil
proxySubscriptionNodeFilter = ""
proxySubscriptionPreviewError = displayError(error)
}
}
}
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 {
let secret = (draft.authMode == .none || draft.authMode == .web) ? nil : draft.secret
let username = normalizedOptional(draft.username)
preserveTailnetLoginSession = draft.authMode == .web && tailnetLoginStatus?.running == true
try await saveTailnetAccount(secret: secret, username: username)
dismiss()
}
private func runAutomationIfNeeded() {
guard !didRunAutomation,
sheet == .tailnet,
let automation = BurrowAutomationConfig.current,
automation.action == .tailnetLogin || automation.action == .tailnetProbe
else {
return
}
didRunAutomation = true
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
switch automation.action {
case .tailnetLogin:
applyTailnetDefaults(for: .tailscale)
startTailnetLogin()
case .tailnetProbe:
usesCustomTailnetAuthority = true
showsAdvancedTailnetSettings = true
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
probeTailnetAuthority()
}
}
}
private func saveTailnetAccount(secret: String?, username: String?) async throws {
let provider = inferredTailnetProvider
let title = titleOrFallback(
hostnameFallback(from: draft.authority, fallback: "Tailnet")
)
let payload = TailnetNetworkPayload(
provider: provider,
authority: normalizedOptional(draft.authority) ?? normalizedOptional(provider.defaultAuthority ?? ""),
account: normalized(draft.accountName, fallback: "default"),
identity: normalized(draft.identityName, fallback: "apple"),
tailnet: normalizedOptional(draft.tailnet),
hostname: normalizedOptional(draft.hostname)
)
var noteParts: [String] = [
"Server: \(hostnameFallback(from: payload.authority ?? "", fallback: "tailnet"))",
]
if showsAdvancedTailnetSettings || draft.authMode != .web {
noteParts.append("Auth: \(draft.authMode.title)")
}
if draft.authMode == .web, tailnetLoginStatus?.running == true {
noteParts.append("Browser sign-in complete")
}
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: .tailnet,
title: title,
authority: payload.authority,
provider: provider,
accountName: payload.account,
identityName: payload.identity,
hostname: payload.hostname,
username: username,
tailnet: payload.tailnet,
authMode: draft.authMode,
note: noteParts.joined(separator: ""),
createdAt: .now,
updatedAt: .now
)
try accountStore.upsert(record, secret: secret)
}
private func applySuggestedIdentity() {
let defaults = AccountDraft(sheet: sheet)
if draft.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
draft.title = defaults.title
}
draft.accountName = defaults.accountName
draft.identityName = defaults.identityName
if sheet == .tailnet {
draft.hostname = defaults.hostname
}
}
private func applyTorDefaults() {
let defaults = AccountDraft(sheet: .tor)
draft.title = defaults.title
draft.accountName = defaults.accountName
draft.identityName = defaults.identityName
draft.torAddresses = defaults.torAddresses
draft.torDNS = defaults.torDNS
draft.torMTU = defaults.torMTU
draft.torListen = defaults.torListen
}
private func applyTailnetDefaults(for provider: TailnetProvider) {
resetTailnetDiscoveryFeedback()
usesCustomTailnetAuthority = provider != .tailscale
draft.authority = provider.defaultAuthority ?? ""
if !availableTailnetAuthModes.contains(draft.authMode) {
draft.authMode = .web
}
}
private func startTailnetLogin() {
isStartingTailnetLogin = true
tailnetLoginError = nil
preserveTailnetLoginSession = false
Task { @MainActor in
defer { isStartingTailnetLogin = false }
do {
let authority = try await resolveTailnetAuthorityForLogin()
let status = try await networkViewModel.startTailnetLogin(
accountName: normalized(draft.accountName, fallback: "default"),
identityName: normalized(draft.identityName, fallback: "apple"),
hostname: normalizedOptional(draft.hostname),
authority: authority
)
tailnetLoginSessionID = status.sessionID
updateTailnetLoginStatus(status)
beginTailnetLoginPolling(sessionID: status.sessionID)
} catch {
tailnetLoginError = error.localizedDescription
}
}
}
private func probeTailnetAuthority() {
guard let authority = normalizedOptional(draft.authority) else {
authorityProbeStatus = nil
authorityProbeError = "Enter a server URL first."
return
}
isProbingAuthority = true
authorityProbeStatus = nil
authorityProbeError = nil
Task { @MainActor in
defer { isProbingAuthority = false }
do {
authorityProbeStatus = try await networkViewModel.probeTailnetAuthority(authority)
} catch {
authorityProbeError = error.localizedDescription
}
}
}
private func resetAuthorityProbe() {
tailnetProbeTask?.cancel()
authorityProbeStatus = nil
authorityProbeError = nil
tailnetLoginError = nil
}
private func resetTailnetDiscoveryFeedback() {
tailnetDiscoveryTask?.cancel()
discoveryStatus = nil
discoveryError = nil
}
private func discoverTailnetAuthority() {
guard let email = normalizedOptional(draft.discoveryEmail) else {
discoveryStatus = nil
discoveryError = "Enter an email address first."
return
}
isDiscoveringTailnet = true
discoveryStatus = nil
discoveryError = nil
Task { @MainActor in
defer { isDiscoveringTailnet = false }
do {
let discovery = try await networkViewModel.discoverTailnet(email: email)
discoveryStatus = discovery
draft.authority = discovery.authority
probeTailnetAuthority()
} catch {
discoveryError = error.localizedDescription
}
}
}
private func scheduleTailnetDiscovery(immediate: Bool = false) {
guard sheet == .tailnet else { return }
tailnetDiscoveryTask?.cancel()
guard !usesCustomTailnetAuthority else {
discoveryStatus = nil
discoveryError = nil
return
}
guard normalizedOptional(draft.discoveryEmail) != nil else {
discoveryStatus = nil
discoveryError = nil
draft.authority = TailnetProvider.tailscale.defaultAuthority ?? ""
return
}
tailnetDiscoveryTask = Task { @MainActor in
if !immediate {
try? await Task.sleep(for: .milliseconds(450))
}
guard !Task.isCancelled else { return }
discoverTailnetAuthority()
}
}
private func scheduleTailnetAuthorityProbe() {
guard sheet == .tailnet else { return }
tailnetProbeTask?.cancel()
guard normalizedOptional(draft.authority) != nil else { return }
tailnetProbeTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
probeTailnetAuthority()
}
}
private func toggleTailnetAuthorityMode() {
let discoveredAuthority = discoveryStatus?.authority
usesCustomTailnetAuthority.toggle()
resetTailnetDiscoveryFeedback()
resetAuthorityProbe()
if usesCustomTailnetAuthority {
draft.authority = discoveredAuthority ?? draft.authority
} else {
draft.authority = TailnetProvider.tailscale.defaultAuthority ?? ""
scheduleTailnetDiscovery(immediate: normalizedOptional(draft.discoveryEmail) != nil)
}
}
private func resolveTailnetAuthorityForLogin() async throws -> String {
if !usesCustomTailnetAuthority {
let authority = TailnetProvider.tailscale.defaultAuthority ?? ""
draft.authority = authority
scheduleTailnetAuthorityProbe()
return authority
}
if let authority = normalizedOptional(draft.authority) {
return authority
}
if let email = normalizedOptional(draft.discoveryEmail) {
let discovery = try await networkViewModel.discoverTailnet(email: email)
discoveryStatus = discovery
discoveryError = nil
draft.authority = discovery.authority
scheduleTailnetAuthorityProbe()
return discovery.authority
}
throw NSError(domain: "BurrowTailnet", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Enter an email address or a custom server URL first."
])
}
private func beginTailnetLoginPolling(sessionID: String) {
tailnetLoginPollTask?.cancel()
tailnetLoginPollTask = Task { @MainActor in
while !Task.isCancelled {
do {
let status = try await networkViewModel.tailnetLoginStatus(sessionID: sessionID)
updateTailnetLoginStatus(status)
if status.running {
tailnetLoginPollTask = nil
return
}
} catch {
tailnetLoginError = error.localizedDescription
tailnetLoginPollTask = nil
return
}
try? await Task.sleep(for: .seconds(1))
}
}
}
private func updateTailnetLoginStatus(_ status: TailnetLoginStatus) {
tailnetLoginStatus = status
tailnetLoginError = nil
tailnetLoginSessionID = status.sessionID
if status.running {
browserAuthenticator.cancel()
tailnetPresentedAuthURL = nil
return
}
guard let authURL = status.authURL else {
return
}
if tailnetPresentedAuthURL != authURL {
tailnetPresentedAuthURL = authURL
browserAuthenticator.start(url: authURL) { [sessionID = status.sessionID] in
Task { @MainActor in
if tailnetLoginStatus?.running != true {
tailnetLoginSessionID = sessionID
}
}
}
}
}
private func cancelTailnetLoginIfNeeded() async {
tailnetLoginPollTask?.cancel()
tailnetLoginPollTask = nil
browserAuthenticator.cancel()
tailnetPresentedAuthURL = nil
guard tailnetLoginStatus?.running != true,
let sessionID = tailnetLoginSessionID
else {
return
}
do {
try await networkViewModel.cancelTailnetLogin(sessionID: sessionID)
} catch {
tailnetLoginError = error.localizedDescription
}
tailnetLoginStatus = nil
tailnetLoginSessionID = nil
}
private func pasteWireGuardConfiguration() {
guard let clipboardString else { return }
draft.wireGuardConfig = clipboardString
}
private var clipboardString: String? {
#if canImport(UIKit)
UIPasteboard.general.string
#elseif canImport(AppKit)
NSPasteboard.general.string(forType: .string)
#else
nil
#endif
}
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
}
private var availableTailnetAuthModes: [AccountAuthMode] {
[.web, .none, .password, .preauthKey]
}
private var tailnetSignInActionTitle: String {
if tailnetLoginStatus?.running == true {
return "Signed In"
}
if tailnetLoginSessionID != nil {
return "Resume Sign-In"
}
return "Continue with Tailscale"
}
private var tailnetAuthenticationFootnote: String {
switch draft.authMode {
case .web:
if usesCustomTailnetAuthority {
return "Burrow signs in through the daemon using your custom Tailnet server."
}
return "Burrow signs in through the daemon using Tailscale's managed browser flow."
case .none:
return "Save the authority only. Useful when the control plane handles authentication elsewhere."
case .password, .preauthKey:
return "Tailnet account material stays on-device. Burrow stores the authority and credentials for daemon-managed registration and refresh."
}
}
private var inferredTailnetProvider: TailnetProvider {
TailnetProvider.inferred(
authority: normalizedOptional(draft.authority),
explicit: discoveryStatus?.provider
)
}
@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())
.lineLimit(3)
.truncationMode(.middle)
}
}
}
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)
.lineLimit(1)
.truncationMode(.tail)
Text(account.kind.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)
.lineLimit(3)
.truncationMode(.middle)
}
}
.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())
.lineLimit(3)
.truncationMode(.middle)
}
}
}
private struct ProxySubscriptionManagerView: View {
@Environment(\.tunnel)
private var tunnel: any Tunnel
let networks: [ProxySubscriptionNetwork]
let networkViewModel: NetworkViewModel
@Binding var selectedNetworkID: Int32?
@State private var nodeFilter = ""
@State private var operationID: String?
@State private var feedback: String?
@State private var errorMessage: String?
@State private var pendingDelete: ProxySubscriptionNetwork?
var body: some View {
if let network = selectedNetwork {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Proxy Subscription")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
Text(network.name)
.font(.title3.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
if let selectedNode = network.selectedNode {
Text("\(selectedNode.proxyProtocol.uppercased()) \(selectedNode.server):\(selectedNode.port)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
Spacer(minLength: 0)
HStack(spacing: 8) {
Button {
refresh(network)
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.disabled(isBusy)
Button(role: .destructive) {
pendingDelete = network
} label: {
Label("Remove", systemImage: "trash")
}
.disabled(isBusy)
}
.burrowGlassControl()
.controlSize(.small)
Button {
withAnimation(.snappy) {
selectedNetworkID = nil
}
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title3)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Close proxy subscription picker")
}
if networks.count > 1 {
Picker("Subscription", selection: selectedNetworkIDBinding) {
ForEach(networks) { network in
Text(network.name).tag(network.id)
}
}
.pickerStyle(.menu)
}
HStack(spacing: 16) {
Label(network.detectedFormat, systemImage: "doc.text")
Label("\(network.runtimeSupportedNodes.count) usable of \(network.nodes.count) nodes", systemImage: "circle.grid.2x1")
}
.font(.caption)
.foregroundStyle(.secondary)
TextField("Filter nodes", text: $nodeFilter)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
HStack {
Text("Nodes")
.font(.subheadline.weight(.semibold))
Spacer()
Text("\(filteredNodes(for: network).count) shown")
.font(.caption)
.foregroundStyle(.secondary)
}
ScrollView {
LazyVGrid(columns: nodeColumns, alignment: .leading, spacing: 10) {
ForEach(filteredNodes(for: network)) { node in
nodeTile(node, in: network)
}
}
.padding(.vertical, 2)
}
.frame(maxHeight: 410)
.scrollIndicators(.visible)
if let feedback {
Text(feedback)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
.truncationMode(.tail)
}
if let errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundStyle(.red)
.lineLimit(3)
.truncationMode(.middle)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.onAppear {
ensureSelectedNetwork()
}
.onChange(of: networks) { _, _ in
ensureSelectedNetwork()
}
.confirmationDialog(
"Remove proxy subscription?",
isPresented: Binding(
get: { pendingDelete != nil },
set: { if !$0 { pendingDelete = nil } }
),
presenting: pendingDelete
) { network in
Button("Remove \(network.name)", role: .destructive) {
delete(network)
}
}
}
}
private var selectedNetwork: ProxySubscriptionNetwork? {
guard let selectedNetworkID else {
return nil
}
return networks.first(where: { $0.id == selectedNetworkID })
}
private var selectedNetworkIDBinding: Binding<Int32> {
Binding(
get: { selectedNetwork?.id ?? networks.first?.id ?? 0 },
set: { selectedNetworkID = $0 }
)
}
private var isBusy: Bool {
operationID != nil
}
private var nodeColumns: [GridItem] {
[
GridItem(
.adaptive(minimum: 245, maximum: 320),
spacing: 10,
alignment: .top
)
]
}
private func ensureSelectedNetwork() {
guard let selectedNetworkID else {
return
}
if !networks.contains(where: { $0.id == selectedNetworkID }) {
self.selectedNetworkID = nil
}
}
private func filteredNodes(for network: ProxySubscriptionNetwork) -> [ProxySubscriptionNetworkNode] {
let query = nodeFilter.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !query.isEmpty else {
return network.nodes
}
return network.nodes.filter { node in
node.name.lowercased().contains(query)
|| node.server.lowercased().contains(query)
|| node.proxyProtocol.lowercased().contains(query)
}
}
private func nodeTile(
_ node: ProxySubscriptionNetworkNode,
in network: ProxySubscriptionNetwork
) -> some View {
let isSelected = network.selectedNode?.ordinal == node.ordinal
return Button {
select(node, in: network)
} label: {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
.frame(width: 18)
VStack(alignment: .leading, spacing: 4) {
Text(node.name)
.font(.footnote.weight(isSelected ? .semibold : .regular))
.lineLimit(1)
.truncationMode(.tail)
HStack(spacing: 6) {
Text(node.proxyProtocol.uppercased())
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(Capsule().fill(.secondary.opacity(0.12)))
if !node.runtimeSupported {
Text("UNSUPPORTED")
.font(.caption2.weight(.medium))
.foregroundStyle(.orange)
}
}
}
Spacer(minLength: 0)
}
Text("\(node.server):\(node.port)")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
if operationID == operationIdentifier(networkID: network.id, ordinal: node.ordinal) {
ProgressView()
.controlSize(.small)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.padding(12)
.frame(minHeight: 94, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isSelected ? Color.accentColor.opacity(0.10) : Color.clear)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected ? Color.accentColor.opacity(0.65) : Color.secondary.opacity(0.16), lineWidth: 1)
)
}
.buttonStyle(.plain)
.disabled(isBusy || !node.runtimeSupported)
}
private func select(
_ node: ProxySubscriptionNetworkNode,
in network: ProxySubscriptionNetwork
) {
let operationIdentifier = operationIdentifier(networkID: network.id, ordinal: node.ordinal)
operationID = operationIdentifier
feedback = nil
errorMessage = nil
Task { @MainActor in
defer { operationID = nil }
do {
_ = try await networkViewModel.updateProxySubscriptionSelection(
network: network,
selectedOrdinal: node.ordinal
)
if isTunnelRunning {
_ = try await networkViewModel.updateActiveProxySubscriptionSelection(
network: network,
selectedOrdinal: node.ordinal
)
feedback = "Selected \(node.name) for the active tunnel"
} else {
feedback = "Selected \(node.name)"
}
} catch {
errorMessage = displayError(error)
}
}
}
private func refresh(_ network: ProxySubscriptionNetwork) {
operationID = "refresh-\(network.id)"
feedback = nil
errorMessage = nil
Task { @MainActor in
defer { operationID = nil }
do {
_ = try await networkViewModel.refreshProxySubscription(network: network)
if isTunnelRunning {
_ = try await networkViewModel.refreshActiveProxySubscription(network: network)
feedback = "Refreshed \(network.name) for the active tunnel"
} else {
feedback = "Refreshed \(network.name)"
}
} catch {
errorMessage = displayError(error)
}
}
}
private func delete(_ network: ProxySubscriptionNetwork) {
operationID = "delete-\(network.id)"
feedback = nil
errorMessage = nil
Task { @MainActor in
defer { operationID = nil }
do {
try await networkViewModel.deleteNetwork(id: network.id)
pendingDelete = nil
selectedNetworkID = nil
feedback = nil
} catch {
errorMessage = displayError(error)
}
}
}
private func operationIdentifier(networkID: Int32, ordinal: Int32) -> String {
"select-\(networkID)-\(ordinal)"
}
@MainActor
private var isTunnelRunning: Bool {
switch tunnel.status {
case .connected, .reasserting:
true
default:
false
}
}
private func labeledValue(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.caption.monospaced())
.lineLimit(1)
.truncationMode(.tail)
}
}
}
private extension View {
@ViewBuilder
func burrowGlassControl() -> some View {
if #available(macOS 26.0, iOS 26.0, *) {
self.buttonStyle(.glass)
} else {
self.buttonStyle(.bordered)
}
}
@ViewBuilder
func burrowLoginField() -> some View {
#if os(iOS)
textInputAutocapitalization(.never)
#else
self
#endif
}
@ViewBuilder
func burrowEmailField() -> some View {
#if os(iOS)
textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
#else
self
#endif
}
}
#if canImport(AuthenticationServices)
@MainActor
private final class TailnetBrowserAuthenticator: NSObject {
private var session: ASWebAuthenticationSession?
private static var prefersEphemeralSessionForCurrentProcess: Bool {
let rawValue = ProcessInfo.processInfo.environment["BURROW_UI_TEST_EPHEMERAL_AUTH"]?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return rawValue == "1" || rawValue == "true" || rawValue == "yes"
}
func start(url: URL, onDismiss: @escaping @Sendable () -> Void) {
cancel()
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: nil) { _, _ in
onDismiss()
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = Self.prefersEphemeralSessionForCurrentProcess
self.session = session
_ = session.start()
}
func cancel() {
session?.cancel()
session = nil
}
}
extension TailnetBrowserAuthenticator: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
#if canImport(AppKit)
return NSApplication.shared.keyWindow
?? NSApplication.shared.windows.first
?? ASPresentationAnchor()
#elseif canImport(UIKit)
return ASPresentationAnchor()
#else
return ASPresentationAnchor()
#endif
}
}
#else
@MainActor
private final class TailnetBrowserAuthenticator {
func start(url: URL, onDismiss: @escaping @Sendable () -> Void) {
_ = url
onDismiss()
}
func cancel() {}
}
#endif
private func displayError(_ error: Error) -> String {
if let status = error as? GRPCStatus {
return status.message ?? status.description
}
return error.localizedDescription
}
private struct BurrowAutomationConfig {
enum Action: String {
case tailnetLogin = "tailnet-login"
case tailnetProbe = "tailnet-probe"
}
let action: Action
let title: String?
let accountName: String?
let identityName: String?
let hostname: String?
let authority: 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"],
authority: environment["BURROW_UI_AUTOMATION_AUTHORITY"]
)
}()
}
#if DEBUG
struct NetworkView_Previews: PreviewProvider {
static var previews: some View {
BurrowView()
.environment(\.tunnel, PreviewTunnel())
}
}
#endif