Initial Commit
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 780 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 1022 KiB |
@@ -0,0 +1,170 @@
|
|||||||
|
{
|
||||||
|
"default-configuration": "Release",
|
||||||
|
"configurations": [
|
||||||
|
"Debug",
|
||||||
|
"Release",
|
||||||
|
],
|
||||||
|
"localizations": {
|
||||||
|
"development": "en",
|
||||||
|
"supported": [
|
||||||
|
"Base",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"kind": "folder",
|
||||||
|
"path": "proxy studio",
|
||||||
|
"target-membership": [
|
||||||
|
"proxy studio",
|
||||||
|
],
|
||||||
|
"membership-exceptions": [
|
||||||
|
{
|
||||||
|
"target": "proxy studio",
|
||||||
|
"exclusions": [
|
||||||
|
"Info.plist",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
"kind": "group",
|
||||||
|
"name": "Products",
|
||||||
|
"children": [
|
||||||
|
{ "path": "<PRODUCTS>/Proxy Studio.app", "id": "000000000000000000000120", "type": "wrapper.application", "index": false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"name": "proxy studio",
|
||||||
|
"id": "000000000000000100000000",
|
||||||
|
"product": "Products/Proxy Studio.app",
|
||||||
|
"product-type": "application",
|
||||||
|
"build-phases": [
|
||||||
|
"compile-sources",
|
||||||
|
"frameworks",
|
||||||
|
"resources",
|
||||||
|
],
|
||||||
|
"build-settings": {
|
||||||
|
"CODE_SIGN_STYLE": "Automatic",
|
||||||
|
"CURRENT_PROJECT_VERSION": "1",
|
||||||
|
"ENABLE_APP_SANDBOX": "YES",
|
||||||
|
"ENABLE_HARDENED_RUNTIME": "YES",
|
||||||
|
"ENABLE_INCOMING_NETWORK_CONNECTIONS": "NO",
|
||||||
|
"ENABLE_OUTGOING_NETWORK_CONNECTIONS": "YES",
|
||||||
|
"ENABLE_PREVIEWS": "YES",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_AUDIO_INPUT": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_BLUETOOTH": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_CALENDARS": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_CAMERA": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_CONTACTS": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_LOCATION": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_PRINTING": "NO",
|
||||||
|
"ENABLE_RESOURCE_ACCESS_USB": "NO",
|
||||||
|
"ENABLE_USER_SELECTED_FILES": "readonly",
|
||||||
|
"GENERATE_INFOPLIST_FILE": "YES",
|
||||||
|
"INFOPLIST_FILE": "MyApp/Info.plist",
|
||||||
|
"INFOPLIST_KEY_CFBundleDisplayName": "Proxy Studio",
|
||||||
|
"INFOPLIST_KEY_LSApplicationCategoryType": "public.app-category.developer-tools",
|
||||||
|
"INFOPLIST_KEY_NSLocalNetworkUsageDescription": "Proxy Studio connects to your Nginx Proxy Manager and UniFi gateways to manage proxy hosts, certificates, and DNS records.",
|
||||||
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]": "YES",
|
||||||
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]": "YES",
|
||||||
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]": "YES",
|
||||||
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]": "YES",
|
||||||
|
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]": "YES",
|
||||||
|
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]": "YES",
|
||||||
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]": "UIStatusBarStyleDefault",
|
||||||
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]": "UIStatusBarStyleDefault",
|
||||||
|
"INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad": "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight",
|
||||||
|
"INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone": "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight",
|
||||||
|
"LD_RUNPATH_SEARCH_PATHS": "@executable_path/Frameworks",
|
||||||
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]": "@executable_path/../Frameworks",
|
||||||
|
"MACOSX_DEPLOYMENT_TARGET": "14.0",
|
||||||
|
"MARKETING_VERSION": "1.0",
|
||||||
|
"PRODUCT_BUNDLE_IDENTIFIER": "io.github.nihaiden.ProxyStudio",
|
||||||
|
"PRODUCT_NAME": "Proxy Studio",
|
||||||
|
"REGISTER_APP_GROUPS": "YES",
|
||||||
|
"SDKROOT": "auto",
|
||||||
|
"STRING_CATALOG_GENERATE_SYMBOLS": "YES",
|
||||||
|
"SUPPORTED_PLATFORMS": "macosx",
|
||||||
|
"SWIFT_APPROACHABLE_CONCURRENCY": "YES",
|
||||||
|
"SWIFT_DEFAULT_ACTOR_ISOLATION": "MainActor",
|
||||||
|
"SWIFT_EMIT_LOC_STRINGS": "YES",
|
||||||
|
"SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY": "YES",
|
||||||
|
"SWIFT_VERSION": "5.0",
|
||||||
|
"TARGETED_DEVICE_FAMILY": "1,2,7",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"build-settings": {
|
||||||
|
"ALWAYS_SEARCH_USER_PATHS": "NO",
|
||||||
|
"ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS": "YES",
|
||||||
|
"CLANG_ANALYZER_NONNULL": "YES",
|
||||||
|
"CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION": "YES_AGGRESSIVE",
|
||||||
|
"CLANG_CXX_LANGUAGE_STANDARD": "gnu++20",
|
||||||
|
"CLANG_ENABLE_MODULES": "YES",
|
||||||
|
"CLANG_ENABLE_OBJC_ARC": "YES",
|
||||||
|
"CLANG_ENABLE_OBJC_WEAK": "YES",
|
||||||
|
"CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING": "YES",
|
||||||
|
"CLANG_WARN_BOOL_CONVERSION": "YES",
|
||||||
|
"CLANG_WARN_COMMA": "YES",
|
||||||
|
"CLANG_WARN_CONSTANT_CONVERSION": "YES",
|
||||||
|
"CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS": "YES",
|
||||||
|
"CLANG_WARN_DIRECT_OBJC_ISA_USAGE": "YES_ERROR",
|
||||||
|
"CLANG_WARN_DOCUMENTATION_COMMENTS": "YES",
|
||||||
|
"CLANG_WARN_EMPTY_BODY": "YES",
|
||||||
|
"CLANG_WARN_ENUM_CONVERSION": "YES",
|
||||||
|
"CLANG_WARN_INFINITE_RECURSION": "YES",
|
||||||
|
"CLANG_WARN_INT_CONVERSION": "YES",
|
||||||
|
"CLANG_WARN_NON_LITERAL_NULL_CONVERSION": "YES",
|
||||||
|
"CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF": "YES",
|
||||||
|
"CLANG_WARN_OBJC_LITERAL_CONVERSION": "YES",
|
||||||
|
"CLANG_WARN_OBJC_ROOT_CLASS": "YES_ERROR",
|
||||||
|
"CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER": "YES",
|
||||||
|
"CLANG_WARN_RANGE_LOOP_ANALYSIS": "YES",
|
||||||
|
"CLANG_WARN_STRICT_PROTOTYPES": "YES",
|
||||||
|
"CLANG_WARN_SUSPICIOUS_MOVE": "YES",
|
||||||
|
"CLANG_WARN_UNGUARDED_AVAILABILITY": "YES_AGGRESSIVE",
|
||||||
|
"CLANG_WARN_UNREACHABLE_CODE": "YES",
|
||||||
|
"CLANG_WARN__DUPLICATE_METHOD_MATCH": "YES",
|
||||||
|
"COPY_PHASE_STRIP": "NO",
|
||||||
|
"DEAD_CODE_STRIPPING": "YES",
|
||||||
|
"DEBUG_INFORMATION_FORMAT[config=Debug]": "dwarf",
|
||||||
|
"DEBUG_INFORMATION_FORMAT[config=Release]": "dwarf-with-dsym",
|
||||||
|
"DRIVERKIT_DEPLOYMENT_TARGET": "27.2",
|
||||||
|
"ENABLE_NS_ASSERTIONS[config=Release]": "NO",
|
||||||
|
"ENABLE_STRICT_OBJC_MSGSEND": "YES",
|
||||||
|
"ENABLE_TESTABILITY[config=Debug]": "YES",
|
||||||
|
"ENABLE_USER_SCRIPT_SANDBOXING": "YES",
|
||||||
|
"GCC_C_LANGUAGE_STANDARD": "gnu17",
|
||||||
|
"GCC_DYNAMIC_NO_PIC[config=Debug]": "NO",
|
||||||
|
"GCC_NO_COMMON_BLOCKS": "YES",
|
||||||
|
"GCC_OPTIMIZATION_LEVEL[config=Debug]": "0",
|
||||||
|
"GCC_PREPROCESSOR_DEFINITIONS[config=Debug]": [
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
],
|
||||||
|
"GCC_WARN_64_TO_32_BIT_CONVERSION": "YES",
|
||||||
|
"GCC_WARN_ABOUT_RETURN_TYPE": "YES_ERROR",
|
||||||
|
"GCC_WARN_UNDECLARED_SELECTOR": "YES",
|
||||||
|
"GCC_WARN_UNINITIALIZED_AUTOS": "YES_AGGRESSIVE",
|
||||||
|
"GCC_WARN_UNUSED_FUNCTION": "YES",
|
||||||
|
"GCC_WARN_UNUSED_VARIABLE": "YES",
|
||||||
|
"IPHONEOS_DEPLOYMENT_TARGET": "27.2",
|
||||||
|
"LOCALIZATION_PREFERS_STRING_CATALOGS": "YES",
|
||||||
|
"MACOSX_DEPLOYMENT_TARGET": "27.0",
|
||||||
|
"MTL_ENABLE_DEBUG_INFO[config=Debug]": "INCLUDE_SOURCE",
|
||||||
|
"MTL_ENABLE_DEBUG_INFO[config=Release]": "NO",
|
||||||
|
"MTL_FAST_MATH": "YES",
|
||||||
|
"ONLY_ACTIVE_ARCH[config=Debug]": "YES",
|
||||||
|
"PROJECT_UNIQUE_VALUE": "NR0BZ3BL",
|
||||||
|
"STRING_CATALOG_GENERATE_SYMBOLS": "YES",
|
||||||
|
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug]": "DEBUG $(inherited)",
|
||||||
|
"SWIFT_COMPILATION_MODE[config=Release]": "wholemodule",
|
||||||
|
"SWIFT_OPTIMIZATION_LEVEL[config=Debug]": "-Onone",
|
||||||
|
"TVOS_DEPLOYMENT_TARGET": "27.2",
|
||||||
|
"WATCHOS_DEPLOYMENT_TARGET": "27.2",
|
||||||
|
"XROS_DEPLOYMENT_TARGET": "27.2",
|
||||||
|
},
|
||||||
|
"last-upgrade": "27.0",
|
||||||
|
"last-swift-update": "26.3",
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?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>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>MyApp.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 780 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 1022 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||