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:
JettChenT 2026-06-01 15:23:17 +08:00
parent 97c569fb35
commit d1638726ca
46 changed files with 15079 additions and 456 deletions

View file

@ -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"

View file

@ -2,6 +2,18 @@ import SwiftUI
struct NetworkCarouselView: View {
var networks: [NetworkCardModel]
var selectedNetworkID: Int32?
var onSelect: ((NetworkCardModel) -> Void)?
init(
networks: [NetworkCardModel],
selectedNetworkID: Int32? = nil,
onSelect: ((NetworkCardModel) -> Void)? = nil
) {
self.networks = networks
self.selectedNetworkID = selectedNetworkID
self.onSelect = onSelect
}
var body: some View {
Group {
@ -29,10 +41,19 @@ struct NetworkCarouselView: View {
.frame(maxWidth: .infinity, minHeight: 175)
#endif
} else {
#if os(macOS)
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(networks) { network in
networkCard(network)
.frame(maxWidth: 560)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
#else
ScrollView(.horizontal) {
LazyHStack {
ForEach(networks) { network in
NetworkView(network: network)
networkCard(network)
.containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center)
.scrollTransition(.interactive, axis: .horizontal) { content, phase in
content
@ -47,9 +68,33 @@ struct NetworkCarouselView: View {
.defaultScrollAnchor(.center)
.scrollTargetBehavior(.viewAligned)
.containerRelativeFrame(.horizontal)
#endif
}
}
}
@ViewBuilder
private func networkCard(_ network: NetworkCardModel) -> some View {
if let onSelect {
Button {
onSelect(network)
} label: {
NetworkView(network: network)
.overlay(selectionOverlay(for: network))
}
.buttonStyle(.plain)
} else {
NetworkView(network: network)
}
}
private func selectionOverlay(for network: NetworkCardModel) -> some View {
RoundedRectangle(cornerRadius: 10)
.stroke(
selectedNetworkID == network.id ? Color.accentColor : Color.clear,
lineWidth: 3
)
}
}
#if DEBUG

View file

@ -98,23 +98,33 @@ public final class NetworkExtensionTunnel: Tunnel {
}
}
guard managers.isEmpty else { return }
let manager = NETunnelProviderManager()
manager.localizedDescription = "Burrow"
let manager = managers.first ?? NETunnelProviderManager()
var needsSave = managers.isEmpty
if manager.localizedDescription != "Burrow" {
manager.localizedDescription = "Burrow"
needsSave = true
}
let proto = NETunnelProviderProtocol()
proto.providerBundleIdentifier = bundleIdentifier
proto.serverAddress = "burrow.rs"
proto.proxySettings = nil
manager.protocolConfiguration = proto
try await manager.save()
if !manager.protocolConfiguration.isBurrowEquivalent(to: proto) {
manager.protocolConfiguration = proto
needsSave = true
}
if needsSave {
try await manager.save()
}
}
public func start() {
Task {
guard let manager = try await NETunnelProviderManager.managers.first else { return }
do {
try await configure()
guard let manager = try await NETunnelProviderManager.managers.first else { return }
if !manager.isEnabled {
manager.isEnabled = true
try await manager.save()
@ -149,6 +159,17 @@ public final class NetworkExtensionTunnel: Tunnel {
}
}
private extension Optional where Wrapped == NEVPNProtocol {
func isBurrowEquivalent(to expected: NETunnelProviderProtocol) -> Bool {
guard let current = self as? NETunnelProviderProtocol else {
return false
}
return current.providerBundleIdentifier == expected.providerBundleIdentifier
&& current.serverAddress == expected.serverAddress
&& current.proxySettings == nil
}
}
extension NEVPNConnection {
fileprivate var tunnelStatus: TunnelStatus {
switch status {

View file

@ -53,6 +53,218 @@ struct TailnetLoginStatus: Sendable {
var health: [String]
}
struct ProxySubscriptionNodePreview: Sendable, Identifiable {
var id: Int32 { ordinal }
var ordinal: Int32
var name: String
var proxyProtocol: String
var server: String
var port: Int32
var warnings: [String]
var runtimeSupported: Bool
}
struct ProxySubscriptionPreview: Sendable {
var suggestedName: String
var detectedFormat: String
var compatibleCount: Int32
var rejectedCount: Int32
var nodes: [ProxySubscriptionNodePreview]
var warnings: [String]
}
struct ProxySubscriptionNetwork: Sendable, Identifiable, Hashable {
var id: Int32
var name: String
var sourceURL: String
var detectedFormat: String
var selectedOrdinal: Int32?
var selectedName: String?
var nodes: [ProxySubscriptionNetworkNode]
var warnings: [String]
var selectedNode: ProxySubscriptionNetworkNode? {
selectedName
.flatMap { name in nodes.first { $0.name == name && $0.runtimeSupported } }
?? selectedOrdinal
.flatMap { ordinal in nodes.first { $0.ordinal == ordinal && $0.runtimeSupported } }
?? nodes.first { $0.runtimeSupported }
?? nodes.first
}
var runtimeSupportedNodes: [ProxySubscriptionNetworkNode] {
nodes.filter(\.runtimeSupported)
}
init?(network: Burrow_Network) {
guard network.type == .proxySubscription,
let payload = try? JSONDecoder().decode(StoredProxySubscriptionPayload.self, from: network.payload)
else {
return nil
}
id = network.id
name = payload.name
sourceURL = payload.source.url
detectedFormat = payload.detectedFormat
selectedOrdinal = payload.selectedOrdinal.map(Int32.init)
selectedName = payload.selectedName
nodes = payload.nodes.map(ProxySubscriptionNetworkNode.init)
warnings = payload.warnings
}
}
struct ProxySubscriptionNetworkNode: Sendable, Identifiable, Hashable {
var id: Int32 { ordinal }
var ordinal: Int32
var name: String
var proxyProtocol: String
var server: String
var port: Int32
var warnings: [String]
var runtimeSupported: Bool
fileprivate init(stored: StoredProxySubscriptionNode) {
ordinal = Int32(stored.ordinal)
name = stored.name
proxyProtocol = stored.proxyProtocol
server = stored.server
port = Int32(stored.port)
warnings = stored.warnings
runtimeSupported = stored.config.runtimeSupported
}
}
private struct StoredProxySubscriptionPayload: Decodable {
var name: String
var source: StoredProxySubscriptionSource
var detectedFormat: String
var selectedOrdinal: Int?
var selectedName: String?
var nodes: [StoredProxySubscriptionNode]
var warnings: [String]
enum CodingKeys: String, CodingKey {
case name
case source
case detectedFormat = "detected_format"
case selectedOrdinal = "selected_ordinal"
case selectedName = "selected_name"
case nodes
case warnings
}
}
private struct StoredProxySubscriptionSource: Decodable {
var url: String
}
private struct StoredProxySubscriptionNode: Decodable {
var ordinal: Int
var name: String
var proxyProtocol: String
var server: String
var port: Int
var warnings: [String]
var config: StoredProxySubscriptionNodeConfig
enum CodingKeys: String, CodingKey {
case ordinal
case name
case proxyProtocol = "protocol"
case server
case port
case warnings
case config
}
}
private struct StoredProxySubscriptionNodeConfig: Decodable {
var type: String
var network: StoredProxySubscriptionNetworkTransport?
var cipher: String?
var plugin: StoredJSONValue?
var udpOverTCP: Bool?
var runtimeSupported: Bool {
switch type {
case "trojan":
return network?.isTrojanTCP ?? true
case "shadowsocks":
return plugin == nil
&& udpOverTCP != true
&& Self.supportedShadowsocksCiphers.contains(cipher?.lowercased() ?? "")
default:
return false
}
}
enum CodingKeys: String, CodingKey {
case type
case network
case cipher
case plugin
case udpOverTCP = "udp_over_tcp"
}
private static let supportedShadowsocksCiphers: Set<String> = [
"aes-128-gcm",
"aead_aes_128_gcm",
"aes-256-gcm",
"aead_aes_256_gcm",
"chacha20-ietf-poly1305",
"chacha20-poly1305",
"aead_chacha20_poly1305",
]
}
private enum StoredProxySubscriptionNetworkTransport: Decodable, Hashable {
case named(String)
case keyed(String)
var isTrojanTCP: Bool {
switch self {
case .named(let value), .keyed(let value):
return value == "tcp"
}
}
init(from decoder: Swift.Decoder) throws {
let container = try decoder.singleValueContainer()
if let value = try? container.decode(String.self) {
self = .named(value)
return
}
let object = try container.decode([String: StoredJSONValue].self)
self = .keyed(object.keys.first ?? "")
}
}
private enum StoredJSONValue: Decodable, Hashable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: StoredJSONValue])
case array([StoredJSONValue])
case null
init(from decoder: Swift.Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([StoredJSONValue].self) {
self = .array(value)
} else {
self = .object(try container.decode([String: StoredJSONValue].self))
}
}
}
enum TailnetDiscoveryClient {
static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse {
var request = Burrow_TailnetDiscoverRequest()
@ -139,6 +351,76 @@ enum TailnetLoginClient {
}
}
enum ProxySubscriptionImportClient {
static func preview(url: String, socketURL: URL) async throws -> ProxySubscriptionPreview {
var request = Burrow_ProxySubscriptionImportRequest()
request.url = url
let response = try await ProxySubscriptionClient.unix(socketURL: socketURL)
.previewImport(request)
return ProxySubscriptionPreview(
suggestedName: response.suggestedName,
detectedFormat: response.detectedFormat,
compatibleCount: response.compatibleCount,
rejectedCount: response.rejectedCount,
nodes: response.node.map { node in
ProxySubscriptionNodePreview(
ordinal: node.ordinal,
name: node.name,
proxyProtocol: node.`protocol`,
server: node.server,
port: node.port,
warnings: node.warnings,
runtimeSupported: node.runtimeSupported
)
},
warnings: response.warnings
)
}
static func apply(
id: Int32,
url: String,
name: String,
selectedOrdinal: Int32?,
socketURL: URL
) async throws -> Int32 {
var request = Burrow_ProxySubscriptionApplyRequest()
request.id = id
request.url = url
request.name = name
request.selectedOrdinal = selectedOrdinal ?? -1
let response = try await ProxySubscriptionClient.unix(socketURL: socketURL)
.applyImport(request)
return response.id
}
static func selectNode(
id: Int32,
selectedOrdinal: Int32,
socketURL: URL
) async throws -> Int32 {
var request = Burrow_ProxySubscriptionSelectRequest()
request.id = id
request.selectedOrdinal = selectedOrdinal
let response = try await ProxySubscriptionClient.unix(socketURL: socketURL)
.selectNode(request)
return response.selectedOrdinal
}
}
private struct DaemonUnavailableError: LocalizedError {
var socketPath: String
var errorDescription: String? {
"Burrow daemon is not reachable yet. Enable the tunnel or start the daemon, then try again."
}
var failureReason: String? {
"The daemon socket does not exist at \(socketPath)."
}
}
@Observable
@MainActor
final class NetworkViewModel: Sendable {
@ -161,6 +443,16 @@ final class NetworkViewModel: Sendable {
networks.map(Self.makeCard(for:))
}
var nonProxyCards: [NetworkCardModel] {
networks
.filter { $0.type != .proxySubscription }
.map(Self.makeCard(for:))
}
var proxySubscriptions: [ProxySubscriptionNetwork] {
networks.compactMap(ProxySubscriptionNetwork.init)
}
var nextNetworkID: Int32 {
(networks.map(\.id).max() ?? 0) + 1
}
@ -173,13 +465,83 @@ final class NetworkViewModel: Sendable {
try await addNetwork(type: .tailnet, payload: payload.encoded())
}
func previewProxySubscription(url: String) async throws -> ProxySubscriptionPreview {
let socketURL = try daemonSocketURL()
return try await ProxySubscriptionImportClient.preview(url: url, socketURL: socketURL)
}
func importProxySubscription(url: String, name: String, selectedOrdinal: Int32?) async throws -> Int32 {
let socketURL = try daemonSocketURL()
let networkID = nextNetworkID
return try await ProxySubscriptionImportClient.apply(
id: networkID,
url: url,
name: name,
selectedOrdinal: selectedOrdinal,
socketURL: socketURL
)
}
func updateProxySubscriptionSelection(
network: ProxySubscriptionNetwork,
selectedOrdinal: Int32
) async throws -> Int32 {
let socketURL = try daemonSocketURL()
return try await ProxySubscriptionImportClient.selectNode(
id: network.id,
selectedOrdinal: selectedOrdinal,
socketURL: socketURL
)
}
func updateActiveProxySubscriptionSelection(
network: ProxySubscriptionNetwork,
selectedOrdinal: Int32
) async throws -> Int32 {
let socketURL = try Constants.packetTunnelSocketURL
return try await ProxySubscriptionImportClient.selectNode(
id: network.id,
selectedOrdinal: selectedOrdinal,
socketURL: socketURL
)
}
func refreshProxySubscription(network: ProxySubscriptionNetwork) async throws -> Int32 {
let socketURL = try daemonSocketURL()
return try await ProxySubscriptionImportClient.apply(
id: network.id,
url: network.sourceURL,
name: network.name,
selectedOrdinal: network.selectedNode?.ordinal,
socketURL: socketURL
)
}
func refreshActiveProxySubscription(network: ProxySubscriptionNetwork) async throws -> Int32 {
let socketURL = try Constants.packetTunnelSocketURL
return try await ProxySubscriptionImportClient.apply(
id: network.id,
url: network.sourceURL,
name: network.name,
selectedOrdinal: network.selectedNode?.ordinal,
socketURL: socketURL
)
}
func deleteNetwork(id: Int32) async throws {
let socketURL = try daemonSocketURL()
var request = Burrow_NetworkDeleteRequest()
request.id = id
_ = try await NetworksClient.unix(socketURL: socketURL).networkDelete(request)
}
func discoverTailnet(email: String) async throws -> TailnetDiscoveryResponse {
let socketURL = try socketURLResult.get()
let socketURL = try daemonSocketURL()
return try await TailnetDiscoveryClient.discover(email: email, socketURL: socketURL)
}
func probeTailnetAuthority(_ authority: String) async throws -> TailnetAuthorityProbeStatus {
let socketURL = try socketURLResult.get()
let socketURL = try daemonSocketURL()
return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL)
}
@ -189,7 +551,7 @@ final class NetworkViewModel: Sendable {
hostname: String?,
authority: String
) async throws -> TailnetLoginStatus {
let socketURL = try socketURLResult.get()
let socketURL = try daemonSocketURL()
return try await TailnetLoginClient.start(
accountName: accountName,
identityName: identityName,
@ -200,17 +562,17 @@ final class NetworkViewModel: Sendable {
}
func tailnetLoginStatus(sessionID: String) async throws -> TailnetLoginStatus {
let socketURL = try socketURLResult.get()
let socketURL = try daemonSocketURL()
return try await TailnetLoginClient.status(sessionID: sessionID, socketURL: socketURL)
}
func cancelTailnetLogin(sessionID: String) async throws {
let socketURL = try socketURLResult.get()
let socketURL = try daemonSocketURL()
try await TailnetLoginClient.cancel(sessionID: sessionID, socketURL: socketURL)
}
private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 {
let socketURL = try socketURLResult.get()
let socketURL = try daemonSocketURL()
let networkID = nextNetworkID
let request = Burrow_Network.with {
$0.id = networkID
@ -223,12 +585,25 @@ final class NetworkViewModel: Sendable {
return networkID
}
private func daemonSocketURL() throws -> URL {
try Self.daemonSocketURL(from: socketURLResult)
}
private static func daemonSocketURL(from result: Result<URL, Error>) throws -> URL {
let socketURL = try result.get()
let socketPath = socketURL.path(percentEncoded: false)
guard FileManager.default.fileExists(atPath: socketPath) else {
throw DaemonUnavailableError(socketPath: socketPath)
}
return socketURL
}
private func startStreaming() {
task?.cancel()
let socketURLResult = self.socketURLResult
task = Task { [weak self] in
do {
let socketURL = try socketURLResult.get()
let socketURL = try Self.daemonSocketURL(from: socketURLResult)
let client = NetworksClient.unix(socketURL: socketURL)
for try await response in client.networkList(.init()) {
guard !Task.isCancelled else { return }
@ -254,6 +629,14 @@ final class NetworkViewModel: Sendable {
WireGuardCard(network: network).card
case .tailnet:
TailnetCard(network: network).card
case .trojan:
unsupportedCard(
id: network.id,
title: "Legacy Trojan Proxy",
detail: "Unsupported SOCKS-era profile. Import a proxy subscription instead."
)
case .proxySubscription:
proxySubscriptionCard(network: network)
case .UNRECOGNIZED(let rawValue):
unsupportedCard(
id: network.id,
@ -269,6 +652,76 @@ final class NetworkViewModel: Sendable {
}
}
private static func proxySubscriptionCard(network: Burrow_Network) -> NetworkCardModel {
guard let subscription = ProxySubscriptionNetwork(network: network) else {
return unsupportedCard(
id: network.id,
title: "Proxy Subscription",
detail: "Imported proxy subscription."
)
}
return NetworkCardModel(
id: subscription.id,
backgroundColor: .teal,
label: AnyView(
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Proxy Subscription")
.font(.headline)
.foregroundStyle(.white.opacity(0.85))
Text(subscription.name)
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer()
}
Spacer()
if let selectedNode = subscription.selectedNode {
VStack(alignment: .leading, spacing: 6) {
Text("Selected node")
.font(.caption.weight(.medium))
.foregroundStyle(.white.opacity(0.72))
HStack(spacing: 8) {
Text(selectedNode.name)
.font(.headline.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(1)
.truncationMode(.tail)
Text(selectedNode.proxyProtocol.uppercased())
.font(.caption2.weight(.bold))
.foregroundStyle(.white.opacity(0.78))
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(
Capsule()
.fill(.white.opacity(0.18))
)
}
Text("\(selectedNode.server):\(selectedNode.port)")
.font(.caption.monospaced())
.foregroundStyle(.white.opacity(0.78))
.lineLimit(1)
.truncationMode(.middle)
}
}
Text("\(subscription.runtimeSupportedNodes.count) usable of \(subscription.nodes.count) nodes · \(subscription.detectedFormat)")
.font(.footnote)
.foregroundStyle(.white.opacity(0.78))
.lineLimit(1)
.truncationMode(.tail)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
)
)
}
private static func unsupportedCard(id: Int32, title: String, detail: String) -> NetworkCardModel {
NetworkCardModel(
id: id,
@ -278,9 +731,13 @@ final class NetworkViewModel: Sendable {
Text(title)
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(2)
.truncationMode(.tail)
Text(detail)
.font(.body)
.foregroundStyle(.white.opacity(0.9))
.lineLimit(4)
.truncationMode(.tail)
Spacer()
Text("Network #\(id)")
.font(.footnote.monospaced())
@ -358,6 +815,7 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
case wireGuard
case proxySubscription
case tor
case tailnet
@ -366,6 +824,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
var title: String {
switch self {
case .wireGuard: "WireGuard"
case .proxySubscription: "Proxy Subscription"
case .tor: "Tor"
case .tailnet: "Tailnet"
}
@ -374,6 +833,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
var subtitle: String {
switch self {
case .wireGuard: "Import a tunnel and optional account metadata."
case .proxySubscription: "Import Trojan and Shadowsocks subscription nodes."
case .tor: "Store Arti account and identity preferences."
case .tailnet: "Save Tailnet authority, identity defaults, and login material."
}
@ -382,6 +842,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
var accentColor: Color {
switch self {
case .wireGuard: .init("WireGuard")
case .proxySubscription: .teal
case .tor: .orange
case .tailnet: .mint
}
@ -390,6 +851,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
var actionTitle: String {
switch self {
case .wireGuard: "Add Network"
case .proxySubscription: "Import"
case .tor: "Save Account"
case .tailnet: "Save Account"
}
@ -397,7 +859,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
var availabilityNote: String? {
switch self {
case .wireGuard:
case .wireGuard, .proxySubscription:
nil
case .tor:
"Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet."