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
|
|
@ -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."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue