1716 lines
57 KiB
Swift
1716 lines
57 KiB
Swift
import BurrowConfiguration
|
|
import Foundation
|
|
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
|
|
|
|
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 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
|
|
)
|
|
}
|
|
.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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@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: .tailnet
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
.accessibilityIdentifier("quick-add-\(sheet.rawValue)")
|
|
.buttonStyle(.floating(color: sheet.quickActionColor, cornerRadius: 18))
|
|
}
|
|
}
|
|
|
|
private struct AccountDraft {
|
|
var title = ""
|
|
var accountName = ""
|
|
var identityName = ""
|
|
var wireGuardConfig = ""
|
|
|
|
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 .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 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
|
|
}
|
|
}
|
|
|
|
switch sheet {
|
|
case .wireGuard:
|
|
Section("WireGuard Configuration") {
|
|
TextEditor(text: $draft.wireGuardConfig)
|
|
.font(.body.monospaced())
|
|
.frame(minHeight: wireGuardEditorHeight)
|
|
.contextMenu {
|
|
wireGuardContextActions
|
|
}
|
|
}
|
|
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)
|
|
#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(minWidth: 520, 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()
|
|
}
|
|
}
|
|
.onDisappear {
|
|
tailnetLoginPollTask?.cancel()
|
|
tailnetDiscoveryTask?.cancel()
|
|
tailnetProbeTask?.cancel()
|
|
browserAuthenticator.cancel()
|
|
if !preserveTailnetLoginSession {
|
|
Task { @MainActor in
|
|
await cancelTailnetLoginIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@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))
|
|
)
|
|
}
|
|
|
|
@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 .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 .tor, .tailnet:
|
|
sheet.kind.accentColor
|
|
}
|
|
}
|
|
|
|
private var summaryTitle: String {
|
|
switch sheet {
|
|
case .wireGuard:
|
|
"Import WireGuard"
|
|
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 .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 .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:
|
|
return true
|
|
}
|
|
}
|
|
|
|
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 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 .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 {
|
|
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())
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
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)
|
|
}
|
|
}
|
|
.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
|
|
}
|
|
|
|
@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 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
|