commit c34956249d835cc3f1f15859992666eacd742d5f Author: Niklas Haiden Date: Sun Sep 27 15:42:22 2026 +0200 Initial Commit diff --git a/Release/ProxyStudio-icon-master.png b/Release/ProxyStudio-icon-master.png new file mode 100644 index 0000000..5f7d42c Binary files /dev/null and b/Release/ProxyStudio-icon-master.png differ diff --git a/Release/ProxyStudio.iconset/icon_128x128.png b/Release/ProxyStudio.iconset/icon_128x128.png new file mode 100644 index 0000000..5e62ab7 Binary files /dev/null and b/Release/ProxyStudio.iconset/icon_128x128.png differ diff --git a/Release/ProxyStudio.iconset/icon_128x128@2x.png b/Release/ProxyStudio.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..279977b Binary files /dev/null and b/Release/ProxyStudio.iconset/icon_128x128@2x.png differ diff --git a/Release/ProxyStudio.iconset/icon_16x16.png b/Release/ProxyStudio.iconset/icon_16x16.png new file mode 100644 index 0000000..99dd23e Binary files /dev/null and b/Release/ProxyStudio.iconset/icon_16x16.png differ diff --git a/Release/ProxyStudio.iconset/icon_16x16@2x.png b/Release/ProxyStudio.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..5419050 Binary files /dev/null and b/Release/ProxyStudio.iconset/icon_16x16@2x.png differ diff --git a/Release/ProxyStudio.iconset/icon_256x256.png b/Release/ProxyStudio.iconset/icon_256x256.png new file mode 100644 index 0000000..279977b Binary files /dev/null and b/Release/ProxyStudio.iconset/icon_256x256.png differ diff --git a/Release/ProxyStudio.iconset/icon_256x256@2x.png b/Release/ProxyStudio.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..99fe700 Binary files /dev/null and b/Release/ProxyStudio.iconset/icon_256x256@2x.png differ diff --git a/Release/ProxyStudio.iconset/icon_32x32.png b/Release/ProxyStudio.iconset/icon_32x32.png new file mode 100644 index 0000000..5419050 Binary files /dev/null and b/Release/ProxyStudio.iconset/icon_32x32.png differ diff --git a/Release/ProxyStudio.iconset/icon_32x32@2x.png b/Release/ProxyStudio.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..c07c712 Binary files /dev/null and b/Release/ProxyStudio.iconset/icon_32x32@2x.png differ diff --git a/Release/ProxyStudio.iconset/icon_512x512.png b/Release/ProxyStudio.iconset/icon_512x512.png new file mode 100644 index 0000000..99fe700 Binary files /dev/null and b/Release/ProxyStudio.iconset/icon_512x512.png differ diff --git a/Release/ProxyStudio.iconset/icon_512x512@2x.png b/Release/ProxyStudio.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..f531963 Binary files /dev/null and b/Release/ProxyStudio.iconset/icon_512x512@2x.png differ diff --git a/proxy studio.xcodeproj/project.xcproj b/proxy studio.xcodeproj/project.xcproj new file mode 100644 index 0000000..e034d6c --- /dev/null +++ b/proxy studio.xcodeproj/project.xcproj @@ -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": "/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", +} diff --git a/proxy studio.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/proxy studio.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/proxy studio.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/proxy studio.xcodeproj/xcuserdata/niklas.xcuserdatad/xcschemes/xcschememanagement.plist b/proxy studio.xcodeproj/xcuserdata/niklas.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..aaf8854 --- /dev/null +++ b/proxy studio.xcodeproj/xcuserdata/niklas.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + MyApp.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/proxy studio/Assets.xcassets/AccentColor.colorset/Contents.json b/proxy studio/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/proxy studio/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/proxy studio/Assets.xcassets/AppIcon.appiconset/Contents.json b/proxy studio/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..0944a63 --- /dev/null +++ b/proxy studio/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_128x128@1x.png b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_128x128@1x.png new file mode 100644 index 0000000..5e62ab7 Binary files /dev/null and b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_128x128@1x.png differ diff --git a/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..279977b Binary files /dev/null and b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_16x16@1x.png b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_16x16@1x.png new file mode 100644 index 0000000..99dd23e Binary files /dev/null and b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_16x16@1x.png differ diff --git a/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..5419050 Binary files /dev/null and b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_256x256@1x.png b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_256x256@1x.png new file mode 100644 index 0000000..279977b Binary files /dev/null and b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_256x256@1x.png differ diff --git a/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..99fe700 Binary files /dev/null and b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_32x32@1x.png b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_32x32@1x.png new file mode 100644 index 0000000..5419050 Binary files /dev/null and b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_32x32@1x.png differ diff --git a/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..c07c712 Binary files /dev/null and b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_512x512@1x.png b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_512x512@1x.png new file mode 100644 index 0000000..99fe700 Binary files /dev/null and b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_512x512@1x.png differ diff --git a/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..f531963 Binary files /dev/null and b/proxy studio/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/proxy studio/Assets.xcassets/Contents.json b/proxy studio/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/proxy studio/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/proxy studio/ContentView.swift b/proxy studio/ContentView.swift new file mode 100644 index 0000000..ec06705 --- /dev/null +++ b/proxy studio/ContentView.swift @@ -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) +} diff --git a/proxy studio/CredentialStore.swift b/proxy studio/CredentialStore.swift new file mode 100644 index 0000000..fc4a665 --- /dev/null +++ b/proxy studio/CredentialStore.swift @@ -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(_ 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(_ 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)") + } + } +} diff --git a/proxy studio/HostEditor.swift b/proxy studio/HostEditor.swift new file mode 100644 index 0000000..cafa742 --- /dev/null +++ b/proxy studio/HostEditor.swift @@ -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 + } +} diff --git a/proxy studio/Info.plist b/proxy studio/Info.plist new file mode 100644 index 0000000..bb1b11a --- /dev/null +++ b/proxy studio/Info.plist @@ -0,0 +1,13 @@ + + + + + CFBundleIconFile + ProxyStudio.icns + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/proxy studio/MyApp.swift b/proxy studio/MyApp.swift new file mode 100644 index 0000000..4cddd00 --- /dev/null +++ b/proxy studio/MyApp.swift @@ -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() + } + } +} diff --git a/proxy studio/ProxyStore.swift b/proxy studio/ProxyStore.swift new file mode 100644 index 0000000..0563fa7 --- /dev/null +++ b/proxy studio/ProxyStore.swift @@ -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(_ 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 } + } +} diff --git a/proxy studio/ProxyStudio.icns b/proxy studio/ProxyStudio.icns new file mode 100644 index 0000000..e69de29 diff --git a/proxy studio/UniFiDNSView.swift b/proxy studio/UniFiDNSView.swift new file mode 100644 index 0000000..84830b2 --- /dev/null +++ b/proxy studio/UniFiDNSView.swift @@ -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) +} diff --git a/proxy studio/UniFiStore.swift b/proxy studio/UniFiStore.swift new file mode 100644 index 0000000..ab7d609 --- /dev/null +++ b/proxy studio/UniFiStore.swift @@ -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: Decodable { + let data: [T] + let totalCount: Int? + } + + private func pages(_ 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.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 + } +}