Initial Commit

This commit is contained in:
2026-09-27 15:42:22 +02:00
commit c34956249d
36 changed files with 1660 additions and 0 deletions
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,68 @@
{
"images": [
{
"idiom": "mac",
"size": "16x16",
"scale": "1x",
"filename": "icon_16x16@1x.png"
},
{
"idiom": "mac",
"size": "16x16",
"scale": "2x",
"filename": "icon_16x16@2x.png"
},
{
"idiom": "mac",
"size": "32x32",
"scale": "1x",
"filename": "icon_32x32@1x.png"
},
{
"idiom": "mac",
"size": "32x32",
"scale": "2x",
"filename": "icon_32x32@2x.png"
},
{
"idiom": "mac",
"size": "128x128",
"scale": "1x",
"filename": "icon_128x128@1x.png"
},
{
"idiom": "mac",
"size": "128x128",
"scale": "2x",
"filename": "icon_128x128@2x.png"
},
{
"idiom": "mac",
"size": "256x256",
"scale": "1x",
"filename": "icon_256x256@1x.png"
},
{
"idiom": "mac",
"size": "256x256",
"scale": "2x",
"filename": "icon_256x256@2x.png"
},
{
"idiom": "mac",
"size": "512x512",
"scale": "1x",
"filename": "icon_512x512@1x.png"
},
{
"idiom": "mac",
"size": "512x512",
"scale": "2x",
"filename": "icon_512x512@2x.png"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+367
View File
@@ -0,0 +1,367 @@
import SwiftUI
enum Destination: String, CaseIterable, Identifiable {
case overview = "Overview"
case hosts = "Proxy Hosts"
case certificates = "Certificates"
case activity = "Activity"
case dns = "UniFi DNS"
var id: String { rawValue }
var icon: String {
switch self {
case .overview: "server.rack"
case .hosts: "network"
case .certificates: "checkmark.shield"
case .activity: "clock"
case .dns: "globe"
}
}
}
struct ContentView: View {
@State private var store = ProxyStore()
@State private var unifi = UniFiStore()
@State private var destination: Destination? = .hosts
@State private var search = ""
@State private var dnsSearch = ""
@State private var filter = "All Hosts"
@State private var selectedID: Int?
@State private var sortOrder = [KeyPathComparator(\ProxyHost.domain)]
@State private var editor: ProxyHost?
@State private var creating = false
@State private var showConnection = false
@State private var showInspector = true
@State private var deleting: ProxyHost?
private var selectedHost: ProxyHost? { store.hosts.first { $0.id == selectedID } }
private var filteredHosts: [ProxyHost] {
store.hosts.filter {
(search.isEmpty || $0.domain.localizedCaseInsensitiveContains(search) || $0.destination.contains(search))
&& (filter == "All Hosts" || (filter == "Enabled" && $0.enabled) || (filter == "Disabled" && !$0.enabled))
}.sorted(using: sortOrder)
}
var body: some View {
NavigationSplitView {
List(selection: $destination) {
Section(store.isDemo ? "Demo Server" : store.serverName) {
ForEach([Destination.overview, .hosts, .certificates, .activity]) { item in
Label(item.rawValue, systemImage: item.icon)
.badge(item == .hosts ? store.hosts.count : 0)
.tag(item)
}
}
Section("UniFi Network") {
Label("DNS Records", systemImage: "globe").tag(Destination.dns)
}
}
.listStyle(.sidebar)
.navigationSplitViewColumnWidth(min: 180, ideal: 210, max: 280)
.safeAreaInset(edge: .bottom) {
Button { showConnection = true } label: {
Label("Connect to Server…", systemImage: "server.rack")
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.borderless)
.padding()
}
} detail: {
detail
.navigationTitle(destination?.rawValue ?? "Proxy Studio")
.navigationSubtitle(destination == .dns ? unifi.connectionLabel : (store.isDemo ? "Demo Server" : store.serverName))
.toolbar {
ToolbarItemGroup {
if destination != .dns {
Button { Task { await store.refresh() } } label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.help("Refresh")
.keyboardShortcut("r")
.disabled(store.isBusy)
}
if destination == .hosts {
Button(action: newHost) {
Label("Add Proxy Host", systemImage: "plus")
}
.help("Add Proxy Host")
.keyboardShortcut("n")
Button { if let host = selectedHost { edit(host) } } label: {
Label("Edit Host", systemImage: "square.and.pencil")
}
.help("Edit Selected Host")
.disabled(selectedHost == nil)
}
}
ToolbarItem {
Button { showInspector.toggle() } label: {
Label("Inspector", systemImage: "sidebar.right")
}
.help("Show or Hide Inspector")
.keyboardShortcut("i", modifiers: [.command, .option])
.disabled(destination != .hosts)
}
}
.inspector(isPresented: Binding(
get: { showInspector && destination == .hosts },
set: { showInspector = $0 }
)) {
if let host = selectedHost {
HostInspector(host: host, edit: { edit(host) }, toggle: {
Task { await store.toggle(host) }
}, delete: { deleting = host })
.inspectorColumnWidth(min: 260, ideal: 290, max: 380)
} else {
ContentUnavailableView("No Selection", systemImage: "network", description: Text("Select a proxy host to view its details."))
.inspectorColumnWidth(min: 260, ideal: 290, max: 380)
}
}
}
// A window must own only one search toolbar item. Keeping this outside the
// changing detail views avoids duplicate AppKit search identifiers.
.searchable(
text: Binding(
get: { destination == .dns ? dnsSearch : search },
set: { if destination == .dns { dnsSearch = $0 } else { search = $0 } }
),
prompt: destination == .dns ? "Search DNS Records" : "Search Hosts"
)
.frame(minWidth: 900, minHeight: 550)
.task {
guard ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" else { return }
await store.restoreConnection()
}
.task {
guard ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" else { return }
await unifi.restoreConnection()
}
.sheet(item: $editor) { host in
HostEditor(store: store, host: host, isNew: creating)
}
.sheet(isPresented: $showConnection) { ConnectionSheet(store: store) }
.alert("Unable to Complete Request", isPresented: Binding(get: { store.error != nil }, set: { if !$0 { store.error = nil } })) {
Button("OK") { store.error = nil }
} message: { Text(store.error ?? "") }
.confirmationDialog("Delete \(deleting?.domain ?? "host")?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) {
Button("Delete Proxy Host", role: .destructive) {
if let host = deleting { Task { await store.delete(host) } }
deleting = nil
}
} message: { Text("This removes the proxy configuration. Your upstream service is not affected.") }
}
@ViewBuilder
private var detail: some View {
switch destination ?? .hosts {
case .hosts:
hostTable
case .overview:
ServerOverview(store: store, connect: { showConnection = true })
case .certificates:
CertificateTable(certificates: store.certificates, isDemo: store.isDemo)
case .activity:
ActivityTable(entries: (store.activity + unifi.activity).sorted { $0.date > $1.date })
case .dns:
UniFiDNSView(store: unifi, search: $dnsSearch)
}
}
private var hostTable: some View {
VStack(spacing: 0) {
HStack {
Picker("Show", selection: $filter) {
Text("All Hosts").tag("All Hosts")
Text("Enabled").tag("Enabled")
Text("Disabled").tag("Disabled")
}
.frame(width: 175)
Spacer()
if store.isBusy { ProgressView().controlSize(.small) }
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
Divider()
Table(filteredHosts, selection: $selectedID, sortOrder: $sortOrder) {
TableColumn("Domain", value: \.domain) { host in
Label(host.domain, systemImage: "globe")
}
.width(min: 140, ideal: 200)
TableColumn("Destination", value: \.destination)
.width(min: 130, ideal: 180)
TableColumn("SSL") { host in
Text(host.secured ? "HTTPS" : "HTTP")
}
.width(65)
TableColumn("Status") { host in
Label(host.enabled ? "Enabled" : "Disabled", systemImage: host.enabled ? "checkmark.circle.fill" : "minus.circle")
.foregroundStyle(host.enabled ? Color.primary : Color.secondary)
}
.width(95)
}
.contextMenu(forSelectionType: Int.self) { ids in
if let id = ids.first, let host = store.hosts.first(where: { $0.id == id }) {
Button("Edit Host…") { edit(host) }
Button(host.enabled ? "Disable Host" : "Enable Host") {
Task { await store.toggle(host) }
}
Divider()
Button("Delete Host…", role: .destructive) { deleting = host }
}
} primaryAction: { ids in
if let id = ids.first, let host = store.hosts.first(where: { $0.id == id }) { edit(host) }
}
.overlay {
if filteredHosts.isEmpty {
ContentUnavailableView("No Hosts", systemImage: "network", description: Text(search.isEmpty ? "Add a proxy host or change the status filter." : "No hosts match your search."))
}
}
Divider()
HStack {
Text("\(filteredHosts.count) hosts")
Spacer()
if store.isDemo {
Text("Demo data — stored on this Mac")
} else if let date = store.lastSynced {
Text("Updated \(date.formatted(date: .omitted, time: .shortened))")
}
}
.font(.callout)
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 7)
}
}
private func edit(_ host: ProxyHost) {
creating = false
editor = host
}
private func newHost() {
creating = true
editor = ProxyHost.sample(0, "", "", 80, secure: false)
}
}
struct HostInspector: View {
let host: ProxyHost
let edit: () -> Void
let toggle: () -> Void
let delete: () -> Void
var body: some View {
Form {
Section {
LabeledContent("Domain", value: host.domain)
LabeledContent("Status", value: host.enabled ? "Enabled" : "Disabled")
LabeledContent("ID", value: String(host.id))
} header: {
Text("Proxy Host")
}
Section("Forwarding") {
LabeledContent("Host", value: host.forwardHost)
LabeledContent("Port", value: String(host.forwardPort))
LabeledContent("Scheme", value: host.forwardScheme.uppercased())
}
Section("Security") {
LabeledContent("Certificate", value: host.secured ? "#\(host.certificateId)" : "None")
LabeledContent("Force HTTPS", value: host.sslForced ? "On" : "Off")
LabeledContent("Block Exploits", value: host.blockExploits ? "On" : "Off")
LabeledContent("Access", value: host.accessListId == 0 ? "Public" : "Restricted")
}
Section("Options") {
LabeledContent("HTTP/2", value: host.http2Support ? "On" : "Off")
LabeledContent("WebSockets", value: host.allowWebsocketUpgrade ? "On" : "Off")
}
Section {
Button("Edit Host…", action: edit)
Button(host.enabled ? "Disable Host" : "Enable Host", action: toggle)
Button("Delete Host…", role: .destructive, action: delete)
}
}
.formStyle(.grouped)
.textSelection(.enabled)
}
}
struct ServerOverview: View {
let store: ProxyStore
let connect: () -> Void
var body: some View {
Form {
Section("Server") {
LabeledContent("Name", value: store.serverName)
LabeledContent("Connection", value: store.isDemo ? "Demo" : "Connected")
if !store.isDemo { LabeledContent("URL", value: store.baseURL) }
Button("Connect to Server…", action: connect)
}
Section("Configuration") {
LabeledContent("Proxy Hosts", value: String(store.hosts.count))
LabeledContent("Enabled Hosts", value: String(store.hosts.filter(\.enabled).count))
LabeledContent("Hosts with SSL", value: String(store.hosts.filter(\.secured).count))
LabeledContent("Certificates", value: String(store.certificates.count))
}
if store.isDemo {
Section {
Text("This workspace contains sample hosts. Changes are stored locally. Connect to a Nginx Proxy Manager server to manage its configuration.")
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
}
}
struct CertificateTable: View {
let certificates: [ProxyCertificate]
let isDemo: Bool
var body: some View {
Table(certificates) {
TableColumn("Name", value: \.niceName)
TableColumn("Domains") { certificate in
Text(certificate.domainNames.joined(separator: ", "))
}
TableColumn("Provider") { certificate in
Text(certificate.provider == "letsencrypt" ? "Let’s Encrypt" : "Custom")
}
TableColumn("Expiration") { certificate in
Text(certificate.expiresOn ?? (isDemo ? "Demo certificate" : "Not provided"))
}
}
.overlay {
if certificates.isEmpty {
ContentUnavailableView("No Certificates", systemImage: "checkmark.shield", description: Text("Certificates from your server appear here."))
}
}
}
}
struct ActivityTable: View {
let entries: [ActivityEntry]
var body: some View {
Table(entries) {
TableColumn("Time") { entry in
Text(entry.date, style: .time)
}
.width(100)
TableColumn("Action", value: \.title)
TableColumn("Host", value: \.detail)
}
.overlay {
if entries.isEmpty {
ContentUnavailableView("No Activity", systemImage: "clock", description: Text("Changes made during this session appear here."))
}
}
}
}
#Preview {
ContentView()
.frame(width: 1180, height: 720)
}
+75
View File
@@ -0,0 +1,75 @@
import Foundation
import Security
struct ProxyCredentials: Codable {
let url: String
let email: String
let password: String
}
struct UniFiCredentials: Codable {
let url: String
let apiKey: String
let allowUntrusted: Bool
}
enum CredentialStore {
static let proxyAccount = "nginx-proxy-manager"
static let unifiAccount = "unifi-network"
// Use the macOS login keychain so local, ad hoc signed builds also work
// without a development team's application-identifier entitlement.
private static func query(account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "io.github.nihaiden.ProxyStudio.credentials",
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: false
]
}
static func save<T: Encodable>(_ credentials: T, account: String) throws {
let data = try JSONEncoder().encode(credentials)
let query = query(account: account)
let attributes = [kSecValueData as String: data]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
var item = query
item[kSecValueData as String] = data
item[kSecAttrLabel as String] = "Proxy Studio — " + account
try check(SecItemAdd(item as CFDictionary, nil))
} else {
try check(status)
}
}
static func load<T: Decodable>(_ type: T.Type, account: String) throws -> T? {
var query = query(account: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
try check(status)
guard let data = result as? Data else {
throw ServiceError.message("The saved credentials could not be read. Please connect again.")
}
do {
return try JSONDecoder().decode(type, from: data)
} catch {
throw ServiceError.message("The saved credentials could not be decoded. Please connect again.")
}
}
static func delete(account: String) throws {
let status = SecItemDelete(query(account: account) as CFDictionary)
if status != errSecItemNotFound { try check(status) }
}
private static func check(_ status: OSStatus) throws {
guard status == errSecSuccess else {
let message = SecCopyErrorMessageString(status, nil) as String? ?? "Error \(status)"
throw ServiceError.message("Keychain: \(message)")
}
}
}
+162
View File
@@ -0,0 +1,162 @@
import SwiftUI
struct HostEditor: View {
let store: ProxyStore
let isNew: Bool
@State private var host: ProxyHost
@Environment(\.dismiss) private var dismiss
@State private var domains: String
@State private var port: String
@State private var saving = false
@State private var error: String?
init(store: ProxyStore, host: ProxyHost, isNew: Bool) {
self.store = store
self.isNew = isNew
_host = State(initialValue: host)
_domains = State(initialValue: host.domainNames.joined(separator: ", "))
_port = State(initialValue: String(host.forwardPort))
}
private var valid: Bool {
let names = domains.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
return !names.isEmpty && names.allSatisfy { !$0.isEmpty && !$0.contains(" ") && !$0.contains("/") }
&& !host.forwardHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (1...65535).contains(Int(port) ?? 0)
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text(isNew ? "New Proxy Host" : "Edit Proxy Host").font(.headline)
Spacer()
}
.padding(20)
Form {
Section {
TextField("Domain Names", text: $domains, prompt: Text("app.example.com"))
Picker("Forward Scheme", selection: $host.forwardScheme) {
Text("HTTP").tag("http")
Text("HTTPS").tag("https")
}
TextField("Forward Host", text: $host.forwardHost, prompt: Text("192.168.1.20"))
TextField("Forward Port", text: $port, prompt: Text("8080"))
} header: {
Text("Forwarding")
} footer: {
Text("Separate multiple domain names with commas.")
}
Section("Security") {
Picker("SSL Certificate", selection: $host.certificateId) {
Text("None").tag(0)
ForEach(store.certificates) { certificate in
Text(certificate.niceName).tag(certificate.id)
}
}
Toggle("Force HTTPS", isOn: $host.sslForced).disabled(host.certificateId == 0)
Toggle("Block Common Exploits", isOn: $host.blockExploits)
}
Section("Options") {
Toggle("HTTP/2 Support", isOn: $host.http2Support)
Toggle("WebSocket Support", isOn: $host.allowWebsocketUpgrade)
}
}
.formStyle(.grouped)
.disabled(saving)
if let error {
Label(error, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red).padding(.horizontal, 20).padding(.bottom, 12)
}
Divider()
HStack {
if saving { ProgressView().controlSize(.small) }
if store.isDemo { Text("Demo workspace").font(.callout).foregroundStyle(.secondary) }
Spacer()
Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction).disabled(saving)
Button(isNew ? "Add" : "Save") { Task { await save() } }
.keyboardShortcut(.defaultAction)
.disabled(!valid || saving)
}
.padding(20)
}
.frame(width: 510, height: 610)
.interactiveDismissDisabled(saving)
}
private func save() async {
saving = true
error = nil
host.domainNames = domains.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
host.forwardHost = host.forwardHost.trimmingCharacters(in: .whitespacesAndNewlines)
host.forwardPort = Int(port) ?? 80
if host.certificateId == 0 { host.sslForced = false }
do {
try await store.save(host, isNew: isNew)
dismiss()
} catch { self.error = error.localizedDescription }
saving = false
}
}
struct ConnectionSheet: View {
let store: ProxyStore
@Environment(\.dismiss) private var dismiss
@State private var url = ""
@State private var email = ""
@State private var password = ""
@State private var connecting = false
@State private var error: String?
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text("Connect to Server").font(.headline)
Text("Enter the address and credentials for your Nginx Proxy Manager server.")
.foregroundStyle(.secondary)
Form {
TextField("Server URL:", text: $url, prompt: Text("https://proxy.example.com"))
TextField("Email:", text: $email, prompt: Text("admin@example.com"))
SecureField("Password:", text: $password)
}
.textFieldStyle(.roundedBorder)
.disabled(connecting || store.isBusy)
Text("Your login is saved in Keychain after connecting and used to reconnect when you open the app.")
.font(.callout).foregroundStyle(.secondary)
Button("Forget Saved Login") {
do {
try store.forgetCredentials()
password = ""
error = nil
} catch { self.error = error.localizedDescription }
}
.disabled(connecting || store.isBusy)
Text("Forgetting the saved login leaves the current session connected.")
.font(.caption).foregroundStyle(.secondary)
if let error {
Label(error, systemImage: "exclamationmark.triangle").foregroundStyle(.red)
}
HStack {
if connecting { ProgressView().controlSize(.small) }
Spacer()
Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction).disabled(connecting)
Button("Connect") { Task { await connect() } }
.keyboardShortcut(.defaultAction)
.disabled(connecting || store.isBusy || url.isEmpty || email.isEmpty || password.isEmpty)
}
}
.padding(24)
.frame(width: 460)
.onAppear { url = store.baseURL }
.interactiveDismissDisabled(connecting)
}
private func connect() async {
connecting = true
error = nil
do {
try await store.connect(url: url, email: email, password: password)
password = ""
dismiss()
} catch { self.error = error.localizedDescription }
connecting = false
}
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIconFile</key>
<string>ProxyStudio.icns</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
+15
View File
@@ -0,0 +1,15 @@
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup("Proxy Studio") {
ContentView()
}
.defaultSize(width: 1180, height: 720)
.commands {
SidebarCommands()
InspectorCommands()
}
}
}
+240
View File
@@ -0,0 +1,240 @@
import Foundation
import SwiftUI
struct ProxyHost: Identifiable, Codable, Hashable {
var id: Int
var domainNames: [String]
var forwardHost: String
var forwardPort: Int
var forwardScheme: String
var certificateId: Int
var sslForced: Bool
var http2Support: Bool
var blockExploits: Bool
var allowWebsocketUpgrade: Bool
var enabled: Bool
var accessListId: Int
var advancedConfig: String
var domain: String { domainNames.first ?? "Untitled host" }
var destination: String { "\(forwardHost):\(forwardPort)" }
var secured: Bool { certificateId > 0 }
var symbol: String {
if domain.contains("photos") { return "photo.on.rectangle.angled" }
if domain.contains("home") { return "house" }
if domain.contains("git") { return "chevron.left.forwardslash.chevron.right" }
if domain.contains("media") { return "play.rectangle" }
if domain.contains("status") { return "chart.xyaxis.line" }
return "globe"
}
static let examples: [ProxyHost] = [
sample(1, "home.lab", "192.168.1.20", 8123),
sample(2, "photos.lab", "192.168.1.24", 2283),
sample(3, "git.lab", "192.168.1.30", 3000),
sample(4, "media.lab", "192.168.1.24", 8096),
sample(5, "status.lab", "192.168.1.20", 3001),
sample(6, "notes.lab", "192.168.1.30", 8080, secure: false, enabled: false)
]
static func sample(_ id: Int, _ domain: String, _ host: String, _ port: Int, secure: Bool = true, enabled: Bool = true) -> ProxyHost {
ProxyHost(id: id, domainNames: [domain], forwardHost: host, forwardPort: port,
forwardScheme: "http", certificateId: secure ? 1 : 0, sslForced: secure,
http2Support: true, blockExploits: true, allowWebsocketUpgrade: true,
enabled: enabled, accessListId: 0, advancedConfig: "")
}
}
struct ProxyCertificate: Identifiable, Codable {
var id: Int
var niceName: String
var domainNames: [String]
var provider: String
var expiresOn: String?
}
struct ActivityEntry: Identifiable {
let id = UUID()
let title: String
let detail: String
let date = Date()
}
@MainActor @Observable
final class ProxyStore {
var hosts = ProxyHost.examples
var certificates = [ProxyCertificate(id: 1, niceName: "Homelab wildcard", domainNames: ["*.lab"], provider: "letsencrypt", expiresOn: nil)]
var activity: [ActivityEntry] = []
var isDemo = true
var isBusy = false
var error: String?
var baseURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
var serverName = "Homelab"
var lastSynced: Date?
private var token = ""
private var didRestoreCredentials = false
init() {
if let data = UserDefaults.standard.data(forKey: "demoHosts"),
let saved = try? JSONDecoder().decode([ProxyHost].self, from: data) {
hosts = saved
}
}
private func persist() {
guard isDemo, let data = try? JSONEncoder().encode(hosts) else { return }
UserDefaults.standard.set(data, forKey: "demoHosts")
}
func restoreConnection() async {
guard !didRestoreCredentials, !isBusy, isDemo else { return }
didRestoreCredentials = true
do {
guard let saved = try CredentialStore.load(ProxyCredentials.self, account: CredentialStore.proxyAccount) else { return }
try await connect(url: saved.url, email: saved.email, password: saved.password, saveCredentials: false)
} catch {
self.error = "Could not restore the proxy connection. " + error.localizedDescription
}
}
func forgetCredentials() throws {
try CredentialStore.delete(account: CredentialStore.proxyAccount)
didRestoreCredentials = true
}
func connect(url: String, email: String, password: String, saveCredentials: Bool = true) async throws {
guard !isBusy else { throw ServiceError.message("Wait for the current request to finish.") }
isBusy = true
defer { isBusy = false }
didRestoreCredentials = true
let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let parsed = URL(string: trimmed), ["http", "https"].contains(parsed.scheme ?? ""), parsed.host != nil else {
throw ServiceError.message("Enter a valid server URL, including http:// or https://.")
}
let previous = baseURL
let previousToken = token
baseURL = trimmed.hasSuffix("/api") ? trimmed : trimmed + "/api"
do {
let data = try await request("tokens", method: "POST", body: ["identity": email, "secret": password], authenticated: false)
struct Login: Decodable { let token: String }
token = try JSONDecoder().decode(Login.self, from: data).token
let loaded: [ProxyHost] = try await fetch("nginx/proxy-hosts")
let certs: [ProxyCertificate] = try await fetch("nginx/certificates")
if saveCredentials {
try CredentialStore.save(ProxyCredentials(url: baseURL, email: email, password: password),
account: CredentialStore.proxyAccount)
}
hosts = loaded
certificates = certs
isDemo = false
error = nil
serverName = parsed.host ?? "My server"
lastSynced = Date()
UserDefaults.standard.set(baseURL, forKey: "serverURL")
activity = [ActivityEntry(title: "Connected to server", detail: serverName)]
} catch {
baseURL = previous
token = previousToken
throw error
}
}
func refresh() async {
guard !isBusy else { return }
isBusy = true
defer { isBusy = false }
if isDemo {
lastSynced = Date()
return
}
do {
let loaded: [ProxyHost] = try await fetch("nginx/proxy-hosts")
let certs: [ProxyCertificate] = try await fetch("nginx/certificates")
hosts = loaded
certificates = certs
lastSynced = Date()
} catch { self.error = error.localizedDescription }
}
func save(_ host: ProxyHost, isNew: Bool) async throws {
var saved = host
if isDemo {
if isNew { saved.id = (hosts.map(\.id).max() ?? 0) + 1 }
} else {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
let encoded = try encoder.encode(host)
var body = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] ?? [:]
body.removeValue(forKey: "id")
if isNew {
body["locations"] = []
body["meta"] = [:]
body["caching_enabled"] = false
body["hsts_enabled"] = false
body["hsts_subdomains"] = false
}
let data = try await request(isNew ? "nginx/proxy-hosts" : "nginx/proxy-hosts/\(host.id)", method: isNew ? "POST" : "PUT", body: body)
saved = try decoder.decode(ProxyHost.self, from: data)
}
if let index = hosts.firstIndex(where: { $0.id == saved.id }) { hosts[index] = saved }
else { hosts.append(saved) }
activity.insert(ActivityEntry(title: isNew ? "Proxy host created" : "Proxy host updated", detail: saved.domain), at: 0)
persist()
}
func toggle(_ host: ProxyHost) async {
do {
if !isDemo {
_ = try await request("nginx/proxy-hosts/\(host.id)/\(host.enabled ? "disable" : "enable")", method: "POST")
}
if let index = hosts.firstIndex(where: { $0.id == host.id }) { hosts[index].enabled.toggle() }
activity.insert(ActivityEntry(title: host.enabled ? "Proxy host disabled" : "Proxy host enabled", detail: host.domain), at: 0)
persist()
} catch { self.error = error.localizedDescription }
}
func delete(_ host: ProxyHost) async {
do {
if !isDemo { _ = try await request("nginx/proxy-hosts/\(host.id)", method: "DELETE") }
hosts.removeAll { $0.id == host.id }
activity.insert(ActivityEntry(title: "Proxy host deleted", detail: host.domain), at: 0)
persist()
} catch { self.error = error.localizedDescription }
}
private var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}
private func fetch<T: Decodable>(_ path: String) async throws -> T {
let data = try await request(path)
return try decoder.decode(T.self, from: data)
}
private func request(_ path: String, method: String = "GET", body: [String: Any]? = nil, authenticated: Bool = true) async throws -> Data {
guard let url = URL(string: baseURL + "/" + path) else { throw ServiceError.message("Invalid server URL.") }
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = 20
if authenticated { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
if let body {
request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let response = response as? HTTPURLResponse else { throw ServiceError.message("The server returned an invalid response.") }
guard (200..<300).contains(response.statusCode) else {
throw ServiceError.message(response.statusCode == 401 ? "Your session expired or your credentials are incorrect. Please reconnect." : "The server returned HTTP \(response.statusCode). Check your connection and permissions.")
}
return data
}
}
enum ServiceError: LocalizedError {
case message(String)
var errorDescription: String? {
switch self { case .message(let message): return message }
}
}
View File
+248
View File
@@ -0,0 +1,248 @@
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(70)
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 A Record…") { edit(record) }.disabled(record.type != "A_RECORD" || 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.type == "A_RECORD", !store.isBusy { edit(record) }
}
.overlay {
if filteredRecords.isEmpty {
ContentUnavailableView("No DNS Records", systemImage: "globe", description: Text(search.isEmpty ? "Add an A 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 A Record").disabled(selectedRecord?.type != "A_RECORD" || 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 domain: String
@State private var address: String
@State private var ttl: String
@State private var error: String?
init(store: UniFiStore, record: DNSRecord, isNew: Bool) {
self.store = store
self.isNew = isNew
_record = State(initialValue: record)
_domain = State(initialValue: record.domain ?? "")
_address = State(initialValue: record.ipv4Address ?? "")
_ttl = State(initialValue: String(record.ttlSeconds ?? 14400))
}
private var valid: Bool {
DNSRecord.isValidDomain(domain.trimmingCharacters(in: .whitespacesAndNewlines))
&& DNSRecord.isValidIPv4(address.trimmingCharacters(in: .whitespacesAndNewlines))
&& Int(ttl).map { (0...86400).contains($0) } == true
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text(isNew ? "New DNS Record" : "Edit DNS Record").font(.headline)
Spacer()
}.padding(20)
Form {
Section("A Record") {
LabeledContent("Site", value: store.sites.first { $0.id == store.selectedSiteID }?.name ?? "")
TextField("Domain", text: $domain, prompt: Text("app.example.com"))
TextField("IPv4 Address", text: $address, prompt: Text("192.168.1.20"))
TextField("TTL (seconds)", text: $ttl)
Toggle("Enabled", isOn: $record.enabled)
}
Section {
Text("TTL must be between 0 and 86400 seconds. For a proxied service, use the IP address of your reverse proxy.")
.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
record.domain = domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
record.ipv4Address = address.trimmingCharacters(in: .whitespacesAndNewlines)
record.ttlSeconds = Int(ttl)
do {
try await store.save(record, isNew: isNew)
dismiss()
} catch { self.error = error.localizedDescription }
}
}.keyboardShortcut(.defaultAction).disabled(!valid || store.isBusy)
}.padding(20)
}
.frame(width: 500, height: 440)
.interactiveDismissDisabled(store.isBusy)
}
}
#Preview("UniFi DNS") {
NavigationStack {
UniFiDNSView(store: UniFiStore(), search: .constant(""))
.navigationTitle("UniFi DNS")
}
.frame(width: 980, height: 650)
}
+264
View File
@@ -0,0 +1,264 @@
import Foundation
import SwiftUI
import Security
struct UniFiSite: Codable, Identifiable, Hashable {
let id: String
let name: String
}
struct DNSRecord: Codable, Identifiable, Hashable {
var id: String
var type: String
var enabled: Bool
var domain: String?
var ipv4Address: String?
var ipv6Address: String?
var targetDomain: String?
var ttlSeconds: Int?
var domainName: String { domain ?? "—" }
var recordType: String { type.replacingOccurrences(of: "_RECORD", with: "") }
var value: String { ipv4Address ?? ipv6Address ?? targetDomain ?? "See UniFi Network" }
static let examples = [
DNSRecord(id: "demo-home", type: "A_RECORD", enabled: true, domain: "home.lab", ipv4Address: "192.168.1.20", ttlSeconds: 14400),
DNSRecord(id: "demo-photos", type: "A_RECORD", enabled: true, domain: "photos.lab", ipv4Address: "192.168.1.24", ttlSeconds: 14400)
]
static func isValidDomain(_ value: String) -> Bool {
guard !value.isEmpty, value.count <= 127 else { return false }
return value.split(separator: ".", omittingEmptySubsequences: false).allSatisfy { label in
!label.isEmpty && label.count <= 63 && label.first != "-" && label.last != "-"
&& label.utf8.allSatisfy { (97...122).contains($0) || (65...90).contains($0) || (48...57).contains($0) || $0 == 45 }
}
}
static func isValidIPv4(_ value: String) -> Bool {
let parts = value.split(separator: ".", omittingEmptySubsequences: false)
return parts.count == 4 && parts.allSatisfy {
!$0.isEmpty && $0.utf8.allSatisfy { (48...57).contains($0) }
&& Int($0).map { (0...255).contains($0) } == true
&& ($0.count == 1 || $0.first != "0")
}
}
func payload() throws -> Data {
guard type == "A_RECORD", Self.isValidDomain(domainName),
Self.isValidIPv4(ipv4Address ?? ""), let ttlSeconds, (0...86400).contains(ttlSeconds) else {
throw ServiceError.message("Enter a valid domain, IPv4 address, and TTL between 0 and 86400 seconds.")
}
return try JSONSerialization.data(withJSONObject: [
"type": "A_RECORD", "domain": domainName, "ipv4Address": ipv4Address ?? "",
"ttlSeconds": ttlSeconds, "enabled": enabled
])
}
}
// The optional trust exception applies only to this gateway session and exact host.
// Redirects are rejected so the API key cannot be forwarded to another endpoint.
nonisolated final class UniFiSessionDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate {
let host: String
let allowUntrusted: Bool
init(host: String, allowUntrusted: Bool) {
self.host = host
self.allowUntrusted = allowUntrusted
}
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
if allowUntrusted, challenge.protectionSpace.host == host,
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust {
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
completionHandler(.performDefaultHandling, nil)
}
}
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}
@MainActor @Observable
final class UniFiStore {
private(set) var sites = [UniFiSite(id: "demo", name: "Default")]
private(set) var selectedSiteID = "demo"
private(set) var records = DNSRecord.examples
private(set) var isDemo = true
private(set) var isBusy = false
private(set) var gatewayURL = UserDefaults.standard.string(forKey: "unifiGatewayURL") ?? ""
private(set) var lastSynced: Date?
var error: String?
var activity: [ActivityEntry] = []
private var apiKey = ""
private var didRestoreCredentials = false
private var session: URLSession?
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if let data = defaults.data(forKey: "demoDNSRecords"),
let saved = try? JSONDecoder().decode([DNSRecord].self, from: data) { records = saved }
}
var connectionLabel: String { isDemo ? "Demo Gateway" : URL(string: gatewayURL)?.host ?? "UniFi Gateway" }
func restoreConnection() async {
guard !didRestoreCredentials, !isBusy, isDemo else { return }
didRestoreCredentials = true
do {
guard let saved = try CredentialStore.load(UniFiCredentials.self, account: CredentialStore.unifiAccount) else { return }
try await connect(url: saved.url, key: saved.apiKey, allowUntrusted: saved.allowUntrusted, saveCredentials: false)
} catch {
self.error = "Could not restore the UniFi connection. " + error.localizedDescription
}
}
func forgetCredentials() throws {
try CredentialStore.delete(account: CredentialStore.unifiAccount)
didRestoreCredentials = true
}
func connect(url: String, key: String, allowUntrusted: Bool, saveCredentials: Bool = true) async throws {
guard !isBusy else { throw ServiceError.message("Wait for the current request to finish.") }
didRestoreCredentials = true
guard let parsed = URL(string: url.trimmingCharacters(in: .whitespacesAndNewlines)),
parsed.scheme == "https", let host = parsed.host,
parsed.user == nil, parsed.password == nil, parsed.query == nil, parsed.fragment == nil,
parsed.path.isEmpty || parsed.path == "/" else {
throw ServiceError.message("Enter the gateway HTTPS address, such as https://192.168.1.1, without an API path.")
}
let key = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { throw ServiceError.message("Enter your UniFi Network API key.") }
isBusy = true
defer { isBusy = false }
let base = parsed.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let candidate = URLSession(configuration: .ephemeral, delegate: UniFiSessionDelegate(host: host, allowUntrusted: allowUntrusted), delegateQueue: nil)
do {
let loadedSites: [UniFiSite] = try await pages("sites", base: base, key: key, session: candidate)
guard let first = loadedSites.first else { throw ServiceError.message("This API key has no accessible sites.") }
let loadedRecords: [DNSRecord] = try await pages("sites/\(first.id)/dns/policies", base: base, key: key, session: candidate)
if saveCredentials {
try CredentialStore.save(UniFiCredentials(url: base, apiKey: key, allowUntrusted: allowUntrusted),
account: CredentialStore.unifiAccount)
}
session?.invalidateAndCancel()
session = candidate
gatewayURL = base
apiKey = key
sites = loadedSites
selectedSiteID = first.id
records = loadedRecords
isDemo = false
lastSynced = Date()
error = nil
defaults.set(base, forKey: "unifiGatewayURL")
activity.insert(ActivityEntry(title: "Connected to UniFi", detail: host), at: 0)
} catch {
candidate.invalidateAndCancel()
throw error
}
}
func loadSite(_ id: String) async {
guard !isBusy, sites.contains(where: { $0.id == id }) else { return }
isBusy = true
defer { isBusy = false }
do {
if !isDemo, let session {
let loaded: [DNSRecord] = try await pages("sites/\(id)/dns/policies", base: gatewayURL, key: apiKey, session: session)
records = loaded
}
selectedSiteID = id
lastSynced = Date()
error = nil
} catch { self.error = error.localizedDescription }
}
func save(_ record: DNSRecord, isNew: Bool) async throws {
guard !isBusy else { throw ServiceError.message("Wait for the current request to finish.") }
let body = try record.payload()
if records.contains(where: { $0.id != record.id && $0.domainName.caseInsensitiveCompare(record.domainName) == .orderedSame && $0.type == record.type }) {
throw ServiceError.message("An A record already exists for this domain in the selected site.")
}
isBusy = true
defer { isBusy = false }
var saved = record
if isDemo {
if isNew { saved.id = UUID().uuidString }
} else {
guard let session else { throw ServiceError.message("Connect to a UniFi gateway first.") }
let path = "sites/\(selectedSiteID)/dns/policies" + (isNew ? "" : "/\(record.id)")
let data = try await request(path, method: isNew ? "POST" : "PUT", body: body, base: gatewayURL, key: apiKey, session: session)
saved = try JSONDecoder().decode(DNSRecord.self, from: data)
}
if let index = records.firstIndex(where: { $0.id == saved.id }) { records[index] = saved }
else { records.append(saved) }
activity.insert(ActivityEntry(title: isNew ? "DNS record created" : "DNS record updated", detail: saved.domainName), at: 0)
persist()
}
func delete(_ record: DNSRecord) async {
guard !isBusy else { return }
isBusy = true
defer { isBusy = false }
do {
if !isDemo {
guard let session else { throw ServiceError.message("Connect to a UniFi gateway first.") }
_ = try await request("sites/\(selectedSiteID)/dns/policies/\(record.id)", method: "DELETE", base: gatewayURL, key: apiKey, session: session)
}
records.removeAll { $0.id == record.id }
activity.insert(ActivityEntry(title: "DNS record deleted", detail: record.domainName), at: 0)
persist()
} catch { self.error = error.localizedDescription }
}
private func persist() {
if isDemo, let data = try? JSONEncoder().encode(records) { defaults.set(data, forKey: "demoDNSRecords") }
}
private struct Page<T: Decodable>: Decodable {
let data: [T]
let totalCount: Int?
}
private func pages<T: Decodable>(_ path: String, base: String, key: String, session: URLSession) async throws -> [T] {
var items: [T] = []
var offset = 0
while offset < 100000 {
let data = try await request(path + "?offset=\(offset)&limit=100", base: base, key: key, session: session)
let page = try JSONDecoder().decode(Page<T>.self, from: data)
items.append(contentsOf: page.data)
offset += page.data.count
if page.data.isEmpty || offset >= (page.totalCount ?? Int.max) || (page.totalCount == nil && page.data.count < 100) { return items }
}
throw ServiceError.message("The gateway returned too many results.")
}
private func request(_ path: String, method: String = "GET", body: Data? = nil,
base: String, key: String, session: URLSession) async throws -> Data {
guard let url = URL(string: base + "/proxy/network/integration/v1/" + path) else { throw ServiceError.message("Invalid gateway URL.") }
var request = URLRequest(url: url)
request.httpMethod = method
request.httpBody = body
request.timeoutInterval = 25
request.setValue(key, forHTTPHeaderField: "X-API-KEY")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if body != nil { request.setValue("application/json", forHTTPHeaderField: "Content-Type") }
let (data, response) = try await session.data(for: request)
guard let response = response as? HTTPURLResponse else { throw ServiceError.message("Invalid gateway response.") }
guard (200..<300).contains(response.statusCode) else {
switch response.statusCode {
case 401, 403: throw ServiceError.message("UniFi rejected the API key or its permissions. Check the key in UniFi Network.")
case 404: throw ServiceError.message("The site or DNS endpoint was not found. Check that your gateway supports the Network Integration DNS API.")
default: throw ServiceError.message("UniFi returned HTTP \(response.statusCode). The operation was not confirmed.")
}
}
return data
}
}