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.
2404 lines
82 KiB
Swift
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
|