Add Tailnet accounts and Tailscale login flow
This commit is contained in:
parent
f9062eae33
commit
7670a75840
29 changed files with 3538 additions and 775 deletions
|
|
@ -6,6 +6,8 @@ import SwiftUI
|
|||
@main
|
||||
@MainActor
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var windowController: NSWindowController?
|
||||
|
||||
private let quitItem: NSMenuItem = {
|
||||
let quitItem = NSMenuItem(
|
||||
title: "Quit Burrow",
|
||||
|
|
@ -17,6 +19,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
return quitItem
|
||||
}()
|
||||
|
||||
private lazy var openItem: NSMenuItem = {
|
||||
let item = NSMenuItem(
|
||||
title: "Open Burrow",
|
||||
action: #selector(openWindow),
|
||||
keyEquivalent: "o"
|
||||
)
|
||||
item.target = self
|
||||
item.keyEquivalentModifierMask = .command
|
||||
return item
|
||||
}()
|
||||
|
||||
private let toggleItem: NSMenuItem = {
|
||||
let toggleView = NSHostingView(rootView: MenuItemToggleView())
|
||||
toggleView.frame.size = CGSize(width: 300, height: 32)
|
||||
|
|
@ -31,6 +44,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
let menu = NSMenu()
|
||||
menu.items = [
|
||||
toggleItem,
|
||||
openItem,
|
||||
.separator(),
|
||||
quitItem
|
||||
]
|
||||
|
|
@ -49,5 +63,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
statusItem.menu = menu
|
||||
}
|
||||
|
||||
@objc
|
||||
private func openWindow() {
|
||||
if let window = windowController?.window {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApplication.shared.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
|
||||
let contentView = BurrowView()
|
||||
let hostingController = NSHostingController(rootView: contentView)
|
||||
let window = NSWindow(contentViewController: hostingController)
|
||||
window.title = "Burrow"
|
||||
window.setContentSize(NSSize(width: 820, height: 720))
|
||||
window.styleMask.insert([.titled, .closable, .miniaturizable, .resizable])
|
||||
window.center()
|
||||
|
||||
let controller = NSWindowController(window: window)
|
||||
controller.shouldCascadeWindows = true
|
||||
controller.showWindow(nil)
|
||||
windowController = controller
|
||||
NSApplication.shared.activate(ignoringOtherApps: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@
|
|||
D0D4E53A2C8D996F007F820A /* BurrowCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
D0D4E56B2C8D9C2F007F820A /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49A2C8D921A007F820A /* Logging.swift */; };
|
||||
D0D4E5702C8D9C62007F820A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; };
|
||||
D0D4E5712C8D9C6F007F820A /* HackClub.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49D2C8D921A007F820A /* HackClub.swift */; };
|
||||
D0D4E5722C8D9C6F007F820A /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49E2C8D921A007F820A /* Network.swift */; };
|
||||
D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E49F2C8D921A007F820A /* WireGuard.swift */; };
|
||||
D0D4E5742C8D9C6F007F820A /* BurrowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A22C8D921A007F820A /* BurrowView.swift */; };
|
||||
|
|
@ -33,7 +32,6 @@
|
|||
D0D4E5782C8D9C6F007F820A /* NetworkExtension+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */; };
|
||||
D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */; };
|
||||
D0D4E57A2C8D9C6F007F820A /* NetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A82C8D921A007F820A /* NetworkView.swift */; };
|
||||
D0D4E57B2C8D9C6F007F820A /* OAuth2.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4A92C8D921A007F820A /* OAuth2.swift */; };
|
||||
D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AA2C8D921A007F820A /* Tunnel.swift */; };
|
||||
D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */; };
|
||||
D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */; };
|
||||
|
|
@ -160,7 +158,6 @@
|
|||
D0D4E4972C8D921A007F820A /* swift-protobuf-config.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "swift-protobuf-config.json"; sourceTree = "<group>"; };
|
||||
D0D4E4992C8D921A007F820A /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = "<group>"; };
|
||||
D0D4E49A2C8D921A007F820A /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = "<group>"; };
|
||||
D0D4E49D2C8D921A007F820A /* HackClub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HackClub.swift; sourceTree = "<group>"; };
|
||||
D0D4E49E2C8D921A007F820A /* Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = "<group>"; };
|
||||
D0D4E49F2C8D921A007F820A /* WireGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireGuard.swift; sourceTree = "<group>"; };
|
||||
D0D4E4A12C8D921A007F820A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
|
|
@ -171,7 +168,6 @@
|
|||
D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NetworkExtension+Async.swift"; sourceTree = "<group>"; };
|
||||
D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkExtensionTunnel.swift; sourceTree = "<group>"; };
|
||||
D0D4E4A82C8D921A007F820A /* NetworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkView.swift; sourceTree = "<group>"; };
|
||||
D0D4E4A92C8D921A007F820A /* OAuth2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuth2.swift; sourceTree = "<group>"; };
|
||||
D0D4E4AA2C8D921A007F820A /* Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tunnel.swift; sourceTree = "<group>"; };
|
||||
D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelButton.swift; sourceTree = "<group>"; };
|
||||
D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelStatusView.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -340,7 +336,6 @@
|
|||
D0D4E4A02C8D921A007F820A /* Networks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D0D4E49D2C8D921A007F820A /* HackClub.swift */,
|
||||
D0D4E49E2C8D921A007F820A /* Network.swift */,
|
||||
D0D4E49F2C8D921A007F820A /* WireGuard.swift */,
|
||||
);
|
||||
|
|
@ -358,7 +353,6 @@
|
|||
D0D4E4A62C8D921A007F820A /* NetworkExtension+Async.swift */,
|
||||
D0D4E4A72C8D921A007F820A /* NetworkExtensionTunnel.swift */,
|
||||
D0D4E4A82C8D921A007F820A /* NetworkView.swift */,
|
||||
D0D4E4A92C8D921A007F820A /* OAuth2.swift */,
|
||||
D0D4E4AA2C8D921A007F820A /* Tunnel.swift */,
|
||||
D0D4E4AB2C8D921A007F820A /* TunnelButton.swift */,
|
||||
D0D4E4AC2C8D921A007F820A /* TunnelStatusView.swift */,
|
||||
|
|
@ -634,7 +628,6 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D0D4E5712C8D9C6F007F820A /* HackClub.swift in Sources */,
|
||||
D0D4E5722C8D9C6F007F820A /* Network.swift in Sources */,
|
||||
D0D4E5732C8D9C6F007F820A /* WireGuard.swift in Sources */,
|
||||
D0D4E5742C8D9C6F007F820A /* BurrowView.swift in Sources */,
|
||||
|
|
@ -644,7 +637,6 @@
|
|||
D0D4E5782C8D9C6F007F820A /* NetworkExtension+Async.swift in Sources */,
|
||||
D0D4E5792C8D9C6F007F820A /* NetworkExtensionTunnel.swift in Sources */,
|
||||
D0D4E57A2C8D9C6F007F820A /* NetworkView.swift in Sources */,
|
||||
D0D4E57B2C8D9C6F007F820A /* OAuth2.swift in Sources */,
|
||||
D0D4E57C2C8D9C6F007F820A /* Tunnel.swift in Sources */,
|
||||
D0D4E57D2C8D9C6F007F820A /* TunnelButton.swift in Sources */,
|
||||
D0D4E57E2C8D9C6F007F820A /* TunnelStatusView.swift in Sources */,
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x50",
|
||||
"green" : "0x37",
|
||||
"red" : "0xEC"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "flag-standalone-wtransparent.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
|
@ -1,67 +1,786 @@
|
|||
import AuthenticationServices
|
||||
import BurrowConfiguration
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
#if !os(macOS)
|
||||
public struct BurrowView: View {
|
||||
@Environment(\.webAuthenticationSession)
|
||||
private var webAuthenticationSession
|
||||
@State private var networkViewModel: NetworkViewModel
|
||||
@State private var accountStore = NetworkAccountStore()
|
||||
@State private var activeSheet: ConfigurationSheet?
|
||||
@State private var didRunAutomation = false
|
||||
|
||||
public var body: some View {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
HStack {
|
||||
Text("Networks")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
Spacer()
|
||||
Menu {
|
||||
Button("Hack Club", action: addHackClubNetwork)
|
||||
Button("WireGuard", action: addWireGuardNetwork)
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title)
|
||||
.accessibilityLabel("Add")
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Burrow")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
Text("Networks and accounts")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Menu {
|
||||
Button("Add WireGuard Network") {
|
||||
activeSheet = .wireGuard
|
||||
}
|
||||
Button("Save Tor Account") {
|
||||
activeSheet = .tor
|
||||
}
|
||||
Button("Add Tailnet Account") {
|
||||
activeSheet = .tailnet
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title)
|
||||
.accessibilityLabel("Add")
|
||||
}
|
||||
}
|
||||
.padding(.top)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader(
|
||||
title: "Networks",
|
||||
detail: "Stored daemon networks and their active account selectors"
|
||||
)
|
||||
if let connectionError = networkViewModel.connectionError {
|
||||
Text(connectionError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
NetworkCarouselView(networks: networkViewModel.cards)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader(
|
||||
title: "Accounts",
|
||||
detail: "Per-network identities and sign-in state"
|
||||
)
|
||||
if accountStore.accounts.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Accounts Yet",
|
||||
systemImage: "person.crop.circle.badge.plus",
|
||||
description: Text("Save a Tor account or sign in to a Tailnet provider to keep network identities ready on this device.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, minHeight: 180)
|
||||
} else {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(accountStore.accounts) { account in
|
||||
AccountRowView(
|
||||
account: account,
|
||||
hasSecret: accountStore.hasStoredSecret(for: account)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
sectionHeader(
|
||||
title: "Tunnel",
|
||||
detail: "Current system extension state"
|
||||
)
|
||||
TunnelStatusView()
|
||||
TunnelButton()
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
.padding(.top)
|
||||
NetworkCarouselView()
|
||||
Spacer()
|
||||
TunnelStatusView()
|
||||
TunnelButton()
|
||||
.padding(.bottom)
|
||||
.padding()
|
||||
}
|
||||
.padding()
|
||||
.handleOAuth2Callback()
|
||||
}
|
||||
.sheet(item: $activeSheet) { sheet in
|
||||
ConfigurationSheetView(
|
||||
sheet: sheet,
|
||||
networkViewModel: networkViewModel,
|
||||
accountStore: accountStore
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
runAutomationIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
public init() {
|
||||
_networkViewModel = State(
|
||||
initialValue: NetworkViewModel(
|
||||
socketURLResult: Result { try Constants.socketURL }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func addHackClubNetwork() {
|
||||
Task {
|
||||
try await authenticateWithSlack()
|
||||
private func runAutomationIfNeeded() {
|
||||
guard !didRunAutomation, BurrowAutomationConfig.current?.action == .tailnetLogin else {
|
||||
return
|
||||
}
|
||||
didRunAutomation = true
|
||||
activeSheet = .tailnet
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionHeader(title: String, detail: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.title2.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ConfigurationSheet: String, Identifiable {
|
||||
case wireGuard
|
||||
case tor
|
||||
case tailnet
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var kind: AccountNetworkKind {
|
||||
switch self {
|
||||
case .wireGuard: .wireGuard
|
||||
case .tor: .tor
|
||||
case .tailnet: .headscale
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountDraft {
|
||||
var title = ""
|
||||
var accountName = ""
|
||||
var identityName = ""
|
||||
var wireGuardConfig = ""
|
||||
|
||||
var tailnetProvider: TailnetProvider = .tailscale
|
||||
var authority = ""
|
||||
var tailnet = ""
|
||||
var hostname = ProcessInfo.processInfo.hostName
|
||||
var username = ""
|
||||
var secret = ""
|
||||
var authMode: AccountAuthMode = .web
|
||||
|
||||
var torAddresses = "100.64.0.2/32"
|
||||
var torDNS = "1.1.1.1, 1.0.0.1"
|
||||
var torMTU = "1400"
|
||||
var torListen = "127.0.0.1:9040"
|
||||
|
||||
init(sheet: ConfigurationSheet) {
|
||||
switch sheet {
|
||||
case .wireGuard:
|
||||
break
|
||||
case .tor:
|
||||
title = "Default Tor"
|
||||
accountName = "default"
|
||||
identityName = "apple"
|
||||
case .tailnet:
|
||||
title = "Tailnet"
|
||||
accountName = "default"
|
||||
identityName = "apple"
|
||||
authority = TailnetProvider.tailscale.defaultAuthority ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConfigurationSheetView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
let sheet: ConfigurationSheet
|
||||
let networkViewModel: NetworkViewModel
|
||||
let accountStore: NetworkAccountStore
|
||||
|
||||
@State private var draft: AccountDraft
|
||||
@State private var isSubmitting = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var loginSessionID: String?
|
||||
@State private var loginStatus: TailnetLoginStatus?
|
||||
@State private var pollingTask: Task<Void, Never>?
|
||||
@State private var didRunAutomation = false
|
||||
|
||||
init(
|
||||
sheet: ConfigurationSheet,
|
||||
networkViewModel: NetworkViewModel,
|
||||
accountStore: NetworkAccountStore
|
||||
) {
|
||||
self.sheet = sheet
|
||||
self.networkViewModel = networkViewModel
|
||||
self.accountStore = accountStore
|
||||
_draft = State(initialValue: AccountDraft(sheet: sheet))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Text(sheet.kind.subtitle)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
if let availabilityNote = sheet.kind.availabilityNote {
|
||||
Text(availabilityNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
switch sheet {
|
||||
case .wireGuard:
|
||||
Section("WireGuard Configuration") {
|
||||
TextEditor(text: $draft.wireGuardConfig)
|
||||
.font(.body.monospaced())
|
||||
.frame(minHeight: 220)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Section {
|
||||
Text(errorMessage)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(sheet.kind.title)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(confirmationTitle) {
|
||||
submit()
|
||||
}
|
||||
.disabled(isSubmitting || submissionDisabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 520, minHeight: 620)
|
||||
.onAppear {
|
||||
runAutomationIfNeeded()
|
||||
}
|
||||
.onDisappear {
|
||||
pollingTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func addWireGuardNetwork() {
|
||||
@ViewBuilder
|
||||
private var tailnetSections: some View {
|
||||
Section("Tailnet Provider") {
|
||||
Picker("Provider", selection: $draft.tailnetProvider) {
|
||||
ForEach(TailnetProvider.allCases) { provider in
|
||||
Text(provider.title).tag(provider)
|
||||
}
|
||||
}
|
||||
Text(draft.tailnetProvider.subtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Tailnet") {
|
||||
if draft.tailnetProvider.requiresControlURL {
|
||||
TextField("Server URL", text: $draft.authority)
|
||||
.burrowLoginField()
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
TextField("Tailnet", text: $draft.tailnet)
|
||||
.burrowLoginField()
|
||||
.autocorrectionDisabled()
|
||||
|
||||
if draft.tailnetProvider.usesWebLogin {
|
||||
Text("Sign-in is brokered by `burrow auth-server` on the host and opens the real Tailscale login page in a browser.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
TextField("Username", text: $draft.username)
|
||||
.burrowLoginField()
|
||||
.autocorrectionDisabled()
|
||||
Picker("Authentication", selection: $draft.authMode) {
|
||||
ForEach([AccountAuthMode.none, .password, .preauthKey]) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
if draft.authMode != .none {
|
||||
SecureField(
|
||||
draft.authMode == .password ? "Password" : "Preauth Key",
|
||||
text: $draft.secret
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if draft.tailnetProvider.usesWebLogin {
|
||||
Section("Tailscale Sign-In") {
|
||||
if let loginStatus {
|
||||
labeledValue("State", loginStatus.backendState)
|
||||
if let tailnetName = loginStatus.tailnetName {
|
||||
labeledValue("Tailnet", tailnetName)
|
||||
}
|
||||
if let dnsName = loginStatus.selfDNSName {
|
||||
labeledValue("Device", dnsName)
|
||||
}
|
||||
if !loginStatus.tailscaleIPs.isEmpty {
|
||||
labeledValue("Addresses", loginStatus.tailscaleIPs.joined(separator: ", "))
|
||||
}
|
||||
if let authURL = loginStatus.authURL {
|
||||
labeledValue("Login URL", authURL)
|
||||
Button("Open Login Page") {
|
||||
if let url = URL(string: authURL) {
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !loginStatus.health.isEmpty {
|
||||
Text(loginStatus.health.joined(separator: " • "))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Start sign-in to launch a local Tailscale bridge and fetch the real browser login URL.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func authenticateWithSlack() async throws {
|
||||
guard
|
||||
let authorizationEndpoint = URL(string: "https://slack.com/openid/connect/authorize"),
|
||||
let tokenEndpoint = URL(string: "https://slack.com/api/openid.connect.token"),
|
||||
let redirectURI = URL(string: "https://burrow.rs/callback/oauth2") else { return }
|
||||
let session = OAuth2.Session(
|
||||
authorizationEndpoint: authorizationEndpoint,
|
||||
tokenEndpoint: tokenEndpoint,
|
||||
redirectURI: redirectURI,
|
||||
scopes: ["openid", "profile"],
|
||||
clientID: "2210535565.6884042183125",
|
||||
clientSecret: "2793c8a5255cae38830934c664eeb62d"
|
||||
)
|
||||
let response = try await session.authorize(webAuthenticationSession)
|
||||
private var confirmationTitle: String {
|
||||
switch sheet {
|
||||
case .wireGuard:
|
||||
return "Add Network"
|
||||
case .tor:
|
||||
return "Save Account"
|
||||
case .tailnet:
|
||||
if draft.tailnetProvider.usesWebLogin {
|
||||
return loginStatus?.running == true ? "Save Account" : "Start Sign-In"
|
||||
}
|
||||
return "Save Account"
|
||||
}
|
||||
}
|
||||
|
||||
private var submissionDisabled: Bool {
|
||||
switch sheet {
|
||||
case .wireGuard:
|
||||
return draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
case .tor:
|
||||
return normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil
|
||||
case .tailnet:
|
||||
if normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil {
|
||||
return true
|
||||
}
|
||||
if draft.tailnetProvider.usesWebLogin {
|
||||
return false
|
||||
}
|
||||
if draft.tailnetProvider.requiresControlURL && normalizedOptional(draft.authority) == nil {
|
||||
return true
|
||||
}
|
||||
if draft.authMode != .none && normalizedOptional(draft.secret) == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
isSubmitting = true
|
||||
errorMessage = nil
|
||||
|
||||
Task { @MainActor in
|
||||
defer { isSubmitting = false }
|
||||
do {
|
||||
switch sheet {
|
||||
case .wireGuard:
|
||||
try await submitWireGuard()
|
||||
dismiss()
|
||||
case .tor:
|
||||
try submitTor()
|
||||
dismiss()
|
||||
case .tailnet:
|
||||
try await submitTailnet()
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func submitWireGuard() async throws {
|
||||
let networkID = try await networkViewModel.addWireGuardNetwork(
|
||||
configText: draft.wireGuardConfig
|
||||
)
|
||||
|
||||
let title = titleOrFallback("WireGuard \(networkID)")
|
||||
let record = NetworkAccountRecord(
|
||||
id: UUID(),
|
||||
kind: .wireGuard,
|
||||
title: title,
|
||||
authority: nil,
|
||||
provider: nil,
|
||||
accountName: normalized(draft.accountName, fallback: "default"),
|
||||
identityName: normalized(draft.identityName, fallback: "network-\(networkID)"),
|
||||
hostname: nil,
|
||||
username: nil,
|
||||
tailnet: nil,
|
||||
authMode: .none,
|
||||
note: "Linked to daemon network #\(networkID).",
|
||||
createdAt: .now,
|
||||
updatedAt: .now
|
||||
)
|
||||
try accountStore.upsert(record, secret: nil)
|
||||
}
|
||||
|
||||
private func submitTor() throws {
|
||||
let title = titleOrFallback("Tor \(normalized(draft.identityName, fallback: "apple"))")
|
||||
let note = [
|
||||
"Addresses: \(csvSummary(draft.torAddresses))",
|
||||
"DNS: \(csvSummary(draft.torDNS))",
|
||||
"MTU: \(normalized(draft.torMTU, fallback: "1400"))",
|
||||
"Listen: \(normalized(draft.torListen, fallback: "127.0.0.1:9040"))",
|
||||
].joined(separator: " • ")
|
||||
|
||||
let record = NetworkAccountRecord(
|
||||
id: UUID(),
|
||||
kind: .tor,
|
||||
title: title,
|
||||
authority: "arti://local",
|
||||
provider: nil,
|
||||
accountName: normalized(draft.accountName, fallback: "default"),
|
||||
identityName: normalized(draft.identityName, fallback: "apple"),
|
||||
hostname: nil,
|
||||
username: nil,
|
||||
tailnet: nil,
|
||||
authMode: .none,
|
||||
note: note,
|
||||
createdAt: .now,
|
||||
updatedAt: .now
|
||||
)
|
||||
try accountStore.upsert(record, secret: nil)
|
||||
}
|
||||
|
||||
private func submitTailnet() async throws {
|
||||
if draft.tailnetProvider.usesWebLogin {
|
||||
if loginStatus?.running == true {
|
||||
try await saveTailnetAccount(secret: nil, username: nil)
|
||||
dismiss()
|
||||
} else {
|
||||
try await startTailscaleLogin()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let secret = draft.authMode == .none ? nil : draft.secret
|
||||
let username = normalizedOptional(draft.username)
|
||||
try await saveTailnetAccount(secret: secret, username: username)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func startTailscaleLogin() async throws {
|
||||
let response = try await TailnetBridgeClient.startLogin(
|
||||
TailnetLoginStartRequest(
|
||||
accountName: normalized(draft.accountName, fallback: "default"),
|
||||
identityName: normalized(draft.identityName, fallback: "apple"),
|
||||
hostname: normalizedOptional(draft.hostname),
|
||||
controlURL: draft.tailnetProvider.defaultAuthority
|
||||
)
|
||||
)
|
||||
loginSessionID = response.sessionID
|
||||
loginStatus = response.status
|
||||
if let authURL = response.status.authURL, let url = URL(string: authURL) {
|
||||
openLoginURL(url)
|
||||
}
|
||||
startPollingTailscaleLogin()
|
||||
}
|
||||
|
||||
private func runAutomationIfNeeded() {
|
||||
guard !didRunAutomation,
|
||||
sheet == .tailnet,
|
||||
let automation = BurrowAutomationConfig.current,
|
||||
automation.action == .tailnetLogin
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
didRunAutomation = true
|
||||
draft.tailnetProvider = .tailscale
|
||||
draft.title = automation.title ?? draft.title
|
||||
draft.accountName = automation.accountName ?? draft.accountName
|
||||
draft.identityName = automation.identityName ?? draft.identityName
|
||||
draft.hostname = automation.hostname ?? draft.hostname
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await startTailscaleLogin()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPollingTailscaleLogin() {
|
||||
pollingTask?.cancel()
|
||||
guard let loginSessionID else { return }
|
||||
pollingTask = Task { @MainActor in
|
||||
while !Task.isCancelled {
|
||||
do {
|
||||
let status = try await TailnetBridgeClient.status(sessionID: loginSessionID)
|
||||
let previousAuthURL = loginStatus?.authURL
|
||||
loginStatus = status
|
||||
if previousAuthURL == nil,
|
||||
let authURL = status.authURL,
|
||||
let url = URL(string: authURL)
|
||||
{
|
||||
openLoginURL(url)
|
||||
}
|
||||
if status.running {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openLoginURL(_ url: URL) {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
openURL(url) { accepted in
|
||||
guard !accepted else { return }
|
||||
errorMessage = "Burrow got a Tailscale login URL, but iOS did not open it automatically."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveTailnetAccount(secret: String?, username: String?) async throws {
|
||||
let provider = draft.tailnetProvider
|
||||
let title = titleOrFallback(
|
||||
hostnameFallback(
|
||||
from: provider.usesWebLogin ? (loginStatus?.tailnetName ?? "") : draft.authority,
|
||||
fallback: provider.title
|
||||
)
|
||||
)
|
||||
|
||||
let payload = TailnetNetworkPayload(
|
||||
provider: provider,
|
||||
authority: normalizedOptional(provider.defaultAuthority ?? draft.authority),
|
||||
account: normalized(draft.accountName, fallback: "default"),
|
||||
identity: normalized(draft.identityName, fallback: "apple"),
|
||||
tailnet: normalizedOptional(loginStatus?.tailnetName ?? draft.tailnet),
|
||||
hostname: normalizedOptional(draft.hostname)
|
||||
)
|
||||
|
||||
var noteParts: [String] = [
|
||||
provider.title,
|
||||
provider.usesWebLogin
|
||||
? "State: \(loginStatus?.backendState ?? "NeedsLogin")"
|
||||
: "Auth: \(draft.authMode.title)",
|
||||
]
|
||||
if let dnsName = loginStatus?.selfDNSName {
|
||||
noteParts.append("Device: \(dnsName)")
|
||||
}
|
||||
if let magicDNSSuffix = loginStatus?.magicDNSSuffix {
|
||||
noteParts.append("MagicDNS: \(magicDNSSuffix)")
|
||||
}
|
||||
|
||||
do {
|
||||
let networkID = try await networkViewModel.addTailnetNetwork(payload: payload)
|
||||
noteParts.append("Linked to daemon network #\(networkID)")
|
||||
} catch {
|
||||
noteParts.append("Daemon network add pending")
|
||||
}
|
||||
|
||||
let record = NetworkAccountRecord(
|
||||
id: UUID(),
|
||||
kind: .headscale,
|
||||
title: title,
|
||||
authority: payload.authority,
|
||||
provider: provider,
|
||||
accountName: payload.account,
|
||||
identityName: payload.identity,
|
||||
hostname: payload.hostname,
|
||||
username: username,
|
||||
tailnet: payload.tailnet,
|
||||
authMode: provider.usesWebLogin ? .web : draft.authMode,
|
||||
note: noteParts.joined(separator: " • "),
|
||||
createdAt: .now,
|
||||
updatedAt: .now
|
||||
)
|
||||
try accountStore.upsert(record, secret: secret)
|
||||
}
|
||||
|
||||
private func normalized(_ value: String, fallback: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? fallback : trimmed
|
||||
}
|
||||
|
||||
private func normalizedOptional(_ value: String) -> String? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func titleOrFallback(_ fallback: String) -> String {
|
||||
normalized(draft.title, fallback: fallback)
|
||||
}
|
||||
|
||||
private func csvSummary(_ value: String) -> String {
|
||||
value
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private func hostnameFallback(from value: String, fallback: String) -> String {
|
||||
guard let url = URL(string: value), let host = url.host, !host.isEmpty else {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? fallback : trimmed
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func labeledValue(_ label: String, _ value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.body.monospaced())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountRowView: View {
|
||||
let account: NetworkAccountRecord
|
||||
let hasSecret: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(account.title)
|
||||
.font(.headline)
|
||||
HStack(spacing: 8) {
|
||||
Text(account.kind.title)
|
||||
if let provider = account.provider {
|
||||
Text(provider.title)
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(account.kind.accentColor)
|
||||
}
|
||||
Spacer()
|
||||
if hasSecret {
|
||||
Label("Credential stored", systemImage: "key.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let authority = account.authority {
|
||||
labeledValue("Authority", authority)
|
||||
}
|
||||
|
||||
labeledValue("Account", account.accountName)
|
||||
labeledValue("Identity", account.identityName)
|
||||
|
||||
if let hostname = account.hostname {
|
||||
labeledValue("Hostname", hostname)
|
||||
}
|
||||
|
||||
if let username = account.username {
|
||||
labeledValue("Username", username)
|
||||
}
|
||||
|
||||
if let tailnet = account.tailnet {
|
||||
labeledValue("Tailnet", tailnet)
|
||||
}
|
||||
|
||||
if let note = account.note {
|
||||
Text(note)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.thinMaterial)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func labeledValue(_ label: String, _ value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.body.monospaced())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func burrowLoginField() -> some View {
|
||||
#if os(iOS)
|
||||
textInputAutocapitalization(.never)
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct BurrowAutomationConfig {
|
||||
enum Action: String {
|
||||
case tailnetLogin = "tailnet-login"
|
||||
}
|
||||
|
||||
let action: Action
|
||||
let title: String?
|
||||
let accountName: String?
|
||||
let identityName: String?
|
||||
let hostname: String?
|
||||
|
||||
static let current: BurrowAutomationConfig? = {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
guard let rawAction = environment["BURROW_UI_AUTOMATION"],
|
||||
let action = Action(rawValue: rawAction)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return BurrowAutomationConfig(
|
||||
action: action,
|
||||
title: environment["BURROW_UI_AUTOMATION_TITLE"],
|
||||
accountName: environment["BURROW_UI_AUTOMATION_ACCOUNT"],
|
||||
identityName: environment["BURROW_UI_AUTOMATION_IDENTITY"],
|
||||
hostname: environment["BURROW_UI_AUTOMATION_HOSTNAME"]
|
||||
)
|
||||
}()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
|
@ -72,4 +791,3 @@ struct NetworkView_Previews: PreviewProvider {
|
|||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -1,39 +1,45 @@
|
|||
import SwiftUI
|
||||
|
||||
struct NetworkCarouselView: View {
|
||||
var networks: [any Network] = [
|
||||
HackClub(id: 1),
|
||||
HackClub(id: 2),
|
||||
WireGuard(id: 4),
|
||||
HackClub(id: 5)
|
||||
]
|
||||
var networks: [NetworkCardModel]
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack {
|
||||
ForEach(networks, id: \.id) { network in
|
||||
NetworkView(network: network)
|
||||
.containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center)
|
||||
.scrollTransition(.interactive, axis: .horizontal) { content, phase in
|
||||
content
|
||||
.scaleEffect(1.0 - abs(phase.value) * 0.1)
|
||||
Group {
|
||||
if networks.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Networks Yet",
|
||||
systemImage: "network.slash",
|
||||
description: Text("Add a WireGuard network, or save a Tailnet account so Burrow can store a managed network when the daemon is reachable.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, minHeight: 175)
|
||||
} else {
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack {
|
||||
ForEach(networks) { network in
|
||||
NetworkView(network: network)
|
||||
.containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center)
|
||||
.scrollTransition(.interactive, axis: .horizontal) { content, phase in
|
||||
content
|
||||
.scaleEffect(1.0 - abs(phase.value) * 0.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
.scrollClipDisabled()
|
||||
.scrollIndicators(.hidden)
|
||||
.defaultScrollAnchor(.center)
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
.containerRelativeFrame(.horizontal)
|
||||
}
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
.scrollClipDisabled()
|
||||
.scrollIndicators(.hidden)
|
||||
.defaultScrollAnchor(.center)
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
.containerRelativeFrame(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct NetworkCarouselView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NetworkCarouselView()
|
||||
NetworkCarouselView(networks: [WireGuardCard(id: 1, detail: "10.13.13.2/24 · wg.burrow.rs:51820").card])
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ public final class NetworkExtensionTunnel: Tunnel {
|
|||
|
||||
let proto = NETunnelProviderProtocol()
|
||||
proto.providerBundleIdentifier = bundleIdentifier
|
||||
proto.serverAddress = "hackclub.com"
|
||||
proto.serverAddress = "burrow.rs"
|
||||
|
||||
manager.protocolConfiguration = proto
|
||||
try await manager.save()
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ struct NetworkView<Content: View>: View {
|
|||
}
|
||||
|
||||
extension NetworkView where Content == AnyView {
|
||||
init(network: any Network) {
|
||||
init(network: NetworkCardModel) {
|
||||
color = network.backgroundColor
|
||||
content = { AnyView(network.label) }
|
||||
content = { network.label }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
import BurrowCore
|
||||
import SwiftUI
|
||||
|
||||
struct HackClub: Network {
|
||||
typealias NetworkType = Burrow_WireGuardNetwork
|
||||
static let type: Burrow_NetworkType = .hackClub
|
||||
|
||||
var id: Int32
|
||||
var backgroundColor: Color { .init("HackClub") }
|
||||
|
||||
@MainActor var label: some View {
|
||||
GeometryReader { reader in
|
||||
VStack(alignment: .leading) {
|
||||
Image("HackClub")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(height: reader.size.height / 4)
|
||||
Spacer()
|
||||
Text("@conradev")
|
||||
.foregroundStyle(.white)
|
||||
.font(.body.monospaced())
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +1,539 @@
|
|||
import Atomics
|
||||
import BurrowConfiguration
|
||||
import BurrowCore
|
||||
import Foundation
|
||||
import Security
|
||||
import SwiftProtobuf
|
||||
import SwiftUI
|
||||
|
||||
protocol Network {
|
||||
associatedtype NetworkType: Message
|
||||
associatedtype Label: View
|
||||
struct NetworkCardModel: Identifiable {
|
||||
let id: Int32
|
||||
let backgroundColor: Color
|
||||
let label: AnyView
|
||||
}
|
||||
|
||||
static var type: Burrow_NetworkType { get }
|
||||
struct TailnetNetworkPayload: Codable, Sendable {
|
||||
var provider: TailnetProvider
|
||||
var authority: String?
|
||||
var account: String
|
||||
var identity: String
|
||||
var tailnet: String?
|
||||
var hostname: String?
|
||||
|
||||
var id: Int32 { get }
|
||||
var backgroundColor: Color { get }
|
||||
func encoded() throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return try encoder.encode(self)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor var label: Label { get }
|
||||
struct TailnetLoginStartRequest: Codable, Sendable {
|
||||
var accountName: String
|
||||
var identityName: String
|
||||
var hostname: String?
|
||||
var controlURL: String?
|
||||
}
|
||||
|
||||
struct TailnetLoginStatus: Codable, Sendable {
|
||||
var backendState: String
|
||||
var authURL: String?
|
||||
var running: Bool
|
||||
var needsLogin: Bool
|
||||
var tailnetName: String?
|
||||
var magicDNSSuffix: String?
|
||||
var selfDNSName: String?
|
||||
var tailscaleIPs: [String]
|
||||
var health: [String]
|
||||
}
|
||||
|
||||
struct TailnetLoginStartResponse: Codable, Sendable {
|
||||
var sessionID: String
|
||||
var status: TailnetLoginStatus
|
||||
}
|
||||
|
||||
enum TailnetBridgeClient {
|
||||
private static let baseURL = URL(string: "http://127.0.0.1:8080")!
|
||||
|
||||
static func startLogin(_ request: TailnetLoginStartRequest) async throws -> TailnetLoginStartResponse {
|
||||
var urlRequest = URLRequest(
|
||||
url: baseURL.appendingPathComponent("v1/tailscale/login/start")
|
||||
)
|
||||
urlRequest.httpMethod = "POST"
|
||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
urlRequest.httpBody = try encoder.encode(request)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest)
|
||||
try validate(response: response, data: data)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
return try decoder.decode(TailnetLoginStartResponse.self, from: data)
|
||||
}
|
||||
|
||||
static func status(sessionID: String) async throws -> TailnetLoginStatus {
|
||||
let url = baseURL
|
||||
.appendingPathComponent("v1/tailscale/login")
|
||||
.appendingPathComponent(sessionID)
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
try validate(response: response, data: data)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
return try decoder.decode(TailnetLoginStatus.self, from: data)
|
||||
}
|
||||
|
||||
private static func validate(response: URLResponse, data: Data) throws {
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
let message = String(data: data, encoding: .utf8)?.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines
|
||||
)
|
||||
throw TailnetBridgeError.server(message?.ifEmpty("HTTP \(http.statusCode)") ?? "HTTP \(http.statusCode)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TailnetBridgeError: LocalizedError {
|
||||
case server(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .server(let message):
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class NetworkViewModel: Sendable {
|
||||
private(set) var networks: [Burrow_Network] = []
|
||||
private(set) var connectionError: String?
|
||||
private let socketURLResult: Result<URL, Error>
|
||||
|
||||
private var task: Task<Void, Error>!
|
||||
nonisolated(unsafe) private var task: Task<Void, Never>?
|
||||
|
||||
init(socketURL: URL) {
|
||||
init(socketURLResult: Result<URL, Error>) {
|
||||
self.socketURLResult = socketURLResult
|
||||
startStreaming()
|
||||
}
|
||||
|
||||
deinit {
|
||||
task?.cancel()
|
||||
}
|
||||
|
||||
var cards: [NetworkCardModel] {
|
||||
networks.map(Self.makeCard(for:))
|
||||
}
|
||||
|
||||
var nextNetworkID: Int32 {
|
||||
(networks.map(\.id).max() ?? 0) + 1
|
||||
}
|
||||
|
||||
func addWireGuardNetwork(configText: String) async throws -> Int32 {
|
||||
try await addNetwork(type: .wireGuard, payload: Data(configText.utf8))
|
||||
}
|
||||
|
||||
func addTailnetNetwork(payload: TailnetNetworkPayload) async throws -> Int32 {
|
||||
try await addNetwork(type: .tailnet, payload: payload.encoded())
|
||||
}
|
||||
|
||||
private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 {
|
||||
let socketURL = try socketURLResult.get()
|
||||
let networkID = nextNetworkID
|
||||
let request = Burrow_Network.with {
|
||||
$0.id = networkID
|
||||
$0.type = type
|
||||
$0.payload = payload
|
||||
}
|
||||
|
||||
let client = NetworksClient.unix(socketURL: socketURL)
|
||||
_ = try await client.networkAdd(request)
|
||||
return networkID
|
||||
}
|
||||
|
||||
private func startStreaming() {
|
||||
task?.cancel()
|
||||
let socketURLResult = self.socketURLResult
|
||||
task = Task { [weak self] in
|
||||
let client = NetworksClient.unix(socketURL: socketURL)
|
||||
for try await networks in client.networkList(.init()) {
|
||||
guard let viewModel = self else { continue }
|
||||
Task { @MainActor in
|
||||
viewModel.networks = networks.network
|
||||
do {
|
||||
let socketURL = try socketURLResult.get()
|
||||
let client = NetworksClient.unix(socketURL: socketURL)
|
||||
for try await response in client.networkList(.init()) {
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.networks = response.network
|
||||
self.connectionError = nil
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.connectionError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeCard(for network: Burrow_Network) -> NetworkCardModel {
|
||||
switch network.type {
|
||||
case .wireGuard:
|
||||
WireGuardCard(network: network).card
|
||||
case .tailnet:
|
||||
TailnetCard(network: network).card
|
||||
case .UNRECOGNIZED(let rawValue):
|
||||
unsupportedCard(
|
||||
id: network.id,
|
||||
title: "Unknown Network",
|
||||
detail: "Type \(rawValue) is not recognized by this build."
|
||||
)
|
||||
@unknown default:
|
||||
unsupportedCard(
|
||||
id: network.id,
|
||||
title: "Unsupported Network",
|
||||
detail: "Update Burrow to view this network."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func unsupportedCard(id: Int32, title: String, detail: String) -> NetworkCardModel {
|
||||
NetworkCardModel(
|
||||
id: id,
|
||||
backgroundColor: .gray.opacity(0.85),
|
||||
label: AnyView(
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(title)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text(detail)
|
||||
.font(.body)
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
Spacer()
|
||||
Text("Network #\(id)")
|
||||
.font(.footnote.monospaced())
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
case tailscale
|
||||
case headscale
|
||||
case burrow
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .tailscale: "Tailscale"
|
||||
case .headscale: "Headscale"
|
||||
case .burrow: "Burrow"
|
||||
}
|
||||
}
|
||||
|
||||
var usesWebLogin: Bool {
|
||||
self == .tailscale
|
||||
}
|
||||
|
||||
var requiresControlURL: Bool {
|
||||
self != .tailscale
|
||||
}
|
||||
|
||||
var defaultAuthority: String? {
|
||||
switch self {
|
||||
case .tailscale:
|
||||
"https://controlplane.tailscale.com"
|
||||
case .headscale, .burrow:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .tailscale:
|
||||
"Use Tailscale's real browser login flow."
|
||||
case .headscale:
|
||||
"Store a Headscale control-plane endpoint and credentials."
|
||||
case .burrow:
|
||||
"Store Burrow control-plane credentials."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
case wireGuard
|
||||
case tor
|
||||
case headscale
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .wireGuard: "WireGuard"
|
||||
case .tor: "Tor"
|
||||
case .headscale: "Tailnet"
|
||||
}
|
||||
}
|
||||
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .wireGuard: "Import a tunnel and optional account metadata."
|
||||
case .tor: "Store Arti account and identity preferences."
|
||||
case .headscale: "Save Tailscale, Headscale, or Burrow control-plane identities."
|
||||
}
|
||||
}
|
||||
|
||||
var accentColor: Color {
|
||||
switch self {
|
||||
case .wireGuard: .init("WireGuard")
|
||||
case .tor: .orange
|
||||
case .headscale: .mint
|
||||
}
|
||||
}
|
||||
|
||||
var actionTitle: String {
|
||||
switch self {
|
||||
case .wireGuard: "Add Network"
|
||||
case .tor: "Save Account"
|
||||
case .headscale: "Save Account"
|
||||
}
|
||||
}
|
||||
|
||||
var availabilityNote: String? {
|
||||
switch self {
|
||||
case .wireGuard:
|
||||
nil
|
||||
case .tor:
|
||||
"Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet."
|
||||
case .headscale:
|
||||
"Tailnet accounts can sign in from Apple now. The managed Apple runtime is still pending, but Tailnet networks can be stored in the daemon."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AccountAuthMode: String, CaseIterable, Codable, Identifiable, Sendable {
|
||||
case none
|
||||
case web
|
||||
case password
|
||||
case preauthKey
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .none: "None"
|
||||
case .web: "Web Login"
|
||||
case .password: "Password"
|
||||
case .preauthKey: "Preauth Key"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkAccountRecord: Codable, Identifiable, Hashable, Sendable {
|
||||
let id: UUID
|
||||
var kind: AccountNetworkKind
|
||||
var title: String
|
||||
var authority: String?
|
||||
var provider: TailnetProvider?
|
||||
var accountName: String
|
||||
var identityName: String
|
||||
var hostname: String?
|
||||
var username: String?
|
||||
var tailnet: String?
|
||||
var authMode: AccountAuthMode
|
||||
var note: String?
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
}
|
||||
|
||||
struct TailnetCard {
|
||||
var id: Int32
|
||||
var provider: String
|
||||
var title: String
|
||||
var detail: String
|
||||
|
||||
init(network: Burrow_Network) {
|
||||
let payload = (try? JSONDecoder().decode(TailnetNetworkPayload.self, from: network.payload))
|
||||
id = network.id
|
||||
provider = payload?.provider.title ?? "Tailnet"
|
||||
title = payload?.tailnet ?? payload?.hostname ?? "Tailnet"
|
||||
detail = [
|
||||
payload?.provider.title,
|
||||
payload?.authority,
|
||||
payload.map { "Account: \($0.account)" },
|
||||
]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " · ")
|
||||
.ifEmpty("Stored Tailnet configuration")
|
||||
}
|
||||
|
||||
var card: NetworkCardModel {
|
||||
NetworkCardModel(
|
||||
id: id,
|
||||
backgroundColor: .mint,
|
||||
label: AnyView(
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(provider)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
Text(title)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
Text(detail)
|
||||
.font(.body.monospaced())
|
||||
.foregroundStyle(.white.opacity(0.92))
|
||||
.lineLimit(4)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class NetworkAccountStore {
|
||||
private static let storageKey = "burrow.network-accounts"
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private(set) var accounts: [NetworkAccountRecord] = []
|
||||
|
||||
init(defaults: UserDefaults = UserDefaults(suiteName: Constants.appGroupIdentifier) ?? .standard) {
|
||||
self.defaults = defaults
|
||||
load()
|
||||
}
|
||||
|
||||
func upsert(_ record: NetworkAccountRecord, secret: String?) throws {
|
||||
if let index = accounts.firstIndex(where: { $0.id == record.id }) {
|
||||
accounts[index] = record
|
||||
} else {
|
||||
accounts.append(record)
|
||||
}
|
||||
accounts.sort {
|
||||
if $0.kind == $1.kind {
|
||||
return $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending
|
||||
}
|
||||
return $0.kind.rawValue < $1.kind.rawValue
|
||||
}
|
||||
try persist()
|
||||
if let secret, !secret.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
try AccountSecretStore.store(secret, for: record.id)
|
||||
} else {
|
||||
try AccountSecretStore.removeSecret(for: record.id)
|
||||
}
|
||||
}
|
||||
|
||||
func delete(_ record: NetworkAccountRecord) throws {
|
||||
accounts.removeAll { $0.id == record.id }
|
||||
try persist()
|
||||
try AccountSecretStore.removeSecret(for: record.id)
|
||||
}
|
||||
|
||||
func hasStoredSecret(for record: NetworkAccountRecord) -> Bool {
|
||||
AccountSecretStore.hasSecret(for: record.id)
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard let data = defaults.data(forKey: Self.storageKey) else {
|
||||
accounts = []
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
accounts = try JSONDecoder().decode([NetworkAccountRecord].self, from: data)
|
||||
} catch {
|
||||
accounts = []
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() throws {
|
||||
let data = try JSONEncoder().encode(accounts)
|
||||
defaults.set(data, forKey: Self.storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
private enum AccountSecretStore {
|
||||
private static let service = "\(Constants.bundleIdentifier).accounts"
|
||||
|
||||
static func hasSecret(for accountID: UUID) -> Bool {
|
||||
let query = baseQuery(for: accountID)
|
||||
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
static func store(_ secret: String, for accountID: UUID) throws {
|
||||
let data = Data(secret.utf8)
|
||||
let query = baseQuery(for: accountID)
|
||||
let status = SecItemCopyMatching(query as CFDictionary, nil)
|
||||
|
||||
if status == errSecSuccess {
|
||||
let updateStatus = SecItemUpdate(
|
||||
query as CFDictionary,
|
||||
[kSecValueData as String: data] as CFDictionary
|
||||
)
|
||||
guard updateStatus == errSecSuccess else {
|
||||
throw AccountSecretStoreError.osStatus(updateStatus)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var item = query
|
||||
item[kSecValueData as String] = data
|
||||
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
let addStatus = SecItemAdd(item as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw AccountSecretStoreError.osStatus(addStatus)
|
||||
}
|
||||
}
|
||||
|
||||
static func removeSecret(for accountID: UUID) throws {
|
||||
let status = SecItemDelete(baseQuery(for: accountID) as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw AccountSecretStoreError.osStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
private static func baseQuery(for accountID: UUID) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: accountID.uuidString,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private enum AccountSecretStoreError: LocalizedError {
|
||||
case osStatus(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .osStatus(let status):
|
||||
if let message = SecCopyErrorMessageString(status, nil) as String? {
|
||||
return message
|
||||
}
|
||||
return "Keychain error \(status)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func ifEmpty(_ fallback: @autoclosure () -> String) -> String {
|
||||
isEmpty ? fallback() : self
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,40 @@
|
|||
import BurrowCore
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct WireGuard: Network {
|
||||
typealias NetworkType = Burrow_WireGuardNetwork
|
||||
static let type: BurrowCore.Burrow_NetworkType = .wireGuard
|
||||
|
||||
struct WireGuardCard {
|
||||
var id: Int32
|
||||
var backgroundColor: Color { .init("WireGuard") }
|
||||
var title: String
|
||||
var detail: String
|
||||
|
||||
@MainActor var label: some View {
|
||||
init(id: Int32, title: String = "WireGuard", detail: String = "Stored configuration") {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.detail = detail
|
||||
}
|
||||
|
||||
init(network: Burrow_Network) {
|
||||
let payload = String(data: network.payload, encoding: .utf8) ?? ""
|
||||
let address = Self.firstValue(for: "Address", in: payload)
|
||||
let endpoint = Self.firstValue(for: "Endpoint", in: payload)
|
||||
self.id = network.id
|
||||
self.title = "WireGuard"
|
||||
self.detail = [address, endpoint]
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " · ")
|
||||
.ifEmpty("Stored configuration")
|
||||
}
|
||||
|
||||
var card: NetworkCardModel {
|
||||
NetworkCardModel(
|
||||
id: id,
|
||||
backgroundColor: .init("WireGuard"),
|
||||
label: AnyView(label)
|
||||
)
|
||||
}
|
||||
|
||||
private var label: some View {
|
||||
GeometryReader { reader in
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
|
|
@ -23,12 +49,29 @@ struct WireGuard: Network {
|
|||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: reader.size.height / 4)
|
||||
Spacer()
|
||||
Text("@conradev")
|
||||
Text(detail)
|
||||
.foregroundStyle(.white)
|
||||
.font(.body.monospaced())
|
||||
.lineLimit(3)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private static func firstValue(for key: String, in config: String) -> String? {
|
||||
config
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map(String.init)
|
||||
.first(where: { $0.hasPrefix("\(key) = ") })?
|
||||
.split(separator: "=", maxSplits: 1)
|
||||
.last
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func ifEmpty(_ fallback: @autoclosure () -> String) -> String {
|
||||
isEmpty ? fallback() : self
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,293 +0,0 @@
|
|||
import AuthenticationServices
|
||||
import Foundation
|
||||
import os
|
||||
import SwiftUI
|
||||
|
||||
enum OAuth2 {
|
||||
enum Error: Swift.Error {
|
||||
case unknown
|
||||
case invalidAuthorizationURL
|
||||
case invalidCallbackURL
|
||||
case invalidRedirectURI
|
||||
}
|
||||
|
||||
struct Credential {
|
||||
var accessToken: String
|
||||
var refreshToken: String?
|
||||
var expirationDate: Date?
|
||||
}
|
||||
|
||||
struct Session {
|
||||
var authorizationEndpoint: URL
|
||||
var tokenEndpoint: URL
|
||||
var redirectURI: URL
|
||||
var responseType = OAuth2.ResponseType.code
|
||||
var scopes: Set<String>
|
||||
var clientID: String
|
||||
var clientSecret: String
|
||||
|
||||
fileprivate static let queue: OSAllocatedUnfairLock<[Int: CheckedContinuation<URL, Swift.Error>]> = {
|
||||
.init(initialState: [:])
|
||||
}()
|
||||
|
||||
fileprivate static func handle(url: URL) {
|
||||
let continuations = queue.withLock { continuations in
|
||||
let copy = continuations
|
||||
continuations.removeAll()
|
||||
return copy
|
||||
}
|
||||
for (_, continuation) in continuations {
|
||||
continuation.resume(returning: url)
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
authorizationEndpoint: URL,
|
||||
tokenEndpoint: URL,
|
||||
redirectURI: URL,
|
||||
scopes: Set<String>,
|
||||
clientID: String,
|
||||
clientSecret: String
|
||||
) {
|
||||
self.authorizationEndpoint = authorizationEndpoint
|
||||
self.tokenEndpoint = tokenEndpoint
|
||||
self.redirectURI = redirectURI
|
||||
self.scopes = scopes
|
||||
self.clientID = clientID
|
||||
self.clientSecret = clientSecret
|
||||
}
|
||||
|
||||
private var authorizationURL: URL {
|
||||
get throws {
|
||||
var queryItems: [URLQueryItem] = [
|
||||
.init(name: "client_id", value: clientID),
|
||||
.init(name: "response_type", value: responseType.rawValue),
|
||||
.init(name: "redirect_uri", value: redirectURI.absoluteString)
|
||||
]
|
||||
if !scopes.isEmpty {
|
||||
queryItems.append(.init(name: "scope", value: scopes.joined(separator: ",")))
|
||||
}
|
||||
guard var components = URLComponents(url: authorizationEndpoint, resolvingAgainstBaseURL: false) else {
|
||||
throw OAuth2.Error.invalidAuthorizationURL
|
||||
}
|
||||
components.queryItems = queryItems
|
||||
guard let authorizationURL = components.url else { throw OAuth2.Error.invalidAuthorizationURL }
|
||||
return authorizationURL
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(callbackURL: URL) async throws -> OAuth2.AccessTokenResponse {
|
||||
switch responseType {
|
||||
case .code:
|
||||
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else {
|
||||
throw OAuth2.Error.invalidCallbackURL
|
||||
}
|
||||
return try await handle(response: try components.decode(OAuth2.CodeResponse.self))
|
||||
default:
|
||||
throw OAuth2.Error.invalidCallbackURL
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(response: OAuth2.CodeResponse) async throws -> OAuth2.AccessTokenResponse {
|
||||
var components = URLComponents()
|
||||
components.queryItems = [
|
||||
.init(name: "client_id", value: clientID),
|
||||
.init(name: "client_secret", value: clientSecret),
|
||||
.init(name: "grant_type", value: GrantType.authorizationCode.rawValue),
|
||||
.init(name: "code", value: response.code),
|
||||
.init(name: "redirect_uri", value: redirectURI.absoluteString)
|
||||
]
|
||||
let httpBody = Data(components.percentEncodedQuery!.utf8)
|
||||
|
||||
var request = URLRequest(url: tokenEndpoint)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = httpBody
|
||||
|
||||
let session = URLSession(configuration: .ephemeral)
|
||||
let (data, _) = try await session.data(for: request)
|
||||
return try OAuth2.decoder.decode(OAuth2.AccessTokenResponse.self, from: data)
|
||||
}
|
||||
|
||||
func authorize(_ session: WebAuthenticationSession) async throws -> Credential {
|
||||
let authorizationURL = try authorizationURL
|
||||
let callbackURL = try await session.start(
|
||||
url: authorizationURL,
|
||||
redirectURI: redirectURI
|
||||
)
|
||||
return try await handle(callbackURL: callbackURL).credential
|
||||
}
|
||||
}
|
||||
|
||||
private struct CodeResponse: Codable {
|
||||
var code: String
|
||||
var state: String?
|
||||
}
|
||||
|
||||
private struct AccessTokenResponse: Codable {
|
||||
var accessToken: String
|
||||
var tokenType: TokenType
|
||||
var expiresIn: Double?
|
||||
var refreshToken: String?
|
||||
|
||||
var credential: Credential {
|
||||
.init(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
expirationDate: expiresIn.map { Date(timeIntervalSinceNow: $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum TokenType: Codable, RawRepresentable {
|
||||
case bearer
|
||||
case unknown(String)
|
||||
|
||||
init(rawValue: String) {
|
||||
self = switch rawValue.lowercased() {
|
||||
case "bearer": .bearer
|
||||
default: .unknown(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
var rawValue: String {
|
||||
switch self {
|
||||
case .bearer: "bearer"
|
||||
case .unknown(let type): type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum GrantType: Codable, RawRepresentable {
|
||||
case authorizationCode
|
||||
case unknown(String)
|
||||
|
||||
init(rawValue: String) {
|
||||
self = switch rawValue.lowercased() {
|
||||
case "authorization_code": .authorizationCode
|
||||
default: .unknown(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
var rawValue: String {
|
||||
switch self {
|
||||
case .authorizationCode: "authorization_code"
|
||||
case .unknown(let type): type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ResponseType: Codable, RawRepresentable {
|
||||
case code
|
||||
case idToken
|
||||
case unknown(String)
|
||||
|
||||
init(rawValue: String) {
|
||||
self = switch rawValue.lowercased() {
|
||||
case "code": .code
|
||||
case "id_token": .idToken
|
||||
default: .unknown(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
var rawValue: String {
|
||||
switch self {
|
||||
case .code: "code"
|
||||
case .idToken: "id_token"
|
||||
case .unknown(let type): type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate static var decoder: JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
return decoder
|
||||
}
|
||||
|
||||
fileprivate static var encoder: JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
return encoder
|
||||
}
|
||||
}
|
||||
|
||||
extension WebAuthenticationSession: @unchecked @retroactive Sendable {
|
||||
}
|
||||
|
||||
extension WebAuthenticationSession {
|
||||
#if canImport(BrowserEngineKit)
|
||||
@available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *)
|
||||
fileprivate static func callback(for redirectURI: URL) throws -> ASWebAuthenticationSession.Callback {
|
||||
switch redirectURI.scheme {
|
||||
case "https":
|
||||
guard let host = redirectURI.host else { throw OAuth2.Error.invalidRedirectURI }
|
||||
return .https(host: host, path: redirectURI.path)
|
||||
case "http":
|
||||
throw OAuth2.Error.invalidRedirectURI
|
||||
case .some(let scheme):
|
||||
return .customScheme(scheme)
|
||||
case .none:
|
||||
throw OAuth2.Error.invalidRedirectURI
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
fileprivate func start(url: URL, redirectURI: URL) async throws -> URL {
|
||||
#if canImport(BrowserEngineKit)
|
||||
if #available(iOS 17.4, macOS 14.4, tvOS 17.4, watchOS 10.4, *) {
|
||||
return try await authenticate(
|
||||
using: url,
|
||||
callback: try Self.callback(for: redirectURI),
|
||||
additionalHeaderFields: [:]
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
return try await withThrowingTaskGroup(of: URL.self) { group in
|
||||
group.addTask {
|
||||
return try await authenticate(using: url, callbackURLScheme: redirectURI.scheme ?? "")
|
||||
}
|
||||
|
||||
let id = Int.random(in: 0..<Int.max)
|
||||
group.addTask {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
OAuth2.Session.queue.withLock { $0[id] = continuation }
|
||||
}
|
||||
}
|
||||
guard let url = try await group.next() else { throw OAuth2.Error.invalidCallbackURL }
|
||||
group.cancelAll()
|
||||
OAuth2.Session.queue.withLock { $0[id] = nil }
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func handleOAuth2Callback() -> some View {
|
||||
onOpenURL { url in OAuth2.Session.handle(url: url) }
|
||||
}
|
||||
}
|
||||
|
||||
extension URLComponents {
|
||||
fileprivate func decode<T: Decodable>(_ type: T.Type) throws -> T {
|
||||
guard let queryItems else {
|
||||
throw DecodingError.valueNotFound(
|
||||
T.self,
|
||||
.init(codingPath: [], debugDescription: "Missing query items")
|
||||
)
|
||||
}
|
||||
let data = try OAuth2.encoder.encode(try queryItems.values)
|
||||
return try OAuth2.decoder.decode(T.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
extension Sequence where Element == URLQueryItem {
|
||||
fileprivate var values: [String: String?] {
|
||||
get throws {
|
||||
try Dictionary(map { ($0.name, $0.value) }) { _, _ in
|
||||
throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Duplicate query items"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue