Refocus Tailnet flow on Tailscale

This commit is contained in:
Conrad Kramer 2026-04-05 02:10:49 -07:00
parent 3ebb0a8e61
commit 64103abbea
16 changed files with 1856 additions and 342 deletions

View file

@ -55,7 +55,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let statusBar = NSStatusBar.system
let statusItem = statusBar.statusItem(withLength: NSStatusItem.squareLength)
if let button = statusItem.button {
button.image = NSImage(systemSymbolName: "network.badge.shield.half.filled", accessibilityDescription: nil)
button.image = NSImage(systemSymbolName: "pipe.and.drop.fill", accessibilityDescription: nil)
}
return statusItem
}()

View file

@ -1,15 +1,31 @@
import XCTest
import UIKit
@MainActor
final class BurrowTailnetLoginUITests: XCTestCase {
private enum TailnetLoginMode: String, Decodable {
case tailscale
case discovered
}
private struct TestConfig: Decodable {
let email: String
let username: String
let password: String
let mode: TailnetLoginMode?
}
override func setUpWithError() throws {
continueAfterFailure = false
}
func testTailnetLoginThroughAuthentikWebSession() throws {
let email = try requiredEnvironment("BURROW_UI_TEST_EMAIL")
let username = ProcessInfo.processInfo.environment["BURROW_UI_TEST_USERNAME"] ?? email
let password = try requiredEnvironment("BURROW_UI_TEST_PASSWORD")
let config = try loadTestConfig()
let email = config.email
let username = config.username
let password = config.password
let mode = config.mode ?? .tailscale
let browserIdentity = mode == .tailscale ? email : username
let app = XCUIApplication()
app.launch()
@ -18,51 +34,90 @@ final class BurrowTailnetLoginUITests: XCTestCase {
XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear")
tailnetButton.tap()
configureTailnetIfNeeded(in: app, mode: mode)
let discoveryField = app.textFields["tailnet-discovery-email"]
XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear")
replaceText(in: discoveryField, with: email)
let findServerButton = app.buttons["tailnet-find-server"]
XCTAssertTrue(findServerButton.waitForExistence(timeout: 5), "Find Server button did not appear")
findServerButton.tap()
let discoveryCard = app.otherElements["tailnet-discovery-card"]
XCTAssertTrue(discoveryCard.waitForExistence(timeout: 20), "Tailnet discovery result did not appear")
let authorityField = app.textFields["tailnet-authority"]
XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear")
XCTAssertTrue(
waitForFieldValue(authorityField, containing: "ts.burrow.net", timeout: 20),
"Tailnet authority was not populated from discovery"
)
let probeButton = app.buttons["tailnet-check-connection"]
XCTAssertTrue(probeButton.waitForExistence(timeout: 5), "Check Connection button did not appear")
probeButton.tap()
let probeCard = app.otherElements["tailnet-authority-probe-card"]
XCTAssertTrue(probeCard.waitForExistence(timeout: 20), "Tailnet connection probe did not complete")
let serverCard = app.descendants(matching: .any)
.matching(identifier: "tailnet-server-card")
.firstMatch
XCTAssertTrue(serverCard.waitForExistence(timeout: 5), "Tailnet server card did not appear")
let signInButton = app.buttons["tailnet-start-sign-in"]
XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear")
signInButton.tap()
acceptAuthenticationPromptIfNeeded(in: app)
acceptAuthenticationPromptIfNeeded(in: app, timeout: 20)
let webSession = webAuthenticationSession()
XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear")
signIntoAuthentik(in: webSession, username: username, password: password)
signIntoAuthentik(in: webSession, username: browserIdentity, password: password)
app.activate()
XCTAssertTrue(
waitForButtonLabel(app.buttons["tailnet-start-sign-in"], equals: "Signed In", timeout: 60),
waitForTailnetSignedIn(in: app, timeout: 60),
"Tailnet sign-in never reached the running state"
)
}
private func acceptAuthenticationPromptIfNeeded(in app: XCUIApplication) {
private func configureTailnetIfNeeded(in app: XCUIApplication, mode: TailnetLoginMode) {
guard mode == .discovered else { return }
openTailnetMenu(in: app)
tapMenuButton(named: "Edit Custom Server", in: app)
openTailnetMenu(in: app)
tapMenuButton(named: "Show Advanced Settings", in: app)
let authorityField = app.textFields["tailnet-authority"]
XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear")
replaceText(in: authorityField, with: "")
}
private func openTailnetMenu(in app: XCUIApplication) {
let moreButton = app.buttons["More"]
XCTAssertTrue(moreButton.waitForExistence(timeout: 5), "Tailnet menu button did not appear")
moreButton.tap()
}
private func tapMenuButton(named title: String, in app: XCUIApplication) {
let menuButton = firstExistingElement(
from: [
app.buttons[title],
app.descendants(matching: .button)[title],
],
timeout: 5
)
XCTAssertTrue(menuButton.exists, "Menu action \(title) did not appear")
menuButton.tap()
}
private func acceptAuthenticationPromptIfNeeded(
in app: XCUIApplication,
timeout: TimeInterval
) {
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let deadline = Date().addingTimeInterval(timeout)
repeat {
let promptCandidates = [
springboard.buttons["Continue"],
springboard.buttons["Allow"],
app.buttons["Continue"],
app.buttons["Allow"],
]
for button in promptCandidates where button.exists && button.isHittable {
button.tap()
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
} while Date() < deadline
let promptCandidates = [
springboard.buttons["Continue"],
springboard.buttons["Allow"],
@ -70,7 +125,7 @@ final class BurrowTailnetLoginUITests: XCTestCase {
app.buttons["Allow"],
]
for button in promptCandidates where button.waitForExistence(timeout: 3) {
for button in promptCandidates where button.exists {
button.tap()
return
}
@ -88,6 +143,19 @@ final class BurrowTailnetLoginUITests: XCTestCase {
}
private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) {
followTailnetRedirectIfNeeded(in: webSession)
if !webSession.exists {
return
}
let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2)
if immediatePasswordField.exists {
replaceSecureText(in: immediatePasswordField, within: webSession, with: password)
submitAuthenticationForm(in: webSession, focusedField: immediatePasswordField)
return
}
let usernameField = firstExistingElement(
in: webSession,
queries: [
@ -99,21 +167,12 @@ final class BurrowTailnetLoginUITests: XCTestCase {
{ $0.webViews.textFields["Email or Username"] },
{ $0.descendants(matching: .textField).firstMatch },
],
timeout: 25
timeout: 12
)
XCTAssertTrue(usernameField.exists, "Authentik username field did not appear")
replaceText(in: usernameField, with: username)
let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2)
if immediatePasswordField.exists {
replaceSecureText(in: immediatePasswordField, with: password)
tapFirstExistingButton(
in: webSession,
titles: ["Continue", "Sign In", "Log in", "Login"],
timeout: 5
)
if !usernameField.exists {
return
}
replaceText(in: usernameField, with: username)
tapFirstExistingButton(
in: webSession,
@ -123,21 +182,31 @@ final class BurrowTailnetLoginUITests: XCTestCase {
let passwordField = firstExistingSecureField(in: webSession, timeout: 20)
XCTAssertTrue(passwordField.exists, "Authentik password field did not appear")
replaceSecureText(in: passwordField, with: password)
tapFirstExistingButton(
in: webSession,
titles: ["Continue", "Sign In", "Log in", "Login"],
timeout: 5
)
replaceSecureText(in: passwordField, within: webSession, with: password)
submitAuthenticationForm(in: webSession, focusedField: passwordField)
}
private func followTailnetRedirectIfNeeded(in webSession: XCUIApplication) {
let redirectCandidates = [
webSession.links["Found"],
webSession.webViews.links["Found"],
webSession.buttons["Found"],
webSession.webViews.buttons["Found"],
]
let redirectLink = firstExistingElement(from: redirectCandidates, timeout: 8)
if redirectLink.exists {
redirectLink.tap()
}
}
private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement {
let candidates = [
app.descendants(matching: .secureTextField).firstMatch,
app.secureTextFields["Password"],
app.secureTextFields["Password or Token"],
app.webViews.secureTextFields["Password"],
app.webViews.secureTextFields["Password or Token"],
app.descendants(matching: .secureTextField).firstMatch,
]
return firstExistingElement(from: candidates, timeout: timeout)
@ -160,11 +229,92 @@ final class BurrowTailnetLoginUITests: XCTestCase {
button.tap()
}
private func requiredEnvironment(_ key: String) throws -> String {
guard let value = ProcessInfo.processInfo.environment[key],
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
private func submitAuthenticationForm(in app: XCUIApplication, focusedField: XCUIElement) {
focus(focusedField)
focusedField.typeText("\n")
if waitForAny(
[
{ !focusedField.exists },
{ !app.staticTexts["Burrow Tailnet Authentication"].exists },
],
timeout: 1.5
) {
return
}
let keyboard = app.keyboards.firstMatch
if keyboard.waitForExistence(timeout: 2) {
let keyboardCandidates = [
"Return",
"return",
"Go",
"go",
"Continue",
"continue",
"Done",
"done",
"Join",
"join",
"Sign In",
"Log In",
"Login",
]
for title in keyboardCandidates {
let key = keyboard.buttons[title]
if key.exists && key.isHittable {
key.tap()
return
}
}
if let lastKey = keyboard.buttons.allElementsBoundByIndex.last,
lastKey.exists,
lastKey.isHittable
{
lastKey.tap()
return
}
}
tapFirstExistingButton(
in: app,
titles: ["Continue", "Sign In", "Log in", "Login"],
timeout: 5
)
}
private func loadTestConfig() throws -> TestConfig {
let environment = ProcessInfo.processInfo.environment
if let email = nonEmptyEnvironment("BURROW_UI_TEST_EMAIL"),
let password = nonEmptyEnvironment("BURROW_UI_TEST_PASSWORD")
{
return TestConfig(
email: email,
username: nonEmptyEnvironment("BURROW_UI_TEST_USERNAME") ?? email,
password: password,
mode: nonEmptyEnvironment("BURROW_UI_TEST_TAILNET_MODE")
.flatMap(TailnetLoginMode.init(rawValue:))
)
}
let configPath = environment["BURROW_UI_TEST_CONFIG_PATH"] ?? "/tmp/burrow-ui-test-config.json"
let configURL = URL(fileURLWithPath: configPath)
guard FileManager.default.fileExists(atPath: configURL.path) else {
throw XCTSkip(
"Missing UI test configuration. Expected env vars or config file at \(configURL.path)"
)
}
let data = try Data(contentsOf: configURL)
return try JSONDecoder().decode(TestConfig.self, from: data)
}
private func nonEmptyEnvironment(_ key: String) -> String? {
guard let value = ProcessInfo.processInfo.environment[key]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty
else {
throw XCTSkip("Missing required UI test environment variable \(key)")
return nil
}
return value
}
@ -189,6 +339,32 @@ final class BurrowTailnetLoginUITests: XCTestCase {
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
}
private func waitForTailnetSignedIn(in app: XCUIApplication, timeout: TimeInterval) -> Bool {
let button = app.buttons["tailnet-start-sign-in"]
let deadline = Date().addingTimeInterval(timeout)
repeat {
acceptAuthenticationPromptIfNeeded(in: app, timeout: 1)
if button.exists, button.label == "Signed In" {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
} while Date() < deadline
return button.exists && button.label == "Signed In"
}
private func waitForAny(_ conditions: [() -> Bool], timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
repeat {
if conditions.contains(where: { $0() }) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
} while Date() < deadline
return conditions.contains(where: { $0() })
}
private func firstExistingElement(
in app: XCUIApplication,
queries: [(XCUIApplication) -> XCUIElement],
@ -210,14 +386,27 @@ final class BurrowTailnetLoginUITests: XCTestCase {
}
private func replaceText(in element: XCUIElement, with value: String) {
element.tap()
focus(element)
clearText(in: element)
element.typeText(value)
}
private func replaceSecureText(in element: XCUIElement, with value: String) {
element.tap()
clearText(in: element)
private func replaceSecureText(in element: XCUIElement, within app: XCUIApplication, with value: String) {
UIPasteboard.general.string = value
focus(element)
for revealMenu in [
{ element.doubleTap() },
{ element.press(forDuration: 1.2) },
] {
revealMenu()
let pasteButton = firstExistingElement(from: pasteCandidates(in: app), timeout: 3)
if pasteButton.exists {
pasteButton.tap()
return
}
}
focus(element)
element.typeText(value)
}
@ -229,4 +418,22 @@ final class BurrowTailnetLoginUITests: XCTestCase {
let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
element.typeText(deleteSequence)
}
private func focus(_ element: XCUIElement) {
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
private func pasteCandidates(in app: XCUIApplication) -> [XCUIElement] {
let pasteLabels = ["Paste", "Incolla", "Paste from Clipboard"]
return pasteLabels.flatMap { label in
[
app.menuItems[label],
app.buttons[label],
app.webViews.buttons[label],
app.descendants(matching: .button).matching(NSPredicate(format: "label == %@", label)).firstMatch,
app.descendants(matching: .menuItem).matching(NSPredicate(format: "label == %@", label)).firstMatch,
]
}
}
}

View file

@ -36,13 +36,9 @@ public enum Constants {
private static func fallbackContainerURL() -> Result<URL, any Swift.Error> {
#if targetEnvironment(simulator)
Result {
let baseURL = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let url = baseURL
// The simulator app's Application Support path lives inside its sandbox container,
// so the host daemon cannot reach it. Use a shared host temp location instead.
let url = URL(filePath: "/tmp", directoryHint: .isDirectory)
.appending(component: bundleIdentifier, directoryHint: .isDirectory)
.appending(component: "SimulatorFallback", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)

View file

@ -108,6 +108,13 @@ public struct Burrow_TailnetLoginStatusResponse: Sendable {
public init() {}
}
public struct Burrow_TunnelPacket: Sendable {
public var payload = Data()
public var unknownFields = SwiftProtobuf.UnknownStorage()
public init() {}
}
extension Burrow_TailnetDiscoverRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = "burrow.TailnetDiscoverRequest"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
@ -387,6 +394,29 @@ extension Burrow_TailnetLoginStatusResponse: SwiftProtobuf.Message, SwiftProtobu
}
}
extension Burrow_TunnelPacket: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = "burrow.TunnelPacket"
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "payload")
]
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
switch fieldNumber {
case 1: try decoder.decodeSingularBytesField(value: &self.payload)
default: break
}
}
}
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if !self.payload.isEmpty {
try visitor.visitSingularBytesField(value: self.payload, fieldNumber: 1)
}
try unknownFields.traverse(visitor: &visitor)
}
}
public struct TailnetClient: Client, GRPCClient {
public let channel: GRPCChannel
public var defaultCallOptions: CallOptions
@ -456,3 +486,23 @@ public struct TailnetClient: Client, GRPCClient {
)
}
}
public struct TunnelPacketClient: Client, GRPCClient {
public let channel: GRPCChannel
public var defaultCallOptions: CallOptions
public init(channel: any GRPCChannel) {
self.channel = channel
self.defaultCallOptions = .init()
}
public func makeTunnelPacketsCall(
callOptions: CallOptions? = nil
) -> GRPCAsyncBidirectionalStreamingCall<Burrow_TunnelPacket, Burrow_TunnelPacket> {
self.makeAsyncBidirectionalStreamingCall(
path: "/burrow.Tunnel/TunnelPackets",
callOptions: callOptions ?? self.defaultCallOptions,
interceptors: []
)
}
}

View file

@ -215,6 +215,14 @@ public struct Burrow_TunnelConfigurationResponse: Sendable {
public var mtu: Int32 = 0
public var routes: [String] = []
public var dnsServers: [String] = []
public var searchDomains: [String] = []
public var includeDefaultRoute: Bool = false
public var unknownFields = SwiftProtobuf.UnknownStorage()
public init() {}
@ -532,6 +540,10 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
1: .same(proto: "addresses"),
2: .same(proto: "mtu"),
3: .same(proto: "routes"),
4: .standard(proto: "dns_servers"),
5: .standard(proto: "search_domains"),
6: .standard(proto: "include_default_route"),
]
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
@ -542,6 +554,10 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
switch fieldNumber {
case 1: try { try decoder.decodeRepeatedStringField(value: &self.addresses) }()
case 2: try { try decoder.decodeSingularInt32Field(value: &self.mtu) }()
case 3: try { try decoder.decodeRepeatedStringField(value: &self.routes) }()
case 4: try { try decoder.decodeRepeatedStringField(value: &self.dnsServers) }()
case 5: try { try decoder.decodeRepeatedStringField(value: &self.searchDomains) }()
case 6: try { try decoder.decodeSingularBoolField(value: &self.includeDefaultRoute) }()
default: break
}
}
@ -554,12 +570,28 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
if self.mtu != 0 {
try visitor.visitSingularInt32Field(value: self.mtu, fieldNumber: 2)
}
if !self.routes.isEmpty {
try visitor.visitRepeatedStringField(value: self.routes, fieldNumber: 3)
}
if !self.dnsServers.isEmpty {
try visitor.visitRepeatedStringField(value: self.dnsServers, fieldNumber: 4)
}
if !self.searchDomains.isEmpty {
try visitor.visitRepeatedStringField(value: self.searchDomains, fieldNumber: 5)
}
if self.includeDefaultRoute {
try visitor.visitSingularBoolField(value: self.includeDefaultRoute, fieldNumber: 6)
}
try unknownFields.traverse(visitor: &visitor)
}
public static func ==(lhs: Burrow_TunnelConfigurationResponse, rhs: Burrow_TunnelConfigurationResponse) -> Bool {
if lhs.addresses != rhs.addresses {return false}
if lhs.mtu != rhs.mtu {return false}
if lhs.routes != rhs.routes {return false}
if lhs.dnsServers != rhs.dnsServers {return false}
if lhs.searchDomains != rhs.searchDomains {return false}
if lhs.includeDefaultRoute != rhs.includeDefaultRoute {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}

View file

@ -1,6 +1,7 @@
import AsyncAlgorithms
import BurrowConfiguration
import BurrowCore
import GRPC
import libburrow
import NetworkExtension
import os
@ -19,6 +20,9 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
}
private let logger = Logger.logger(for: PacketTunnelProvider.self)
private var packetCall: GRPCAsyncBidirectionalStreamingCall<Burrow_TunnelPacket, Burrow_TunnelPacket>?
private var inboundPacketTask: Task<Void, Never>?
private var outboundPacketTask: Task<Void, Never>?
private var client: TunnelClient {
get throws { try _client.get() }
@ -45,16 +49,18 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
let completion = SendableCallbackBox(completionHandler)
Task {
do {
_ = try await client.tunnelStart(.init())
let configuration = try await Array(client.tunnelConfiguration(.init()).prefix(1)).first
guard let settings = configuration?.settings else {
throw Error.missingTunnelConfiguration
}
try await setTunnelNetworkSettings(settings)
_ = try await client.tunnelStart(.init())
try startPacketBridge()
logger.log("Started tunnel with network settings: \(settings)")
completion.callback(nil)
} catch {
logger.error("Failed to start tunnel: \(error)")
stopPacketBridge()
completion.callback(error)
}
}
@ -66,6 +72,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
) {
let completion = SendableCallbackBox(completionHandler)
Task {
stopPacketBridge()
do {
_ = try await client.tunnelStop(.init())
logger.log("Stopped client")
@ -77,20 +84,243 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
}
}
extension PacketTunnelProvider {
private func startPacketBridge() throws {
stopPacketBridge()
let packetClient = TunnelPacketClient.unix(socketURL: try Constants.socketURL)
let call = packetClient.makeTunnelPacketsCall()
self.packetCall = call
inboundPacketTask = Task { [weak self] in
guard let self else { return }
do {
for try await packet in call.responseStream {
let payload = packet.payload
self.packetFlow.writePackets(
[payload],
withProtocols: [Self.protocolNumber(for: payload)]
)
}
} catch {
guard !Task.isCancelled else { return }
self.logger.error("Tunnel packet receive loop failed: \(error)")
}
}
outboundPacketTask = Task { [weak self] in
guard let self else { return }
defer { call.requestStream.finish() }
do {
while !Task.isCancelled {
let packets = await self.readPacketsBatch()
for (payload, _) in packets {
var packet = Burrow_TunnelPacket()
packet.payload = payload
try await call.requestStream.send(packet)
}
}
} catch {
guard !Task.isCancelled else { return }
self.logger.error("Tunnel packet send loop failed: \(error)")
}
}
}
private func stopPacketBridge() {
inboundPacketTask?.cancel()
inboundPacketTask = nil
outboundPacketTask?.cancel()
outboundPacketTask = nil
packetCall?.cancel()
packetCall = nil
}
private func readPacketsBatch() async -> [(Data, NSNumber)] {
await withCheckedContinuation { continuation in
packetFlow.readPackets { packets, protocols in
continuation.resume(returning: Array(zip(packets, protocols)))
}
}
}
private static func protocolNumber(for payload: Data) -> NSNumber {
guard let version = payload.first.map({ $0 >> 4 }) else {
return NSNumber(value: AF_INET)
}
switch version {
case 6:
return NSNumber(value: AF_INET6)
default:
return NSNumber(value: AF_INET)
}
}
}
extension Burrow_TunnelConfigurationResponse {
fileprivate var settings: NEPacketTunnelNetworkSettings {
let ipv6Addresses = addresses.filter { IPv6Address($0) != nil }
let parsedAddresses = addresses.compactMap(ParsedTunnelAddress.init(rawValue:))
let ipv4Addresses = parsedAddresses.compactMap(\.ipv4Address)
let ipv6Addresses = parsedAddresses.compactMap(\.ipv6Address)
let parsedRoutes = routes.compactMap(ParsedTunnelRoute.init(rawValue:))
var ipv4Routes = parsedRoutes.compactMap(\.ipv4Route)
var ipv6Routes = parsedRoutes.compactMap(\.ipv6Route)
if includeDefaultRoute {
ipv4Routes.append(.default())
ipv6Routes.append(.default())
}
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "1.1.1.1")
settings.mtu = NSNumber(value: mtu)
settings.ipv4Settings = NEIPv4Settings(
addresses: addresses.filter { IPv4Address($0) != nil },
subnetMasks: ["255.255.255.0"]
)
settings.ipv6Settings = NEIPv6Settings(
addresses: ipv6Addresses,
networkPrefixLengths: ipv6Addresses.map { _ in 64 }
)
if !ipv4Addresses.isEmpty {
let ipv4Settings = NEIPv4Settings(
addresses: ipv4Addresses.map(\.address),
subnetMasks: ipv4Addresses.map(\.subnetMask)
)
if !ipv4Routes.isEmpty {
ipv4Settings.includedRoutes = ipv4Routes
}
settings.ipv4Settings = ipv4Settings
}
if !ipv6Addresses.isEmpty {
let ipv6Settings = NEIPv6Settings(
addresses: ipv6Addresses.map(\.address),
networkPrefixLengths: ipv6Addresses.map(\.prefixLength)
)
if !ipv6Routes.isEmpty {
ipv6Settings.includedRoutes = ipv6Routes
}
settings.ipv6Settings = ipv6Settings
}
if !dnsServers.isEmpty {
let dnsSettings = NEDNSSettings(servers: dnsServers)
if !searchDomains.isEmpty {
dnsSettings.matchDomains = searchDomains
}
settings.dnsSettings = dnsSettings
}
return settings
}
}
private struct ParsedTunnelAddress {
struct IPv4AddressSetting {
let address: String
let subnetMask: String
}
struct IPv6AddressSetting {
let address: String
let prefixLength: NSNumber
}
let ipv4Address: IPv4AddressSetting?
let ipv6Address: IPv6AddressSetting?
init?(rawValue: String) {
let components = rawValue.split(separator: "/", maxSplits: 1).map(String.init)
let address = components.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !address.isEmpty else {
return nil
}
let prefix = components.count == 2 ? Int(components[1]) : nil
if IPv4Address(address) != nil {
let prefixLength = prefix ?? 32
guard (0 ... 32).contains(prefixLength) else {
return nil
}
ipv4Address = IPv4AddressSetting(
address: address,
subnetMask: Self.ipv4SubnetMask(prefixLength: prefixLength)
)
ipv6Address = nil
return
}
if IPv6Address(address) != nil {
let prefixLength = prefix ?? 128
guard (0 ... 128).contains(prefixLength) else {
return nil
}
ipv4Address = nil
ipv6Address = IPv6AddressSetting(
address: address,
prefixLength: NSNumber(value: prefixLength)
)
return
}
return nil
}
private static func ipv4SubnetMask(prefixLength: Int) -> String {
guard prefixLength > 0 else {
return "0.0.0.0"
}
let mask = UInt32.max << (32 - prefixLength)
let octets = [
(mask >> 24) & 0xff,
(mask >> 16) & 0xff,
(mask >> 8) & 0xff,
mask & 0xff,
]
return octets.map(String.init).joined(separator: ".")
}
}
private struct ParsedTunnelRoute {
let ipv4Route: NEIPv4Route?
let ipv6Route: NEIPv6Route?
init?(rawValue: String) {
let components = rawValue.split(separator: "/", maxSplits: 1).map(String.init)
let address = components.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !address.isEmpty else {
return nil
}
let prefix = components.count == 2 ? Int(components[1]) : nil
if IPv4Address(address) != nil {
let prefixLength = prefix ?? 32
guard (0 ... 32).contains(prefixLength) else {
return nil
}
ipv4Route = NEIPv4Route(
destinationAddress: address,
subnetMask: Self.ipv4SubnetMask(prefixLength: prefixLength)
)
ipv6Route = nil
return
}
if IPv6Address(address) != nil {
let prefixLength = prefix ?? 128
guard (0 ... 128).contains(prefixLength) else {
return nil
}
ipv4Route = nil
ipv6Route = NEIPv6Route(
destinationAddress: address,
networkPrefixLength: NSNumber(value: prefixLength)
)
return
}
return nil
}
private static func ipv4SubnetMask(prefixLength: Int) -> String {
var mask = UInt32.max << (32 - prefixLength)
if prefixLength == 0 {
mask = 0
}
let octets = [
String((mask >> 24) & 0xff),
String((mask >> 16) & 0xff),
String((mask >> 8) & 0xff),
String(mask & 0xff),
]
return octets.joined(separator: ".")
}
}

View file

@ -83,7 +83,7 @@ public struct BurrowView: View {
ContentUnavailableView(
"No Accounts Yet",
systemImage: "person.crop.circle.badge.plus",
description: Text("Save a Tor account or sign in to a Tailnet provider to keep network identities ready on this device.")
description: Text("Save a Tor account or sign in to Tailnet to keep network identities ready on this device.")
)
.frame(maxWidth: .infinity, minHeight: 180)
} else {
@ -135,7 +135,7 @@ public struct BurrowView: View {
private func runAutomationIfNeeded() {
guard !didRunAutomation,
let automation = BurrowAutomationConfig.current,
automation.action == .tailnetLogin || automation.action == .headscaleProbe
automation.action == .tailnetLogin || automation.action == .tailnetProbe
else {
return
}
@ -340,8 +340,12 @@ private struct ConfigurationSheetView: View {
@State private var isStartingTailnetLogin = false
@State private var tailnetPresentedAuthURL: URL?
@State private var preserveTailnetLoginSession = false
@State private var usesCustomTailnetAuthority = false
@State private var showsAdvancedTailnetSettings = false
@State private var browserAuthenticator = TailnetBrowserAuthenticator()
@State private var tailnetLoginPollTask: Task<Void, Never>?
@State private var tailnetDiscoveryTask: Task<Void, Never>?
@State private var tailnetProbeTask: Task<Void, Never>?
@State private var didRunAutomation = false
init(
@ -364,14 +368,9 @@ private struct ConfigurationSheetView: View {
.listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0))
.listRowBackground(Color.clear)
Section("Identity") {
TextField("Title", text: $draft.title)
TextField("Account", text: $draft.accountName)
TextField("Identity", text: $draft.identityName)
if sheet == .tailnet {
TextField("Hostname", text: $draft.hostname)
.burrowLoginField()
.autocorrectionDisabled()
if showsIdentitySection {
Section("Identity") {
identityFields
}
}
@ -458,9 +457,15 @@ private struct ConfigurationSheetView: View {
}
.onChange(of: draft.authority) { _, _ in
resetAuthorityProbe()
if sheet == .tailnet, usesCustomTailnetAuthority {
scheduleTailnetAuthorityProbe()
}
}
.onChange(of: draft.discoveryEmail) { _, _ in
resetTailnetDiscoveryFeedback()
if sheet == .tailnet, !usesCustomTailnetAuthority {
scheduleTailnetDiscovery()
}
}
.onChange(of: draft.authMode) { _, newMode in
guard newMode != .web else { return }
@ -470,6 +475,8 @@ private struct ConfigurationSheetView: View {
}
.onDisappear {
tailnetLoginPollTask?.cancel()
tailnetDiscoveryTask?.cancel()
tailnetProbeTask?.cancel()
browserAuthenticator.cancel()
if !preserveTailnetLoginSession {
Task { @MainActor in
@ -479,6 +486,18 @@ private struct ConfigurationSheetView: View {
}
}
@ViewBuilder
private var identityFields: some View {
TextField("Title", text: $draft.title)
TextField("Account", text: $draft.accountName)
TextField("Identity", text: $draft.identityName)
if sheet == .tailnet {
TextField("Hostname", text: $draft.hostname)
.burrowLoginField()
.autocorrectionDisabled()
}
}
@ViewBuilder
private var tailnetSections: some View {
Section("Connection") {
@ -487,67 +506,39 @@ private struct ConfigurationSheetView: View {
.burrowLoginField()
.autocorrectionDisabled()
.accessibilityIdentifier("tailnet-discovery-email")
Button {
discoverTailnetAuthority()
} label: {
Label {
Text(isDiscoveringTailnet ? "Finding Server" : "Find Server")
} icon: {
Image(systemName: isDiscoveringTailnet ? "hourglass" : "at.circle")
.submitLabel(.continue)
.onSubmit {
if !usesCustomTailnetAuthority {
scheduleTailnetDiscovery(immediate: true)
}
}
.buttonStyle(.borderless)
.disabled(isDiscoveringTailnet || normalizedOptional(draft.discoveryEmail) == nil)
.accessibilityIdentifier("tailnet-find-server")
if let discoveryStatus {
tailnetDiscoveryCard(status: discoveryStatus, failure: nil)
} else if let discoveryError {
tailnetDiscoveryCard(status: nil, failure: discoveryError)
}
tailnetServerCard
TextField("Authority URL", text: $draft.authority)
.burrowLoginField()
.autocorrectionDisabled()
.accessibilityIdentifier("tailnet-authority")
Text("Use the managed Tailnet authority or enter a custom Tailnet control server.")
.font(.footnote)
.foregroundStyle(.secondary)
Button {
probeTailnetAuthority()
} label: {
Label {
Text(isProbingAuthority ? "Checking Connection" : "Check Connection")
} icon: {
Image(systemName: isProbingAuthority ? "hourglass" : "bolt.horizontal.circle")
if showsAdvancedTailnetSettings {
if usesCustomTailnetAuthority {
TextField("Server URL", text: $draft.authority)
.burrowLoginField()
.autocorrectionDisabled()
.accessibilityIdentifier("tailnet-authority")
} else {
TextField("Tailnet", text: $draft.tailnet)
.burrowLoginField()
.autocorrectionDisabled()
.accessibilityIdentifier("tailnet-name")
}
}
.buttonStyle(.borderless)
.disabled(isProbingAuthority || normalizedOptional(draft.authority) == nil)
.accessibilityIdentifier("tailnet-check-connection")
if let authorityProbeStatus {
tailnetAuthorityProbeCard(status: authorityProbeStatus, failure: nil)
} else if let authorityProbeError {
tailnetAuthorityProbeCard(status: nil, failure: authorityProbeError)
}
TextField("Tailnet", text: $draft.tailnet)
.burrowLoginField()
.autocorrectionDisabled()
.accessibilityIdentifier("tailnet-name")
}
Section("Authentication") {
Picker("Authentication", selection: $draft.authMode) {
ForEach(availableTailnetAuthModes) { mode in
Text(mode.title).tag(mode)
if showsAdvancedTailnetSettings {
Picker("Authentication", selection: $draft.authMode) {
ForEach(availableTailnetAuthModes) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.menu)
}
.pickerStyle(.menu)
if draft.authMode == .web {
Button {
@ -560,7 +551,7 @@ private struct ConfigurationSheetView: View {
}
}
.buttonStyle(.borderless)
.disabled(isStartingTailnetLogin || normalizedOptional(draft.authority) == nil)
.disabled(isStartingTailnetLogin || tailnetLoginActionDisabled)
.accessibilityIdentifier("tailnet-start-sign-in")
if let tailnetLoginStatus {
@ -616,32 +607,14 @@ private struct ConfigurationSheetView: View {
}
if sheet == .tailnet {
if let authorityProbeStatus {
Text(authorityProbeStatus.summary)
labeledValue("Server", tailnetServerDisplayLabel)
if let connectionSummary = tailnetConnectionSummary {
Text(connectionSummary)
.font(.footnote.weight(.medium))
.foregroundStyle(.primary)
if let detail = authorityProbeStatus.detail {
Text(detail)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(3)
}
} else if let authorityProbeError {
Text("Connection failed")
.font(.footnote.weight(.medium))
.foregroundStyle(.red)
Text(authorityProbeError)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(3)
.foregroundStyle(tailnetConnectionSummaryColor)
}
}
if sheet == .tailnet {
HStack(spacing: 8) {
summaryBadge(isManagedTailnetAuthority ? "Managed" : "Custom")
summaryBadge(draft.authMode.title)
if tailnetLoginStatus?.running == true {
if tailnetLoginStatus?.running == true {
HStack(spacing: 8) {
summaryBadge("Signed In")
}
}
@ -654,6 +627,44 @@ private struct ConfigurationSheetView: View {
)
}
private var tailnetServerCard: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(usesCustomTailnetAuthority ? "Custom Server" : "Server")
.font(.subheadline.weight(.medium))
Text(tailnetServerDisplayLabel)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
Spacer()
if isDiscoveringTailnet || isProbingAuthority {
ProgressView()
.controlSize(.small)
} else if let summary = tailnetConnectionSummary {
Text(summary)
.font(.caption.weight(.medium))
.foregroundStyle(tailnetConnectionSummaryColor)
}
}
if let detail = tailnetServerDetail {
Text(detail)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.thinMaterial)
)
.accessibilityIdentifier("tailnet-server-card")
}
private func tailnetAuthorityProbeCard(
status: TailnetAuthorityProbeStatus?,
failure: String?
@ -827,11 +838,15 @@ private struct ConfigurationSheetView: View {
}
case .tailnet:
Button("Use Tailscale Managed Server") {
applyTailnetDefaults(for: .tailscale)
Button(usesCustomTailnetAuthority ? "Use Automatic Server" : "Edit Custom Server") {
toggleTailnetAuthorityMode()
}
if availableTailnetAuthModes.count > 1 {
Button(showsAdvancedTailnetSettings ? "Hide Advanced Settings" : "Show Advanced Settings") {
showsAdvancedTailnetSettings.toggle()
}
if showsAdvancedTailnetSettings, availableTailnetAuthModes.count > 1 {
Menu("Authentication") {
ForEach(availableTailnetAuthModes) { mode in
Button(mode.title) {
@ -844,9 +859,10 @@ private struct ConfigurationSheetView: View {
}
}
Button("Clear Discovery Result") {
resetTailnetDiscoveryFeedback()
Button("Refresh Server Lookup") {
scheduleTailnetDiscovery(immediate: true)
}
.disabled(usesCustomTailnetAuthority || normalizedOptional(draft.discoveryEmail) == nil)
}
}
@ -885,12 +901,21 @@ private struct ConfigurationSheetView: View {
private var showsBottomActionButton: Bool {
#if os(iOS)
true
return true
#else
false
return false
#endif
}
private var showsIdentitySection: Bool {
switch sheet {
case .wireGuard, .tor:
return true
case .tailnet:
return showsAdvancedTailnetSettings
}
}
private var wireGuardEditorHeight: CGFloat {
#if os(iOS)
180
@ -910,6 +935,18 @@ private struct ConfigurationSheetView: View {
}
}
private var tailnetLoginActionDisabled: Bool {
switch sheet {
case .tailnet:
if usesCustomTailnetAuthority {
return normalizedOptional(draft.authority) == nil
}
return false
case .wireGuard, .tor:
return true
}
}
private var submissionDisabled: Bool {
switch sheet {
case .wireGuard:
@ -933,6 +970,50 @@ private struct ConfigurationSheetView: View {
}
}
private var tailnetServerDisplayLabel: String {
if usesCustomTailnetAuthority {
return normalizedOptional(draft.authority)
?? "Enter a custom Tailnet server"
}
return TailnetProvider.tailscale.defaultAuthority ?? "Tailscale managed"
}
private var tailnetServerDetail: String? {
if usesCustomTailnetAuthority {
if let discovery = discoveryStatus {
return "Discovered from \(discovery.domain)."
}
if let discoveryError {
return discoveryError
}
return "Use a custom Tailnet authority when your domain does not advertise one."
}
return "Continue with Tailscale, or open advanced settings to use a custom server."
}
private var tailnetConnectionSummary: String? {
if isDiscoveringTailnet {
return "Finding server"
}
if isProbingAuthority {
return "Checking"
}
if let authorityProbeStatus {
return authorityProbeStatus.summary
}
if authorityProbeError != nil {
return "Unavailable"
}
return nil
}
private var tailnetConnectionSummaryColor: Color {
if authorityProbeError != nil {
return .red
}
return .secondary
}
private func submit() {
isSubmitting = true
errorMessage = nil
@ -1021,7 +1102,7 @@ private struct ConfigurationSheetView: View {
guard !didRunAutomation,
sheet == .tailnet,
let automation = BurrowAutomationConfig.current,
automation.action == .tailnetLogin || automation.action == .headscaleProbe
automation.action == .tailnetLogin || automation.action == .tailnetProbe
else {
return
}
@ -1037,7 +1118,9 @@ private struct ConfigurationSheetView: View {
case .tailnetLogin:
applyTailnetDefaults(for: .tailscale)
startTailnetLogin()
case .headscaleProbe:
case .tailnetProbe:
usesCustomTailnetAuthority = true
showsAdvancedTailnetSettings = true
draft.authority = automation.authority ?? TailnetProvider.headscale.defaultAuthority ?? draft.authority
probeTailnetAuthority()
}
@ -1060,10 +1143,13 @@ private struct ConfigurationSheetView: View {
)
var noteParts: [String] = [
isManagedTailnetAuthority ? "Managed Tailnet" : "Custom Tailnet",
"Auth: \(draft.authMode.title)",
"Server: \(hostnameFallback(from: payload.authority ?? "", fallback: "tailnet"))",
]
if showsAdvancedTailnetSettings || draft.authMode != .web {
noteParts.append("Auth: \(draft.authMode.title)")
}
if draft.authMode == .web, tailnetLoginStatus?.running == true {
noteParts.append("Browser sign-in complete")
}
@ -1119,6 +1205,7 @@ private struct ConfigurationSheetView: View {
private func applyTailnetDefaults(for provider: TailnetProvider) {
resetTailnetDiscoveryFeedback()
usesCustomTailnetAuthority = provider != .tailscale
draft.authority = provider.defaultAuthority ?? ""
if !availableTailnetAuthModes.contains(draft.authMode) {
draft.authMode = .web
@ -1126,12 +1213,6 @@ private struct ConfigurationSheetView: View {
}
private func startTailnetLogin() {
guard let authority = normalizedOptional(draft.authority) else {
tailnetLoginStatus = nil
tailnetLoginError = "Enter a server URL first."
return
}
isStartingTailnetLogin = true
tailnetLoginError = nil
preserveTailnetLoginSession = false
@ -1139,6 +1220,7 @@ private struct ConfigurationSheetView: View {
Task { @MainActor in
defer { isStartingTailnetLogin = false }
do {
let authority = try await resolveTailnetAuthorityForLogin()
let status = try await networkViewModel.startTailnetLogin(
accountName: normalized(draft.accountName, fallback: "default"),
identityName: normalized(draft.identityName, fallback: "apple"),
@ -1176,12 +1258,14 @@ private struct ConfigurationSheetView: View {
}
private func resetAuthorityProbe() {
tailnetProbeTask?.cancel()
authorityProbeStatus = nil
authorityProbeError = nil
tailnetLoginError = nil
}
private func resetTailnetDiscoveryFeedback() {
tailnetDiscoveryTask?.cancel()
discoveryStatus = nil
discoveryError = nil
}
@ -1210,6 +1294,83 @@ private struct ConfigurationSheetView: View {
}
}
private func scheduleTailnetDiscovery(immediate: Bool = false) {
guard sheet == .tailnet else { return }
tailnetDiscoveryTask?.cancel()
guard !usesCustomTailnetAuthority else {
discoveryStatus = nil
discoveryError = nil
return
}
guard normalizedOptional(draft.discoveryEmail) != nil else {
discoveryStatus = nil
discoveryError = nil
draft.authority = TailnetProvider.tailscale.defaultAuthority ?? ""
return
}
tailnetDiscoveryTask = Task { @MainActor in
if !immediate {
try? await Task.sleep(for: .milliseconds(450))
}
guard !Task.isCancelled else { return }
discoverTailnetAuthority()
}
}
private func scheduleTailnetAuthorityProbe() {
guard sheet == .tailnet else { return }
tailnetProbeTask?.cancel()
guard normalizedOptional(draft.authority) != nil else { return }
tailnetProbeTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
probeTailnetAuthority()
}
}
private func toggleTailnetAuthorityMode() {
let discoveredAuthority = discoveryStatus?.authority
usesCustomTailnetAuthority.toggle()
resetTailnetDiscoveryFeedback()
resetAuthorityProbe()
if usesCustomTailnetAuthority {
draft.authority = discoveredAuthority ?? draft.authority
} else {
draft.authority = TailnetProvider.tailscale.defaultAuthority ?? ""
scheduleTailnetDiscovery(immediate: normalizedOptional(draft.discoveryEmail) != nil)
}
}
private func resolveTailnetAuthorityForLogin() async throws -> String {
if !usesCustomTailnetAuthority {
let authority = TailnetProvider.tailscale.defaultAuthority ?? ""
draft.authority = authority
scheduleTailnetAuthorityProbe()
return authority
}
if let authority = normalizedOptional(draft.authority) {
return authority
}
if let email = normalizedOptional(draft.discoveryEmail) {
let discovery = try await networkViewModel.discoverTailnet(email: email)
discoveryStatus = discovery
discoveryError = nil
draft.authority = discovery.authority
scheduleTailnetAuthorityProbe()
return discovery.authority
}
throw NSError(domain: "BurrowTailnet", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Enter an email address or a custom server URL first."
])
}
private func beginTailnetLoginPolling(sessionID: String) {
tailnetLoginPollTask?.cancel()
tailnetLoginPollTask = Task { @MainActor in
@ -1336,13 +1497,16 @@ private struct ConfigurationSheetView: View {
if tailnetLoginSessionID != nil {
return "Resume Sign-In"
}
return "Start Sign-In"
return "Continue with Tailscale"
}
private var tailnetAuthenticationFootnote: String {
switch draft.authMode {
case .web:
return "Burrow asks the daemon to start a Tailnet browser sign-in session, then closes it locally once the daemon reports the device is running."
if usesCustomTailnetAuthority {
return "Burrow signs in through the daemon using your custom Tailnet server."
}
return "Burrow signs in through the daemon using Tailscale's managed browser flow."
case .none:
return "Save the authority only. Useful when the control plane handles authentication elsewhere."
case .password, .preauthKey:
@ -1357,10 +1521,6 @@ private struct ConfigurationSheetView: View {
)
}
private var isManagedTailnetAuthority: Bool {
TailnetProvider.isManagedTailscaleAuthority(normalizedOptional(draft.authority))
}
@ViewBuilder
private func labeledValue(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
@ -1383,12 +1543,7 @@ private struct AccountRowView: View {
VStack(alignment: .leading, spacing: 4) {
Text(account.title)
.font(.headline)
HStack(spacing: 8) {
Text(account.kind.title)
if let provider = account.provider {
Text(provider.title)
}
}
Text(account.kind.title)
.font(.subheadline)
.foregroundStyle(account.kind.accentColor)
}
@ -1470,6 +1625,12 @@ private extension View {
@MainActor
private final class TailnetBrowserAuthenticator: NSObject {
private var session: ASWebAuthenticationSession?
private static var prefersEphemeralSessionForCurrentProcess: Bool {
let rawValue = ProcessInfo.processInfo.environment["BURROW_UI_TEST_EPHEMERAL_AUTH"]?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return rawValue == "1" || rawValue == "true" || rawValue == "yes"
}
func start(url: URL, onDismiss: @escaping @Sendable () -> Void) {
cancel()
@ -1477,7 +1638,7 @@ private final class TailnetBrowserAuthenticator: NSObject {
onDismiss()
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false
session.prefersEphemeralWebBrowserSession = Self.prefersEphemeralSessionForCurrentProcess
self.session = session
_ = session.start()
}
@ -1516,7 +1677,7 @@ private final class TailnetBrowserAuthenticator {
private struct BurrowAutomationConfig {
enum Action: String {
case tailnetLogin = "tailnet-login"
case headscaleProbe = "headscale-probe"
case tailnetProbe = "tailnet-probe"
}
let action: Action

View file

@ -303,7 +303,7 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
var title: String {
switch self {
case .tailscale: "Tailscale"
case .headscale: "Headscale"
case .headscale: "Custom Tailnet"
case .burrow: "Burrow"
}
}
@ -375,7 +375,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
switch self {
case .wireGuard: "Import a tunnel and optional account metadata."
case .tor: "Store Arti account and identity preferences."
case .tailnet: "Save Tailnet authority, identity, and login material."
case .tailnet: "Save Tailnet authority, identity defaults, and login material."
}
}
@ -402,7 +402,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
case .tor:
"Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet."
case .tailnet:
"Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can be stored in the daemon."
"Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can already be stored in the daemon."
}
}
}