348 lines
16 KiB
Swift
348 lines
16 KiB
Swift
import SwiftUI
|
|
|
|
struct UniFiDNSView: View {
|
|
let store: UniFiStore
|
|
@Binding var search: String
|
|
@State private var selectedID: String?
|
|
@State private var sortOrder = [KeyPathComparator(\DNSRecord.domainName)]
|
|
@State private var showConnection = false
|
|
@State private var editor: DNSRecord?
|
|
@State private var creating = false
|
|
@State private var deleting: DNSRecord?
|
|
|
|
private var selectedRecord: DNSRecord? { store.records.first { $0.id == selectedID } }
|
|
private var filteredRecords: [DNSRecord] {
|
|
store.records.filter {
|
|
search.isEmpty || $0.domainName.localizedCaseInsensitiveContains(search)
|
|
|| $0.value.localizedCaseInsensitiveContains(search)
|
|
}.sorted(using: sortOrder)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Picker("Site", selection: Binding(get: { store.selectedSiteID }, set: { id in
|
|
selectedID = nil
|
|
Task { await store.loadSite(id) }
|
|
})) {
|
|
ForEach(store.sites) { site in Text(site.name).tag(site.id) }
|
|
}
|
|
.frame(width: 230)
|
|
.disabled(store.isBusy)
|
|
Spacer()
|
|
if store.isBusy { ProgressView().controlSize(.small) }
|
|
Button("Connection…") { showConnection = true }.disabled(store.isBusy)
|
|
}
|
|
.padding(.horizontal, 12).padding(.vertical, 8)
|
|
Divider()
|
|
Table(filteredRecords, selection: $selectedID, sortOrder: $sortOrder) {
|
|
TableColumn("Domain", value: \.domainName)
|
|
.width(min: 160, ideal: 220)
|
|
TableColumn("Type", value: \.recordType).width(80)
|
|
TableColumn("Value", value: \.value).width(min: 150, ideal: 200)
|
|
TableColumn("TTL") { record in
|
|
Text(record.ttlSeconds.map { "\($0) s" } ?? "—")
|
|
}.width(85)
|
|
TableColumn("Status") { record in
|
|
Label(record.enabled ? "Enabled" : "Disabled", systemImage: record.enabled ? "checkmark.circle" : "minus.circle")
|
|
}.width(100)
|
|
}
|
|
.contextMenu(forSelectionType: String.self) { ids in
|
|
if let id = ids.first, let record = store.records.first(where: { $0.id == id }) {
|
|
Button("Edit \(record.recordType) Record…") { edit(record) }.disabled(record.kind == nil || store.isBusy)
|
|
Divider()
|
|
Button("Delete Record…", role: .destructive) { deleting = record }.disabled(store.isBusy)
|
|
}
|
|
} primaryAction: { ids in
|
|
if let id = ids.first, let record = store.records.first(where: { $0.id == id }), record.kind != nil, !store.isBusy { edit(record) }
|
|
}
|
|
.overlay {
|
|
if filteredRecords.isEmpty {
|
|
ContentUnavailableView("No DNS Records", systemImage: "globe", description: Text(search.isEmpty ? "Add a DNS record for the selected site." : "No records match your search."))
|
|
}
|
|
}
|
|
Divider()
|
|
HStack {
|
|
Text("\(filteredRecords.count) records")
|
|
Spacer()
|
|
Text(store.isDemo ? "Demo DNS — stored on this Mac" : store.connectionLabel)
|
|
}
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
.padding(.horizontal, 12).padding(.vertical, 7)
|
|
}
|
|
|
|
.toolbar {
|
|
ToolbarItemGroup {
|
|
Button { Task { await store.loadSite(store.selectedSiteID) } } label: {
|
|
Label("Refresh DNS", systemImage: "arrow.clockwise")
|
|
}.help("Refresh DNS Records").keyboardShortcut("r").disabled(store.isBusy)
|
|
Button {
|
|
creating = true
|
|
editor = DNSRecord(id: UUID().uuidString, type: "A_RECORD", enabled: true, domain: "", ipv4Address: "", ttlSeconds: 14400)
|
|
} label: { Label("Add DNS Record", systemImage: "plus") }
|
|
.help("Add DNS Record").keyboardShortcut("n").disabled(store.isBusy)
|
|
Button { if let record = selectedRecord { edit(record) } } label: {
|
|
Label("Edit DNS Record", systemImage: "square.and.pencil")
|
|
}.help("Edit DNS Record").disabled(selectedRecord?.kind == nil || store.isBusy)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showConnection) { UniFiConnectionSheet(store: store) }
|
|
.sheet(item: $editor) { record in DNSRecordEditor(store: store, record: record, isNew: creating) }
|
|
.alert("UniFi Request Failed", isPresented: Binding(get: { store.error != nil }, set: { if !$0 { store.error = nil } })) {
|
|
Button("OK") { store.error = nil }
|
|
} message: { Text(store.error ?? "") }
|
|
.confirmationDialog("Delete \(deleting?.domainName ?? "record")?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) {
|
|
Button("Delete DNS Record", role: .destructive) {
|
|
if let record = deleting { Task { await store.delete(record) } }
|
|
deleting = nil
|
|
}
|
|
} message: {
|
|
Text("Devices using this gateway for DNS may no longer resolve this domain.")
|
|
}
|
|
}
|
|
|
|
private func edit(_ record: DNSRecord) {
|
|
creating = false
|
|
editor = record
|
|
}
|
|
}
|
|
|
|
struct UniFiConnectionSheet: View {
|
|
let store: UniFiStore
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var url = ""
|
|
@State private var apiKey = ""
|
|
@State private var allowUntrusted = false
|
|
@State private var error: String?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
Text("Connect to UniFi Gateway").font(.headline)
|
|
Text("Use a local API key from UniFi Network → Settings → Control Plane → Integrations.")
|
|
.foregroundStyle(.secondary)
|
|
Form {
|
|
TextField("Gateway URL:", text: $url, prompt: Text("https://192.168.1.1"))
|
|
SecureField("API Key:", text: $apiKey)
|
|
Toggle("Allow Untrusted Gateway Certificate", isOn: $allowUntrusted)
|
|
}
|
|
.textFieldStyle(.roundedBorder)
|
|
.disabled(store.isBusy)
|
|
Text(allowUntrusted
|
|
? "Certificate verification will be disabled for this gateway connection only. Use this only on a network you trust."
|
|
: "TLS certificates are verified. Enable the option above only if your gateway uses a self-signed certificate.")
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
Text("Your API key and certificate trust choice are saved in Keychain for this gateway and used to reconnect when you open the app.")
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
Button("Forget Saved API Key") {
|
|
do {
|
|
try store.forgetCredentials()
|
|
apiKey = ""
|
|
error = nil
|
|
} catch { self.error = error.localizedDescription }
|
|
}
|
|
.disabled(store.isBusy)
|
|
Text("Forgetting the saved key leaves the current session connected.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
if let error { Text(error).foregroundStyle(.red) }
|
|
HStack {
|
|
if store.isBusy { ProgressView().controlSize(.small) }
|
|
Spacer()
|
|
Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction).disabled(store.isBusy)
|
|
Button("Connect") {
|
|
Task {
|
|
error = nil
|
|
do {
|
|
try await store.connect(url: url, key: apiKey, allowUntrusted: allowUntrusted)
|
|
apiKey = ""
|
|
dismiss()
|
|
} catch { self.error = error.localizedDescription }
|
|
}
|
|
}
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(store.isBusy || url.isEmpty || apiKey.isEmpty)
|
|
}
|
|
}
|
|
.padding(24).frame(width: 500)
|
|
.onAppear { url = store.gatewayURL }
|
|
.interactiveDismissDisabled(store.isBusy)
|
|
}
|
|
}
|
|
|
|
struct DNSRecordEditor: View {
|
|
let store: UniFiStore
|
|
let isNew: Bool
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var record: DNSRecord
|
|
@State private var kind: DNSRecordKind
|
|
@State private var domain: String
|
|
@State private var target: String
|
|
@State private var ttl: String
|
|
@State private var priority: String
|
|
@State private var weight: String
|
|
@State private var port: String
|
|
@State private var service: String
|
|
@State private var srvProtocol: String
|
|
@State private var error: String?
|
|
|
|
init(store: UniFiStore, record: DNSRecord, isNew: Bool) {
|
|
self.store = store
|
|
self.isNew = isNew
|
|
_record = State(initialValue: record)
|
|
_kind = State(initialValue: record.kind ?? .a)
|
|
_domain = State(initialValue: record.domain ?? "")
|
|
_target = State(initialValue: record.target ?? "")
|
|
_ttl = State(initialValue: String(record.ttlSeconds ?? 14400))
|
|
_priority = State(initialValue: String(record.priority ?? 10))
|
|
_weight = State(initialValue: String(record.weight ?? 0))
|
|
_port = State(initialValue: record.port.map(String.init) ?? "")
|
|
_service = State(initialValue: record.service ?? "")
|
|
_srvProtocol = State(initialValue: record.protocol ?? "_tcp")
|
|
}
|
|
|
|
/// Builds the record from the form, keeping only the fields that belong to the selected type.
|
|
private var draft: DNSRecord {
|
|
var draft = DNSRecord(id: record.id, type: kind.rawValue, enabled: record.enabled,
|
|
domain: Self.normalizedDomain(domain))
|
|
draft.target = kind == .txt ? target : kind.targetIsDomain
|
|
? Self.normalizedDomain(target) : target.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if kind.usesTTL { draft.ttlSeconds = Int(ttl) }
|
|
if kind.usesPriority { draft.priority = Int(priority) }
|
|
if kind == .srv {
|
|
draft.weight = Int(weight)
|
|
draft.port = Int(port)
|
|
draft.service = Self.normalizedServiceLabel(service)
|
|
draft.protocol = Self.normalizedServiceLabel(srvProtocol)
|
|
}
|
|
return draft
|
|
}
|
|
|
|
private var valid: Bool { (try? draft.payload()) != nil }
|
|
|
|
private var protocolOptions: [String] {
|
|
["_tcp", "_udp"].contains(srvProtocol) ? ["_tcp", "_udp"] : ["_tcp", "_udp", srvProtocol]
|
|
}
|
|
|
|
private static func normalizedDomain(_ value: String) -> String {
|
|
var value = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if value.hasSuffix(".") { value.removeLast() }
|
|
return value
|
|
}
|
|
|
|
private static func normalizedServiceLabel(_ value: String) -> String {
|
|
let value = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
return value.isEmpty || value.hasPrefix("_") ? value : "_" + value
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Text(isNew ? "New DNS Record" : "Edit DNS Record").font(.headline)
|
|
Spacer()
|
|
}.padding(20)
|
|
Form {
|
|
Section(kind == .forwardDomain ? "Forward Domain" : "\(kind.label) Record") {
|
|
LabeledContent("Site", value: store.sites.first { $0.id == store.selectedSiteID }?.name ?? "")
|
|
Picker("Type", selection: $kind) {
|
|
ForEach(DNSRecordKind.allCases) { kind in
|
|
Text(kind == .forwardDomain ? "Forward Domain" : kind.label).tag(kind)
|
|
}
|
|
}
|
|
.disabled(!isNew)
|
|
if kind == .srv {
|
|
TextField("Service", text: $service, prompt: Text("_ldap"))
|
|
Picker("Protocol", selection: $srvProtocol) {
|
|
ForEach(protocolOptions, id: \.self) { Text($0).tag($0) }
|
|
}
|
|
}
|
|
TextField("Domain", text: $domain, prompt: Text(kind == .srv ? "example.com" : "app.example.com"))
|
|
if kind == .txt {
|
|
TextField(kind.targetLabel, text: $target, prompt: Text(kind.targetPrompt), axis: .vertical)
|
|
.lineLimit(1...4)
|
|
} else {
|
|
TextField(kind.targetLabel, text: $target, prompt: Text(kind.targetPrompt))
|
|
}
|
|
if kind.usesPriority { TextField("Priority", text: $priority) }
|
|
if kind == .srv {
|
|
TextField("Weight", text: $weight)
|
|
TextField("Port", text: $port, prompt: Text("389"))
|
|
}
|
|
if kind.usesTTL { TextField("TTL (seconds)", text: $ttl) }
|
|
Toggle("Enabled", isOn: $record.enabled)
|
|
}
|
|
Section {
|
|
Text(kind.helpText)
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.formStyle(.grouped).disabled(store.isBusy)
|
|
if let error { Text(error).foregroundStyle(.red).padding(.horizontal, 20).padding(.bottom, 12) }
|
|
Divider()
|
|
HStack {
|
|
if store.isBusy { ProgressView().controlSize(.small) }
|
|
if store.isDemo { Text("Demo gateway").font(.callout).foregroundStyle(.secondary) }
|
|
Spacer()
|
|
Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction).disabled(store.isBusy)
|
|
Button(isNew ? "Add" : "Save") {
|
|
Task {
|
|
error = nil
|
|
do {
|
|
try await store.save(draft, isNew: isNew)
|
|
dismiss()
|
|
} catch { self.error = error.localizedDescription }
|
|
}
|
|
}.keyboardShortcut(.defaultAction).disabled(!valid || store.isBusy)
|
|
}.padding(20)
|
|
}
|
|
.frame(width: 500, height: kind == .srv ? 620 : 480)
|
|
.interactiveDismissDisabled(store.isBusy)
|
|
// A value typed for one type (such as an IPv4 address) rarely fits another.
|
|
.onChange(of: kind) { target = "" }
|
|
}
|
|
}
|
|
|
|
private extension DNSRecordKind {
|
|
var targetLabel: String {
|
|
switch self {
|
|
case .a: "IPv4 Address"
|
|
case .aaaa: "IPv6 Address"
|
|
case .cname: "Target Domain"
|
|
case .mx: "Mail Server"
|
|
case .txt: "Text"
|
|
case .srv: "Target Server"
|
|
case .forwardDomain: "DNS Server"
|
|
}
|
|
}
|
|
|
|
var targetPrompt: String {
|
|
switch self {
|
|
case .a: "192.168.1.20"
|
|
case .aaaa: "fd00::20"
|
|
case .cname: "server.example.com"
|
|
case .mx: "mail.example.com"
|
|
case .txt: "v=spf1 -all"
|
|
case .srv: "server.example.com"
|
|
case .forwardDomain: "192.168.1.53"
|
|
}
|
|
}
|
|
|
|
var helpText: String {
|
|
switch self {
|
|
case .a: "TTL must be between 0 and 86400 seconds. For a proxied service, use the IP address of your reverse proxy."
|
|
case .aaaa: "TTL must be between 0 and 86400 seconds. For a proxied service, use the IPv6 address of your reverse proxy."
|
|
case .cname: "The domain resolves to the same address as the target domain. TTL must be between 0 and 86400 seconds."
|
|
case .mx: "Mail servers with lower priority values are preferred. Priority must be between 0 and 65535."
|
|
case .txt: "TXT records hold free-form text, commonly used for domain verification and SPF."
|
|
case .srv: "The record is published as _service._protocol.domain. Lower priority values are preferred; weight balances servers with the same priority."
|
|
case .forwardDomain: "Queries for this domain and its subdomains are forwarded to the DNS server instead of the gateway's upstream resolvers."
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview("UniFi DNS") {
|
|
NavigationStack {
|
|
UniFiDNSView(store: UniFiStore(), search: .constant(""))
|
|
.navigationTitle("UniFi DNS")
|
|
}
|
|
.frame(width: 980, height: 650)
|
|
}
|