Polish Apple network config sheets

This commit is contained in:
Conrad Kramer 2026-03-31 14:27:14 -07:00
parent 2f69987742
commit 014bca073f

View file

@ -2,6 +2,11 @@ import AuthenticationServices
import BurrowConfiguration import BurrowConfiguration
import Foundation import Foundation
import SwiftUI import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
public struct BurrowView: View { public struct BurrowView: View {
@State private var networkViewModel: NetworkViewModel @State private var networkViewModel: NetworkViewModel
@ -338,15 +343,10 @@ private struct ConfigurationSheetView: View {
NavigationStack { NavigationStack {
Form { Form {
Section { Section {
Text(sheet.kind.subtitle) sheetSummaryCard
.font(.callout)
.foregroundStyle(.secondary)
if let availabilityNote = sheet.kind.availabilityNote {
Text(availabilityNote)
.font(.footnote)
.foregroundStyle(.secondary)
}
} }
.listRowInsets(.init(top: 4, leading: 0, bottom: 4, trailing: 0))
.listRowBackground(Color.clear)
Section("Identity") { Section("Identity") {
TextField("Title", text: $draft.title) TextField("Title", text: $draft.title)
@ -364,7 +364,10 @@ private struct ConfigurationSheetView: View {
Section("WireGuard Configuration") { Section("WireGuard Configuration") {
TextEditor(text: $draft.wireGuardConfig) TextEditor(text: $draft.wireGuardConfig)
.font(.body.monospaced()) .font(.body.monospaced())
.frame(minHeight: 220) .frame(minHeight: wireGuardEditorHeight)
.contextMenu {
wireGuardContextActions
}
} }
case .tor: case .tor:
Section("Tor Preferences") { Section("Tor Preferences") {
@ -385,23 +388,52 @@ private struct ConfigurationSheetView: View {
} }
} }
.navigationTitle(sheet.kind.title) .navigationTitle(sheet.kind.title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { Button("Cancel") {
dismiss() dismiss()
} }
} }
ToolbarItem(placement: .confirmationAction) { #if os(iOS)
Button(confirmationTitle) { ToolbarItem(placement: .topBarTrailing) {
submit() Menu {
sheetMenuActions
} label: {
Image(systemName: "ellipsis.circle")
}
.accessibilityLabel("More")
}
#else
ToolbarItem(placement: .primaryAction) {
Menu {
sheetMenuActions
} label: {
Image(systemName: "ellipsis.circle")
}
.accessibilityLabel("More")
}
#endif
if !showsBottomActionButton {
ToolbarItem(placement: .confirmationAction) {
Button(confirmationTitle) {
submit()
}
.disabled(isSubmitting || submissionDisabled)
} }
.disabled(isSubmitting || submissionDisabled)
} }
} }
} }
#if os(macOS) #if os(macOS)
.frame(minWidth: 520, minHeight: 620) .frame(minWidth: 520, minHeight: 620)
#endif #endif
.safeAreaInset(edge: .bottom) {
if showsBottomActionButton {
bottomActionBar
}
}
.onAppear { .onAppear {
runAutomationIfNeeded() runAutomationIfNeeded()
} }
@ -420,6 +452,7 @@ private struct ConfigurationSheetView: View {
Text(provider.title).tag(provider) Text(provider.title).tag(provider)
} }
} }
.pickerStyle(.menu)
Text(draft.tailnetProvider.subtitle) Text(draft.tailnetProvider.subtitle)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@ -448,6 +481,7 @@ private struct ConfigurationSheetView: View {
Text(mode.title).tag(mode) Text(mode.title).tag(mode)
} }
} }
.pickerStyle(.menu)
if draft.authMode != .none { if draft.authMode != .none {
SecureField( SecureField(
draft.authMode == .password ? "Password" : "Preauth Key", draft.authMode == .password ? "Password" : "Preauth Key",
@ -492,6 +526,164 @@ private struct ConfigurationSheetView: View {
} }
} }
private var sheetSummaryCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
Image(systemName: sheet.iconName)
.font(.title3.weight(.semibold))
.foregroundStyle(sheetAccentColor)
.frame(width: 28, height: 28)
.background(
Circle()
.fill(sheetAccentColor.opacity(0.14))
)
VStack(alignment: .leading, spacing: 3) {
Text(summaryTitle)
.font(.headline)
Text(sheet.kind.subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
}
if let availabilityNote = sheet.kind.availabilityNote {
Text(availabilityNote)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(.thinMaterial)
)
}
@ViewBuilder
private var bottomActionBar: some View {
VStack(spacing: 0) {
Divider()
.overlay(.white.opacity(0.3))
Button(confirmationTitle) {
submit()
}
.buttonStyle(.floating(color: sheetAccentColor, cornerRadius: 18))
.disabled(isSubmitting || submissionDisabled)
.padding(.horizontal)
.padding(.top, 12)
.padding(.bottom, 8)
}
.background(.ultraThinMaterial)
}
@ViewBuilder
private var sheetMenuActions: some View {
Button("Use Suggested Identity") {
applySuggestedIdentity()
}
switch sheet {
case .wireGuard:
Button("Paste Configuration") {
pasteWireGuardConfiguration()
}
.disabled(clipboardString?.isEmpty ?? true)
Button("Clear Configuration", role: .destructive) {
draft.wireGuardConfig = ""
}
.disabled(draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
case .tor:
Menu("Presets") {
Button("Recommended Tor Defaults") {
applyTorDefaults()
}
Button("Restore Suggested Identity") {
applySuggestedIdentity()
}
}
case .tailnet:
Menu("Provider") {
ForEach(TailnetProvider.allCases) { provider in
Button(provider.title) {
applyTailnetProvider(provider)
}
}
}
if !draft.tailnetProvider.usesWebLogin {
Menu("Authentication") {
ForEach([AccountAuthMode.none, .password, .preauthKey]) { mode in
Button(mode.title) {
draft.authMode = mode
if mode == .none {
draft.secret = ""
}
}
}
}
}
Button("Restore Provider Defaults") {
applyTailnetDefaults(for: draft.tailnetProvider)
}
}
}
@ViewBuilder
private var wireGuardContextActions: some View {
Button("Paste Configuration") {
pasteWireGuardConfiguration()
}
.disabled(clipboardString?.isEmpty ?? true)
Button("Clear", role: .destructive) {
draft.wireGuardConfig = ""
}
.disabled(draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
private var sheetAccentColor: Color {
switch sheet {
case .wireGuard:
.blue
case .tor, .tailnet:
sheet.kind.accentColor
}
}
private var summaryTitle: String {
switch sheet {
case .wireGuard:
"Import WireGuard"
case .tor:
"Configure Tor"
case .tailnet:
"Connect Tailnet"
}
}
private var showsBottomActionButton: Bool {
#if os(iOS)
true
#else
false
#endif
}
private var wireGuardEditorHeight: CGFloat {
#if os(iOS)
180
#else
220
#endif
}
private var confirmationTitle: String { private var confirmationTitle: String {
switch sheet { switch sheet {
case .wireGuard: case .wireGuard:
@ -775,6 +967,62 @@ private struct ConfigurationSheetView: View {
try accountStore.upsert(record, secret: secret) try accountStore.upsert(record, secret: secret)
} }
private func applySuggestedIdentity() {
let defaults = AccountDraft(sheet: sheet)
if draft.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
draft.title = defaults.title
}
draft.accountName = defaults.accountName
draft.identityName = defaults.identityName
if sheet == .tailnet {
draft.hostname = defaults.hostname
}
}
private func applyTorDefaults() {
let defaults = AccountDraft(sheet: .tor)
draft.title = defaults.title
draft.accountName = defaults.accountName
draft.identityName = defaults.identityName
draft.torAddresses = defaults.torAddresses
draft.torDNS = defaults.torDNS
draft.torMTU = defaults.torMTU
draft.torListen = defaults.torListen
}
private func applyTailnetProvider(_ provider: TailnetProvider) {
draft.tailnetProvider = provider
applyTailnetDefaults(for: provider)
}
private func applyTailnetDefaults(for provider: TailnetProvider) {
draft.authority = provider.defaultAuthority ?? ""
if provider.usesWebLogin {
draft.authMode = .web
draft.username = ""
draft.secret = ""
} else {
if draft.authMode == .web {
draft.authMode = .none
}
}
}
private func pasteWireGuardConfiguration() {
guard let clipboardString else { return }
draft.wireGuardConfig = clipboardString
}
private var clipboardString: String? {
#if canImport(UIKit)
UIPasteboard.general.string
#elseif canImport(AppKit)
NSPasteboard.general.string(forType: .string)
#else
nil
#endif
}
private func normalized(_ value: String, fallback: String) -> String { private func normalized(_ value: String, fallback: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? fallback : trimmed return trimmed.isEmpty ? fallback : trimmed