Add proxy subscription runtime support
Add daemon RPCs, Apple and GTK import flows, packet proxy runtime support, diagnostics, and BEPs for proxy subscription handling. Redact subscription URL secrets from fetch errors before they reach logs or UI surfaces.
This commit is contained in:
parent
97c569fb35
commit
d1638726ca
46 changed files with 15079 additions and 456 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import BurrowConfiguration
|
||||
import Foundation
|
||||
import GRPC
|
||||
import SwiftUI
|
||||
#if canImport(AuthenticationServices)
|
||||
import AuthenticationServices
|
||||
|
|
@ -15,6 +16,7 @@ public struct BurrowView: View {
|
|||
@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 {
|
||||
|
|
@ -37,6 +39,9 @@ public struct BurrowView: View {
|
|||
Button("Add WireGuard Network") {
|
||||
activeSheet = .wireGuard
|
||||
}
|
||||
Button("Import Proxy Subscription") {
|
||||
activeSheet = .proxySubscription
|
||||
}
|
||||
Button("Save Tor Account") {
|
||||
activeSheet = .tor
|
||||
}
|
||||
|
|
@ -67,8 +72,22 @@ public struct BurrowView: View {
|
|||
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
|
||||
}
|
||||
}
|
||||
NetworkCarouselView(networks: networkViewModel.cards)
|
||||
}
|
||||
|
||||
if showsAccountsSection {
|
||||
|
|
@ -119,6 +138,15 @@ public struct BurrowView: View {
|
|||
accountStore: accountStore
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: proxySubscriptionSheetBinding) {
|
||||
ProxySubscriptionManagerView(
|
||||
networks: networkViewModel.proxySubscriptions,
|
||||
networkViewModel: networkViewModel,
|
||||
selectedNetworkID: $expandedProxySubscriptionID
|
||||
)
|
||||
.frame(width: 820, height: 600)
|
||||
.padding(20)
|
||||
}
|
||||
.onAppear {
|
||||
runAutomationIfNeeded()
|
||||
}
|
||||
|
|
@ -157,15 +185,29 @@ public struct BurrowView: View {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -190,13 +232,14 @@ public struct BurrowView: View {
|
|||
#if os(iOS)
|
||||
!accountStore.accounts.isEmpty
|
||||
#else
|
||||
true
|
||||
!accountStore.accounts.isEmpty || networkViewModel.networks.isEmpty
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private enum ConfigurationSheet: String, CaseIterable, Identifiable {
|
||||
case wireGuard
|
||||
case proxySubscription
|
||||
case tor
|
||||
case tailnet
|
||||
|
||||
|
|
@ -205,6 +248,7 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable {
|
|||
var kind: AccountNetworkKind {
|
||||
switch self {
|
||||
case .wireGuard: .wireGuard
|
||||
case .proxySubscription: .proxySubscription
|
||||
case .tor: .tor
|
||||
case .tailnet: .tailnet
|
||||
}
|
||||
|
|
@ -214,6 +258,8 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable {
|
|||
switch self {
|
||||
case .wireGuard:
|
||||
"wave.3.right"
|
||||
case .proxySubscription:
|
||||
"link.badge.plus"
|
||||
case .tor:
|
||||
"shield.lefthalf.filled.badge.checkmark"
|
||||
case .tailnet:
|
||||
|
|
@ -225,6 +271,8 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable {
|
|||
switch self {
|
||||
case .wireGuard:
|
||||
"WireGuard"
|
||||
case .proxySubscription:
|
||||
"Proxy Subscription"
|
||||
case .tor:
|
||||
"Tor"
|
||||
case .tailnet:
|
||||
|
|
@ -236,6 +284,8 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable {
|
|||
switch self {
|
||||
case .wireGuard:
|
||||
"Import a tunnel"
|
||||
case .proxySubscription:
|
||||
"Import Trojan or Shadowsocks"
|
||||
case .tor:
|
||||
"Save an Arti profile"
|
||||
case .tailnet:
|
||||
|
|
@ -247,6 +297,8 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable {
|
|||
switch self {
|
||||
case .wireGuard:
|
||||
.blue
|
||||
case .proxySubscription:
|
||||
.teal
|
||||
case .tor, .tailnet:
|
||||
kind.accentColor
|
||||
}
|
||||
|
|
@ -286,6 +338,8 @@ private struct AccountDraft {
|
|||
var accountName = ""
|
||||
var identityName = ""
|
||||
var wireGuardConfig = ""
|
||||
var proxySubscriptionURL = ""
|
||||
var proxySubscriptionName = ""
|
||||
|
||||
var discoveryEmail = ""
|
||||
var authority = ""
|
||||
|
|
@ -304,6 +358,8 @@ private struct AccountDraft {
|
|||
switch sheet {
|
||||
case .wireGuard:
|
||||
break
|
||||
case .proxySubscription:
|
||||
title = "Proxy Subscription"
|
||||
case .tor:
|
||||
title = "Default Tor"
|
||||
accountName = "default"
|
||||
|
|
@ -338,6 +394,11 @@ private struct ConfigurationSheetView: View {
|
|||
@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
|
||||
|
|
@ -374,31 +435,11 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
configurationSections
|
||||
|
||||
if let errorMessage {
|
||||
Section {
|
||||
Text(errorMessage)
|
||||
.foregroundStyle(.red)
|
||||
feedbackText(errorMessage, color: .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -445,7 +486,8 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 520, minHeight: 620)
|
||||
.frame(width: 520)
|
||||
.frame(minHeight: 620)
|
||||
#endif
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if showsBottomActionButton {
|
||||
|
|
@ -473,6 +515,12 @@ private struct ConfigurationSheetView: View {
|
|||
await cancelTailnetLoginIfNeeded()
|
||||
}
|
||||
}
|
||||
.onChange(of: draft.proxySubscriptionURL) { _, _ in
|
||||
proxySubscriptionPreview = nil
|
||||
proxySubscriptionPreviewError = nil
|
||||
selectedProxySubscriptionOrdinal = nil
|
||||
proxySubscriptionNodeFilter = ""
|
||||
}
|
||||
.onDisappear {
|
||||
tailnetLoginPollTask?.cancel()
|
||||
tailnetDiscoveryTask?.cancel()
|
||||
|
|
@ -486,6 +534,71 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
|
|
@ -792,6 +905,143 @@ private struct ConfigurationSheetView: View {
|
|||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -827,6 +1077,12 @@ private struct ConfigurationSheetView: View {
|
|||
}
|
||||
.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") {
|
||||
|
|
@ -883,6 +1139,8 @@ private struct ConfigurationSheetView: View {
|
|||
switch sheet {
|
||||
case .wireGuard:
|
||||
.blue
|
||||
case .proxySubscription:
|
||||
.teal
|
||||
case .tor, .tailnet:
|
||||
sheet.kind.accentColor
|
||||
}
|
||||
|
|
@ -892,6 +1150,8 @@ private struct ConfigurationSheetView: View {
|
|||
switch sheet {
|
||||
case .wireGuard:
|
||||
"Import WireGuard"
|
||||
case .proxySubscription:
|
||||
"Import Proxy Subscription"
|
||||
case .tor:
|
||||
"Configure Tor"
|
||||
case .tailnet:
|
||||
|
|
@ -911,6 +1171,8 @@ private struct ConfigurationSheetView: View {
|
|||
switch sheet {
|
||||
case .wireGuard, .tor:
|
||||
return true
|
||||
case .proxySubscription:
|
||||
return false
|
||||
case .tailnet:
|
||||
return showsAdvancedTailnetSettings
|
||||
}
|
||||
|
|
@ -928,6 +1190,8 @@ private struct ConfigurationSheetView: View {
|
|||
switch sheet {
|
||||
case .wireGuard:
|
||||
return "Add Network"
|
||||
case .proxySubscription:
|
||||
return "Import"
|
||||
case .tor:
|
||||
return "Save Account"
|
||||
case .tailnet:
|
||||
|
|
@ -942,7 +1206,7 @@ private struct ConfigurationSheetView: View {
|
|||
return normalizedOptional(draft.authority) == nil
|
||||
}
|
||||
return false
|
||||
case .wireGuard, .tor:
|
||||
case .wireGuard, .tor, .proxySubscription:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -951,6 +1215,9 @@ private struct ConfigurationSheetView: View {
|
|||
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:
|
||||
|
|
@ -1025,6 +1292,9 @@ private struct ConfigurationSheetView: View {
|
|||
case .wireGuard:
|
||||
try await submitWireGuard()
|
||||
dismiss()
|
||||
case .proxySubscription:
|
||||
try await submitProxySubscription()
|
||||
dismiss()
|
||||
case .tor:
|
||||
try submitTor()
|
||||
dismiss()
|
||||
|
|
@ -1032,7 +1302,7 @@ private struct ConfigurationSheetView: View {
|
|||
try await submitTailnet()
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorMessage = displayError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1062,6 +1332,44 @@ private struct ConfigurationSheetView: View {
|
|||
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 = [
|
||||
|
|
@ -1529,6 +1837,8 @@ private struct ConfigurationSheetView: View {
|
|||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.body.monospaced())
|
||||
.lineLimit(3)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1543,9 +1853,11 @@ private struct AccountRowView: View {
|
|||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(account.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Text(account.kind.title)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(account.kind.accentColor)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(account.kind.accentColor)
|
||||
}
|
||||
Spacer()
|
||||
if hasSecret {
|
||||
|
|
@ -1578,6 +1890,8 @@ private struct AccountRowView: View {
|
|||
Text(note)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
|
@ -1596,11 +1910,378 @@ private struct AccountRowView: View {
|
|||
.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)
|
||||
|
|
@ -1674,6 +2355,13 @@ private final class TailnetBrowserAuthenticator {
|
|||
}
|
||||
#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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue