From f287d1f5c7e4a88451fc6e1c68cf843b1049ff25 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 09:36:24 +0000 Subject: [PATCH 01/21] Improve error handling, observability, and robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add structured logging (os.Logger) to UniFiClient and StatusBarController so network failures and state transitions are no longer silent - Add exponential backoff to polling (30s → 5min) on consecutive errors to avoid hammering the controller during outages - Fix observer/monitor leak: add deinit to StatusBarController that removes the wake observer and cancels the NWPathMonitor - Surface WiFi tx retries percentage from V2ClientDTO (was available but never displayed); remove dead txRetriesPct field from APStats - Improve SetupView error messages: distinguish auth failures, HTTP errors, unreachable controller, and missing sites - Make build scripts resilient: default version.env values, graceful handling of missing icon assets https://claude.ai/code/session_01Pmx6pGDgBmZ6aCxwQeHPPz --- Scripts/compile_and_run.sh | 53 ++++++++++++------- Scripts/package_app.sh | 53 ++++++++++++------- Sources/UniFiBar/Models/DeviceDTO.swift | 4 +- Sources/UniFiBar/Models/WiFiStatus.swift | 7 +++ Sources/UniFiBar/Network/UniFiClient.swift | 16 +++++- .../StatusBar/StatusBarController.swift | 28 +++++++++- .../Views/Sections/ConnectionSection.swift | 4 ++ Sources/UniFiBar/Views/SetupView.swift | 12 +++-- 8 files changed, 128 insertions(+), 49 deletions(-) diff --git a/Scripts/compile_and_run.sh b/Scripts/compile_and_run.sh index 6cfe899..ea65f3e 100755 --- a/Scripts/compile_and_run.sh +++ b/Scripts/compile_and_run.sh @@ -7,8 +7,12 @@ APP_NAME="UniFiBar" BUILD_DIR="$PROJECT_DIR/.build" APP_BUNDLE="$BUILD_DIR/$APP_NAME.app" -# Source version info -source "$PROJECT_DIR/version.env" +# Source version info (with defaults) +APP_VERSION="0.0.0" +BUILD_NUMBER="0" +if [ -f "$PROJECT_DIR/version.env" ]; then + source "$PROJECT_DIR/version.env" +fi echo "==> Killing existing $APP_NAME..." pkill -x "$APP_NAME" 2>/dev/null || true @@ -69,26 +73,35 @@ if [ -f "$PROJECT_DIR/UniFiBar.entitlements" ]; then cp "$PROJECT_DIR/UniFiBar.entitlements" "$APP_BUNDLE/Contents/Resources/" fi -# Generate .icns from app icon PNGs -ICONSET_DIR="$BUILD_DIR/AppIcon.iconset" -rm -rf "$ICONSET_DIR" -mkdir -p "$ICONSET_DIR" +# Generate .icns from app icon PNGs (if available) ICON_SRC="$PROJECT_DIR/Resources/Assets.xcassets/AppIcon.appiconset" -cp "$ICON_SRC/icon_16x16.png" "$ICONSET_DIR/icon_16x16.png" -cp "$ICON_SRC/icon_16x16@2x.png" "$ICONSET_DIR/icon_16x16@2x.png" -cp "$ICON_SRC/icon_32x32.png" "$ICONSET_DIR/icon_32x32.png" -cp "$ICON_SRC/icon_32x32@2x.png" "$ICONSET_DIR/icon_32x32@2x.png" -cp "$ICON_SRC/icon_128x128.png" "$ICONSET_DIR/icon_128x128.png" -cp "$ICON_SRC/icon_128x128@2x.png" "$ICONSET_DIR/icon_128x128@2x.png" -cp "$ICON_SRC/icon_256x256.png" "$ICONSET_DIR/icon_256x256.png" -cp "$ICON_SRC/icon_256x256@2x.png" "$ICONSET_DIR/icon_256x256@2x.png" -cp "$ICON_SRC/icon_512x512.png" "$ICONSET_DIR/icon_512x512.png" -cp "$ICON_SRC/icon_512x512@2x.png" "$ICONSET_DIR/icon_512x512@2x.png" -iconutil -c icns "$ICONSET_DIR" -o "$APP_BUNDLE/Contents/Resources/AppIcon.icns" 2>/dev/null || true +if [ -d "$ICON_SRC" ]; then + ICONSET_DIR="$BUILD_DIR/AppIcon.iconset" + rm -rf "$ICONSET_DIR" + mkdir -p "$ICONSET_DIR" + cp "$ICON_SRC/icon_16x16.png" "$ICONSET_DIR/icon_16x16.png" + cp "$ICON_SRC/icon_16x16@2x.png" "$ICONSET_DIR/icon_16x16@2x.png" + cp "$ICON_SRC/icon_32x32.png" "$ICONSET_DIR/icon_32x32.png" + cp "$ICON_SRC/icon_32x32@2x.png" "$ICONSET_DIR/icon_32x32@2x.png" + cp "$ICON_SRC/icon_128x128.png" "$ICONSET_DIR/icon_128x128.png" + cp "$ICON_SRC/icon_128x128@2x.png" "$ICONSET_DIR/icon_128x128@2x.png" + cp "$ICON_SRC/icon_256x256.png" "$ICONSET_DIR/icon_256x256.png" + cp "$ICON_SRC/icon_256x256@2x.png" "$ICONSET_DIR/icon_256x256@2x.png" + cp "$ICON_SRC/icon_512x512.png" "$ICONSET_DIR/icon_512x512.png" + cp "$ICON_SRC/icon_512x512@2x.png" "$ICONSET_DIR/icon_512x512@2x.png" + iconutil -c icns "$ICONSET_DIR" -o "$APP_BUNDLE/Contents/Resources/AppIcon.icns" 2>/dev/null || true +else + echo "WARNING: App icon assets not found at $ICON_SRC, skipping icon generation" +fi -# Copy status bar icon -cp "$PROJECT_DIR/Resources/Assets.xcassets/StatusBarIcon.imageset/icon@1x.png" "$APP_BUNDLE/Contents/Resources/" -cp "$PROJECT_DIR/Resources/Assets.xcassets/StatusBarIcon.imageset/icon@2x.png" "$APP_BUNDLE/Contents/Resources/" +# Copy status bar icon (if available) +STATUSBAR_SRC="$PROJECT_DIR/Resources/Assets.xcassets/StatusBarIcon.imageset" +if [ -d "$STATUSBAR_SRC" ]; then + cp "$STATUSBAR_SRC/icon@1x.png" "$APP_BUNDLE/Contents/Resources/" + cp "$STATUSBAR_SRC/icon@2x.png" "$APP_BUNDLE/Contents/Resources/" +else + echo "WARNING: Status bar icon assets not found, skipping" +fi echo "==> Signing (ad-hoc)..." codesign --force --sign - "$APP_BUNDLE" 2>/dev/null || true diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index d564e91..97b55a5 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -7,8 +7,12 @@ APP_NAME="UniFiBar" BUILD_DIR="$PROJECT_DIR/.build" APP_BUNDLE="$BUILD_DIR/release/$APP_NAME.app" -# Source version info -source "$PROJECT_DIR/version.env" +# Source version info (with defaults) +APP_VERSION="0.0.0" +BUILD_NUMBER="0" +if [ -f "$PROJECT_DIR/version.env" ]; then + source "$PROJECT_DIR/version.env" +fi echo "==> Building $APP_NAME (release)..." cd "$PROJECT_DIR" @@ -60,26 +64,35 @@ cat > "$APP_BUNDLE/Contents/Info.plist" << PLIST PLIST -# Generate .icns from app icon PNGs -ICONSET_DIR="$BUILD_DIR/AppIcon.iconset" -rm -rf "$ICONSET_DIR" -mkdir -p "$ICONSET_DIR" +# Generate .icns from app icon PNGs (if available) ICON_SRC="$PROJECT_DIR/Resources/Assets.xcassets/AppIcon.appiconset" -cp "$ICON_SRC/icon_16x16.png" "$ICONSET_DIR/icon_16x16.png" -cp "$ICON_SRC/icon_16x16@2x.png" "$ICONSET_DIR/icon_16x16@2x.png" -cp "$ICON_SRC/icon_32x32.png" "$ICONSET_DIR/icon_32x32.png" -cp "$ICON_SRC/icon_32x32@2x.png" "$ICONSET_DIR/icon_32x32@2x.png" -cp "$ICON_SRC/icon_128x128.png" "$ICONSET_DIR/icon_128x128.png" -cp "$ICON_SRC/icon_128x128@2x.png" "$ICONSET_DIR/icon_128x128@2x.png" -cp "$ICON_SRC/icon_256x256.png" "$ICONSET_DIR/icon_256x256.png" -cp "$ICON_SRC/icon_256x256@2x.png" "$ICONSET_DIR/icon_256x256@2x.png" -cp "$ICON_SRC/icon_512x512.png" "$ICONSET_DIR/icon_512x512.png" -cp "$ICON_SRC/icon_512x512@2x.png" "$ICONSET_DIR/icon_512x512@2x.png" -iconutil -c icns "$ICONSET_DIR" -o "$APP_BUNDLE/Contents/Resources/AppIcon.icns" 2>/dev/null || true +if [ -d "$ICON_SRC" ]; then + ICONSET_DIR="$BUILD_DIR/AppIcon.iconset" + rm -rf "$ICONSET_DIR" + mkdir -p "$ICONSET_DIR" + cp "$ICON_SRC/icon_16x16.png" "$ICONSET_DIR/icon_16x16.png" + cp "$ICON_SRC/icon_16x16@2x.png" "$ICONSET_DIR/icon_16x16@2x.png" + cp "$ICON_SRC/icon_32x32.png" "$ICONSET_DIR/icon_32x32.png" + cp "$ICON_SRC/icon_32x32@2x.png" "$ICONSET_DIR/icon_32x32@2x.png" + cp "$ICON_SRC/icon_128x128.png" "$ICONSET_DIR/icon_128x128.png" + cp "$ICON_SRC/icon_128x128@2x.png" "$ICONSET_DIR/icon_128x128@2x.png" + cp "$ICON_SRC/icon_256x256.png" "$ICONSET_DIR/icon_256x256.png" + cp "$ICON_SRC/icon_256x256@2x.png" "$ICONSET_DIR/icon_256x256@2x.png" + cp "$ICON_SRC/icon_512x512.png" "$ICONSET_DIR/icon_512x512.png" + cp "$ICON_SRC/icon_512x512@2x.png" "$ICONSET_DIR/icon_512x512@2x.png" + iconutil -c icns "$ICONSET_DIR" -o "$APP_BUNDLE/Contents/Resources/AppIcon.icns" 2>/dev/null || true +else + echo "WARNING: App icon assets not found at $ICON_SRC, skipping icon generation" +fi -# Copy status bar icon -cp "$PROJECT_DIR/Resources/Assets.xcassets/StatusBarIcon.imageset/icon@1x.png" "$APP_BUNDLE/Contents/Resources/" -cp "$PROJECT_DIR/Resources/Assets.xcassets/StatusBarIcon.imageset/icon@2x.png" "$APP_BUNDLE/Contents/Resources/" +# Copy status bar icon (if available) +STATUSBAR_SRC="$PROJECT_DIR/Resources/Assets.xcassets/StatusBarIcon.imageset" +if [ -d "$STATUSBAR_SRC" ]; then + cp "$STATUSBAR_SRC/icon@1x.png" "$APP_BUNDLE/Contents/Resources/" + cp "$STATUSBAR_SRC/icon@2x.png" "$APP_BUNDLE/Contents/Resources/" +else + echo "WARNING: Status bar icon assets not found, skipping" +fi echo "==> Signing (ad-hoc)..." codesign --force --sign - "$APP_BUNDLE" diff --git a/Sources/UniFiBar/Models/DeviceDTO.swift b/Sources/UniFiBar/Models/DeviceDTO.swift index 10156b4..4960b8d 100644 --- a/Sources/UniFiBar/Models/DeviceDTO.swift +++ b/Sources/UniFiBar/Models/DeviceDTO.swift @@ -154,7 +154,6 @@ struct APStats: Sendable { let uptimeSec: Int? let cpuUtilizationPct: Double? let memoryUtilizationPct: Double? - let txRetriesPct: Double? } struct APStatsResponse: Decodable, Sendable { @@ -166,8 +165,7 @@ struct APStatsResponse: Decodable, Sendable { APStats( uptimeSec: uptimeSec, cpuUtilizationPct: cpuUtilizationPct, - memoryUtilizationPct: memoryUtilizationPct, - txRetriesPct: nil + memoryUtilizationPct: memoryUtilizationPct ) } } diff --git a/Sources/UniFiBar/Models/WiFiStatus.swift b/Sources/UniFiBar/Models/WiFiStatus.swift index 4200183..3641320 100644 --- a/Sources/UniFiBar/Models/WiFiStatus.swift +++ b/Sources/UniFiBar/Models/WiFiStatus.swift @@ -25,6 +25,7 @@ final class WiFiStatus { var mimoDescription: String? = nil var rxRate: Int? = nil var txRate: Int? = nil + var txRetriesPct: Double? = nil var rxBytes: Int? = nil var txBytes: Int? = nil var ip: String? = nil @@ -161,6 +162,11 @@ final class WiFiStatus { return "\(count) roam\(count == 1 ? "" : "s")" } + var formattedTxRetries: String? { + guard let pct = txRetriesPct, pct > 0 else { return nil } + return String(format: "%.1f%%", pct) + } + var formattedAPLoad: String? { guard let cpu = apCPU, let mem = apMemory else { return nil } return "CPU \(Int(cpu))% · Mem \(Int(mem))%" @@ -252,6 +258,7 @@ final class WiFiStatus { mimoDescription = client.mimoDescription rxRate = client.rxRate txRate = client.txRate + txRetriesPct = client.wifiTxRetriesPercentage rxBytes = client.rxBytes txBytes = client.txBytes uptime = client.uptime diff --git a/Sources/UniFiBar/Network/UniFiClient.swift b/Sources/UniFiBar/Network/UniFiClient.swift index e362ddc..ba01926 100644 --- a/Sources/UniFiBar/Network/UniFiClient.swift +++ b/Sources/UniFiBar/Network/UniFiClient.swift @@ -10,6 +10,7 @@ actor UniFiClient { private var siteId: String? private static let requestTimeout: TimeInterval = 15 + private static let logger = Logger(subsystem: "com.unifbar.app", category: "UniFiClient") init(baseURL: URL, apiKey: String, allowSelfSigned: Bool = false) { self.baseURL = baseURL @@ -119,7 +120,10 @@ actor UniFiClient { // MARK: - AP Statistics func fetchAPStats(deviceId: String, siteId: String) async -> APStats? { - guard Self.isValidIdentifier(deviceId), Self.isValidIdentifier(siteId) else { return nil } + guard Self.isValidIdentifier(deviceId), Self.isValidIdentifier(siteId) else { + Self.logger.warning("Invalid identifier for AP stats: deviceId=\(deviceId), siteId=\(siteId)") + return nil + } do { let data = try await request( "/proxy/network/integrations/v1/sites/\(siteId)/devices/\(deviceId)/statistics/latest" @@ -127,6 +131,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(APStatsResponse.self, from: data) return response.toAPStats } catch { + Self.logger.error("Failed to fetch AP stats for device \(deviceId): \(error)") return nil } } @@ -145,6 +150,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(LegacyResponse.self, from: data) return response.data.isEmpty ? nil : response.data } catch { + Self.logger.error("Failed to fetch session history for \(mac): \(error)") return nil } } @@ -169,6 +175,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(VPNTunnelResponse.self, from: data) return response.data.isEmpty ? nil : response.data } catch { + Self.logger.error("Failed to fetch VPN tunnels: \(error)") return nil } } @@ -181,6 +188,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(WANHealthResponse.self, from: data) return response.toWANHealth() } catch { + Self.logger.error("Failed to fetch WAN health: \(error)") return nil } } @@ -188,7 +196,10 @@ actor UniFiClient { // MARK: - Gateway Statistics func fetchGatewayStats(deviceId: String, siteId: String) async -> GatewayStats? { - guard Self.isValidIdentifier(deviceId), Self.isValidIdentifier(siteId) else { return nil } + guard Self.isValidIdentifier(deviceId), Self.isValidIdentifier(siteId) else { + Self.logger.warning("Invalid identifier for gateway stats: deviceId=\(deviceId), siteId=\(siteId)") + return nil + } do { let data = try await request( "/proxy/network/integrations/v1/sites/\(siteId)/devices/\(deviceId)/statistics/latest" @@ -196,6 +207,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(GatewayStatsResponse.self, from: data) return response.toGatewayStats } catch { + Self.logger.error("Failed to fetch gateway stats for device \(deviceId): \(error)") return nil } } diff --git a/Sources/UniFiBar/StatusBar/StatusBarController.swift b/Sources/UniFiBar/StatusBar/StatusBarController.swift index bf371ba..b20d888 100644 --- a/Sources/UniFiBar/StatusBar/StatusBarController.swift +++ b/Sources/UniFiBar/StatusBar/StatusBarController.swift @@ -23,11 +23,21 @@ final class StatusBarController { let wifiStatus = WiFiStatus() let preferences = PreferencesManager() + private static let logger = Logger(subsystem: "com.unifbar.app", category: "StatusBarController") + private var pollTask: Task? private var client: UniFiClient? private var pathMonitor: NWPathMonitor? private var wakeObserver: NSObjectProtocol? private var hasStarted = false + private var consecutiveErrors = 0 + + deinit { + if let observer = wakeObserver { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + } + pathMonitor?.cancel() + } func start() async { guard !hasStarted else { return } @@ -65,11 +75,18 @@ final class StatusBarController { guard let self else { return } while !Task.isCancelled { await self.refresh() - try? await Task.sleep(for: .seconds(30)) + let delay = self.pollInterval + try? await Task.sleep(for: .seconds(delay)) } } } + /// Returns poll interval: 30s normally, backs off up to 5 minutes on consecutive errors. + private var pollInterval: Int { + guard consecutiveErrors > 0 else { return 30 } + return min(30 * (1 << consecutiveErrors), 300) + } + func stopPolling() { pollTask?.cancel() pollTask = nil @@ -117,6 +134,8 @@ final class StatusBarController { preferences.siteId = siteId } } catch { + Self.logger.error("Site discovery failed: \(error)") + consecutiveErrors += 1 wifiStatus.markError(.controllerUnreachable) return } @@ -126,20 +145,27 @@ final class StatusBarController { do { selfInfo = try await client.fetchSelfV2() } catch let error as UniFiError { + consecutiveErrors += 1 switch error { case .httpError(let code) where code == 401 || code == 403: + Self.logger.error("Authentication failed (HTTP \(code))") wifiStatus.markError(.invalidAPIKey) case .selfNotFound: + Self.logger.info("This device not found in active clients — likely disconnected") wifiStatus.markDisconnected() default: + Self.logger.error("Failed to fetch self: \(error)") wifiStatus.markError(.controllerUnreachable) } return } catch { + consecutiveErrors += 1 + Self.logger.error("Failed to fetch self: \(error)") wifiStatus.markError(.controllerUnreachable) return } + consecutiveErrors = 0 wifiStatus.update(from: selfInfo) let me = selfInfo.client diff --git a/Sources/UniFiBar/Views/Sections/ConnectionSection.swift b/Sources/UniFiBar/Views/Sections/ConnectionSection.swift index cbe3197..799a868 100644 --- a/Sources/UniFiBar/Views/Sections/ConnectionSection.swift +++ b/Sources/UniFiBar/Views/Sections/ConnectionSection.swift @@ -89,6 +89,10 @@ struct LinkSection: View { MetricRow(label: "Rx", value: wifiStatus.formattedRxRate, systemImage: "arrow.down") MetricRow(label: "Tx", value: wifiStatus.formattedTxRate, systemImage: "arrow.up") + if let retries = wifiStatus.formattedTxRetries { + MetricRow(label: "Tx Retries", value: retries, systemImage: "arrow.counterclockwise") + } + if let sessionData = wifiStatus.formattedSessionData { MetricRow(label: "Data", value: sessionData, systemImage: "chart.bar") } diff --git a/Sources/UniFiBar/Views/SetupView.swift b/Sources/UniFiBar/Views/SetupView.swift index 2040950..2562846 100644 --- a/Sources/UniFiBar/Views/SetupView.swift +++ b/Sources/UniFiBar/Views/SetupView.swift @@ -123,7 +123,7 @@ struct SetupView: View { switch error { case .httpError(let code) where code == 401 || code == 403: failedAttempts += 1 - errorMessage = "Could not connect. Check your URL, API key, and certificate settings." + errorMessage = "Authentication failed. Check your API key." // Rate limit only on auth failures (possible brute force) if failedAttempts >= 5 { @@ -135,11 +135,17 @@ struct SetupView: View { retryAvailableAt = nil } } + case .httpError(let code): + errorMessage = "Server returned HTTP \(code). Check your controller URL." + case .noSitesFound: + errorMessage = "Connected, but no sites found on this controller." default: - errorMessage = "Could not connect. Check your URL, API key, and certificate settings." + errorMessage = "Could not connect. Check your URL and certificate settings." } + } catch is URLError { + errorMessage = "Could not reach the controller. Check the URL and your network connection." } catch { - errorMessage = "Could not connect. Check your URL, API key, and certificate settings." + errorMessage = "Unexpected error: \(error.localizedDescription)" } isValidating = false From 0874fc1a04a21d5bcd3821b9e0319ec58bc4f86d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 12:56:49 +0000 Subject: [PATCH 02/21] Add comprehensive monitoring: alerts, security, traffic, events, DDNS, port forwards, nearby APs, speed test New features: - Alerts section with active alarm count and badge in status bar - Security section (IDS/IPS events, anomalies) with threat summary - Traffic section (DPI) showing top bandwidth categories - Recent events section with subsystem-specific icons - Dynamic DNS status section - Port forwards section showing active rules - Nearby APs section with signal strength - Speed test results in Internet section (download/upload/ping) - Collapsible sections with persistent expand/collapse state - Section visibility preferences (toggle sections on/off) - Smart data fetching: only queries enabled sections' APIs Architecture: - All new fields are optional (nil default) for backward compatibility - New API calls are parallel, independent, and fail silently - Section visibility persisted in UserDefaults with sensible defaults - Core sections (Internet, VPN, WiFi, Network, Session History) enabled by default - Monitoring sections (Security, Traffic, Events, DDNS, Port Forwards, Nearby APs) opt-in - Alerts enabled by default as they're high-signal - ViewBuilder 10-component limit respected via view decomposition https://claude.ai/code/session_01Pmx6pGDgBmZ6aCxwQeHPPz --- Sources/UniFiBar/Models/DeviceDTO.swift | 29 +- Sources/UniFiBar/Models/MonitoringDTO.swift | 361 ++++++++++++++++++ Sources/UniFiBar/Models/WiFiStatus.swift | 61 +++ Sources/UniFiBar/Network/UniFiClient.swift | 112 ++++++ .../Preferences/PreferencesManager.swift | 83 ++++ .../StatusBar/StatusBarController.swift | 49 ++- .../Views/Components/CollapsibleSection.swift | 136 +++++++ Sources/UniFiBar/Views/MenuContentView.swift | 95 ++++- Sources/UniFiBar/Views/PreferencesView.swift | 39 ++ .../Views/Sections/AlertsSection.swift | 35 ++ .../UniFiBar/Views/Sections/DDNSSection.swift | 37 ++ .../Views/Sections/EventsSection.swift | 35 ++ .../Views/Sections/InternetSection.swift | 43 ++- .../Views/Sections/NearbyAPsSection.swift | 48 +++ .../Views/Sections/PortForwardsSection.swift | 37 ++ .../Views/Sections/SecuritySection.swift | 77 ++++ .../Views/Sections/TrafficSection.swift | 54 +++ Sources/UniFiBar/Views/StatusBarLabel.swift | 5 + 18 files changed, 1312 insertions(+), 24 deletions(-) create mode 100644 Sources/UniFiBar/Models/MonitoringDTO.swift create mode 100644 Sources/UniFiBar/Views/Components/CollapsibleSection.swift create mode 100644 Sources/UniFiBar/Views/Sections/AlertsSection.swift create mode 100644 Sources/UniFiBar/Views/Sections/DDNSSection.swift create mode 100644 Sources/UniFiBar/Views/Sections/EventsSection.swift create mode 100644 Sources/UniFiBar/Views/Sections/NearbyAPsSection.swift create mode 100644 Sources/UniFiBar/Views/Sections/PortForwardsSection.swift create mode 100644 Sources/UniFiBar/Views/Sections/SecuritySection.swift create mode 100644 Sources/UniFiBar/Views/Sections/TrafficSection.swift diff --git a/Sources/UniFiBar/Models/DeviceDTO.swift b/Sources/UniFiBar/Models/DeviceDTO.swift index 4960b8d..94497f1 100644 --- a/Sources/UniFiBar/Models/DeviceDTO.swift +++ b/Sources/UniFiBar/Models/DeviceDTO.swift @@ -39,6 +39,7 @@ struct WANHealth: Sendable { let drops: Int? let rxBytesRate: Double? let txBytesRate: Double? + let speedTest: SpeedTestResult? } struct WANHealthResponse: Decodable, Sendable { @@ -57,6 +58,13 @@ struct WANHealthResponse: Decodable, Sendable { let rxBytesR: Double? let txBytesR: Double? + // Speed test fields + let speedtestLastrun: Int? + let speedtestPing: Int? + let speedtestStatus: String? + let xputDown: Double? + let xputUp: Double? + enum CodingKeys: String, CodingKey { case subsystem, status case ispName = "isp_name" @@ -65,6 +73,11 @@ struct WANHealthResponse: Decodable, Sendable { case latency, drops case rxBytesR = "rx_bytes-r" case txBytesR = "tx_bytes-r" + case speedtestLastrun = "speedtest_lastrun" + case speedtestPing = "speedtest_ping" + case speedtestStatus = "speedtest_status" + case xputDown = "xput_down" + case xputUp = "xput_up" } } @@ -87,6 +100,19 @@ struct WANHealthResponse: Decodable, Sendable { let www = data.first(where: { $0.subsystem == "www" }) guard wan != nil || www != nil else { return nil } + let speedTest: SpeedTestResult? + if let lastrun = wan?.speedtestLastrun, lastrun > 0 { + speedTest = SpeedTestResult( + downloadMbps: wan?.xputDown, + uploadMbps: wan?.xputUp, + pingMs: wan?.speedtestPing, + lastRun: Date(timeIntervalSince1970: TimeInterval(lastrun)), + status: wan?.speedtestStatus + ) + } else { + speedTest = nil + } + return WANHealth( ispName: wan?.ispName, wanIP: wan?.wanIP, @@ -95,7 +121,8 @@ struct WANHealthResponse: Decodable, Sendable { availability: wan?.uptimeStats?.WAN?.availability, drops: www?.drops, rxBytesRate: wan?.rxBytesR, - txBytesRate: wan?.txBytesR + txBytesRate: wan?.txBytesR, + speedTest: speedTest ) } } diff --git a/Sources/UniFiBar/Models/MonitoringDTO.swift b/Sources/UniFiBar/Models/MonitoringDTO.swift new file mode 100644 index 0000000..4cb1912 --- /dev/null +++ b/Sources/UniFiBar/Models/MonitoringDTO.swift @@ -0,0 +1,361 @@ +import Foundation + +// MARK: - Alarms + +struct AlarmDTO: Decodable, Sendable, Identifiable { + let id: String + let key: String? + let msg: String? + let time: Int? + let archived: Bool? + let handledAdminId: String? + let siteId: String? + let deviceMac: String? + let subsystem: String? + + enum CodingKeys: String, CodingKey { + case id = "_id" + case key, msg, time, archived + case handledAdminId = "handled_admin_id" + case siteId = "site_id" + case deviceMac = "device_mac" + case subsystem + } + + var displayMessage: String { + if let msg { return msg } + if let key { return key.replacingOccurrences(of: "EVT_", with: "").replacingOccurrences(of: "_", with: " ").capitalized } + return "Unknown Alert" + } + + var date: Date? { + guard let time else { return nil } + return Date(timeIntervalSince1970: TimeInterval(time) / 1000.0) + } + + var relativeTime: String { + guard let date else { return "" } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } +} + +// MARK: - DPI (Deep Packet Inspection) Stats + +struct DPICategoryDTO: Sendable, Identifiable { + let id: Int + let name: String + let rxBytes: Int + let txBytes: Int + + var totalBytes: Int { rxBytes + txBytes } + + var formattedTotal: String { + formatBytes(totalBytes) + } + + private func formatBytes(_ bytes: Int) -> String { + let gb = Double(bytes) / 1_073_741_824.0 + if gb >= 1.0 { return String(format: "%.1f GB", gb) } + let mb = Double(bytes) / 1_048_576.0 + if mb >= 1.0 { return String(format: "%.0f MB", mb) } + let kb = Double(bytes) / 1_024.0 + return String(format: "%.0f KB", kb) + } +} + +struct DPIStatsResponse: Decodable, Sendable { + let data: [DPIEntry] + + struct DPIEntry: Decodable, Sendable { + let byCat: [DPICategoryRaw]? + + enum CodingKeys: String, CodingKey { + case byCat = "by_cat" + } + } + + struct DPICategoryRaw: Decodable, Sendable { + let cat: Int? + let rxBytes: Int? + let txBytes: Int? + let apps: [DPIAppRaw]? + + enum CodingKeys: String, CodingKey { + case cat + case rxBytes = "rx_bytes" + case txBytes = "tx_bytes" + case apps + } + } + + struct DPIAppRaw: Decodable, Sendable { + let app: Int? + let cat: Int? + let rxBytes: Int? + let txBytes: Int? + + enum CodingKeys: String, CodingKey { + case app, cat + case rxBytes = "rx_bytes" + case txBytes = "tx_bytes" + } + } + + func toCategories() -> [DPICategoryDTO] { + guard let entry = data.first, let cats = entry.byCat else { return [] } + return cats.compactMap { raw in + guard let cat = raw.cat else { return nil } + return DPICategoryDTO( + id: cat, + name: Self.categoryName(cat), + rxBytes: raw.rxBytes ?? 0, + txBytes: raw.txBytes ?? 0 + ) + } + .filter { $0.totalBytes > 0 } + .sorted { $0.totalBytes > $1.totalBytes } + } + + // UniFi DPI category mapping + static func categoryName(_ cat: Int) -> String { + switch cat { + case 0: return "Instant Messaging" + case 1: return "P2P" + case 3: return "File Transfer" + case 4: return "Streaming" + case 5: return "Email" + case 6: return "Network Protocols" + case 7: return "Web" + case 8: return "Gaming" + case 9: return "Security" + case 10: return "Database" + case 13: return "Social" + case 14: return "Apple" + case 15: return "Microsoft" + case 17: return "VPN/Tunnel" + case 18: return "Video" + case 19: return "IoT" + case 20: return "Shopping" + case 24: return "Productivity" + case 25: return "Health" + default: return "Category \(cat)" + } + } +} + +// MARK: - IDS/IPS Events + +struct IPSEventDTO: Decodable, Sendable, Identifiable { + let id: String + let msg: String? + let srcIP: String? + let dstIP: String? + let catname: String? + let action: String? + let timestamp: Int? + let inIface: String? + + enum CodingKeys: String, CodingKey { + case id = "_id" + case msg + case srcIP = "src_ip" + case dstIP = "dst_ip" + case catname, action, timestamp + case inIface = "in_iface" + } + + var displayMessage: String { + msg ?? catname ?? "IPS Event" + } + + var relativeTime: String { + guard let timestamp else { return "" } + let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0) + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } +} + +// MARK: - Anomalies + +struct AnomalyDTO: Decodable, Sendable, Identifiable { + let id: String + let anomaly: String? + let datetime: String? + let deviceMac: String? + + enum CodingKeys: String, CodingKey { + case id = "_id" + case anomaly, datetime + case deviceMac = "device_mac" + } +} + +// MARK: - Site Events + +struct SiteEventDTO: Decodable, Sendable, Identifiable { + let id: String + let key: String? + let msg: String? + let time: Int? + let subsystem: String? + let isAdmin: Bool? + + enum CodingKeys: String, CodingKey { + case id = "_id" + case key, msg, time, subsystem + case isAdmin = "is_admin" + } + + var displayMessage: String { + if let msg { return msg } + if let key { return key.replacingOccurrences(of: "EVT_", with: "").replacingOccurrences(of: "_", with: " ").capitalized } + return "Event" + } + + var relativeTime: String { + guard let time else { return "" } + let date = Date(timeIntervalSince1970: TimeInterval(time) / 1000.0) + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } + + var subsystemIcon: String { + switch subsystem { + case "wlan": return "wifi" + case "lan": return "cable.connector.horizontal" + case "wan": return "globe" + case "vpn": return "lock.shield" + default: return "info.circle" + } + } +} + +// MARK: - Dynamic DNS + +struct DDNSStatusDTO: Decodable, Sendable { + let status: String? + let ip: String? + let hostname: String? + let lastChanged: Int? + + enum CodingKeys: String, CodingKey { + case status, ip, hostname + case lastChanged = "last_changed" + } + + var isActive: Bool { + status == "good" || status == "nochg" + } + + var displayStatus: String { + switch status { + case "good", "nochg": return "Active" + case "abuse": return "Abuse" + case "nohost": return "No Host" + case "badauth": return "Auth Error" + default: return status?.capitalized ?? "Unknown" + } + } +} + +// MARK: - Port Forwards + +struct PortForwardDTO: Decodable, Sendable, Identifiable { + let id: String + let name: String? + let enabled: Bool? + let dstPort: String? + let fwd: String? + let fwdPort: String? + let proto: String? + let pfwd_interface: String? + + enum CodingKeys: String, CodingKey { + case id = "_id" + case name, enabled + case dstPort = "dst_port" + case fwd + case fwdPort = "fwd_port" + case proto + case pfwd_interface + } + + var displayName: String { + name ?? "\(proto?.uppercased() ?? ""):\(dstPort ?? "?")" + } + + var summary: String { + let p = proto?.uppercased() ?? "TCP" + return "\(p) :\(dstPort ?? "?") → \(fwd ?? "?"):\(fwdPort ?? dstPort ?? "?")" + } +} + +// MARK: - Rogue / Neighboring APs + +struct RogueAPDTO: Decodable, Sendable, Identifiable { + let id: String + let bssid: String? + let essid: String? + let rssi: Int? + let channel: Int? + let isRogue: Bool? + let age: Int? + let apMac: String? + + enum CodingKeys: String, CodingKey { + case id = "_id" + case bssid, essid, rssi, channel, age + case isRogue = "is_rogue" + case apMac = "ap_mac" + } + + var signalDescription: String { + guard let rssi else { return "—" } + return "\(rssi) dBm" + } + + var displayName: String { + essid ?? bssid ?? "Hidden" + } +} + +// MARK: - Speed Test Result (extracted from stat/health) + +struct SpeedTestResult: Sendable { + let downloadMbps: Double? + let uploadMbps: Double? + let pingMs: Int? + let lastRun: Date? + let status: String? + + var isRunning: Bool { status == "Running" } + + var formattedDownload: String? { + guard let dl = downloadMbps, dl > 0 else { return nil } + if dl >= 1000 { return String(format: "%.2f Gbps", dl / 1000) } + return String(format: "%.0f Mbps", dl) + } + + var formattedUpload: String? { + guard let ul = uploadMbps, ul > 0 else { return nil } + if ul >= 1000 { return String(format: "%.2f Gbps", ul / 1000) } + return String(format: "%.0f Mbps", ul) + } + + var formattedPing: String? { + guard let p = pingMs else { return nil } + return "\(p) ms" + } + + var formattedLastRun: String? { + guard let date = lastRun else { return nil } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } +} diff --git a/Sources/UniFiBar/Models/WiFiStatus.swift b/Sources/UniFiBar/Models/WiFiStatus.swift index 3641320..acb17a4 100644 --- a/Sources/UniFiBar/Models/WiFiStatus.swift +++ b/Sources/UniFiBar/Models/WiFiStatus.swift @@ -82,6 +82,19 @@ final class WiFiStatus { var onlineDevices: Int? = nil var offlineDeviceNames: [String]? = nil + // Speed test + var speedTest: SpeedTestResult? = nil + + // Monitoring data + var activeAlarms: [AlarmDTO]? = nil + var dpiCategories: [DPICategoryDTO]? = nil + var ipsEvents: [IPSEventDTO]? = nil + var anomalies: [AnomalyDTO]? = nil + var siteEvents: [SiteEventDTO]? = nil + var ddnsStatuses: [DDNSStatusDTO]? = nil + var portForwards: [PortForwardDTO]? = nil + var nearbyAPs: [RogueAPDTO]? = nil + // Metadata var lastUpdated: Date? = nil @@ -214,6 +227,32 @@ final class WiFiStatus { return "\(online) online · \(offline) offline" } + var activeAlarmCount: Int { + activeAlarms?.count ?? 0 + } + + var ipsEventCount: Int { + ipsEvents?.count ?? 0 + } + + var anomalyCount: Int { + anomalies?.count ?? 0 + } + + var securitySummary: String? { + let threats = ipsEventCount + let anomalyN = anomalyCount + guard threats > 0 || anomalyN > 0 else { return nil } + var parts: [String] = [] + if threats > 0 { parts.append("\(threats) threat\(threats == 1 ? "" : "s")") } + if anomalyN > 0 { parts.append("\(anomalyN) anomal\(anomalyN == 1 ? "y" : "ies")") } + return parts.joined(separator: " · ") + } + + var nearbyAPCount: Int { + nearbyAPs?.count ?? 0 + } + var firmwareBadge: String? { guard let names = devicesWithUpdates, !names.isEmpty else { return nil } let count = names.count @@ -322,6 +361,7 @@ final class WiFiStatus { wanDrops = (health.drops ?? 0) > 0 ? health.drops : nil wanTxBytesRate = health.txBytesRate wanRxBytesRate = health.rxBytesRate + speedTest = health.speedTest } else { wanIsUp = nil wanIP = nil @@ -331,6 +371,7 @@ final class WiFiStatus { wanDrops = nil wanTxBytesRate = nil wanRxBytesRate = nil + speedTest = nil } } @@ -390,6 +431,26 @@ final class WiFiStatus { )} } + func updateMonitoring( + alarms: [AlarmDTO]?, + dpi: [DPICategoryDTO]?, + ips: [IPSEventDTO]?, + anomalies: [AnomalyDTO]?, + events: [SiteEventDTO]?, + ddns: [DDNSStatusDTO]?, + portForwards: [PortForwardDTO]?, + rogueAPs: [RogueAPDTO]? + ) { + self.activeAlarms = alarms + self.dpiCategories = dpi + self.ipsEvents = ips + self.anomalies = anomalies + self.siteEvents = events + self.ddnsStatuses = ddns + self.portForwards = portForwards + self.nearbyAPs = rogueAPs + } + func markDisconnected() { isConnected = false isWired = false diff --git a/Sources/UniFiBar/Network/UniFiClient.swift b/Sources/UniFiBar/Network/UniFiClient.swift index ba01926..782e0c0 100644 --- a/Sources/UniFiBar/Network/UniFiClient.swift +++ b/Sources/UniFiBar/Network/UniFiClient.swift @@ -212,6 +212,118 @@ actor UniFiClient { } } + // MARK: - Alarms + + func fetchAlarms() async -> [AlarmDTO]? { + do { + let data = try await request("/proxy/network/api/s/default/rest/alarm") + let response = try JSONDecoder().decode(LegacyResponse.self, from: data) + let active = response.data.filter { $0.archived != true } + return active.isEmpty ? nil : Array(active.prefix(10)) + } catch { + Self.logger.error("Failed to fetch alarms: \(error)") + return nil + } + } + + // MARK: - DPI Stats + + func fetchDPIStats() async -> [DPICategoryDTO]? { + do { + let body: [String: Any] = ["type": "by_cat"] + let data = try await post("/proxy/network/api/s/default/stat/sitedpi", body: body) + let response = try JSONDecoder().decode(DPIStatsResponse.self, from: data) + let categories = response.toCategories() + return categories.isEmpty ? nil : Array(categories.prefix(8)) + } catch { + Self.logger.error("Failed to fetch DPI stats: \(error)") + return nil + } + } + + // MARK: - IDS/IPS Events + + func fetchIPSEvents() async -> [IPSEventDTO]? { + do { + let data = try await request("/proxy/network/api/s/default/stat/ips/event") + let response = try JSONDecoder().decode(LegacyResponse.self, from: data) + return response.data.isEmpty ? nil : Array(response.data.prefix(10)) + } catch { + Self.logger.error("Failed to fetch IPS events: \(error)") + return nil + } + } + + // MARK: - Anomalies + + func fetchAnomalies() async -> [AnomalyDTO]? { + do { + let data = try await request("/proxy/network/api/s/default/stat/anomalies") + let response = try JSONDecoder().decode(LegacyResponse.self, from: data) + return response.data.isEmpty ? nil : Array(response.data.prefix(10)) + } catch { + Self.logger.error("Failed to fetch anomalies: \(error)") + return nil + } + } + + // MARK: - Site Events + + func fetchSiteEvents() async -> [SiteEventDTO]? { + do { + let data = try await request("/proxy/network/api/s/default/stat/event") + let response = try JSONDecoder().decode(LegacyResponse.self, from: data) + return response.data.isEmpty ? nil : Array(response.data.prefix(10)) + } catch { + Self.logger.error("Failed to fetch site events: \(error)") + return nil + } + } + + // MARK: - Dynamic DNS + + func fetchDDNSStatus() async -> [DDNSStatusDTO]? { + do { + let data = try await request("/proxy/network/api/s/default/stat/dynamicdns") + let response = try JSONDecoder().decode(LegacyResponse.self, from: data) + return response.data.isEmpty ? nil : response.data + } catch { + Self.logger.error("Failed to fetch DDNS status: \(error)") + return nil + } + } + + // MARK: - Port Forwards + + func fetchPortForwards() async -> [PortForwardDTO]? { + do { + let data = try await request("/proxy/network/api/s/default/stat/portforward") + let response = try JSONDecoder().decode(LegacyResponse.self, from: data) + let active = response.data.filter { $0.enabled == true } + return active.isEmpty ? nil : active + } catch { + Self.logger.error("Failed to fetch port forwards: \(error)") + return nil + } + } + + // MARK: - Rogue / Neighboring APs + + func fetchRogueAPs() async -> [RogueAPDTO]? { + do { + let body: [String: Any] = ["within": 24] + let data = try await post("/proxy/network/api/s/default/stat/rogueap", body: body) + let response = try JSONDecoder().decode(LegacyResponse.self, from: data) + guard !response.data.isEmpty else { return nil } + // Return top 10 by signal strength + let sorted = response.data.sorted { ($0.rssi ?? -100) > ($1.rssi ?? -100) } + return Array(sorted.prefix(10)) + } catch { + Self.logger.error("Failed to fetch rogue APs: \(error)") + return nil + } + } + // MARK: - Validation /// Validates that an identifier is safe for URL path interpolation (alphanumeric, hyphens, colons). diff --git a/Sources/UniFiBar/Preferences/PreferencesManager.swift b/Sources/UniFiBar/Preferences/PreferencesManager.swift index 12fcdbe..d2d3b5b 100644 --- a/Sources/UniFiBar/Preferences/PreferencesManager.swift +++ b/Sources/UniFiBar/Preferences/PreferencesManager.swift @@ -1,17 +1,82 @@ import Foundation +// MARK: - Section Visibility + +enum MenuSection: String, CaseIterable, Sendable { + case internet = "internet" + case vpn = "vpn" + case wifi = "wifi" + case network = "network" + case sessionHistory = "sessionHistory" + case alerts = "alerts" + case security = "security" + case traffic = "traffic" + case events = "events" + case ddns = "ddns" + case portForwards = "portForwards" + case nearbyAPs = "nearbyAPs" + + var displayName: String { + switch self { + case .internet: return "Internet & Gateway" + case .vpn: return "VPN Tunnels" + case .wifi: return "WiFi & Connection" + case .network: return "Network Overview" + case .sessionHistory: return "Session History" + case .alerts: return "Alerts" + case .security: return "Security (IPS)" + case .traffic: return "Traffic (DPI)" + case .events: return "Recent Events" + case .ddns: return "Dynamic DNS" + case .portForwards: return "Port Forwards" + case .nearbyAPs: return "Nearby APs" + } + } + + var icon: String { + switch self { + case .internet: return "globe" + case .vpn: return "lock.shield" + case .wifi: return "wifi" + case .network: return "network" + case .sessionHistory: return "clock" + case .alerts: return "bell.badge" + case .security: return "shield.lefthalf.filled" + case .traffic: return "chart.pie" + case .events: return "list.bullet" + case .ddns: return "link" + case .portForwards: return "arrow.right.arrow.left" + case .nearbyAPs: return "antenna.radiowaves.left.and.right" + } + } + + /// Whether this section is shown by default + var defaultEnabled: Bool { + switch self { + case .internet, .vpn, .wifi, .network, .sessionHistory, .alerts: + return true + case .security, .traffic, .events, .ddns, .portForwards, .nearbyAPs: + return false + } + } +} + @MainActor @Observable final class PreferencesManager { var isConfigured: Bool = false var allowSelfSignedCerts: Bool = false + // Section visibility + private var sectionVisibility: [String: Bool] = [:] + // Cached credentials — read from Keychain once, then reuse private var cachedURL: String? private var cachedAPIKey: String? private let siteIdKey = "com.unifbar.siteId" private let selfSignedKey = "com.unifbar.allowSelfSigned" + private let sectionVisibilityKey = "com.unifbar.sectionVisibility" var siteId: String? { get { UserDefaults.standard.string(forKey: siteIdKey) } @@ -20,6 +85,24 @@ final class PreferencesManager { init() { allowSelfSignedCerts = UserDefaults.standard.bool(forKey: selfSignedKey) + if let saved = UserDefaults.standard.dictionary(forKey: sectionVisibilityKey) as? [String: Bool] { + sectionVisibility = saved + } + } + + func isSectionEnabled(_ section: MenuSection) -> Bool { + sectionVisibility[section.rawValue] ?? section.defaultEnabled + } + + func setSectionEnabled(_ section: MenuSection, enabled: Bool) { + sectionVisibility[section.rawValue] = enabled + UserDefaults.standard.set(sectionVisibility, forKey: sectionVisibilityKey) + } + + /// Returns true if any optional monitoring section is enabled (requiring extra API calls) + var hasMonitoringSectionsEnabled: Bool { + let monitoringSections: [MenuSection] = [.alerts, .security, .traffic, .events, .ddns, .portForwards, .nearbyAPs] + return monitoringSections.contains { isSectionEnabled($0) } } /// Reads Keychain once and caches. Subsequent calls use cache. diff --git a/Sources/UniFiBar/StatusBar/StatusBarController.swift b/Sources/UniFiBar/StatusBar/StatusBarController.swift index b20d888..d137304 100644 --- a/Sources/UniFiBar/StatusBar/StatusBarController.swift +++ b/Sources/UniFiBar/StatusBar/StatusBarController.swift @@ -170,7 +170,7 @@ final class StatusBarController { let me = selfInfo.client - // Parallel batch: devices, WAN health, VPN tunnels, session history + // Parallel batch 1: devices, WAN health, VPN tunnels, session history async let devicesTask = client.fetchDevices(siteId: siteId) async let wanHealthTask = client.fetchWANHealth() async let vpnTask = client.fetchVPNTunnels(siteId: siteId) @@ -191,7 +191,7 @@ final class StatusBarController { wifiStatus.updateVPN(tunnels) wifiStatus.updateSessions(sessions, devices: devices) - // Parallel batch: AP stats + gateway stats (depend on devices result) + // Parallel batch 2: AP stats + gateway stats (depend on devices result) let apDevice = me.apMac.flatMap { apMac in devices.first(where: { $0.mac?.lowercased() == apMac.lowercased() }) } @@ -216,5 +216,50 @@ final class StatusBarController { wifiStatus.updateAPStats(apStats) wifiStatus.updateGateway(gwStats, device: gwDevice) + + // Parallel batch 3: monitoring data (only fetch enabled sections) + await fetchMonitoringData(client: client) + } + + /// Fetches optional monitoring data based on which sections are enabled in preferences. + /// Each call is independent and fails silently — monitoring data is best-effort. + private func fetchMonitoringData(client: UniFiClient) async { + // Evaluate section visibility on @MainActor before spawning child tasks + let wantAlarms = preferences.isSectionEnabled(.alerts) + let wantTraffic = preferences.isSectionEnabled(.traffic) + let wantSecurity = preferences.isSectionEnabled(.security) + let wantEvents = preferences.isSectionEnabled(.events) + let wantDDNS = preferences.isSectionEnabled(.ddns) + let wantPF = preferences.isSectionEnabled(.portForwards) + let wantRogue = preferences.isSectionEnabled(.nearbyAPs) + + async let alarmsTask: [AlarmDTO]? = wantAlarms ? await client.fetchAlarms() : nil + async let dpiTask: [DPICategoryDTO]? = wantTraffic ? await client.fetchDPIStats() : nil + async let ipsTask: [IPSEventDTO]? = wantSecurity ? await client.fetchIPSEvents() : nil + async let anomaliesTask: [AnomalyDTO]? = wantSecurity ? await client.fetchAnomalies() : nil + async let eventsTask: [SiteEventDTO]? = wantEvents ? await client.fetchSiteEvents() : nil + async let ddnsTask: [DDNSStatusDTO]? = wantDDNS ? await client.fetchDDNSStatus() : nil + async let pfTask: [PortForwardDTO]? = wantPF ? await client.fetchPortForwards() : nil + async let rogueTask: [RogueAPDTO]? = wantRogue ? await client.fetchRogueAPs() : nil + + let alarms = await alarmsTask + let dpi = await dpiTask + let ips = await ipsTask + let anomalies = await anomaliesTask + let events = await eventsTask + let ddns = await ddnsTask + let pf = await pfTask + let rogue = await rogueTask + + wifiStatus.updateMonitoring( + alarms: alarms, + dpi: dpi, + ips: ips, + anomalies: anomalies, + events: events, + ddns: ddns, + portForwards: pf, + rogueAPs: rogue + ) } } diff --git a/Sources/UniFiBar/Views/Components/CollapsibleSection.swift b/Sources/UniFiBar/Views/Components/CollapsibleSection.swift new file mode 100644 index 0000000..6eeb629 --- /dev/null +++ b/Sources/UniFiBar/Views/Components/CollapsibleSection.swift @@ -0,0 +1,136 @@ +import SwiftUI + +struct CollapsibleSection: View { + let title: String + let showDivider: Bool + @ViewBuilder let content: () -> Content + + @State private var isExpanded: Bool + + private let storageKey: String + + init(title: String, showDivider: Bool = true, defaultExpanded: Bool = true, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.showDivider = showDivider + self.content = content + self.storageKey = "com.unifbar.section.expanded.\(title.lowercased().replacingOccurrences(of: " ", with: "_"))" + let saved = UserDefaults.standard.object(forKey: storageKey) as? Bool + self._isExpanded = State(initialValue: saved ?? defaultExpanded) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if showDivider { + Divider() + .padding(.horizontal, 12) + .padding(.top, 6) + } + + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + UserDefaults.standard.set(isExpanded, forKey: storageKey) + } + } label: { + HStack { + Text(title) + .font(.callout) + .fontWeight(.bold) + .textCase(.uppercase) + .foregroundStyle(.secondary) + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.quaternary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .contentShape(Rectangle()) + .padding(.horizontal, 16) + .padding(.top, showDivider ? 8 : 12) + .padding(.bottom, 4) + } + .buttonStyle(.plain) + + if isExpanded { + content() + } + } + } +} + +/// Variant with a badge count next to the title +struct CollapsibleSectionWithBadge: View { + let title: String + let badge: Int + let badgeColor: Color + let showDivider: Bool + @ViewBuilder let content: () -> Content + + @State private var isExpanded: Bool + + private let storageKey: String + + init(title: String, badge: Int, badgeColor: Color = .red, showDivider: Bool = true, defaultExpanded: Bool = true, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.badge = badge + self.badgeColor = badgeColor + self.showDivider = showDivider + self.content = content + self.storageKey = "com.unifbar.section.expanded.\(title.lowercased().replacingOccurrences(of: " ", with: "_"))" + let saved = UserDefaults.standard.object(forKey: storageKey) as? Bool + self._isExpanded = State(initialValue: saved ?? defaultExpanded) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if showDivider { + Divider() + .padding(.horizontal, 12) + .padding(.top, 6) + } + + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + UserDefaults.standard.set(isExpanded, forKey: storageKey) + } + } label: { + HStack(spacing: 6) { + Text(title) + .font(.callout) + .fontWeight(.bold) + .textCase(.uppercase) + .foregroundStyle(.secondary) + + if badge > 0 { + Text("\(badge)") + .font(.caption2) + .fontWeight(.bold) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(badgeColor, in: Capsule()) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.quaternary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .contentShape(Rectangle()) + .padding(.horizontal, 16) + .padding(.top, showDivider ? 8 : 12) + .padding(.bottom, 4) + } + .buttonStyle(.plain) + + if isExpanded { + content() + } + } + } +} diff --git a/Sources/UniFiBar/Views/MenuContentView.swift b/Sources/UniFiBar/Views/MenuContentView.swift index e18ff84..e00c448 100644 --- a/Sources/UniFiBar/Views/MenuContentView.swift +++ b/Sources/UniFiBar/Views/MenuContentView.swift @@ -34,20 +34,45 @@ struct MenuContentView: View { // MARK: - Connected View + private var prefs: PreferencesManager { controller.preferences } + private var status: WiFiStatus { controller.wifiStatus } + @ViewBuilder private var connectedView: some View { - // Internet — WAN status, throughput, gateway - if controller.wifiStatus.wanIsUp != nil { - InternetSection(wifiStatus: controller.wifiStatus) + coreSections + monitoringSections + footerTimestamp + } + + // MARK: - Core sections (Internet, VPN, WiFi/Connection, Session History, Network) + + @ViewBuilder + private var coreSections: some View { + if prefs.isSectionEnabled(.internet), status.wanIsUp != nil { + InternetSection(wifiStatus: status) } - // VPN tunnels - if let tunnels = controller.wifiStatus.vpnTunnels { + if prefs.isSectionEnabled(.vpn), let tunnels = status.vpnTunnels { VPNSection(tunnels: tunnels) } - if controller.wifiStatus.isWired { - // Wired connection — show Ethernet indicator, skip WiFi sections + if prefs.isSectionEnabled(.wifi) { + connectionContent + } + + if prefs.isSectionEnabled(.sessionHistory), !status.isWired, + let sessions = status.sessions { + SessionTimeSection(sessions: sessions) + } + + if prefs.isSectionEnabled(.network) { + NetworkSection(wifiStatus: status) + } + } + + @ViewBuilder + private var connectionContent: some View { + if status.isWired { SectionHeader(title: "Connection") HStack(spacing: 6) { Image(systemName: "cable.connector.horizontal") @@ -60,25 +85,55 @@ struct MenuContentView: View { .padding(.horizontal, 16) .padding(.vertical, 1) - if let ip = controller.wifiStatus.ip { + if let ip = status.ip { MetricRow(label: "IP", value: ip, systemImage: "network") } } else { - // WiFi — experience, signal, AP, link, session, session history - WiFiExperienceSection(wifiStatus: controller.wifiStatus) - SignalSection(wifiStatus: controller.wifiStatus) - AccessPointSection(wifiStatus: controller.wifiStatus) - LinkSection(wifiStatus: controller.wifiStatus) - SessionSection(wifiStatus: controller.wifiStatus) - if let sessions = controller.wifiStatus.sessions { - SessionTimeSection(sessions: sessions) - } + WiFiExperienceSection(wifiStatus: status) + SignalSection(wifiStatus: status) + AccessPointSection(wifiStatus: status) + LinkSection(wifiStatus: status) + SessionSection(wifiStatus: status) + } + } + + // MARK: - Monitoring sections (Alerts, Security, Traffic, Events, DDNS, Port Forwards, Nearby APs) + + @ViewBuilder + private var monitoringSections: some View { + if prefs.isSectionEnabled(.alerts), let alarms = status.activeAlarms { + AlertsSection(alarms: alarms) + } + + if prefs.isSectionEnabled(.security), + (status.ipsEvents != nil) || (status.anomalies != nil) { + SecuritySection(ipsEvents: status.ipsEvents, anomalies: status.anomalies) + } + + if prefs.isSectionEnabled(.traffic), let categories = status.dpiCategories { + TrafficSection(categories: categories) + } + + if prefs.isSectionEnabled(.events), let events = status.siteEvents { + EventsSection(events: events) + } + + if prefs.isSectionEnabled(.ddns), let ddns = status.ddnsStatuses { + DDNSSection(statuses: ddns) + } + + if prefs.isSectionEnabled(.portForwards), let pf = status.portForwards { + PortForwardsSection(portForwards: pf) } - // Network — clients, devices, firmware - NetworkSection(wifiStatus: controller.wifiStatus) + if prefs.isSectionEnabled(.nearbyAPs), let aps = status.nearbyAPs { + NearbyAPsSection(rogueAPs: aps) + } + } - if let lastUpdated = controller.wifiStatus.lastUpdated { + @ViewBuilder + private var footerTimestamp: some View { + if let lastUpdated = status.lastUpdated { Text("Last updated: \(lastUpdated.formatted(date: .omitted, time: .standard))") .font(.caption2) .foregroundStyle(.tertiary) diff --git a/Sources/UniFiBar/Views/PreferencesView.swift b/Sources/UniFiBar/Views/PreferencesView.swift index 4fbb6c7..d32311e 100644 --- a/Sources/UniFiBar/Views/PreferencesView.swift +++ b/Sources/UniFiBar/Views/PreferencesView.swift @@ -12,6 +12,7 @@ struct PreferencesView: View { @State private var isLoading = true @State private var showResetConfirmation = false @State private var errorMessage: String? + @State private var showSectionSettings = false var body: some View { VStack(spacing: 20) { @@ -60,6 +61,18 @@ struct PreferencesView: View { .textSelection(.enabled) } } + + Divider() + + DisclosureGroup("Visible Sections", isExpanded: $showSectionSettings) { + VStack(alignment: .leading, spacing: 6) { + ForEach(MenuSection.allCases, id: \.rawValue) { section in + SectionToggleRow(section: section, preferences: controller.preferences) + } + } + .padding(.top, 4) + } + .font(.callout) } if let errorMessage { @@ -165,3 +178,29 @@ struct PreferencesView: View { } } } + +// MARK: - Section Toggle Row + +private struct SectionToggleRow: View { + let section: MenuSection + let preferences: PreferencesManager + + @State private var isEnabled: Bool + + init(section: MenuSection, preferences: PreferencesManager) { + self.section = section + self.preferences = preferences + self._isEnabled = State(initialValue: preferences.isSectionEnabled(section)) + } + + var body: some View { + Toggle(isOn: $isEnabled) { + Label(section.displayName, systemImage: section.icon) + } + .toggleStyle(.checkbox) + .font(.callout) + .onChange(of: isEnabled) { _, newValue in + preferences.setSectionEnabled(section, enabled: newValue) + } + } +} diff --git a/Sources/UniFiBar/Views/Sections/AlertsSection.swift b/Sources/UniFiBar/Views/Sections/AlertsSection.swift new file mode 100644 index 0000000..5de9d77 --- /dev/null +++ b/Sources/UniFiBar/Views/Sections/AlertsSection.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct AlertsSection: View { + let alarms: [AlarmDTO] + + var body: some View { + CollapsibleSectionWithBadge(title: "Alerts", badge: alarms.count, badgeColor: .orange) { + ForEach(alarms.prefix(5)) { alarm in + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .frame(width: 20, alignment: .center) + Text(alarm.displayMessage) + .foregroundStyle(.primary) + .lineLimit(2) + .font(.callout) + Spacer() + Text(alarm.relativeTime) + .foregroundStyle(.tertiary) + .font(.caption2) + } + .padding(.horizontal, 16) + .padding(.vertical, 1) + } + + if alarms.count > 5 { + Text("+\(alarms.count - 5) more") + .font(.caption2) + .foregroundStyle(.tertiary) + .padding(.horizontal, 16) + .padding(.top, 2) + } + } + } +} diff --git a/Sources/UniFiBar/Views/Sections/DDNSSection.swift b/Sources/UniFiBar/Views/Sections/DDNSSection.swift new file mode 100644 index 0000000..8a4c01f --- /dev/null +++ b/Sources/UniFiBar/Views/Sections/DDNSSection.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct DDNSSection: View { + let statuses: [DDNSStatusDTO] + + var body: some View { + CollapsibleSection(title: "Dynamic DNS", defaultExpanded: false) { + ForEach(Array(statuses.enumerated()), id: \.offset) { _, ddns in + HStack(spacing: 6) { + Image(systemName: ddns.isActive ? "link" : "link.badge.plus") + .foregroundStyle(ddns.isActive ? .green : .red) + .frame(width: 20, alignment: .center) + VStack(alignment: .leading, spacing: 1) { + if let hostname = ddns.hostname { + Text(hostname) + .font(.callout) + .foregroundStyle(.primary) + .lineLimit(1) + } + if let ip = ddns.ip { + Text(ip) + .font(.caption2) + .foregroundStyle(.tertiary) + .monospacedDigit() + } + } + Spacer() + Text(ddns.displayStatus) + .font(.callout) + .foregroundStyle(ddns.isActive ? .secondary : .red) + } + .padding(.horizontal, 16) + .padding(.vertical, 1) + } + } + } +} diff --git a/Sources/UniFiBar/Views/Sections/EventsSection.swift b/Sources/UniFiBar/Views/Sections/EventsSection.swift new file mode 100644 index 0000000..5620043 --- /dev/null +++ b/Sources/UniFiBar/Views/Sections/EventsSection.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct EventsSection: View { + let events: [SiteEventDTO] + + var body: some View { + CollapsibleSection(title: "Events", defaultExpanded: false) { + ForEach(events.prefix(5)) { event in + HStack(spacing: 6) { + Image(systemName: event.subsystemIcon) + .foregroundStyle(.secondary) + .frame(width: 20, alignment: .center) + Text(event.displayMessage) + .foregroundStyle(.primary) + .lineLimit(2) + .font(.callout) + Spacer() + Text(event.relativeTime) + .foregroundStyle(.tertiary) + .font(.caption2) + } + .padding(.horizontal, 16) + .padding(.vertical, 1) + } + + if events.count > 5 { + Text("+\(events.count - 5) more events") + .font(.caption2) + .foregroundStyle(.tertiary) + .padding(.horizontal, 16) + .padding(.top, 2) + } + } + } +} diff --git a/Sources/UniFiBar/Views/Sections/InternetSection.swift b/Sources/UniFiBar/Views/Sections/InternetSection.swift index cb63c0a..c2fa8c1 100644 --- a/Sources/UniFiBar/Views/Sections/InternetSection.swift +++ b/Sources/UniFiBar/Views/Sections/InternetSection.swift @@ -6,6 +6,13 @@ struct InternetSection: View { var body: some View { SectionHeader(title: "Internet", showDivider: false) + wanStatusGroup + speedTestGroup + gatewayGroup + } + + @ViewBuilder + private var wanStatusGroup: some View { if let isUp = wifiStatus.wanIsUp { HStack(spacing: 6) { Image(systemName: isUp ? "globe" : "globe.badge.chevron.backward") @@ -44,8 +51,42 @@ struct InternetSection: View { if let throughput = wifiStatus.formattedWANThroughput { MetricRow(label: "Throughput", value: throughput, systemImage: "arrow.up.arrow.down") } + } + + @ViewBuilder + private var speedTestGroup: some View { + if let speedTest = wifiStatus.speedTest, !speedTest.isRunning { + SubSectionHeader(title: "Speed Test") + + if let dl = speedTest.formattedDownload { + MetricRow(label: "Download", value: dl, systemImage: "arrow.down.circle") + } + if let ul = speedTest.formattedUpload { + MetricRow(label: "Upload", value: ul, systemImage: "arrow.up.circle") + } + if let ping = speedTest.formattedPing { + MetricRow(label: "Ping", value: ping, systemImage: "stopwatch") + } + if let lastRun = speedTest.formattedLastRun { + MetricRow(label: "Tested", value: lastRun, systemImage: "clock") + } + } else if let speedTest = wifiStatus.speedTest, speedTest.isRunning { + SubSectionHeader(title: "Speed Test") + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + .frame(width: 20, alignment: .center) + Text("Speed test running...") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 1) + } + } - // Gateway health + @ViewBuilder + private var gatewayGroup: some View { if let gwName = wifiStatus.gatewayName { SubSectionHeader(title: "Gateway") MetricRow(label: "Device", value: gwName, systemImage: "server.rack") diff --git a/Sources/UniFiBar/Views/Sections/NearbyAPsSection.swift b/Sources/UniFiBar/Views/Sections/NearbyAPsSection.swift new file mode 100644 index 0000000..b5925cb --- /dev/null +++ b/Sources/UniFiBar/Views/Sections/NearbyAPsSection.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct NearbyAPsSection: View { + let rogueAPs: [RogueAPDTO] + + var body: some View { + CollapsibleSectionWithBadge( + title: "Nearby APs", + badge: rogueAPs.count, + badgeColor: .secondary, + defaultExpanded: false + ) { + ForEach(rogueAPs.prefix(6)) { ap in + HStack(spacing: 6) { + Image(systemName: ap.isRogue == true ? "wifi.exclamationmark" : "wifi") + .foregroundStyle(ap.isRogue == true ? .orange : .secondary) + .frame(width: 20, alignment: .center) + VStack(alignment: .leading, spacing: 1) { + Text(ap.displayName) + .font(.callout) + .foregroundStyle(.primary) + .lineLimit(1) + if let ch = ap.channel { + Text("Ch \(ch)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + Spacer() + Text(ap.signalDescription) + .font(.callout) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .padding(.horizontal, 16) + .padding(.vertical, 1) + } + + if rogueAPs.count > 6 { + Text("+\(rogueAPs.count - 6) more") + .font(.caption2) + .foregroundStyle(.tertiary) + .padding(.horizontal, 16) + .padding(.top, 2) + } + } + } +} diff --git a/Sources/UniFiBar/Views/Sections/PortForwardsSection.swift b/Sources/UniFiBar/Views/Sections/PortForwardsSection.swift new file mode 100644 index 0000000..6747b7e --- /dev/null +++ b/Sources/UniFiBar/Views/Sections/PortForwardsSection.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct PortForwardsSection: View { + let portForwards: [PortForwardDTO] + + var body: some View { + CollapsibleSection(title: "Port Forwards", defaultExpanded: false) { + ForEach(portForwards.prefix(8)) { pf in + HStack(spacing: 6) { + Image(systemName: "arrow.right.arrow.left") + .foregroundStyle(.secondary) + .frame(width: 20, alignment: .center) + Text(pf.displayName) + .font(.callout) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer() + Text(pf.summary) + .font(.caption) + .foregroundStyle(.tertiary) + .monospacedDigit() + .lineLimit(1) + } + .padding(.horizontal, 16) + .padding(.vertical, 1) + } + + if portForwards.count > 8 { + Text("+\(portForwards.count - 8) more rules") + .font(.caption2) + .foregroundStyle(.tertiary) + .padding(.horizontal, 16) + .padding(.top, 2) + } + } + } +} diff --git a/Sources/UniFiBar/Views/Sections/SecuritySection.swift b/Sources/UniFiBar/Views/Sections/SecuritySection.swift new file mode 100644 index 0000000..f3acdfd --- /dev/null +++ b/Sources/UniFiBar/Views/Sections/SecuritySection.swift @@ -0,0 +1,77 @@ +import SwiftUI + +struct SecuritySection: View { + let ipsEvents: [IPSEventDTO]? + let anomalies: [AnomalyDTO]? + + private var totalThreats: Int { ipsEvents?.count ?? 0 } + private var totalAnomalies: Int { anomalies?.count ?? 0 } + private var totalBadge: Int { totalThreats + totalAnomalies } + + var body: some View { + CollapsibleSectionWithBadge( + title: "Security", + badge: totalBadge, + badgeColor: totalThreats > 0 ? .red : .yellow, + defaultExpanded: false + ) { + if totalThreats > 0 { + MetricRow( + label: "Threats Blocked", + value: "\(totalThreats)", + systemImage: "shield.lefthalf.filled.slash" + ) + } + + if totalAnomalies > 0 { + MetricRow( + label: "Anomalies", + value: "\(totalAnomalies)", + systemImage: "waveform.path.ecg" + ) + } + + if let events = ipsEvents, !events.isEmpty { + SubSectionHeader(title: "Recent Threats") + ForEach(events.prefix(3)) { event in + HStack(spacing: 6) { + Image(systemName: "xmark.shield.fill") + .foregroundStyle(.red) + .frame(width: 20, alignment: .center) + VStack(alignment: .leading, spacing: 1) { + Text(event.displayMessage) + .font(.caption) + .foregroundStyle(.primary) + .lineLimit(1) + if let src = event.srcIP { + Text(src) + .font(.caption2) + .foregroundStyle(.tertiary) + .monospacedDigit() + } + } + Spacer() + Text(event.relativeTime) + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 1) + } + } + + if totalThreats == 0 && totalAnomalies == 0 { + HStack(spacing: 6) { + Image(systemName: "checkmark.shield.fill") + .foregroundStyle(.green) + .frame(width: 20, alignment: .center) + Text("No threats detected") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 1) + } + } + } +} diff --git a/Sources/UniFiBar/Views/Sections/TrafficSection.swift b/Sources/UniFiBar/Views/Sections/TrafficSection.swift new file mode 100644 index 0000000..6932286 --- /dev/null +++ b/Sources/UniFiBar/Views/Sections/TrafficSection.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct TrafficSection: View { + let categories: [DPICategoryDTO] + + var body: some View { + CollapsibleSection(title: "Traffic", defaultExpanded: false) { + ForEach(categories.prefix(6)) { cat in + HStack(spacing: 6) { + Image(systemName: iconForCategory(cat.name)) + .foregroundStyle(.secondary) + .frame(width: 20, alignment: .center) + Text(cat.name) + .foregroundStyle(.primary) + Spacer() + Text(cat.formattedTotal) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .font(.callout) + .padding(.horizontal, 16) + .padding(.vertical, 1) + } + + if categories.count > 6 { + Text("+\(categories.count - 6) more categories") + .font(.caption2) + .foregroundStyle(.tertiary) + .padding(.horizontal, 16) + .padding(.top, 2) + } + } + } + + private func iconForCategory(_ name: String) -> String { + switch name { + case "Web": return "safari" + case "Streaming", "Video": return "tv" + case "Gaming": return "gamecontroller" + case "Social": return "person.2" + case "Email": return "envelope" + case "File Transfer": return "doc.on.doc" + case "VPN/Tunnel": return "lock.shield" + case "Instant Messaging": return "message" + case "P2P": return "arrow.triangle.swap" + case "Shopping": return "cart" + case "Productivity": return "doc.text" + case "IoT": return "house" + case "Apple": return "app.badge" + case "Microsoft": return "desktopcomputer" + default: return "chart.pie" + } + } +} diff --git a/Sources/UniFiBar/Views/StatusBarLabel.swift b/Sources/UniFiBar/Views/StatusBarLabel.swift index 002c69e..992c56b 100644 --- a/Sources/UniFiBar/Views/StatusBarLabel.swift +++ b/Sources/UniFiBar/Views/StatusBarLabel.swift @@ -16,6 +16,11 @@ struct StatusBarLabel: View { Text("\(satisfaction)%") .monospacedDigit() } + if controller.wifiStatus.activeAlarmCount > 0 { + Image(systemName: "bell.badge.fill") + .font(.caption2) + .foregroundStyle(.orange) + } } .task { await controller.start() From e71d5f1fc753fa7ef56dcf2300fb6990ef983e2c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 15:01:53 +0000 Subject: [PATCH 03/21] Harden new monitoring code: bound arrays, sanitize API key, clean up on reset Security fixes: - Cap fetchDDNSStatus and fetchPortForwards response arrays to prevent memory exhaustion from malicious controller responses - Strip control characters from API key to prevent HTTP header injection - Clear certificate pin hash and section visibility on resetAll() to avoid orphaned sensitive data in UserDefaults https://claude.ai/code/session_01Pmx6pGDgBmZ6aCxwQeHPPz --- Sources/UniFiBar/Network/UniFiClient.swift | 8 +++++--- Sources/UniFiBar/Preferences/PreferencesManager.swift | 6 ++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Sources/UniFiBar/Network/UniFiClient.swift b/Sources/UniFiBar/Network/UniFiClient.swift index 782e0c0..bb26692 100644 --- a/Sources/UniFiBar/Network/UniFiClient.swift +++ b/Sources/UniFiBar/Network/UniFiClient.swift @@ -14,7 +14,9 @@ actor UniFiClient { init(baseURL: URL, apiKey: String, allowSelfSigned: Bool = false) { self.baseURL = baseURL - self.apiKey = apiKey + // Strip control characters to prevent HTTP header injection + self.apiKey = apiKey.unicodeScalars.filter { !CharacterSet.controlCharacters.contains($0) } + .map(String.init).joined() if allowSelfSigned { let config = URLSessionConfiguration.default @@ -286,7 +288,7 @@ actor UniFiClient { do { let data = try await request("/proxy/network/api/s/default/stat/dynamicdns") let response = try JSONDecoder().decode(LegacyResponse.self, from: data) - return response.data.isEmpty ? nil : response.data + return response.data.isEmpty ? nil : Array(response.data.prefix(10)) } catch { Self.logger.error("Failed to fetch DDNS status: \(error)") return nil @@ -300,7 +302,7 @@ actor UniFiClient { let data = try await request("/proxy/network/api/s/default/stat/portforward") let response = try JSONDecoder().decode(LegacyResponse.self, from: data) let active = response.data.filter { $0.enabled == true } - return active.isEmpty ? nil : active + return active.isEmpty ? nil : Array(active.prefix(50)) } catch { Self.logger.error("Failed to fetch port forwards: \(error)") return nil diff --git a/Sources/UniFiBar/Preferences/PreferencesManager.swift b/Sources/UniFiBar/Preferences/PreferencesManager.swift index d2d3b5b..91e7ffe 100644 --- a/Sources/UniFiBar/Preferences/PreferencesManager.swift +++ b/Sources/UniFiBar/Preferences/PreferencesManager.swift @@ -146,12 +146,18 @@ final class PreferencesManager { } func resetAll() async { + // Clear certificate pin for the current controller host + if let urlString = cachedURL, let url = URL(string: urlString), let host = url.host() { + UserDefaults.standard.removeObject(forKey: "com.unifbar.cert-pin.\(host)") + } await KeychainHelper.shared.delete(.controllerURL) await KeychainHelper.shared.delete(.apiKey) cachedURL = nil cachedAPIKey = nil UserDefaults.standard.removeObject(forKey: siteIdKey) UserDefaults.standard.removeObject(forKey: selfSignedKey) + UserDefaults.standard.removeObject(forKey: sectionVisibilityKey) + sectionVisibility = [:] allowSelfSignedCerts = false isConfigured = false } From f1673e2aaa3c714d18e230b03da8dab9c8190bd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 15:11:24 +0000 Subject: [PATCH 04/21] Address all security audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Certificate pinning (HIGH): - Reject connections on cert mismatch instead of silently re-pinning (the silent re-pin completely defeated TOFU protection against MITM) - Introduce PinState enum: unpinned → pinned → mismatch (terminal) - Move certificate pin hash from UserDefaults to Keychain (UserDefaults is a plaintext plist readable by same-user processes) - Add deletePinFromKeychain() for clean reset flow - User must reset via Preferences to re-pin after cert rotation Input validation (MEDIUM): - Truncate all displayMessage properties to 200 chars max - Truncate AP names, port forward names/summaries to 64 chars - Clamp RSSI values to physically plausible range [-120, 0] dBm - Clamp timestamps to reasonable epoch range (2000-2100) before passing to RelativeDateTimeFormatter Logging (MEDIUM): - Replace all `\(error)` in log statements with safeErrorDescription() that only emits error domain/code — never full URLs or hostnames - Remove device/site identifiers from warning log messages Denial of service (LOW): - Add 5-second debounce to refreshNow() to prevent request flooding from rapid manual refresh clicks Concurrency (LOW): - Replace deinit with explicit tearDown() method called from applicationWillTerminate — deinit is nonisolated in Swift 6 and cannot safely access @MainActor-isolated properties https://claude.ai/code/session_01Pmx6pGDgBmZ6aCxwQeHPPz --- Sources/UniFiBar/App/AppDelegate.swift | 4 + Sources/UniFiBar/Models/MonitoringDTO.swift | 41 +++-- Sources/UniFiBar/Network/UniFiClient.swift | 147 +++++++++++++----- .../Preferences/PreferencesManager.swift | 4 +- .../StatusBar/StatusBarController.swift | 17 +- 5 files changed, 162 insertions(+), 51 deletions(-) diff --git a/Sources/UniFiBar/App/AppDelegate.swift b/Sources/UniFiBar/App/AppDelegate.swift index 4717ae7..6caba50 100644 --- a/Sources/UniFiBar/App/AppDelegate.swift +++ b/Sources/UniFiBar/App/AppDelegate.swift @@ -11,4 +11,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } + + func applicationWillTerminate(_ notification: Notification) { + controller.tearDown() + } } diff --git a/Sources/UniFiBar/Models/MonitoringDTO.swift b/Sources/UniFiBar/Models/MonitoringDTO.swift index 4cb1912..24dbd63 100644 --- a/Sources/UniFiBar/Models/MonitoringDTO.swift +++ b/Sources/UniFiBar/Models/MonitoringDTO.swift @@ -1,5 +1,13 @@ import Foundation +// MARK: - Helpers + +/// Truncates a string to a safe display length to prevent layout abuse from malicious API responses. +private func truncated(_ string: String, maxLength: Int = 200) -> String { + if string.count <= maxLength { return string } + return String(string.prefix(maxLength)) + "…" +} + // MARK: - Alarms struct AlarmDTO: Decodable, Sendable, Identifiable { @@ -23,14 +31,17 @@ struct AlarmDTO: Decodable, Sendable, Identifiable { } var displayMessage: String { - if let msg { return msg } - if let key { return key.replacingOccurrences(of: "EVT_", with: "").replacingOccurrences(of: "_", with: " ").capitalized } + if let msg { return truncated(msg) } + if let key { return truncated(key.replacingOccurrences(of: "EVT_", with: "").replacingOccurrences(of: "_", with: " ").capitalized) } return "Unknown Alert" } var date: Date? { guard let time else { return nil } - return Date(timeIntervalSince1970: TimeInterval(time) / 1000.0) + // Clamp to reasonable range (year 2000 to year 2100) to prevent formatter abuse + let seconds = TimeInterval(time) / 1000.0 + guard seconds > 946_684_800 && seconds < 4_102_444_800 else { return nil } + return Date(timeIntervalSince1970: seconds) } var relativeTime: String { @@ -167,12 +178,14 @@ struct IPSEventDTO: Decodable, Sendable, Identifiable { } var displayMessage: String { - msg ?? catname ?? "IPS Event" + truncated(msg ?? catname ?? "IPS Event") } var relativeTime: String { guard let timestamp else { return "" } - let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0) + let seconds = TimeInterval(timestamp) / 1000.0 + guard seconds > 946_684_800 && seconds < 4_102_444_800 else { return "" } + let date = Date(timeIntervalSince1970: seconds) let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .abbreviated return formatter.localizedString(for: date, relativeTo: Date()) @@ -211,14 +224,16 @@ struct SiteEventDTO: Decodable, Sendable, Identifiable { } var displayMessage: String { - if let msg { return msg } - if let key { return key.replacingOccurrences(of: "EVT_", with: "").replacingOccurrences(of: "_", with: " ").capitalized } + if let msg { return truncated(msg) } + if let key { return truncated(key.replacingOccurrences(of: "EVT_", with: "").replacingOccurrences(of: "_", with: " ").capitalized) } return "Event" } var relativeTime: String { guard let time else { return "" } - let date = Date(timeIntervalSince1970: TimeInterval(time) / 1000.0) + let seconds = TimeInterval(time) / 1000.0 + guard seconds > 946_684_800 && seconds < 4_102_444_800 else { return "" } + let date = Date(timeIntervalSince1970: seconds) let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .abbreviated return formatter.localizedString(for: date, relativeTo: Date()) @@ -286,12 +301,12 @@ struct PortForwardDTO: Decodable, Sendable, Identifiable { } var displayName: String { - name ?? "\(proto?.uppercased() ?? ""):\(dstPort ?? "?")" + truncated(name ?? "\(proto?.uppercased() ?? ""):\(dstPort ?? "?")", maxLength: 64) } var summary: String { let p = proto?.uppercased() ?? "TCP" - return "\(p) :\(dstPort ?? "?") → \(fwd ?? "?"):\(fwdPort ?? dstPort ?? "?")" + return truncated("\(p) :\(dstPort ?? "?") → \(fwd ?? "?"):\(fwdPort ?? dstPort ?? "?")", maxLength: 64) } } @@ -316,11 +331,13 @@ struct RogueAPDTO: Decodable, Sendable, Identifiable { var signalDescription: String { guard let rssi else { return "—" } - return "\(rssi) dBm" + // Clamp to physically plausible range + let clamped = max(-120, min(0, rssi)) + return "\(clamped) dBm" } var displayName: String { - essid ?? bssid ?? "Hidden" + truncated(essid ?? bssid ?? "Hidden", maxLength: 64) } } diff --git a/Sources/UniFiBar/Network/UniFiClient.swift b/Sources/UniFiBar/Network/UniFiClient.swift index bb26692..07e6d66 100644 --- a/Sources/UniFiBar/Network/UniFiClient.swift +++ b/Sources/UniFiBar/Network/UniFiClient.swift @@ -123,7 +123,7 @@ actor UniFiClient { func fetchAPStats(deviceId: String, siteId: String) async -> APStats? { guard Self.isValidIdentifier(deviceId), Self.isValidIdentifier(siteId) else { - Self.logger.warning("Invalid identifier for AP stats: deviceId=\(deviceId), siteId=\(siteId)") + Self.logger.warning("Invalid identifier for AP stats request") return nil } do { @@ -133,7 +133,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(APStatsResponse.self, from: data) return response.toAPStats } catch { - Self.logger.error("Failed to fetch AP stats for device \(deviceId): \(error)") + Self.logger.error("Failed to fetch AP stats: \(Self.safeErrorDescription(error))") return nil } } @@ -152,7 +152,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(LegacyResponse.self, from: data) return response.data.isEmpty ? nil : response.data } catch { - Self.logger.error("Failed to fetch session history for \(mac): \(error)") + Self.logger.error("Failed to fetch session history: \(Self.safeErrorDescription(error))") return nil } } @@ -177,7 +177,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(VPNTunnelResponse.self, from: data) return response.data.isEmpty ? nil : response.data } catch { - Self.logger.error("Failed to fetch VPN tunnels: \(error)") + Self.logger.error("Failed to fetch VPN tunnels: \(Self.safeErrorDescription(error))") return nil } } @@ -190,7 +190,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(WANHealthResponse.self, from: data) return response.toWANHealth() } catch { - Self.logger.error("Failed to fetch WAN health: \(error)") + Self.logger.error("Failed to fetch WAN health: \(Self.safeErrorDescription(error))") return nil } } @@ -199,7 +199,7 @@ actor UniFiClient { func fetchGatewayStats(deviceId: String, siteId: String) async -> GatewayStats? { guard Self.isValidIdentifier(deviceId), Self.isValidIdentifier(siteId) else { - Self.logger.warning("Invalid identifier for gateway stats: deviceId=\(deviceId), siteId=\(siteId)") + Self.logger.warning("Invalid identifier for gateway stats request") return nil } do { @@ -209,7 +209,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(GatewayStatsResponse.self, from: data) return response.toGatewayStats } catch { - Self.logger.error("Failed to fetch gateway stats for device \(deviceId): \(error)") + Self.logger.error("Failed to fetch gateway stats: \(Self.safeErrorDescription(error))") return nil } } @@ -223,7 +223,7 @@ actor UniFiClient { let active = response.data.filter { $0.archived != true } return active.isEmpty ? nil : Array(active.prefix(10)) } catch { - Self.logger.error("Failed to fetch alarms: \(error)") + Self.logger.error("Failed to fetch alarms: \(Self.safeErrorDescription(error))") return nil } } @@ -238,7 +238,7 @@ actor UniFiClient { let categories = response.toCategories() return categories.isEmpty ? nil : Array(categories.prefix(8)) } catch { - Self.logger.error("Failed to fetch DPI stats: \(error)") + Self.logger.error("Failed to fetch DPI stats: \(Self.safeErrorDescription(error))") return nil } } @@ -251,7 +251,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(LegacyResponse.self, from: data) return response.data.isEmpty ? nil : Array(response.data.prefix(10)) } catch { - Self.logger.error("Failed to fetch IPS events: \(error)") + Self.logger.error("Failed to fetch IPS events: \(Self.safeErrorDescription(error))") return nil } } @@ -264,7 +264,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(LegacyResponse.self, from: data) return response.data.isEmpty ? nil : Array(response.data.prefix(10)) } catch { - Self.logger.error("Failed to fetch anomalies: \(error)") + Self.logger.error("Failed to fetch anomalies: \(Self.safeErrorDescription(error))") return nil } } @@ -277,7 +277,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(LegacyResponse.self, from: data) return response.data.isEmpty ? nil : Array(response.data.prefix(10)) } catch { - Self.logger.error("Failed to fetch site events: \(error)") + Self.logger.error("Failed to fetch site events: \(Self.safeErrorDescription(error))") return nil } } @@ -290,7 +290,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(LegacyResponse.self, from: data) return response.data.isEmpty ? nil : Array(response.data.prefix(10)) } catch { - Self.logger.error("Failed to fetch DDNS status: \(error)") + Self.logger.error("Failed to fetch DDNS status: \(Self.safeErrorDescription(error))") return nil } } @@ -304,7 +304,7 @@ actor UniFiClient { let active = response.data.filter { $0.enabled == true } return active.isEmpty ? nil : Array(active.prefix(50)) } catch { - Self.logger.error("Failed to fetch port forwards: \(error)") + Self.logger.error("Failed to fetch port forwards: \(Self.safeErrorDescription(error))") return nil } } @@ -321,7 +321,7 @@ actor UniFiClient { let sorted = response.data.sorted { ($0.rssi ?? -100) > ($1.rssi ?? -100) } return Array(sorted.prefix(10)) } catch { - Self.logger.error("Failed to fetch rogue APs: \(error)") + Self.logger.error("Failed to fetch rogue APs: \(Self.safeErrorDescription(error))") return nil } } @@ -332,23 +332,50 @@ actor UniFiClient { private static func isValidIdentifier(_ id: String) -> Bool { !id.isEmpty && id.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-" || $0 == ":") } } + + /// Returns a safe error description for logging (strips URLs and potential credential fragments). + private static func safeErrorDescription(_ error: Error) -> String { + if let unifiError = error as? UniFiError { + switch unifiError { + case .httpError(let code): return "HTTP \(code)" + case .noSitesFound: return "no sites found" + case .selfNotFound: return "self not found" + case .invalidURL: return "invalid URL" + case .notConfigured: return "not configured" + } + } + // For URLSession errors, only log the code/domain — not the full description which may contain URLs + let nsError = error as NSError + return "\(nsError.domain) code=\(nsError.code)" + } } // MARK: - Certificate Pinning Delegate (Trust-On-First-Use) /// Pins the server certificate on first connection. Subsequent connections must present /// the same certificate public key, preventing MITM attacks even with self-signed certs. +/// On mismatch, the connection is rejected — the user must reset via Preferences to re-pin. final class PinnedCertDelegate: NSObject, URLSessionDelegate, Sendable { private let expectedHost: String - private let pinnedKeyKey: String - private let state: OSAllocatedUnfairLock + private let keychainKey: String + private let state: OSAllocatedUnfairLock + + enum PinState: Sendable { + case unpinned + case pinned(Data) + case mismatch + } init(host: String) { self.expectedHost = host - self.pinnedKeyKey = "com.unifbar.cert-pin.\(host)" - self.state = OSAllocatedUnfairLock( - initialState: UserDefaults.standard.data(forKey: pinnedKeyKey) - ) + self.keychainKey = "com.unifbar.cert-pin.\(host)" + // Load existing pin from Keychain + let existingPin = Self.loadPinFromKeychain(key: "com.unifbar.cert-pin.\(host)") + if let pin = existingPin { + self.state = OSAllocatedUnfairLock(initialState: .pinned(pin)) + } else { + self.state = OSAllocatedUnfairLock(initialState: .unpinned) + } } func urlSession( @@ -372,22 +399,29 @@ final class PinnedCertDelegate: NSObject, URLSessionDelegate, Sendable { return (.useCredential, URLCredential(trust: serverTrust)) } - let storedHash = state.withLock { $0 } - - if let storedHash { - // Verify against pinned key — if mismatch, clear stale pin and re-pin - // (cert rotation is common for self-signed certs) - if serverKeyHash != storedHash { - state.withLock { $0 = serverKeyHash } - UserDefaults.standard.set(serverKeyHash, forKey: pinnedKeyKey) + let currentState = state.withLock { $0 } + + switch currentState { + case .pinned(let storedHash): + if serverKeyHash == storedHash { + // Pin matches — allow connection + return (.useCredential, URLCredential(trust: serverTrust)) + } else { + // Pin MISMATCH — reject connection (possible MITM) + state.withLock { $0 = .mismatch } + return (.cancelAuthenticationChallenge, nil) } - } else { - // Trust-on-first-use: pin the key - state.withLock { $0 = serverKeyHash } - UserDefaults.standard.set(serverKeyHash, forKey: pinnedKeyKey) - } - return (.useCredential, URLCredential(trust: serverTrust)) + case .unpinned: + // Trust-on-first-use: pin the key in Keychain + state.withLock { $0 = .pinned(serverKeyHash) } + Self.savePinToKeychain(key: keychainKey, data: serverKeyHash) + return (.useCredential, URLCredential(trust: serverTrust)) + + case .mismatch: + // Already in mismatch state — keep rejecting + return (.cancelAuthenticationChallenge, nil) + } } /// Extracts SHA-256 hash of the public key from a server trust. @@ -402,4 +436,47 @@ final class PinnedCertDelegate: NSObject, URLSessionDelegate, Sendable { let digest = SHA256.hash(data: keyData) return Data(digest) } + + // MARK: - Keychain storage for certificate pins + + private static func loadPinFromKeychain(key: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.unifbar.cert-pins", + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return data + } + + private static func savePinToKeychain(key: String, data: Data) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.unifbar.cert-pins", + kSecAttrAccount as String: key, + ] + // Try update first + let update: [String: Any] = [kSecValueData as String: data] + let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary) + if updateStatus == errSecSuccess { return } + + // Add new item + var addQuery = query + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + SecItemAdd(addQuery as CFDictionary, nil) + } + + static func deletePinFromKeychain(host: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.unifbar.cert-pins", + kSecAttrAccount as String: "com.unifbar.cert-pin.\(host)", + ] + SecItemDelete(query as CFDictionary) + } } diff --git a/Sources/UniFiBar/Preferences/PreferencesManager.swift b/Sources/UniFiBar/Preferences/PreferencesManager.swift index 91e7ffe..356dcb8 100644 --- a/Sources/UniFiBar/Preferences/PreferencesManager.swift +++ b/Sources/UniFiBar/Preferences/PreferencesManager.swift @@ -146,8 +146,10 @@ final class PreferencesManager { } func resetAll() async { - // Clear certificate pin for the current controller host + // Clear certificate pin for the current controller host from Keychain if let urlString = cachedURL, let url = URL(string: urlString), let host = url.host() { + PinnedCertDelegate.deletePinFromKeychain(host: host) + // Also clean up legacy UserDefaults pin if present from older versions UserDefaults.standard.removeObject(forKey: "com.unifbar.cert-pin.\(host)") } await KeychainHelper.shared.delete(.controllerURL) diff --git a/Sources/UniFiBar/StatusBar/StatusBarController.swift b/Sources/UniFiBar/StatusBar/StatusBarController.swift index d137304..88326cb 100644 --- a/Sources/UniFiBar/StatusBar/StatusBarController.swift +++ b/Sources/UniFiBar/StatusBar/StatusBarController.swift @@ -31,12 +31,19 @@ final class StatusBarController { private var wakeObserver: NSObjectProtocol? private var hasStarted = false private var consecutiveErrors = 0 + private var lastManualRefresh: Date = .distantPast - deinit { + /// Tears down observers. Must be called on @MainActor before the object is released, + /// since `deinit` is nonisolated in Swift 6 and cannot safely access actor-isolated state. + func tearDown() { if let observer = wakeObserver { NSWorkspace.shared.notificationCenter.removeObserver(observer) + wakeObserver = nil } pathMonitor?.cancel() + pathMonitor = nil + pollTask?.cancel() + pollTask = nil } func start() async { @@ -64,6 +71,10 @@ final class StatusBarController { } func refreshNow() { + // Debounce: ignore rapid-fire manual refreshes (min 5s apart) + let now = Date() + guard now.timeIntervalSince(lastManualRefresh) >= 5 else { return } + lastManualRefresh = now Task { await refresh() } @@ -134,7 +145,7 @@ final class StatusBarController { preferences.siteId = siteId } } catch { - Self.logger.error("Site discovery failed: \(error)") + Self.logger.error("Site discovery failed: \((error as NSError).domain) code=\((error as NSError).code)") consecutiveErrors += 1 wifiStatus.markError(.controllerUnreachable) return @@ -154,7 +165,7 @@ final class StatusBarController { Self.logger.info("This device not found in active clients — likely disconnected") wifiStatus.markDisconnected() default: - Self.logger.error("Failed to fetch self: \(error)") + Self.logger.error("Failed to fetch self: \((error as NSError).domain) code=\((error as NSError).code)") wifiStatus.markError(.controllerUnreachable) } return From 07301af98a0eb6ef31685936d77340623b11b088 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 15:32:13 +0000 Subject: [PATCH 05/21] Fix remaining security findings from re-audit (5 medium, 6 low) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Certificate pinning (MEDIUM): - M1: Reject connection when public key extraction fails instead of trusting blindly — prevents bypass via malformed certificates - M2: Make TOFU pin state read-check-write atomic in single withLock block — prevents race between concurrent TLS challenges - M4: Use delete+add instead of SecItemUpdate for Keychain cert pin to ensure kSecAttrAccessible is always set correctly Transport security (MEDIUM): - M3: Enforce HTTPS-only for controller URL in both SetupView and PreferencesView — prevents API key transmission in cleartext Error handling (MEDIUM+LOW): - M5: Replace error.localizedDescription with generic message in SetupView catch-all to avoid leaking controller URL - L1: Fix last remaining raw \(error) interpolation in os_log UI hardening (LOW): - L3: Add lineLimit(1) + truncationMode to MetricRow value text - L4: Wrap DDNSStatusDTO.displayStatus default case in truncated() - L5: Truncate DDNS hostname to 128 chars at view layer - L6: Truncate VPN tunnel name to 128 chars at view layer https://claude.ai/code/session_01Pmx6pGDgBmZ6aCxwQeHPPz --- Sources/UniFiBar/Models/MonitoringDTO.swift | 2 +- Sources/UniFiBar/Network/UniFiClient.swift | 61 ++++++++++--------- .../StatusBar/StatusBarController.swift | 2 +- .../UniFiBar/Views/Components/MetricRow.swift | 2 + Sources/UniFiBar/Views/PreferencesView.swift | 4 +- .../UniFiBar/Views/Sections/DDNSSection.swift | 2 +- .../UniFiBar/Views/Sections/VPNSection.swift | 2 +- Sources/UniFiBar/Views/SetupView.swift | 6 +- 8 files changed, 44 insertions(+), 37 deletions(-) diff --git a/Sources/UniFiBar/Models/MonitoringDTO.swift b/Sources/UniFiBar/Models/MonitoringDTO.swift index 24dbd63..5372cc0 100644 --- a/Sources/UniFiBar/Models/MonitoringDTO.swift +++ b/Sources/UniFiBar/Models/MonitoringDTO.swift @@ -273,7 +273,7 @@ struct DDNSStatusDTO: Decodable, Sendable { case "abuse": return "Abuse" case "nohost": return "No Host" case "badauth": return "Auth Error" - default: return status?.capitalized ?? "Unknown" + default: return truncated(status?.capitalized ?? "Unknown", maxLength: 32) } } } diff --git a/Sources/UniFiBar/Network/UniFiClient.swift b/Sources/UniFiBar/Network/UniFiClient.swift index 07e6d66..9b0d54a 100644 --- a/Sources/UniFiBar/Network/UniFiClient.swift +++ b/Sources/UniFiBar/Network/UniFiClient.swift @@ -394,34 +394,37 @@ final class PinnedCertDelegate: NSObject, URLSessionDelegate, Sendable { } // Extract public key hash from server certificate - // If extraction fails, still allow connection (user opted into self-signed) + // If extraction fails, reject — a cert we can't fingerprint is not trustworthy guard let serverKeyHash = Self.publicKeyHash(from: serverTrust) else { - return (.useCredential, URLCredential(trust: serverTrust)) + return (.cancelAuthenticationChallenge, nil) } - let currentState = state.withLock { $0 } + // Atomic read-check-write to prevent race between concurrent TLS challenges + let decision: (URLSession.AuthChallengeDisposition, URLCredential?) = state.withLock { currentState in + switch currentState { + case .pinned(let storedHash): + if serverKeyHash == storedHash { + return (.useCredential, URLCredential(trust: serverTrust)) + } else { + currentState = .mismatch + return (.cancelAuthenticationChallenge, nil) + } - switch currentState { - case .pinned(let storedHash): - if serverKeyHash == storedHash { - // Pin matches — allow connection + case .unpinned: + currentState = .pinned(serverKeyHash) return (.useCredential, URLCredential(trust: serverTrust)) - } else { - // Pin MISMATCH — reject connection (possible MITM) - state.withLock { $0 = .mismatch } + + case .mismatch: return (.cancelAuthenticationChallenge, nil) } + } - case .unpinned: - // Trust-on-first-use: pin the key in Keychain - state.withLock { $0 = .pinned(serverKeyHash) } + // Persist pin to Keychain outside the lock (only on first pin) + if case .pinned = state.withLock({ $0 }) { Self.savePinToKeychain(key: keychainKey, data: serverKeyHash) - return (.useCredential, URLCredential(trust: serverTrust)) - - case .mismatch: - // Already in mismatch state — keep rejecting - return (.cancelAuthenticationChallenge, nil) } + + return decision } /// Extracts SHA-256 hash of the public key from a server trust. @@ -454,20 +457,22 @@ final class PinnedCertDelegate: NSObject, URLSessionDelegate, Sendable { } private static func savePinToKeychain(key: String, data: Data) { - let query: [String: Any] = [ + // Delete any existing item first to ensure kSecAttrAccessible is always set correctly + // (SecItemUpdate cannot change the accessibility attribute of existing items) + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.unifbar.cert-pins", + kSecAttrAccount as String: key, + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "com.unifbar.cert-pins", kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, ] - // Try update first - let update: [String: Any] = [kSecValueData as String: data] - let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary) - if updateStatus == errSecSuccess { return } - - // Add new item - var addQuery = query - addQuery[kSecValueData as String] = data - addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly SecItemAdd(addQuery as CFDictionary, nil) } diff --git a/Sources/UniFiBar/StatusBar/StatusBarController.swift b/Sources/UniFiBar/StatusBar/StatusBarController.swift index 88326cb..189f240 100644 --- a/Sources/UniFiBar/StatusBar/StatusBarController.swift +++ b/Sources/UniFiBar/StatusBar/StatusBarController.swift @@ -171,7 +171,7 @@ final class StatusBarController { return } catch { consecutiveErrors += 1 - Self.logger.error("Failed to fetch self: \(error)") + Self.logger.error("Failed to fetch self: \((error as NSError).domain) code=\((error as NSError).code)") wifiStatus.markError(.controllerUnreachable) return } diff --git a/Sources/UniFiBar/Views/Components/MetricRow.swift b/Sources/UniFiBar/Views/Components/MetricRow.swift index bfb9210..a0aabc7 100644 --- a/Sources/UniFiBar/Views/Components/MetricRow.swift +++ b/Sources/UniFiBar/Views/Components/MetricRow.swift @@ -18,6 +18,8 @@ struct MetricRow: View { Text(value) .foregroundStyle(.secondary) .monospacedDigit() + .lineLimit(1) + .truncationMode(.tail) } .font(.callout) .padding(.horizontal, 16) diff --git a/Sources/UniFiBar/Views/PreferencesView.swift b/Sources/UniFiBar/Views/PreferencesView.swift index d32311e..42582e2 100644 --- a/Sources/UniFiBar/Views/PreferencesView.swift +++ b/Sources/UniFiBar/Views/PreferencesView.swift @@ -139,10 +139,10 @@ struct PreferencesView: View { } guard let url = URL(string: urlString), - let scheme = url.scheme, scheme == "http" || scheme == "https", + let scheme = url.scheme, scheme == "https", let host = url.host(), !host.isEmpty else { - errorMessage = "Invalid URL. Use format: https://192.168.1.1" + errorMessage = "Invalid URL. Use HTTPS format: https://192.168.1.1" return } _ = url // validated diff --git a/Sources/UniFiBar/Views/Sections/DDNSSection.swift b/Sources/UniFiBar/Views/Sections/DDNSSection.swift index 8a4c01f..d43ad97 100644 --- a/Sources/UniFiBar/Views/Sections/DDNSSection.swift +++ b/Sources/UniFiBar/Views/Sections/DDNSSection.swift @@ -12,7 +12,7 @@ struct DDNSSection: View { .frame(width: 20, alignment: .center) VStack(alignment: .leading, spacing: 1) { if let hostname = ddns.hostname { - Text(hostname) + Text(String(hostname.prefix(128))) .font(.callout) .foregroundStyle(.primary) .lineLimit(1) diff --git a/Sources/UniFiBar/Views/Sections/VPNSection.swift b/Sources/UniFiBar/Views/Sections/VPNSection.swift index 733c4e7..4efc8e5 100644 --- a/Sources/UniFiBar/Views/Sections/VPNSection.swift +++ b/Sources/UniFiBar/Views/Sections/VPNSection.swift @@ -12,7 +12,7 @@ struct VPNSection: View { Image(systemName: connected ? "lock.shield" : "lock.slash") .foregroundStyle(connected ? .green : .red) .frame(width: 20, alignment: .center) - Text(tunnel.name ?? tunnel.id) + Text(String((tunnel.name ?? tunnel.id).prefix(128))) .foregroundStyle(.primary) .lineLimit(1) Spacer() diff --git a/Sources/UniFiBar/Views/SetupView.swift b/Sources/UniFiBar/Views/SetupView.swift index 2562846..a204f44 100644 --- a/Sources/UniFiBar/Views/SetupView.swift +++ b/Sources/UniFiBar/Views/SetupView.swift @@ -94,10 +94,10 @@ struct SetupView: View { } guard let url = URL(string: urlString), - let scheme = url.scheme, scheme == "http" || scheme == "https", + let scheme = url.scheme, scheme == "https", let host = url.host(), !host.isEmpty else { - errorMessage = "Invalid URL. Use format: https://192.168.1.1" + errorMessage = "Invalid URL. Use HTTPS format: https://192.168.1.1" isValidating = false return } @@ -145,7 +145,7 @@ struct SetupView: View { } catch is URLError { errorMessage = "Could not reach the controller. Check the URL and your network connection." } catch { - errorMessage = "Unexpected error: \(error.localizedDescription)" + errorMessage = "Connection failed. Check your URL and network settings." } isValidating = false From 6cfbad1e8ec3ee11ff94ccfb8cf5d655b4c87525 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 15:35:45 +0000 Subject: [PATCH 06/21] Fix final low-severity findings from third audit - Eliminate redundant Keychain write on every TLS handshake: capture shouldPersist flag from atomic withLock block, only write on first pin - Reject controller URLs containing query parameters or fragments to prevent malformed API requests https://claude.ai/code/session_01Pmx6pGDgBmZ6aCxwQeHPPz --- Sources/UniFiBar/Network/UniFiClient.swift | 14 +++++++------- Sources/UniFiBar/Views/PreferencesView.swift | 3 ++- Sources/UniFiBar/Views/SetupView.swift | 3 ++- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Sources/UniFiBar/Network/UniFiClient.swift b/Sources/UniFiBar/Network/UniFiClient.swift index 9b0d54a..629b13d 100644 --- a/Sources/UniFiBar/Network/UniFiClient.swift +++ b/Sources/UniFiBar/Network/UniFiClient.swift @@ -400,27 +400,27 @@ final class PinnedCertDelegate: NSObject, URLSessionDelegate, Sendable { } // Atomic read-check-write to prevent race between concurrent TLS challenges - let decision: (URLSession.AuthChallengeDisposition, URLCredential?) = state.withLock { currentState in + let (decision, shouldPersist): ((URLSession.AuthChallengeDisposition, URLCredential?), Bool) = state.withLock { currentState in switch currentState { case .pinned(let storedHash): if serverKeyHash == storedHash { - return (.useCredential, URLCredential(trust: serverTrust)) + return ((.useCredential, URLCredential(trust: serverTrust)), false) } else { currentState = .mismatch - return (.cancelAuthenticationChallenge, nil) + return ((.cancelAuthenticationChallenge, nil), false) } case .unpinned: currentState = .pinned(serverKeyHash) - return (.useCredential, URLCredential(trust: serverTrust)) + return ((.useCredential, URLCredential(trust: serverTrust)), true) case .mismatch: - return (.cancelAuthenticationChallenge, nil) + return ((.cancelAuthenticationChallenge, nil), false) } } - // Persist pin to Keychain outside the lock (only on first pin) - if case .pinned = state.withLock({ $0 }) { + // Persist pin to Keychain outside the lock — only on first-use pin + if shouldPersist { Self.savePinToKeychain(key: keychainKey, data: serverKeyHash) } diff --git a/Sources/UniFiBar/Views/PreferencesView.swift b/Sources/UniFiBar/Views/PreferencesView.swift index 42582e2..b873e2b 100644 --- a/Sources/UniFiBar/Views/PreferencesView.swift +++ b/Sources/UniFiBar/Views/PreferencesView.swift @@ -140,7 +140,8 @@ struct PreferencesView: View { guard let url = URL(string: urlString), let scheme = url.scheme, scheme == "https", - let host = url.host(), !host.isEmpty + let host = url.host(), !host.isEmpty, + url.query == nil, url.fragment == nil else { errorMessage = "Invalid URL. Use HTTPS format: https://192.168.1.1" return diff --git a/Sources/UniFiBar/Views/SetupView.swift b/Sources/UniFiBar/Views/SetupView.swift index a204f44..cd98e6b 100644 --- a/Sources/UniFiBar/Views/SetupView.swift +++ b/Sources/UniFiBar/Views/SetupView.swift @@ -95,7 +95,8 @@ struct SetupView: View { guard let url = URL(string: urlString), let scheme = url.scheme, scheme == "https", - let host = url.host(), !host.isEmpty + let host = url.host(), !host.isEmpty, + url.query == nil, url.fragment == nil else { errorMessage = "Invalid URL. Use HTTPS format: https://192.168.1.1" isValidating = false From 8af6b3a5504ce21a5af40adf7e52a82103f2701c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 15:42:41 +0000 Subject: [PATCH 07/21] Bound remaining unbounded arrays and cap error counter - Cap decoded v2 active clients array at 5,000 entries to prevent memory exhaustion from compromised controller responses - Cap session history response at 1,000 entries - Cap consecutiveErrors at 10 to prevent integer overflow in poll interval bit-shift calculation (1 << 10 = 1024, safely < Int.max) https://claude.ai/code/session_01Pmx6pGDgBmZ6aCxwQeHPPz --- Sources/UniFiBar/Network/UniFiClient.swift | 7 +++++-- Sources/UniFiBar/StatusBar/StatusBarController.swift | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/UniFiBar/Network/UniFiClient.swift b/Sources/UniFiBar/Network/UniFiClient.swift index 629b13d..b9325a5 100644 --- a/Sources/UniFiBar/Network/UniFiClient.swift +++ b/Sources/UniFiBar/Network/UniFiClient.swift @@ -94,7 +94,9 @@ actor UniFiClient { func fetchSelfV2() async throws -> SelfInfo { let data = try await request("/proxy/network/v2/api/site/default/clients/active") - let clients = try JSONDecoder().decode([V2ClientDTO].self, from: data) + let allClients = try JSONDecoder().decode([V2ClientDTO].self, from: data) + // Sanity cap to prevent memory exhaustion from a compromised controller + let clients = allClients.count > 5_000 ? Array(allClients.prefix(5_000)) : allClients guard let myIP = DeviceDetector.activeIPv4Address() else { throw UniFiError.selfNotFound @@ -150,7 +152,8 @@ actor UniFiClient { let body: [String: Any] = ["macs": [mac], "start": oneDayAgo] let data = try await post("/proxy/network/api/s/default/stat/session", body: body) let response = try JSONDecoder().decode(LegacyResponse.self, from: data) - return response.data.isEmpty ? nil : response.data + let sessions = response.data + return sessions.isEmpty ? nil : Array(sessions.prefix(1_000)) } catch { Self.logger.error("Failed to fetch session history: \(Self.safeErrorDescription(error))") return nil diff --git a/Sources/UniFiBar/StatusBar/StatusBarController.swift b/Sources/UniFiBar/StatusBar/StatusBarController.swift index 189f240..ff31cc7 100644 --- a/Sources/UniFiBar/StatusBar/StatusBarController.swift +++ b/Sources/UniFiBar/StatusBar/StatusBarController.swift @@ -146,7 +146,7 @@ final class StatusBarController { } } catch { Self.logger.error("Site discovery failed: \((error as NSError).domain) code=\((error as NSError).code)") - consecutiveErrors += 1 + consecutiveErrors = min(consecutiveErrors + 1, 10) wifiStatus.markError(.controllerUnreachable) return } @@ -156,7 +156,7 @@ final class StatusBarController { do { selfInfo = try await client.fetchSelfV2() } catch let error as UniFiError { - consecutiveErrors += 1 + consecutiveErrors = min(consecutiveErrors + 1, 10) switch error { case .httpError(let code) where code == 401 || code == 403: Self.logger.error("Authentication failed (HTTP \(code))") @@ -170,7 +170,7 @@ final class StatusBarController { } return } catch { - consecutiveErrors += 1 + consecutiveErrors = min(consecutiveErrors + 1, 10) Self.logger.error("Failed to fetch self: \((error as NSError).domain) code=\((error as NSError).code)") wifiStatus.markError(.controllerUnreachable) return From 09c3b8c1cbf4d681b07d0e4e019baca0a397221c Mon Sep 17 00:00:00 2001 From: Dario Mader Date: Wed, 15 Apr 2026 17:42:41 +0200 Subject: [PATCH 08/21] Security hardening, UX improvements, and CI setup Security: - Fix cert pin MITM vulnerability: reject mismatch instead of auto-repin - Add cert validity checks (expiration, hostname) to PinnedCertDelegate - Surface certChanged error state with reset button in menu & preferences - Stop polling on auth failure (401/403) instead of infinite retry loop - Cap consecutive error backoff at 4 (max ~8min) instead of 10 - Clear stale data (satisfaction, signal) on error states - Remove DDNS login credentials from UI display - Cap unbounded device array at 500 entries - Fix nearby APs showing 0 dBm (use signal field, convert rssi correctly) UX: - Scrollable menu setting (on by default) - Redesign Preferences with grouped Form, glass effect, edit/configured states - No Keychain prompt on cancel, settings save immediately - Version number shown in Preferences - Distinct status bar icons per error type (unreachable, auth, cert changed) CI: - Release workflow triggered from GitHub UI (tag-only, no binaries) - PR-Agent workflow with GLM 5.1:cloud - Actions pinned to SHAs, SHA pinning enforced - Branch protection: CODEOWNERS review for .github/ changes - Dependabot for weekly action updates AI assisted to create this change --- Sources/UniFiBar/App/UniFiBarApp.swift | 2 + Sources/UniFiBar/Models/MonitoringDTO.swift | 84 +++---- Sources/UniFiBar/Models/WiFiStatus.swift | 21 +- Sources/UniFiBar/Network/UniFiClient.swift | 98 +++++--- .../Preferences/PreferencesManager.swift | 17 +- .../StatusBar/StatusBarController.swift | 68 +++++- Sources/UniFiBar/Views/MenuContentView.swift | 34 ++- Sources/UniFiBar/Views/PreferencesView.swift | 224 +++++++++++------- .../UniFiBar/Views/Sections/DDNSSection.swift | 17 +- .../Views/Sections/EventsSection.swift | 35 --- .../Views/Sections/NearbyAPsSection.swift | 10 +- Sources/UniFiBar/Views/SetupView.swift | 92 +++---- 12 files changed, 398 insertions(+), 304 deletions(-) delete mode 100644 Sources/UniFiBar/Views/Sections/EventsSection.swift diff --git a/Sources/UniFiBar/App/UniFiBarApp.swift b/Sources/UniFiBar/App/UniFiBarApp.swift index 9d2867c..fe5ec39 100644 --- a/Sources/UniFiBar/App/UniFiBarApp.swift +++ b/Sources/UniFiBar/App/UniFiBarApp.swift @@ -21,10 +21,12 @@ struct UniFiBarApp: App { SetupView(controller: controller) } .windowResizability(.contentSize) + .defaultSize(width: 380, height: 440) Window("Preferences", id: "preferences") { PreferencesView(controller: controller) } .windowResizability(.contentSize) + .defaultSize(width: 480, height: 700) } } diff --git a/Sources/UniFiBar/Models/MonitoringDTO.swift b/Sources/UniFiBar/Models/MonitoringDTO.swift index 5372cc0..b3f6d00 100644 --- a/Sources/UniFiBar/Models/MonitoringDTO.swift +++ b/Sources/UniFiBar/Models/MonitoringDTO.swift @@ -207,64 +207,26 @@ struct AnomalyDTO: Decodable, Sendable, Identifiable { } } -// MARK: - Site Events - -struct SiteEventDTO: Decodable, Sendable, Identifiable { - let id: String - let key: String? - let msg: String? - let time: Int? - let subsystem: String? - let isAdmin: Bool? - - enum CodingKeys: String, CodingKey { - case id = "_id" - case key, msg, time, subsystem - case isAdmin = "is_admin" - } - - var displayMessage: String { - if let msg { return truncated(msg) } - if let key { return truncated(key.replacingOccurrences(of: "EVT_", with: "").replacingOccurrences(of: "_", with: " ").capitalized) } - return "Event" - } - - var relativeTime: String { - guard let time else { return "" } - let seconds = TimeInterval(time) / 1000.0 - guard seconds > 946_684_800 && seconds < 4_102_444_800 else { return "" } - let date = Date(timeIntervalSince1970: seconds) - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - return formatter.localizedString(for: date, relativeTo: Date()) - } - - var subsystemIcon: String { - switch subsystem { - case "wlan": return "wifi" - case "lan": return "cable.connector.horizontal" - case "wan": return "globe" - case "vpn": return "lock.shield" - default: return "info.circle" - } - } -} - // MARK: - Dynamic DNS struct DDNSStatusDTO: Decodable, Sendable { let status: String? - let ip: String? - let hostname: String? - let lastChanged: Int? + let service: String? + let hostName: String? + let login: String? + let interface: String? enum CodingKeys: String, CodingKey { - case status, ip, hostname - case lastChanged = "last_changed" + case status, service, login, interface + case hostName = "host_name" } var isActive: Bool { - status == "good" || status == "nochg" + // rest/dynamicdns doesn't always return status — presence implies configured + if let status { + return status == "good" || status == "nochg" + } + return service != nil } var displayStatus: String { @@ -273,6 +235,7 @@ struct DDNSStatusDTO: Decodable, Sendable { case "abuse": return "Abuse" case "nohost": return "No Host" case "badauth": return "Auth Error" + case nil: return service != nil ? "Configured" : "Unknown" default: return truncated(status?.capitalized ?? "Unknown", maxLength: 32) } } @@ -313,26 +276,37 @@ struct PortForwardDTO: Decodable, Sendable, Identifiable { // MARK: - Rogue / Neighboring APs struct RogueAPDTO: Decodable, Sendable, Identifiable { - let id: String + let _id: String? let bssid: String? let essid: String? let rssi: Int? + let signal: Int? let channel: Int? let isRogue: Bool? let age: Int? let apMac: String? + var id: String { _id ?? bssid ?? UUID().uuidString } + enum CodingKeys: String, CodingKey { - case id = "_id" - case bssid, essid, rssi, channel, age + case _id + case bssid, essid, rssi, signal, channel, age case isRogue = "is_rogue" case apMac = "ap_mac" } var signalDescription: String { - guard let rssi else { return "—" } - // Clamp to physically plausible range - let clamped = max(-120, min(0, rssi)) + // Prefer the 'signal' field (already in dBm, negative). + // Fall back to converting rssi: dBm = rssi - 95. + let dBm: Int + if let signal { + dBm = signal + } else if let rssi { + dBm = rssi - 95 + } else { + return "—" + } + let clamped = max(-120, min(0, dBm)) return "\(clamped) dBm" } diff --git a/Sources/UniFiBar/Models/WiFiStatus.swift b/Sources/UniFiBar/Models/WiFiStatus.swift index acb17a4..a33a093 100644 --- a/Sources/UniFiBar/Models/WiFiStatus.swift +++ b/Sources/UniFiBar/Models/WiFiStatus.swift @@ -90,7 +90,6 @@ final class WiFiStatus { var dpiCategories: [DPICategoryDTO]? = nil var ipsEvents: [IPSEventDTO]? = nil var anomalies: [AnomalyDTO]? = nil - var siteEvents: [SiteEventDTO]? = nil var ddnsStatuses: [DDNSStatusDTO]? = nil var portForwards: [PortForwardDTO]? = nil var nearbyAPs: [RogueAPDTO]? = nil @@ -121,6 +120,7 @@ final class WiFiStatus { case controllerUnreachable case invalidAPIKey case notConnected + case certChanged } // MARK: - Display Properties @@ -136,6 +136,13 @@ final class WiFiStatus { } var statusBarColor: Color { + switch errorState { + case .controllerUnreachable: return .orange + case .invalidAPIKey: return .red + case .notConnected: return .gray + case .certChanged: return .orange + case nil: break + } if isConnected && isWired { return .blue } guard isConnected, let satisfaction else { return .gray } switch satisfaction { @@ -146,6 +153,13 @@ final class WiFiStatus { } var statusBarSymbol: String { + switch errorState { + case .controllerUnreachable: return "wifi.exclamationmark" + case .invalidAPIKey: return "lock.shield" + case .notConnected: return "wifi.slash" + case .certChanged: return "lock.shield" + case nil: break + } guard isConnected else { return "wifi.slash" } if isWired { return "cable.connector.horizontal" } guard let satisfaction, satisfaction >= 50 else { return "wifi.exclamationmark" } @@ -436,7 +450,6 @@ final class WiFiStatus { dpi: [DPICategoryDTO]?, ips: [IPSEventDTO]?, anomalies: [AnomalyDTO]?, - events: [SiteEventDTO]?, ddns: [DDNSStatusDTO]?, portForwards: [PortForwardDTO]?, rogueAPs: [RogueAPDTO]? @@ -445,7 +458,6 @@ final class WiFiStatus { self.dpiCategories = dpi self.ipsEvents = ips self.anomalies = anomalies - self.siteEvents = events self.ddnsStatuses = ddns self.portForwards = portForwards self.nearbyAPs = rogueAPs @@ -463,6 +475,9 @@ final class WiFiStatus { func markError(_ error: ErrorState) { isConnected = false errorState = error + // Clear stale connection data so the UI doesn't show ghost values + satisfaction = nil + signal = nil lastUpdated = Date() } diff --git a/Sources/UniFiBar/Network/UniFiClient.swift b/Sources/UniFiBar/Network/UniFiClient.swift index b9325a5..e4a481f 100644 --- a/Sources/UniFiBar/Network/UniFiClient.swift +++ b/Sources/UniFiBar/Network/UniFiClient.swift @@ -1,12 +1,13 @@ import CryptoKit import Foundation import os -import Security +@preconcurrency import Security actor UniFiClient { private let baseURL: URL private let apiKey: String private let session: URLSession + private let pinnedCertDelegate: PinnedCertDelegate? private var siteId: String? private static let requestTimeout: TimeInterval = 15 @@ -21,18 +22,35 @@ actor UniFiClient { if allowSelfSigned { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = Self.requestTimeout + let delegate = PinnedCertDelegate(host: baseURL.host() ?? "") + self.pinnedCertDelegate = delegate self.session = URLSession( configuration: config, - delegate: PinnedCertDelegate(host: baseURL.host() ?? ""), + delegate: delegate, delegateQueue: nil ) } else { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = Self.requestTimeout + self.pinnedCertDelegate = nil self.session = URLSession(configuration: config) } } + /// Whether the certificate pin has detected a mismatch (cert changed since first pin). + /// Check this when requests fail to distinguish "cert changed" from "controller unreachable". + var certificateChanged: Bool { + pinnedCertDelegate?.certChanged ?? false + } + + /// Clear the stored certificate pin so the next connection re-pins. + /// Call this after the user confirms they renewed their controller certificate. + func resetCertificatePin() { + if let host = baseURL.host() { + PinnedCertDelegate.deletePinFromKeychain(host: host) + } + } + // MARK: - Core Requests private func request(_ path: String) async throws -> Data { @@ -165,10 +183,13 @@ actor UniFiClient { func fetchDevices(siteId: String) async throws -> [DeviceDTO] { guard Self.isValidIdentifier(siteId) else { return [] } let data = try await request("/proxy/network/integrations/v1/sites/\(siteId)/devices") + var devices: [DeviceDTO] if let response = try? JSONDecoder().decode(DeviceListResponse.self, from: data) { - return response.data + devices = response.data + } else { + devices = try JSONDecoder().decode([DeviceDTO].self, from: data) } - return try JSONDecoder().decode([DeviceDTO].self, from: data) + return devices.count > 500 ? Array(devices.prefix(500)) : devices } // MARK: - VPN Tunnels @@ -272,24 +293,11 @@ actor UniFiClient { } } - // MARK: - Site Events - - func fetchSiteEvents() async -> [SiteEventDTO]? { - do { - let data = try await request("/proxy/network/api/s/default/stat/event") - let response = try JSONDecoder().decode(LegacyResponse.self, from: data) - return response.data.isEmpty ? nil : Array(response.data.prefix(10)) - } catch { - Self.logger.error("Failed to fetch site events: \(Self.safeErrorDescription(error))") - return nil - } - } - // MARK: - Dynamic DNS func fetchDDNSStatus() async -> [DDNSStatusDTO]? { do { - let data = try await request("/proxy/network/api/s/default/stat/dynamicdns") + let data = try await request("/proxy/network/api/s/default/rest/dynamicdns") let response = try JSONDecoder().decode(LegacyResponse.self, from: data) return response.data.isEmpty ? nil : Array(response.data.prefix(10)) } catch { @@ -320,8 +328,12 @@ actor UniFiClient { let data = try await post("/proxy/network/api/s/default/stat/rogueap", body: body) let response = try JSONDecoder().decode(LegacyResponse.self, from: data) guard !response.data.isEmpty else { return nil } - // Return top 10 by signal strength - let sorted = response.data.sorted { ($0.rssi ?? -100) > ($1.rssi ?? -100) } + // Return top 10 by signal strength (prefer dBm signal, fall back to rssi) + let sorted = response.data.sorted { + let lhs = $0.signal ?? ($0.rssi ?? Int.min) - 95 + let rhs = $1.signal ?? ($1.rssi ?? Int.min) - 95 + return lhs > rhs + } return Array(sorted.prefix(10)) } catch { Self.logger.error("Failed to fetch rogue APs: \(Self.safeErrorDescription(error))") @@ -356,8 +368,8 @@ actor UniFiClient { // MARK: - Certificate Pinning Delegate (Trust-On-First-Use) /// Pins the server certificate on first connection. Subsequent connections must present -/// the same certificate public key, preventing MITM attacks even with self-signed certs. -/// On mismatch, the connection is rejected — the user must reset via Preferences to re-pin. +/// the same certificate public key. On mismatch (e.g. cert renewal or MITM), the connection +/// is rejected and a specific error is surfaced so the user can intentionally reset the pin. final class PinnedCertDelegate: NSObject, URLSessionDelegate, Sendable { private let expectedHost: String private let keychainKey: String @@ -366,13 +378,18 @@ final class PinnedCertDelegate: NSObject, URLSessionDelegate, Sendable { enum PinState: Sendable { case unpinned case pinned(Data) - case mismatch + case certChanged + } + + /// Whether a certificate mismatch was detected (set after a failed handshake). + /// Check this when a request fails with URLError.cancelledAuthenticationChallenge. + var certChanged: Bool { + state.withLock { if case .certChanged = $0 { return true }; return false } } init(host: String) { self.expectedHost = host self.keychainKey = "com.unifbar.cert-pin.\(host)" - // Load existing pin from Keychain let existingPin = Self.loadPinFromKeychain(key: "com.unifbar.cert-pin.\(host)") if let pin = existingPin { self.state = OSAllocatedUnfairLock(initialState: .pinned(pin)) @@ -391,25 +408,28 @@ final class PinnedCertDelegate: NSObject, URLSessionDelegate, Sendable { return (.performDefaultHandling, nil) } - // Only handle challenges for our expected host guard challenge.protectionSpace.host == expectedHost else { return (.performDefaultHandling, nil) } - // Extract public key hash from server certificate - // If extraction fails, reject — a cert we can't fingerprint is not trustworthy guard let serverKeyHash = Self.publicKeyHash(from: serverTrust) else { return (.cancelAuthenticationChallenge, nil) } - // Atomic read-check-write to prevent race between concurrent TLS challenges + // Validate certificate hasn't expired and hostname matches. + // We allow self-signed roots (the whole point of this delegate) but reject expired certs. + guard Self.validateCertificate(serverTrust, for: expectedHost) else { + return (.cancelAuthenticationChallenge, nil) + } + let (decision, shouldPersist): ((URLSession.AuthChallengeDisposition, URLCredential?), Bool) = state.withLock { currentState in switch currentState { case .pinned(let storedHash): if serverKeyHash == storedHash { return ((.useCredential, URLCredential(trust: serverTrust)), false) } else { - currentState = .mismatch + // Cert changed — could be renewal or MITM. Reject and flag it. + currentState = .certChanged return ((.cancelAuthenticationChallenge, nil), false) } @@ -417,12 +437,11 @@ final class PinnedCertDelegate: NSObject, URLSessionDelegate, Sendable { currentState = .pinned(serverKeyHash) return ((.useCredential, URLCredential(trust: serverTrust)), true) - case .mismatch: + case .certChanged: return ((.cancelAuthenticationChallenge, nil), false) } } - // Persist pin to Keychain outside the lock — only on first-use pin if shouldPersist { Self.savePinToKeychain(key: keychainKey, data: serverKeyHash) } @@ -430,6 +449,23 @@ final class PinnedCertDelegate: NSObject, URLSessionDelegate, Sendable { return decision } + /// Validates certificate expiration and hostname while allowing self-signed roots. + private static func validateCertificate(_ trust: SecTrust, for host: String) -> Bool { + let policy = SecPolicyCreateSSL(true, host as CFString) + SecTrustSetPolicies(trust, policy) + // Evaluate — this checks expiration, hostname, etc. + // Self-signed certs will fail standard evaluation, which is expected; + // the pin check (done separately) is what authorizes them. + var error: CFError? + let valid = SecTrustEvaluateWithError(trust, &error) + // If evaluation fails, check if it's solely due to a self-signed root (which we allow). + // A valid pin already confirmed the cert identity, so we only need to reject expired leafs. + if !valid, let error, CFErrorGetCode(error) != errSecNotTrusted { + return false + } + return true + } + /// Extracts SHA-256 hash of the public key from a server trust. private static func publicKeyHash(from trust: SecTrust) -> Data? { guard let certChain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], diff --git a/Sources/UniFiBar/Preferences/PreferencesManager.swift b/Sources/UniFiBar/Preferences/PreferencesManager.swift index 356dcb8..4987550 100644 --- a/Sources/UniFiBar/Preferences/PreferencesManager.swift +++ b/Sources/UniFiBar/Preferences/PreferencesManager.swift @@ -11,7 +11,6 @@ enum MenuSection: String, CaseIterable, Sendable { case alerts = "alerts" case security = "security" case traffic = "traffic" - case events = "events" case ddns = "ddns" case portForwards = "portForwards" case nearbyAPs = "nearbyAPs" @@ -26,7 +25,6 @@ enum MenuSection: String, CaseIterable, Sendable { case .alerts: return "Alerts" case .security: return "Security (IPS)" case .traffic: return "Traffic (DPI)" - case .events: return "Recent Events" case .ddns: return "Dynamic DNS" case .portForwards: return "Port Forwards" case .nearbyAPs: return "Nearby APs" @@ -43,7 +41,6 @@ enum MenuSection: String, CaseIterable, Sendable { case .alerts: return "bell.badge" case .security: return "shield.lefthalf.filled" case .traffic: return "chart.pie" - case .events: return "list.bullet" case .ddns: return "link" case .portForwards: return "arrow.right.arrow.left" case .nearbyAPs: return "antenna.radiowaves.left.and.right" @@ -55,7 +52,7 @@ enum MenuSection: String, CaseIterable, Sendable { switch self { case .internet, .vpn, .wifi, .network, .sessionHistory, .alerts: return true - case .security, .traffic, .events, .ddns, .portForwards, .nearbyAPs: + case .security, .traffic, .ddns, .portForwards, .nearbyAPs: return false } } @@ -66,17 +63,19 @@ enum MenuSection: String, CaseIterable, Sendable { final class PreferencesManager { var isConfigured: Bool = false var allowSelfSignedCerts: Bool = false + var scrollableMenu: Bool = true // Section visibility private var sectionVisibility: [String: Bool] = [:] // Cached credentials — read from Keychain once, then reuse - private var cachedURL: String? - private var cachedAPIKey: String? + private(set) var cachedURL: String? + private(set) var cachedAPIKey: String? private let siteIdKey = "com.unifbar.siteId" private let selfSignedKey = "com.unifbar.allowSelfSigned" private let sectionVisibilityKey = "com.unifbar.sectionVisibility" + private let scrollableMenuKey = "com.unifbar.scrollableMenu" var siteId: String? { get { UserDefaults.standard.string(forKey: siteIdKey) } @@ -85,6 +84,7 @@ final class PreferencesManager { init() { allowSelfSignedCerts = UserDefaults.standard.bool(forKey: selfSignedKey) + scrollableMenu = UserDefaults.standard.object(forKey: scrollableMenuKey) as? Bool ?? true if let saved = UserDefaults.standard.dictionary(forKey: sectionVisibilityKey) as? [String: Bool] { sectionVisibility = saved } @@ -101,7 +101,7 @@ final class PreferencesManager { /// Returns true if any optional monitoring section is enabled (requiring extra API calls) var hasMonitoringSectionsEnabled: Bool { - let monitoringSections: [MenuSection] = [.alerts, .security, .traffic, .events, .ddns, .portForwards, .nearbyAPs] + let monitoringSections: [MenuSection] = [.alerts, .security, .traffic, .ddns, .portForwards, .nearbyAPs] return monitoringSections.contains { isSectionEnabled($0) } } @@ -134,6 +134,7 @@ final class PreferencesManager { ) } + func save(controllerURL: String, apiKey: String, allowSelfSigned: Bool) async throws { try await KeychainHelper.shared.save(controllerURL, for: .controllerURL) try await KeychainHelper.shared.save(apiKey, for: .apiKey) @@ -159,8 +160,10 @@ final class PreferencesManager { UserDefaults.standard.removeObject(forKey: siteIdKey) UserDefaults.standard.removeObject(forKey: selfSignedKey) UserDefaults.standard.removeObject(forKey: sectionVisibilityKey) + UserDefaults.standard.removeObject(forKey: scrollableMenuKey) sectionVisibility = [:] allowSelfSignedCerts = false + scrollableMenu = true isConfigured = false } } diff --git a/Sources/UniFiBar/StatusBar/StatusBarController.swift b/Sources/UniFiBar/StatusBar/StatusBarController.swift index ff31cc7..e1a60fa 100644 --- a/Sources/UniFiBar/StatusBar/StatusBarController.swift +++ b/Sources/UniFiBar/StatusBar/StatusBarController.swift @@ -31,6 +31,7 @@ final class StatusBarController { private var wakeObserver: NSObjectProtocol? private var hasStarted = false private var consecutiveErrors = 0 + private var authFailed = false private var lastManualRefresh: Date = .distantPast /// Tears down observers. Must be called on @MainActor before the object is released, @@ -71,31 +72,41 @@ final class StatusBarController { } func refreshNow() { - // Debounce: ignore rapid-fire manual refreshes (min 5s apart) let now = Date() guard now.timeIntervalSince(lastManualRefresh) >= 5 else { return } lastManualRefresh = now + authFailed = false Task { await refresh() } } + func resetCertPin() async { + guard let client else { return } + await client.resetCertificatePin() + // Reconfigure to get a fresh URLSession with a fresh delegate + await reconfigure() + } + private func startPolling() { pollTask?.cancel() pollTask = Task { [weak self] in guard let self else { return } while !Task.isCancelled { await self.refresh() + // Stop polling if auth has permanently failed — user must fix credentials + if self.authFailed { return } let delay = self.pollInterval try? await Task.sleep(for: .seconds(delay)) } } } - /// Returns poll interval: 30s normally, backs off up to 5 minutes on consecutive errors. + /// Returns poll interval: 30s normally, backs off up to 5 minutes on transient errors. + /// Auth failures don't back off — they stop polling entirely. private var pollInterval: Int { guard consecutiveErrors > 0 else { return 30 } - return min(30 * (1 << consecutiveErrors), 300) + return min(30 * (1 << min(consecutiveErrors, 4)), 300) } func stopPolling() { @@ -104,7 +115,7 @@ final class StatusBarController { } private func observeSystemEvents() { - // Wake from sleep — immediate refresh + // Wake from sleep — wait for network, then refresh with reset backoff wakeObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didWakeNotification, object: nil, @@ -112,8 +123,14 @@ final class StatusBarController { ) { [weak self] _ in guard let self else { return } Task { @MainActor in - try? await Task.sleep(for: .seconds(3)) + self.consecutiveErrors = 0 + // Wait longer for Wi-Fi to reassociate after wake + try? await Task.sleep(for: .seconds(8)) await self.refresh() + // If still failing, restart normal polling + if !self.authFailed && self.pollTask == nil { + self.startPolling() + } } } @@ -124,7 +141,12 @@ final class StatusBarController { guard skipFirst.check() else { return } guard let self, path.status == .satisfied else { return } Task { @MainActor in + self.consecutiveErrors = 0 await self.refresh() + // If polling had stopped (auth failure), don't restart + if !self.authFailed && self.pollTask == nil { + self.startPolling() + } } } monitor.start(queue: DispatchQueue(label: "com.unifbar.pathmonitor")) @@ -144,9 +166,27 @@ final class StatusBarController { if preferences.siteId != siteId { preferences.siteId = siteId } + } catch let error as UniFiError { + switch error { + case .httpError(let code) where code == 401 || code == 403: + Self.logger.error("Auth failed during site discovery (HTTP \(code))") + authFailed = true + consecutiveErrors = 0 + wifiStatus.markError(.invalidAPIKey) + default: + Self.logger.error("Site discovery failed: \((error as NSError).domain) code=\((error as NSError).code)") + consecutiveErrors = min(consecutiveErrors + 1, 4) + wifiStatus.markError(.controllerUnreachable) + } + return } catch { + if await client.certificateChanged { + Self.logger.warning("Certificate pin mismatch detected — cert may have been renewed") + wifiStatus.markError(.certChanged) + return + } Self.logger.error("Site discovery failed: \((error as NSError).domain) code=\((error as NSError).code)") - consecutiveErrors = min(consecutiveErrors + 1, 10) + consecutiveErrors = min(consecutiveErrors + 1, 4) wifiStatus.markError(.controllerUnreachable) return } @@ -156,27 +196,35 @@ final class StatusBarController { do { selfInfo = try await client.fetchSelfV2() } catch let error as UniFiError { - consecutiveErrors = min(consecutiveErrors + 1, 10) switch error { case .httpError(let code) where code == 401 || code == 403: Self.logger.error("Authentication failed (HTTP \(code))") + authFailed = true + consecutiveErrors = 0 wifiStatus.markError(.invalidAPIKey) case .selfNotFound: Self.logger.info("This device not found in active clients — likely disconnected") wifiStatus.markDisconnected() default: Self.logger.error("Failed to fetch self: \((error as NSError).domain) code=\((error as NSError).code)") + consecutiveErrors = min(consecutiveErrors + 1, 4) wifiStatus.markError(.controllerUnreachable) } return } catch { - consecutiveErrors = min(consecutiveErrors + 1, 10) + if await client.certificateChanged { + Self.logger.warning("Certificate pin mismatch detected — cert may have been renewed") + wifiStatus.markError(.certChanged) + return + } + consecutiveErrors = min(consecutiveErrors + 1, 4) Self.logger.error("Failed to fetch self: \((error as NSError).domain) code=\((error as NSError).code)") wifiStatus.markError(.controllerUnreachable) return } consecutiveErrors = 0 + authFailed = false wifiStatus.update(from: selfInfo) let me = selfInfo.client @@ -239,7 +287,6 @@ final class StatusBarController { let wantAlarms = preferences.isSectionEnabled(.alerts) let wantTraffic = preferences.isSectionEnabled(.traffic) let wantSecurity = preferences.isSectionEnabled(.security) - let wantEvents = preferences.isSectionEnabled(.events) let wantDDNS = preferences.isSectionEnabled(.ddns) let wantPF = preferences.isSectionEnabled(.portForwards) let wantRogue = preferences.isSectionEnabled(.nearbyAPs) @@ -248,7 +295,6 @@ final class StatusBarController { async let dpiTask: [DPICategoryDTO]? = wantTraffic ? await client.fetchDPIStats() : nil async let ipsTask: [IPSEventDTO]? = wantSecurity ? await client.fetchIPSEvents() : nil async let anomaliesTask: [AnomalyDTO]? = wantSecurity ? await client.fetchAnomalies() : nil - async let eventsTask: [SiteEventDTO]? = wantEvents ? await client.fetchSiteEvents() : nil async let ddnsTask: [DDNSStatusDTO]? = wantDDNS ? await client.fetchDDNSStatus() : nil async let pfTask: [PortForwardDTO]? = wantPF ? await client.fetchPortForwards() : nil async let rogueTask: [RogueAPDTO]? = wantRogue ? await client.fetchRogueAPs() : nil @@ -257,7 +303,6 @@ final class StatusBarController { let dpi = await dpiTask let ips = await ipsTask let anomalies = await anomaliesTask - let events = await eventsTask let ddns = await ddnsTask let pf = await pfTask let rogue = await rogueTask @@ -267,7 +312,6 @@ final class StatusBarController { dpi: dpi, ips: ips, anomalies: anomalies, - events: events, ddns: ddns, portForwards: pf, rogueAPs: rogue diff --git a/Sources/UniFiBar/Views/MenuContentView.swift b/Sources/UniFiBar/Views/MenuContentView.swift index e00c448..b45f327 100644 --- a/Sources/UniFiBar/Views/MenuContentView.swift +++ b/Sources/UniFiBar/Views/MenuContentView.swift @@ -39,9 +39,18 @@ struct MenuContentView: View { @ViewBuilder private var connectedView: some View { - coreSections - monitoringSections - footerTimestamp + let content = VStack(alignment: .leading, spacing: 0) { + coreSections + monitoringSections + footerTimestamp + } + if prefs.scrollableMenu { + ScrollView { + content + } + } else { + content + } } // MARK: - Core sections (Internet, VPN, WiFi/Connection, Session History, Network) @@ -97,7 +106,7 @@ struct MenuContentView: View { } } - // MARK: - Monitoring sections (Alerts, Security, Traffic, Events, DDNS, Port Forwards, Nearby APs) + // MARK: - Monitoring sections (Alerts, Security, Traffic, DDNS, Port Forwards, Nearby APs) @ViewBuilder private var monitoringSections: some View { @@ -114,10 +123,6 @@ struct MenuContentView: View { TrafficSection(categories: categories) } - if prefs.isSectionEnabled(.events), let events = status.siteEvents { - EventsSection(events: events) - } - if prefs.isSectionEnabled(.ddns), let ddns = status.ddnsStatuses { DDNSSection(statuses: ddns) } @@ -181,6 +186,19 @@ struct MenuContentView: View { activateAndOpenWindow("preferences") } .buttonStyle(.borderedProminent) + case .certChanged: + Label("Certificate Changed", systemImage: "lock.shield") + .foregroundStyle(.orange) + Text("The controller certificate has changed.\nReset the pin if you renewed it.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Button("Reset Certificate Pin") { + Task { + await controller.resetCertPin() + } + } + .buttonStyle(.borderedProminent) case .notConnected: Label("Not Connected", systemImage: "wifi.slash") .foregroundStyle(.secondary) diff --git a/Sources/UniFiBar/Views/PreferencesView.swift b/Sources/UniFiBar/Views/PreferencesView.swift index b873e2b..239c122 100644 --- a/Sources/UniFiBar/Views/PreferencesView.swift +++ b/Sources/UniFiBar/Views/PreferencesView.swift @@ -8,124 +8,170 @@ struct PreferencesView: View { @State private var controllerURL = "" @State private var apiKey = "" @State private var allowSelfSigned = false + @State private var isEditingCredentials = false + @State private var scrollableMenu = true @State private var launchAtLogin = false @State private var isLoading = true @State private var showResetConfirmation = false @State private var errorMessage: String? - @State private var showSectionSettings = false var body: some View { - VStack(spacing: 20) { - Text("Preferences") - .font(.title2) - .fontWeight(.semibold) - + Group { if isLoading { ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Controller URL") - .font(.caption) - .foregroundStyle(.secondary) - TextField("https://192.168.1.1", text: $controllerURL) - .textFieldStyle(.roundedBorder) - } - - VStack(alignment: .leading, spacing: 4) { - Text("API Key") - .font(.caption) - .foregroundStyle(.secondary) - SecureField("Paste your API key", text: $apiKey) - .textFieldStyle(.roundedBorder) - } - - Toggle("Allow self-signed certificates", isOn: $allowSelfSigned) - .font(.callout) - - Toggle("Launch at login", isOn: $launchAtLogin) - .font(.callout) - .onChange(of: launchAtLogin) { _, newValue in - setLaunchAtLogin(newValue) - } - - if let siteId = controller.preferences.siteId { - HStack { - Text("Site ID") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Text(siteId) - .font(.caption) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - } - } - - Divider() - - DisclosureGroup("Visible Sections", isExpanded: $showSectionSettings) { - VStack(alignment: .leading, spacing: 6) { - ForEach(MenuSection.allCases, id: \.rawValue) { section in - SectionToggleRow(section: section, preferences: controller.preferences) - } - } - .padding(.top, 4) - } - .font(.callout) + Form { + connectionSection + siteSection + behaviorSection + visibilitySection + resetSection } + .formStyle(.grouped) + } + } + .frame(width: 480) + .task { await loadExisting() } + .confirmationDialog("Reset UniFiBar?", isPresented: $showResetConfirmation) { + Button("Reset & Forget", role: .destructive) { + Task { await reset() } + } + } message: { + Text("This will remove all saved credentials and settings. You will need to set up again.") + } + } - if let errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundStyle(.red) - } + // MARK: - Connection + private var connectionSection: some View { + Section { + if isEditingCredentials { + TextField("Controller URL", text: $controllerURL, prompt: Text("https://192.168.1.1")) + SecureField("API Key", text: $apiKey, prompt: Text("Paste your API key")) + Toggle("Allow self-signed certificates", isOn: $allowSelfSigned) HStack { - Button("Reset & Forget", role: .destructive) { - showResetConfirmation = true - } - - Spacer() - Button("Cancel") { - dismiss() + revertCredentials() } - .keyboardShortcut(.cancelAction) - - Button("Save") { + Spacer() + Button("Update") { Task { await save() } } .buttonStyle(.borderedProminent) .disabled(controllerURL.isEmpty || apiKey.isEmpty) - .keyboardShortcut(.defaultAction) + } + } else { + LabeledContent("Controller URL", value: controllerURL) + LabeledContent("API Key") { + Button("Change\u{2026}") { + apiKey = "" + isEditingCredentials = true + } + .buttonStyle(.borderless) + } + LabeledContent("Self-signed certificates") { + Text(allowSelfSigned ? "Allowed" : "Blocked") + } + Button("Edit Connection\u{2026}") { + isEditingCredentials = true + } + if allowSelfSigned { + Button("Reset Certificate Pin") { + Task { + await controller.resetCertPin() + } + } } } + } header: { + Text("Connection") + } footer: { + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } } - .padding(24) - .frame(width: 380) - .task { await loadExisting() } - .confirmationDialog("Reset UniFiBar?", isPresented: $showResetConfirmation) { - Button("Reset & Forget", role: .destructive) { - Task { await reset() } + } + + private var siteSection: some View { + Group { + if let siteId = controller.preferences.siteId { + Section { + LabeledContent("Site ID", value: siteId) + } header: { + Text("Site") + } } - } message: { - Text("This will remove all saved credentials and settings. You will need to set up again.") } } - private func loadExisting() async { - if let url = await KeychainHelper.shared.read(.controllerURL) { - controllerURL = url + // MARK: - Behavior + + private var behaviorSection: some View { + Section { + Toggle("Scrollable menu", isOn: $scrollableMenu) + .onChange(of: scrollableMenu) { _, newValue in + controller.preferences.scrollableMenu = newValue + UserDefaults.standard.set(newValue, forKey: "com.unifbar.scrollableMenu") + } + Toggle("Launch at login", isOn: $launchAtLogin) + .onChange(of: launchAtLogin) { _, newValue in + setLaunchAtLogin(newValue) + } + } header: { + Text("Behavior") + } + } + + // MARK: - Visibility + + private var visibilitySection: some View { + Section { + ForEach(MenuSection.allCases, id: \.rawValue) { section in + SectionToggleRow(section: section, preferences: controller.preferences) + } + } header: { + Text("Visible Sections") } - if let key = await KeychainHelper.shared.read(.apiKey) { - apiKey = key + } + + // MARK: - Reset + + private var resetSection: some View { + Section { + Button("Reset & Forget All Settings\u{2026}", role: .destructive) { + showResetConfirmation = true + } + } footer: { + if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + Text("UniFiBar v\(version)") + } } + } + + // MARK: - Actions + + /// Load credentials from Keychain (first load only). + private func loadExisting() async { + await controller.preferences.checkConfiguration() + controllerURL = controller.preferences.cachedURL ?? "" + apiKey = controller.preferences.cachedAPIKey ?? "" allowSelfSigned = controller.preferences.allowSelfSignedCerts + scrollableMenu = controller.preferences.scrollableMenu launchAtLogin = SMAppService.mainApp.status == .enabled isLoading = false } + /// Revert to cached values without touching Keychain. + private func revertCredentials() { + controllerURL = controller.preferences.cachedURL ?? "" + apiKey = controller.preferences.cachedAPIKey ?? "" + allowSelfSigned = controller.preferences.allowSelfSignedCerts + isEditingCredentials = false + errorMessage = nil + } + private func save() async { errorMessage = nil var urlString = controllerURL.trimmingCharacters(in: .whitespacesAndNewlines) @@ -146,7 +192,7 @@ struct PreferencesView: View { errorMessage = "Invalid URL. Use HTTPS format: https://192.168.1.1" return } - _ = url // validated + _ = url do { try await controller.preferences.save( @@ -155,7 +201,7 @@ struct PreferencesView: View { allowSelfSigned: allowSelfSigned ) await controller.reconfigure() - dismiss() + isEditingCredentials = false } catch { errorMessage = "Failed to save credentials. Please try again." } @@ -198,10 +244,8 @@ private struct SectionToggleRow: View { Toggle(isOn: $isEnabled) { Label(section.displayName, systemImage: section.icon) } - .toggleStyle(.checkbox) - .font(.callout) .onChange(of: isEnabled) { _, newValue in preferences.setSectionEnabled(section, enabled: newValue) } } -} +} \ No newline at end of file diff --git a/Sources/UniFiBar/Views/Sections/DDNSSection.swift b/Sources/UniFiBar/Views/Sections/DDNSSection.swift index d43ad97..303dcc7 100644 --- a/Sources/UniFiBar/Views/Sections/DDNSSection.swift +++ b/Sources/UniFiBar/Views/Sections/DDNSSection.swift @@ -11,23 +11,20 @@ struct DDNSSection: View { .foregroundStyle(ddns.isActive ? .green : .red) .frame(width: 20, alignment: .center) VStack(alignment: .leading, spacing: 1) { - if let hostname = ddns.hostname { - Text(String(hostname.prefix(128))) - .font(.callout) - .foregroundStyle(.primary) - .lineLimit(1) - } - if let ip = ddns.ip { - Text(ip) + Text(String((ddns.hostName ?? "DDNS").prefix(64))) + .font(.callout) + .foregroundStyle(.primary) + .lineLimit(1) + if let service = ddns.service { + Text(service.capitalized) .font(.caption2) .foregroundStyle(.tertiary) - .monospacedDigit() } } Spacer() Text(ddns.displayStatus) .font(.callout) - .foregroundStyle(ddns.isActive ? .secondary : .red) + .foregroundStyle(ddns.isActive ? Color.secondary : Color.red) } .padding(.horizontal, 16) .padding(.vertical, 1) diff --git a/Sources/UniFiBar/Views/Sections/EventsSection.swift b/Sources/UniFiBar/Views/Sections/EventsSection.swift deleted file mode 100644 index 5620043..0000000 --- a/Sources/UniFiBar/Views/Sections/EventsSection.swift +++ /dev/null @@ -1,35 +0,0 @@ -import SwiftUI - -struct EventsSection: View { - let events: [SiteEventDTO] - - var body: some View { - CollapsibleSection(title: "Events", defaultExpanded: false) { - ForEach(events.prefix(5)) { event in - HStack(spacing: 6) { - Image(systemName: event.subsystemIcon) - .foregroundStyle(.secondary) - .frame(width: 20, alignment: .center) - Text(event.displayMessage) - .foregroundStyle(.primary) - .lineLimit(2) - .font(.callout) - Spacer() - Text(event.relativeTime) - .foregroundStyle(.tertiary) - .font(.caption2) - } - .padding(.horizontal, 16) - .padding(.vertical, 1) - } - - if events.count > 5 { - Text("+\(events.count - 5) more events") - .font(.caption2) - .foregroundStyle(.tertiary) - .padding(.horizontal, 16) - .padding(.top, 2) - } - } - } -} diff --git a/Sources/UniFiBar/Views/Sections/NearbyAPsSection.swift b/Sources/UniFiBar/Views/Sections/NearbyAPsSection.swift index b5925cb..ac87581 100644 --- a/Sources/UniFiBar/Views/Sections/NearbyAPsSection.swift +++ b/Sources/UniFiBar/Views/Sections/NearbyAPsSection.swift @@ -10,7 +10,7 @@ struct NearbyAPsSection: View { badgeColor: .secondary, defaultExpanded: false ) { - ForEach(rogueAPs.prefix(6)) { ap in + ForEach(rogueAPs) { ap in HStack(spacing: 6) { Image(systemName: ap.isRogue == true ? "wifi.exclamationmark" : "wifi") .foregroundStyle(ap.isRogue == true ? .orange : .secondary) @@ -35,14 +35,6 @@ struct NearbyAPsSection: View { .padding(.horizontal, 16) .padding(.vertical, 1) } - - if rogueAPs.count > 6 { - Text("+\(rogueAPs.count - 6) more") - .font(.caption2) - .foregroundStyle(.tertiary) - .padding(.horizontal, 16) - .padding(.top, 2) - } } } } diff --git a/Sources/UniFiBar/Views/SetupView.swift b/Sources/UniFiBar/Views/SetupView.swift index cd98e6b..487f56a 100644 --- a/Sources/UniFiBar/Views/SetupView.swift +++ b/Sources/UniFiBar/Views/SetupView.swift @@ -13,65 +13,70 @@ struct SetupView: View { @State private var retryAvailableAt: Date? var body: some View { - VStack(spacing: 20) { + VStack(spacing: 24) { + header + formFields + errorMessageView + actionButtons + } + .padding(24) + .frame(width: 420, height: 480) + } + + private var header: some View { + VStack(spacing: 8) { Image(systemName: "wifi.router") - .font(.system(size: 48)) + .font(.system(size: 40)) .foregroundStyle(.tint) - Text("Set Up UniFiBar") .font(.title2) .fontWeight(.semibold) - Text("Enter your UniFi controller details to get started.") .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) + } + } - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Controller URL") - .font(.caption) - .foregroundStyle(.secondary) - TextField("https://192.168.1.1", text: $controllerURL) - .textFieldStyle(.roundedBorder) - } - - VStack(alignment: .leading, spacing: 4) { - Text("API Key") - .font(.caption) - .foregroundStyle(.secondary) - SecureField("Paste your API key", text: $apiKey) - .textFieldStyle(.roundedBorder) - } - - Toggle("Allow self-signed certificates", isOn: $allowSelfSigned) - .font(.callout) + private var formFields: some View { + VStack(alignment: .leading, spacing: 12) { + LabeledContent("Controller URL") { + TextField("https://192.168.1.1", text: $controllerURL) + .textFieldStyle(.roundedBorder) } - - if let errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundStyle(.red) + LabeledContent("API Key") { + SecureField("Paste your API key", text: $apiKey) + .textFieldStyle(.roundedBorder) } + Toggle("Allow self-signed certificates", isOn: $allowSelfSigned) + } + } - HStack { - Button("Cancel") { - dismiss() - } - .keyboardShortcut(.cancelAction) + @ViewBuilder + private var errorMessageView: some View { + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + } + } - Spacer() + private var actionButtons: some View { + HStack { + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) - Button("Connect") { - Task { await validate() } - } - .buttonStyle(.borderedProminent) - .disabled(controllerURL.isEmpty || apiKey.isEmpty || isValidating || isRateLimited) - .keyboardShortcut(.defaultAction) + Spacer() + + Button("Connect") { + Task { await validate() } } + .buttonStyle(.borderedProminent) + .disabled(controllerURL.isEmpty || apiKey.isEmpty || isValidating || isRateLimited) + .keyboardShortcut(.defaultAction) } - .padding(24) - .frame(width: 380) } private var isRateLimited: Bool { @@ -126,7 +131,6 @@ struct SetupView: View { failedAttempts += 1 errorMessage = "Authentication failed. Check your API key." - // Rate limit only on auth failures (possible brute force) if failedAttempts >= 5 { let delay = min(pow(2.0, Double(failedAttempts - 4)), 30.0) retryAvailableAt = Date().addingTimeInterval(delay) @@ -151,4 +155,4 @@ struct SetupView: View { isValidating = false } -} +} \ No newline at end of file From bd1565ba26c3114173e70d211022bd388fae9923 Mon Sep 17 00:00:00 2001 From: Dario Mader Date: Thu, 16 Apr 2026 09:15:09 +0200 Subject: [PATCH 09/21] Fix PR-Agent CI config and address code review findings - Use openai/ prefix for LiteLLM model routing - Add custom_reasoning_model for glm-5.1:cloud reasoning output - Expand comment trigger to match standard PR-Agent commands - Fix integer overflow in rogue AP sort (Int.min - 95) - Use stable identity for RogueAPDTO instead of UUID() - Make DDNSStatusDTO Identifiable with composite key - Replace fragile offset-based ForEach with stable identity AI assisted to create this change --- Sources/UniFiBar/Models/MonitoringDTO.swift | 6 ++++-- Sources/UniFiBar/Network/UniFiClient.swift | 4 ++-- Sources/UniFiBar/Views/Sections/DDNSSection.swift | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/UniFiBar/Models/MonitoringDTO.swift b/Sources/UniFiBar/Models/MonitoringDTO.swift index b3f6d00..c71ba0d 100644 --- a/Sources/UniFiBar/Models/MonitoringDTO.swift +++ b/Sources/UniFiBar/Models/MonitoringDTO.swift @@ -209,7 +209,7 @@ struct AnomalyDTO: Decodable, Sendable, Identifiable { // MARK: - Dynamic DNS -struct DDNSStatusDTO: Decodable, Sendable { +struct DDNSStatusDTO: Decodable, Sendable, Identifiable { let status: String? let service: String? let hostName: String? @@ -221,6 +221,8 @@ struct DDNSStatusDTO: Decodable, Sendable { case hostName = "host_name" } + var id: String { "\(hostName ?? "")-\(service ?? "")" } + var isActive: Bool { // rest/dynamicdns doesn't always return status — presence implies configured if let status { @@ -286,7 +288,7 @@ struct RogueAPDTO: Decodable, Sendable, Identifiable { let age: Int? let apMac: String? - var id: String { _id ?? bssid ?? UUID().uuidString } + var id: String { _id ?? bssid ?? "\(essid ?? "")-\(channel ?? 0)-\(apMac ?? "")" } enum CodingKeys: String, CodingKey { case _id diff --git a/Sources/UniFiBar/Network/UniFiClient.swift b/Sources/UniFiBar/Network/UniFiClient.swift index e4a481f..0da810c 100644 --- a/Sources/UniFiBar/Network/UniFiClient.swift +++ b/Sources/UniFiBar/Network/UniFiClient.swift @@ -330,8 +330,8 @@ actor UniFiClient { guard !response.data.isEmpty else { return nil } // Return top 10 by signal strength (prefer dBm signal, fall back to rssi) let sorted = response.data.sorted { - let lhs = $0.signal ?? ($0.rssi ?? Int.min) - 95 - let rhs = $1.signal ?? ($1.rssi ?? Int.min) - 95 + let lhs = $0.signal ?? ($0.rssi.map { $0 - 95 } ?? -200) + let rhs = $1.signal ?? ($1.rssi.map { $0 - 95 } ?? -200) return lhs > rhs } return Array(sorted.prefix(10)) diff --git a/Sources/UniFiBar/Views/Sections/DDNSSection.swift b/Sources/UniFiBar/Views/Sections/DDNSSection.swift index 303dcc7..cf5fd95 100644 --- a/Sources/UniFiBar/Views/Sections/DDNSSection.swift +++ b/Sources/UniFiBar/Views/Sections/DDNSSection.swift @@ -5,7 +5,7 @@ struct DDNSSection: View { var body: some View { CollapsibleSection(title: "Dynamic DNS", defaultExpanded: false) { - ForEach(Array(statuses.enumerated()), id: \.offset) { _, ddns in + ForEach(statuses) { ddns in HStack(spacing: 6) { Image(systemName: ddns.isActive ? "link" : "link.badge.plus") .foregroundStyle(ddns.isActive ? .green : .red) From af30d8386381d03720e1ad20673e79a1aaff6b0d Mon Sep 17 00:00:00 2001 From: Dario Mader Date: Thu, 16 Apr 2026 09:37:32 +0200 Subject: [PATCH 10/21] Fix cert validation: reject expired self-signed certificates SecTrustEvaluateWithError returns errSecNotTrusted for both untrusted-root and expired certificates, allowing expired self-signed certs through. Now extracts the leaf cert and checks validity dates directly via SecCertificateCopyValues before the trust evaluation step. AI assisted to create this change --- Sources/UniFiBar/Network/UniFiClient.swift | 35 ++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/Sources/UniFiBar/Network/UniFiClient.swift b/Sources/UniFiBar/Network/UniFiClient.swift index 0da810c..41bbacc 100644 --- a/Sources/UniFiBar/Network/UniFiClient.swift +++ b/Sources/UniFiBar/Network/UniFiClient.swift @@ -451,21 +451,46 @@ final class PinnedCertDelegate: NSObject, URLSessionDelegate, Sendable { /// Validates certificate expiration and hostname while allowing self-signed roots. private static func validateCertificate(_ trust: SecTrust, for host: String) -> Bool { + // Check leaf certificate validity dates directly — this reliably + // rejects expired certs regardless of chain trust status. + guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], + let leaf = chain.first + else { return false } + + guard Self.isLeafValid(leaf) else { return false } + + // Now evaluate trust. Self-signed certs will fail (errSecNotTrusted) + // which we allow, but any other failure (e.g. hostname mismatch) is rejected. let policy = SecPolicyCreateSSL(true, host as CFString) SecTrustSetPolicies(trust, policy) - // Evaluate — this checks expiration, hostname, etc. - // Self-signed certs will fail standard evaluation, which is expected; - // the pin check (done separately) is what authorizes them. var error: CFError? let valid = SecTrustEvaluateWithError(trust, &error) - // If evaluation fails, check if it's solely due to a self-signed root (which we allow). - // A valid pin already confirmed the cert identity, so we only need to reject expired leafs. if !valid, let error, CFErrorGetCode(error) != errSecNotTrusted { return false } return true } + /// Checks that the leaf certificate is within its validity period (not expired, not not-yet-valid). + private static func isLeafValid(_ cert: SecCertificate) -> Bool { + // Request validity date OIDs from SecCertificateCopyValues. + // Passing an empty array returns all known values. + guard let values = SecCertificateCopyValues(cert, [] as CFArray, nil) as? [String: Any] else { + return false + } + let now = Date() + // SecCertificateCopyValues uses these OID strings as dictionary keys: + // "1.2.840.113549.1.9.5" = notBefore + // "1.2.840.113549.1.9.6" = notAfter + if let notBefore = values["1.2.840.113549.1.9.5"] as? [String: Any], + let date = notBefore[kSecPropertyKeyValue as String] as? Date, + now < date { return false } + if let notAfter = values["1.2.840.113549.1.9.6"] as? [String: Any], + let date = notAfter[kSecPropertyKeyValue as String] as? Date, + now > date { return false } + return true + } + /// Extracts SHA-256 hash of the public key from a server trust. private static func publicKeyHash(from trust: SecTrust) -> Data? { guard let certChain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], From f05ca5fcdfd8a22e0c6404a41e72c53dbc4330e9 Mon Sep 17 00:00:00 2001 From: Dario Mader Date: Thu, 16 Apr 2026 10:08:32 +0200 Subject: [PATCH 11/21] Add diagnostics, error detail, and update checker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses issue #6 — users had no way to diagnose "Controller Unreachable" or report bugs. Changes: - DiagnosticsLog: in-memory ring buffer (200 events) with export to clipboard - ErrorState now carries reason strings (e.g. "DNS lookup failed", "Connection timed out") and HTTP codes for API key errors - StatusBarController records all errors/info to DiagnosticsLog, exposes consecutiveErrorCount and currentPollInterval - Error view shows reason, retry interval, and "Copy Diagnostics" button - Diagnostics footer section with recent events, copy/clear actions - UpdateChecker fetches GitHub Releases API, shows new version in footer - Version displayed in menu footer timestamp area AI assisted to create this change --- Sources/UniFiBar/Models/WiFiStatus.swift | 29 ++- .../Preferences/PreferencesManager.swift | 4 + .../StatusBar/StatusBarController.swift | 94 +++++++- Sources/UniFiBar/Utils/DiagnosticsLog.swift | 110 ++++++++++ Sources/UniFiBar/Utils/UpdateChecker.swift | 88 ++++++++ Sources/UniFiBar/Views/MenuContentView.swift | 203 +++++++++++++++--- 6 files changed, 483 insertions(+), 45 deletions(-) create mode 100644 Sources/UniFiBar/Utils/DiagnosticsLog.swift create mode 100644 Sources/UniFiBar/Utils/UpdateChecker.swift diff --git a/Sources/UniFiBar/Models/WiFiStatus.swift b/Sources/UniFiBar/Models/WiFiStatus.swift index a33a093..bddb3c7 100644 --- a/Sources/UniFiBar/Models/WiFiStatus.swift +++ b/Sources/UniFiBar/Models/WiFiStatus.swift @@ -117,10 +117,27 @@ final class WiFiStatus { } enum ErrorState: Sendable { - case controllerUnreachable - case invalidAPIKey + case controllerUnreachable(reason: String?) + case invalidAPIKey(httpCode: Int?) case notConnected case certChanged + + var displayTitle: String { + switch self { + case .controllerUnreachable: return "Controller Unreachable" + case .invalidAPIKey: return "Invalid API Key" + case .notConnected: return "Not Connected" + case .certChanged: return "Certificate Changed" + } + } + + var displayReason: String? { + switch self { + case .controllerUnreachable(let reason): return reason + case .invalidAPIKey(let code): return code.map { "HTTP \($0)" } + case .notConnected, .certChanged: return nil + } + } } // MARK: - Display Properties @@ -137,8 +154,8 @@ final class WiFiStatus { var statusBarColor: Color { switch errorState { - case .controllerUnreachable: return .orange - case .invalidAPIKey: return .red + case .controllerUnreachable(_): return .orange + case .invalidAPIKey(_): return .red case .notConnected: return .gray case .certChanged: return .orange case nil: break @@ -154,8 +171,8 @@ final class WiFiStatus { var statusBarSymbol: String { switch errorState { - case .controllerUnreachable: return "wifi.exclamationmark" - case .invalidAPIKey: return "lock.shield" + case .controllerUnreachable(_): return "wifi.exclamationmark" + case .invalidAPIKey(_): return "lock.shield" case .notConnected: return "wifi.slash" case .certChanged: return "lock.shield" case nil: break diff --git a/Sources/UniFiBar/Preferences/PreferencesManager.swift b/Sources/UniFiBar/Preferences/PreferencesManager.swift index 4987550..fe7e2a7 100644 --- a/Sources/UniFiBar/Preferences/PreferencesManager.swift +++ b/Sources/UniFiBar/Preferences/PreferencesManager.swift @@ -82,6 +82,10 @@ final class PreferencesManager { set { UserDefaults.standard.set(newValue, forKey: siteIdKey) } } + var controllerURL: URL? { + cachedURL.flatMap { URL(string: $0) } + } + init() { allowSelfSignedCerts = UserDefaults.standard.bool(forKey: selfSignedKey) scrollableMenu = UserDefaults.standard.object(forKey: scrollableMenuKey) as? Bool ?? true diff --git a/Sources/UniFiBar/StatusBar/StatusBarController.swift b/Sources/UniFiBar/StatusBar/StatusBarController.swift index e1a60fa..7b25017 100644 --- a/Sources/UniFiBar/StatusBar/StatusBarController.swift +++ b/Sources/UniFiBar/StatusBar/StatusBarController.swift @@ -22,6 +22,8 @@ private final class SkipFirstFlag: Sendable { final class StatusBarController { let wifiStatus = WiFiStatus() let preferences = PreferencesManager() + let diagnosticsLog = DiagnosticsLog() + let updateChecker = UpdateChecker() private static let logger = Logger(subsystem: "com.unifbar.app", category: "StatusBarController") @@ -33,6 +35,10 @@ final class StatusBarController { private var consecutiveErrors = 0 private var authFailed = false private var lastManualRefresh: Date = .distantPast + private var successCount = 0 + + var consecutiveErrorCount: Int { consecutiveErrors } + var currentPollInterval: Int { pollInterval } /// Tears down observers. Must be called on @MainActor before the object is released, /// since `deinit` is nonisolated in Swift 6 and cannot safely access actor-isolated state. @@ -51,6 +57,10 @@ final class StatusBarController { guard !hasStarted else { return } hasStarted = true + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" + let macOS = ProcessInfo.processInfo.operatingSystemVersionString + diagnosticsLog.record(.system, level: .info, message: "UniFiBar started", detail: "v\(version) macOS \(macOS)") + await preferences.checkConfiguration() guard preferences.isConfigured else { return } client = await preferences.loadClient() @@ -155,7 +165,7 @@ final class StatusBarController { private func refresh() async { guard let client else { - wifiStatus.markError(.controllerUnreachable) + wifiStatus.markError(.controllerUnreachable(reason: "Not configured")) return } @@ -170,24 +180,30 @@ final class StatusBarController { switch error { case .httpError(let code) where code == 401 || code == 403: Self.logger.error("Auth failed during site discovery (HTTP \(code))") + diagnosticsLog.record(.authentication, level: .error, message: "Auth failed during site discovery", detail: "HTTP \(code)") authFailed = true consecutiveErrors = 0 - wifiStatus.markError(.invalidAPIKey) + wifiStatus.markError(.invalidAPIKey(httpCode: code)) default: Self.logger.error("Site discovery failed: \((error as NSError).domain) code=\((error as NSError).code)") + let detail = "\((error as NSError).domain) code=\((error as NSError).code)" + diagnosticsLog.record(.connection, level: .error, message: "Site discovery failed", detail: detail) consecutiveErrors = min(consecutiveErrors + 1, 4) - wifiStatus.markError(.controllerUnreachable) + wifiStatus.markError(.controllerUnreachable(reason: Self.reasonFromError(error))) } return } catch { if await client.certificateChanged { Self.logger.warning("Certificate pin mismatch detected — cert may have been renewed") + diagnosticsLog.record(.certificate, level: .warning, message: "Certificate pin mismatch detected") wifiStatus.markError(.certChanged) return } - Self.logger.error("Site discovery failed: \((error as NSError).domain) code=\((error as NSError).code)") + let detail = "\((error as NSError).domain) code=\((error as NSError).code)" + Self.logger.error("Site discovery failed: \(detail)") + diagnosticsLog.record(.connection, level: .error, message: "Site discovery failed", detail: detail) consecutiveErrors = min(consecutiveErrors + 1, 4) - wifiStatus.markError(.controllerUnreachable) + wifiStatus.markError(.controllerUnreachable(reason: Self.reasonFromError(error))) return } @@ -199,27 +215,34 @@ final class StatusBarController { switch error { case .httpError(let code) where code == 401 || code == 403: Self.logger.error("Authentication failed (HTTP \(code))") + diagnosticsLog.record(.authentication, level: .error, message: "Authentication failed", detail: "HTTP \(code)") authFailed = true consecutiveErrors = 0 - wifiStatus.markError(.invalidAPIKey) + wifiStatus.markError(.invalidAPIKey(httpCode: code)) case .selfNotFound: Self.logger.info("This device not found in active clients — likely disconnected") + diagnosticsLog.record(.connection, level: .info, message: "Device not found in active clients") wifiStatus.markDisconnected() default: Self.logger.error("Failed to fetch self: \((error as NSError).domain) code=\((error as NSError).code)") + let detail = "\((error as NSError).domain) code=\((error as NSError).code)" + diagnosticsLog.record(.connection, level: .error, message: "Failed to fetch self", detail: detail) consecutiveErrors = min(consecutiveErrors + 1, 4) - wifiStatus.markError(.controllerUnreachable) + wifiStatus.markError(.controllerUnreachable(reason: Self.reasonFromError(error))) } return } catch { if await client.certificateChanged { Self.logger.warning("Certificate pin mismatch detected — cert may have been renewed") + diagnosticsLog.record(.certificate, level: .warning, message: "Certificate pin mismatch detected") wifiStatus.markError(.certChanged) return } consecutiveErrors = min(consecutiveErrors + 1, 4) - Self.logger.error("Failed to fetch self: \((error as NSError).domain) code=\((error as NSError).code)") - wifiStatus.markError(.controllerUnreachable) + let detail = "\((error as NSError).domain) code=\((error as NSError).code)" + Self.logger.error("Failed to fetch self: \(detail)") + diagnosticsLog.record(.connection, level: .error, message: "Failed to fetch self", detail: detail) + wifiStatus.markError(.controllerUnreachable(reason: Self.reasonFromError(error))) return } @@ -227,6 +250,15 @@ final class StatusBarController { authFailed = false wifiStatus.update(from: selfInfo) + successCount += 1 + if successCount % 10 == 0 { + diagnosticsLog.record(.connection, level: .info, message: "Poll succeeded", detail: "\(successCount) consecutive") + } + + if successCount == 1 { + updateChecker.schedulePeriodicCheck() + } + let me = selfInfo.client // Parallel batch 1: devices, WAN health, VPN tunnels, session history @@ -280,6 +312,28 @@ final class StatusBarController { await fetchMonitoringData(client: client) } + /// Maps network errors to human-readable reasons for the UI. + private static func reasonFromError(_ error: Error) -> String? { + let nsError = error as NSError + guard nsError.domain == NSURLErrorDomain else { + return "\(nsError.domain) code=\(nsError.code)" + } + switch nsError.code { + case NSURLErrorCannotFindHost: return "DNS lookup failed" + case NSURLErrorDNSLookupFailed: return "DNS lookup failed" + case NSURLErrorTimedOut: return "Connection timed out" + case NSURLErrorCannotConnectToHost: return "Connection refused" + case NSURLErrorNetworkConnectionLost: return "Network connection lost" + case NSURLErrorNotConnectedToInternet: return "No internet connection" + case NSURLErrorSecureConnectionFailed: return "TLS handshake failed" + case NSURLErrorServerCertificateHasBadDate: return "Server certificate expired" + case NSURLErrorServerCertificateUntrusted: return "Server certificate untrusted" + case NSURLErrorServerCertificateHasUnknownRoot: return "Self-signed certificate" + case NSURLErrorClientCertificateRejected: return "Client certificate rejected" + default: return "Network error (\(nsError.code))" + } + } + /// Fetches optional monitoring data based on which sections are enabled in preferences. /// Each call is independent and fails silently — monitoring data is best-effort. private func fetchMonitoringData(client: UniFiClient) async { @@ -307,6 +361,28 @@ final class StatusBarController { let pf = await pfTask let rogue = await rogueTask + if wantAlarms && alarms == nil { + diagnosticsLog.record(.monitoring, level: .warning, message: "Failed to fetch alarms") + } + if wantTraffic && dpi == nil { + diagnosticsLog.record(.monitoring, level: .warning, message: "Failed to fetch DPI stats") + } + if wantSecurity && ips == nil { + diagnosticsLog.record(.monitoring, level: .warning, message: "Failed to fetch IPS events") + } + if wantSecurity && anomalies == nil { + diagnosticsLog.record(.monitoring, level: .warning, message: "Failed to fetch anomalies") + } + if wantDDNS && ddns == nil { + diagnosticsLog.record(.monitoring, level: .warning, message: "Failed to fetch DDNS status") + } + if wantPF && pf == nil { + diagnosticsLog.record(.monitoring, level: .warning, message: "Failed to fetch port forwards") + } + if wantRogue && rogue == nil { + diagnosticsLog.record(.monitoring, level: .warning, message: "Failed to fetch nearby APs") + } + wifiStatus.updateMonitoring( alarms: alarms, dpi: dpi, diff --git a/Sources/UniFiBar/Utils/DiagnosticsLog.swift b/Sources/UniFiBar/Utils/DiagnosticsLog.swift new file mode 100644 index 0000000..0d98d76 --- /dev/null +++ b/Sources/UniFiBar/Utils/DiagnosticsLog.swift @@ -0,0 +1,110 @@ +import Foundation + +@MainActor +@Observable +final class DiagnosticsLog { + struct Event: Identifiable, Sendable { + let id: UUID + let timestamp: Date + let category: Category + let level: Level + let message: String + let detail: String? + + init(category: Category, level: Level, message: String, detail: String? = nil) { + self.id = UUID() + self.timestamp = Date() + self.category = category + self.level = level + self.message = message + self.detail = detail + } + } + + enum Category: String, Sendable, CaseIterable { + case connection + case authentication + case certificate + case monitoring + case configuration + case system + } + + enum Level: String, Sendable, CaseIterable { + case error + case warning + case info + } + + private var events: [Event] = [] + private let maxEvents = 200 + + var recentEvents: [Event] { + events.reversed() + } + + var errorCount: Int { + events.filter { $0.level == .error }.count + } + + func record(_ category: Category, level: Level, message: String, detail: String? = nil) { + let event = Event(category: category, level: level, message: message, detail: detail) + events.append(event) + if events.count > maxEvents { + events.removeFirst(events.count - maxEvents) + } + } + + func clear() { + events.removeAll() + } + + func exportText( + errorState: WiFiStatus.ErrorState?, + consecutiveErrors: Int, + pollInterval: Int, + controllerHost: String?, + allowSelfSignedCerts: Bool + ) -> String { + var lines: [String] = [] + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?" + let macOS = ProcessInfo.processInfo.operatingSystemVersionString + + lines.append("UniFiBar Diagnostics") + lines.append("====================") + lines.append("Version: \(version) (build \(build))") + lines.append("macOS: \(macOS)") + lines.append("Controller: \(controllerHost ?? "not configured")") + lines.append("Self-signed certs: \(allowSelfSignedCerts ? "allowed" : "not allowed")") + + if let errorState { + lines.append("Status: \(errorState.displayTitle) \(errorState.displayReason.map { "(\($0))" } ?? "")") + } else { + lines.append("Status: connected") + } + + lines.append("Consecutive errors: \(consecutiveErrors)") + lines.append("Poll interval: \(pollInterval)s") + lines.append("") + + if events.isEmpty { + lines.append("No events recorded.") + } else { + lines.append("Recent Events (newest first):") + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + for event in events.reversed() { + let time = formatter.string(from: event.timestamp) + let prefix = "\(time) \(event.level.rawValue.uppercased()) \(event.category.rawValue)" + if let detail = event.detail { + lines.append("[\(prefix)] \(event.message): \(detail)") + } else { + lines.append("[\(prefix)] \(event.message)") + } + } + } + + return lines.joined(separator: "\n") + } +} \ No newline at end of file diff --git a/Sources/UniFiBar/Utils/UpdateChecker.swift b/Sources/UniFiBar/Utils/UpdateChecker.swift new file mode 100644 index 0000000..90f8232 --- /dev/null +++ b/Sources/UniFiBar/Utils/UpdateChecker.swift @@ -0,0 +1,88 @@ +import Foundation + +@MainActor +@Observable +final class UpdateChecker { + var updateAvailable = false + var latestVersion: String? + var releaseURL: URL? + var releaseNotes: String? + + private let repoOwner = "darox" + private let repoName = "UniFiBar" + private let checkInterval: TimeInterval = 86_400 // 24 hours + private let lastCheckKey = "com.unifbar.lastUpdateCheck" + + var currentVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + } + + func checkNow() { + Task { + await performCheck() + } + } + + func schedulePeriodicCheck() { + let lastCheck = UserDefaults.standard.object(forKey: lastCheckKey) as? Date + if let lastCheck, Date().timeIntervalSince(lastCheck) < checkInterval { + return + } + checkNow() + } + + private func performCheck() async { + let urlString = "https://api.github.com/repos/\(repoOwner)/\(repoName)/releases/latest" + guard let url = URL(string: urlString) else { return } + + var request = URLRequest(url: url) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("2026-03-10", forHTTPHeaderField: "X-GitHub-Api-Version") + request.timeoutInterval = 15 + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + return + } + let release = try JSONDecoder().decode(GitHubRelease.self, from: data) + let tag = release.tagName.hasPrefix("v") ? String(release.tagName.dropFirst()) : release.tagName + + if Self.isNewer(current: currentVersion, remote: tag) { + updateAvailable = true + latestVersion = tag + releaseURL = URL(string: release.htmlURL) + releaseNotes = release.body + } + UserDefaults.standard.set(Date(), forKey: lastCheckKey) + } catch { + // Silently fail — update checks must never disrupt the user + } + } + + /// Simple semver comparison: "1.2.3" vs "1.3.0". + private static func isNewer(current: String, remote: String) -> Bool { + let currentParts = current.split(separator: ".").compactMap { Int($0) } + let remoteParts = remote.split(separator: ".").compactMap { Int($0) } + let count = max(currentParts.count, remoteParts.count) + for i in 0.. c { return true } + if r < c { return false } + } + return false + } +} + +private struct GitHubRelease: Decodable, Sendable { + let tagName: String + let htmlURL: String + let body: String? + + enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + case htmlURL = "html_url" + case body + } +} \ No newline at end of file diff --git a/Sources/UniFiBar/Views/MenuContentView.swift b/Sources/UniFiBar/Views/MenuContentView.swift index b45f327..132d45c 100644 --- a/Sources/UniFiBar/Views/MenuContentView.swift +++ b/Sources/UniFiBar/Views/MenuContentView.swift @@ -4,6 +4,7 @@ struct MenuContentView: View { let controller: StatusBarController @Environment(\.openWindow) private var openWindow + @State private var showDiagnostics = false var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -138,12 +139,38 @@ struct MenuContentView: View { @ViewBuilder private var footerTimestamp: some View { - if let lastUpdated = status.lastUpdated { - Text("Last updated: \(lastUpdated.formatted(date: .omitted, time: .standard))") + VStack(alignment: .leading, spacing: 2) { + if let lastUpdated = status.lastUpdated { + Text("Last updated: \(lastUpdated.formatted(date: .omitted, time: .standard))") + .font(.caption2) + .foregroundStyle(.tertiary) + } + versionLine + } + .padding(.horizontal, 16) + .padding(.top, 8) + } + + @ViewBuilder + private var versionLine: some View { + let version = controller.updateChecker.currentVersion + if controller.updateChecker.updateAvailable, let latest = controller.updateChecker.latestVersion { + HStack(spacing: 4) { + Text("UniFiBar v\(version)") + Text("(v\(latest) available)") + .foregroundStyle(.blue) + } + .font(.caption2) + .foregroundStyle(.quaternary) + .onTapGesture { + if let url = controller.updateChecker.releaseURL { + NSWorkspace.shared.open(url) + } + } + } else { + Text("UniFiBar v\(version)") .font(.caption2) - .foregroundStyle(.tertiary) - .padding(.horizontal, 16) - .padding(.top, 8) + .foregroundStyle(.quaternary) } } @@ -173,15 +200,27 @@ struct MenuContentView: View { private func errorView(_ state: WiFiStatus.ErrorState) -> some View { VStack(spacing: 8) { switch state { - case .controllerUnreachable: + case .controllerUnreachable(let reason): Label("Controller Unreachable", systemImage: "exclamationmark.triangle") .foregroundStyle(.orange) - Text("Will retry on next poll cycle.") - .font(.caption) - .foregroundStyle(.secondary) - case .invalidAPIKey: + if let reason { + Text(reason) + .font(.callout) + .foregroundStyle(.secondary) + } + if controller.consecutiveErrorCount > 0 { + Text("Retry in \(controller.currentPollInterval)s · \(controller.consecutiveErrorCount) error\(controller.consecutiveErrorCount == 1 ? "" : "s")") + .font(.caption2) + .foregroundStyle(.tertiary) + } + case .invalidAPIKey(let httpCode): Label("Invalid API Key", systemImage: "key.slash") .foregroundStyle(.red) + if let code = httpCode { + Text("Server returned HTTP \(code)") + .font(.caption) + .foregroundStyle(.secondary) + } Button("Open Preferences") { activateAndOpenWindow("preferences") } @@ -206,38 +245,142 @@ struct MenuContentView: View { .font(.caption) .foregroundStyle(.secondary) } + + Button { + copyDiagnostics() + } label: { + Label("Copy Diagnostics", systemImage: "doc.on.doc") + .font(.caption) + } + .buttonStyle(.bordered) } .frame(maxWidth: .infinity) .padding() } - // MARK: - Footer + // MARK: - Diagnostics Section @ViewBuilder - private var footerActions: some View { - HStack(spacing: 8) { - Button { - controller.refreshNow() - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - .frame(maxWidth: .infinity) + private var diagnosticsSection: some View { + VStack(alignment: .leading, spacing: 6) { + let version = controller.updateChecker.currentVersion + Text("UniFiBar v\(version)") + .font(.caption) + .foregroundStyle(.secondary) + + if controller.updateChecker.updateAvailable, let latest = controller.updateChecker.latestVersion { + Button { + if let url = controller.updateChecker.releaseURL { + NSWorkspace.shared.open(url) + } + } label: { + Label("v\(latest) available", systemImage: "arrow.down.circle") + .font(.caption) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) } - Button { - activateAndOpenWindow("preferences") - } label: { - Label("Preferences", systemImage: "gearshape") - .frame(maxWidth: .infinity) + let events = controller.diagnosticsLog.recentEvents + if events.isEmpty { + Text("No events recorded") + .font(.caption2) + .foregroundStyle(.tertiary) + } else { + ForEach(events.prefix(10)) { event in + HStack(spacing: 4) { + Circle() + .fill(event.level == .error ? Color.red : event.level == .warning ? Color.orange : Color.green) + .frame(width: 6, height: 6) + Text(event.timestamp.formatted(date: .omitted, time: .shortened)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + Text(event.message) + .font(.caption2) + .lineLimit(1) + } + } } - Button { - NSApplication.shared.terminate(nil) - } label: { - Label("Quit", systemImage: "xmark") - .frame(maxWidth: .infinity) + HStack(spacing: 8) { + Button { + copyDiagnostics() + } label: { + Label("Copy Report", systemImage: "doc.on.doc") + .font(.caption2) + } + .buttonStyle(.bordered) + .controlSize(.small) + + Button { + controller.diagnosticsLog.clear() + } label: { + Label("Clear", systemImage: "trash") + .font(.caption2) + } + .buttonStyle(.bordered) + .controlSize(.small) } } .padding(.horizontal, 16) - .padding(.vertical, 2) + .padding(.vertical, 4) + } + + // MARK: - Footer + + @ViewBuilder + private var footerActions: some View { + VStack(spacing: 4) { + if showDiagnostics { + diagnosticsSection + Divider() + } + + HStack(spacing: 8) { + Button { + controller.refreshNow() + } label: { + Image(systemName: "arrow.clockwise") + .frame(maxWidth: .infinity) + } + + Button { + withAnimation { showDiagnostics.toggle() } + } label: { + Label("Diagnostics", systemImage: "stethoscope") + .frame(maxWidth: .infinity) + } + + Button { + activateAndOpenWindow("preferences") + } label: { + Label("Preferences", systemImage: "gearshape") + .frame(maxWidth: .infinity) + } + + Button { + NSApplication.shared.terminate(nil) + } label: { + Image(systemName: "xmark") + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 2) + } + } + + // MARK: - Actions + + private func copyDiagnostics() { + let report = controller.diagnosticsLog.exportText( + errorState: controller.wifiStatus.errorState, + consecutiveErrors: controller.consecutiveErrorCount, + pollInterval: controller.currentPollInterval, + controllerHost: controller.preferences.controllerURL?.host, + allowSelfSignedCerts: controller.preferences.allowSelfSignedCerts + ) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(report, forType: .string) } -} +} \ No newline at end of file From 2a12c0fcaa57ac017132656ad396be3e539fd84c Mon Sep 17 00:00:00 2001 From: Dario Mader Date: Thu, 16 Apr 2026 11:10:03 +0200 Subject: [PATCH 12/21] Rename scrollable to compact mode, fix expanded window height - Rename scrollableMenu to compactMode (default off = expanded) - Compact on: auto-sized window, content determines height - Compact off: window expands to bottom of screen, scrollable - Footer buttons are all icon-only (refresh, diagnostics, prefs, quit) - Revert section collapse logic tied to compact mode AI assisted to create this change --- .../Preferences/PreferencesManager.swift | 10 ++++---- .../Views/Components/CollapsibleSection.swift | 20 ++++++--------- Sources/UniFiBar/Views/MenuContentView.swift | 25 ++++++++++--------- Sources/UniFiBar/Views/PreferencesView.swift | 12 ++++----- 4 files changed, 31 insertions(+), 36 deletions(-) diff --git a/Sources/UniFiBar/Preferences/PreferencesManager.swift b/Sources/UniFiBar/Preferences/PreferencesManager.swift index fe7e2a7..34e6e6f 100644 --- a/Sources/UniFiBar/Preferences/PreferencesManager.swift +++ b/Sources/UniFiBar/Preferences/PreferencesManager.swift @@ -63,7 +63,7 @@ enum MenuSection: String, CaseIterable, Sendable { final class PreferencesManager { var isConfigured: Bool = false var allowSelfSignedCerts: Bool = false - var scrollableMenu: Bool = true + var compactMode: Bool = false // Section visibility private var sectionVisibility: [String: Bool] = [:] @@ -75,7 +75,7 @@ final class PreferencesManager { private let siteIdKey = "com.unifbar.siteId" private let selfSignedKey = "com.unifbar.allowSelfSigned" private let sectionVisibilityKey = "com.unifbar.sectionVisibility" - private let scrollableMenuKey = "com.unifbar.scrollableMenu" + private let compactModeKey = "com.unifbar.compactMode" var siteId: String? { get { UserDefaults.standard.string(forKey: siteIdKey) } @@ -88,7 +88,7 @@ final class PreferencesManager { init() { allowSelfSignedCerts = UserDefaults.standard.bool(forKey: selfSignedKey) - scrollableMenu = UserDefaults.standard.object(forKey: scrollableMenuKey) as? Bool ?? true + compactMode = UserDefaults.standard.object(forKey: compactModeKey) as? Bool ?? false if let saved = UserDefaults.standard.dictionary(forKey: sectionVisibilityKey) as? [String: Bool] { sectionVisibility = saved } @@ -164,10 +164,10 @@ final class PreferencesManager { UserDefaults.standard.removeObject(forKey: siteIdKey) UserDefaults.standard.removeObject(forKey: selfSignedKey) UserDefaults.standard.removeObject(forKey: sectionVisibilityKey) - UserDefaults.standard.removeObject(forKey: scrollableMenuKey) + UserDefaults.standard.removeObject(forKey: compactModeKey) sectionVisibility = [:] allowSelfSignedCerts = false - scrollableMenu = true + compactMode = false isConfigured = false } } diff --git a/Sources/UniFiBar/Views/Components/CollapsibleSection.swift b/Sources/UniFiBar/Views/Components/CollapsibleSection.swift index 6eeb629..b521f46 100644 --- a/Sources/UniFiBar/Views/Components/CollapsibleSection.swift +++ b/Sources/UniFiBar/Views/Components/CollapsibleSection.swift @@ -3,19 +3,17 @@ import SwiftUI struct CollapsibleSection: View { let title: String let showDivider: Bool + let defaultExpanded: Bool @ViewBuilder let content: () -> Content @State private var isExpanded: Bool - private let storageKey: String - init(title: String, showDivider: Bool = true, defaultExpanded: Bool = true, @ViewBuilder content: @escaping () -> Content) { self.title = title self.showDivider = showDivider + self.defaultExpanded = defaultExpanded self.content = content - self.storageKey = "com.unifbar.section.expanded.\(title.lowercased().replacingOccurrences(of: " ", with: "_"))" - let saved = UserDefaults.standard.object(forKey: storageKey) as? Bool - self._isExpanded = State(initialValue: saved ?? defaultExpanded) + self._isExpanded = State(initialValue: defaultExpanded) } var body: some View { @@ -29,7 +27,6 @@ struct CollapsibleSection: View { Button { withAnimation(.easeInOut(duration: 0.2)) { isExpanded.toggle() - UserDefaults.standard.set(isExpanded, forKey: storageKey) } } label: { HStack { @@ -66,21 +63,19 @@ struct CollapsibleSectionWithBadge: View { let badge: Int let badgeColor: Color let showDivider: Bool + let defaultExpanded: Bool @ViewBuilder let content: () -> Content @State private var isExpanded: Bool - private let storageKey: String - init(title: String, badge: Int, badgeColor: Color = .red, showDivider: Bool = true, defaultExpanded: Bool = true, @ViewBuilder content: @escaping () -> Content) { self.title = title self.badge = badge self.badgeColor = badgeColor self.showDivider = showDivider + self.defaultExpanded = defaultExpanded self.content = content - self.storageKey = "com.unifbar.section.expanded.\(title.lowercased().replacingOccurrences(of: " ", with: "_"))" - let saved = UserDefaults.standard.object(forKey: storageKey) as? Bool - self._isExpanded = State(initialValue: saved ?? defaultExpanded) + self._isExpanded = State(initialValue: defaultExpanded) } var body: some View { @@ -94,7 +89,6 @@ struct CollapsibleSectionWithBadge: View { Button { withAnimation(.easeInOut(duration: 0.2)) { isExpanded.toggle() - UserDefaults.standard.set(isExpanded, forKey: storageKey) } } label: { HStack(spacing: 6) { @@ -133,4 +127,4 @@ struct CollapsibleSectionWithBadge: View { } } } -} +} \ No newline at end of file diff --git a/Sources/UniFiBar/Views/MenuContentView.swift b/Sources/UniFiBar/Views/MenuContentView.swift index 132d45c..626ba22 100644 --- a/Sources/UniFiBar/Views/MenuContentView.swift +++ b/Sources/UniFiBar/Views/MenuContentView.swift @@ -26,6 +26,12 @@ struct MenuContentView: View { footerActions } .padding(.vertical, 8) + .frame(height: controller.preferences.compactMode ? nil : screenUsableHeight) + } + + private var screenUsableHeight: CGFloat { + guard let screen = NSScreen.main else { return 600 } + return screen.visibleFrame.height - 40 } private func activateAndOpenWindow(_ id: String) { @@ -40,17 +46,12 @@ struct MenuContentView: View { @ViewBuilder private var connectedView: some View { - let content = VStack(alignment: .leading, spacing: 0) { - coreSections - monitoringSections - footerTimestamp - } - if prefs.scrollableMenu { - ScrollView { - content + ScrollView { + VStack(alignment: .leading, spacing: 0) { + coreSections + monitoringSections + footerTimestamp } - } else { - content } } @@ -347,14 +348,14 @@ struct MenuContentView: View { Button { withAnimation { showDiagnostics.toggle() } } label: { - Label("Diagnostics", systemImage: "stethoscope") + Image(systemName: "stethoscope") .frame(maxWidth: .infinity) } Button { activateAndOpenWindow("preferences") } label: { - Label("Preferences", systemImage: "gearshape") + Image(systemName: "gearshape") .frame(maxWidth: .infinity) } diff --git a/Sources/UniFiBar/Views/PreferencesView.swift b/Sources/UniFiBar/Views/PreferencesView.swift index 239c122..9e1fc8a 100644 --- a/Sources/UniFiBar/Views/PreferencesView.swift +++ b/Sources/UniFiBar/Views/PreferencesView.swift @@ -9,7 +9,7 @@ struct PreferencesView: View { @State private var apiKey = "" @State private var allowSelfSigned = false @State private var isEditingCredentials = false - @State private var scrollableMenu = true + @State private var compactMode = true @State private var launchAtLogin = false @State private var isLoading = true @State private var showResetConfirmation = false @@ -110,10 +110,10 @@ struct PreferencesView: View { private var behaviorSection: some View { Section { - Toggle("Scrollable menu", isOn: $scrollableMenu) - .onChange(of: scrollableMenu) { _, newValue in - controller.preferences.scrollableMenu = newValue - UserDefaults.standard.set(newValue, forKey: "com.unifbar.scrollableMenu") + Toggle("Compact mode", isOn: $compactMode) + .onChange(of: compactMode) { _, newValue in + controller.preferences.compactMode = newValue + UserDefaults.standard.set(newValue, forKey: "com.unifbar.compactMode") } Toggle("Launch at login", isOn: $launchAtLogin) .onChange(of: launchAtLogin) { _, newValue in @@ -158,7 +158,7 @@ struct PreferencesView: View { controllerURL = controller.preferences.cachedURL ?? "" apiKey = controller.preferences.cachedAPIKey ?? "" allowSelfSigned = controller.preferences.allowSelfSignedCerts - scrollableMenu = controller.preferences.scrollableMenu + compactMode = controller.preferences.compactMode launchAtLogin = SMAppService.mainApp.status == .enabled isLoading = false } From cdb33776857a2cf413e3814380ad4e440094a5a8 Mon Sep 17 00:00:00 2001 From: Dario Mader Date: Thu, 16 Apr 2026 12:13:21 +0200 Subject: [PATCH 13/21] Switch DPI to v2 traffic API, move diagnostics to Preferences - Replace legacy stat/sitedpi endpoint with v2 traffic API (client_usage_by_app) which works on Network 9.1+ - Add V2TrafficResponse, ClientAppUsage, AppUsage DTOs - Pass siteId to fetchMonitoringData and fetchDPIStats - Remove monitoring nil-result warning spam from diagnostics log - Move diagnostics section from menu to Preferences window - Move version/update indicator from menu footer to Preferences - Simplify footer to 3 icon-only buttons (refresh, prefs, quit) - Add debug logging when v2 traffic API returns empty categories AI assisted to create this change --- Sources/UniFiBar/Models/MonitoringDTO.swift | 57 ++++++- Sources/UniFiBar/Network/UniFiClient.swift | 14 +- .../StatusBar/StatusBarController.swift | 28 +--- Sources/UniFiBar/Views/MenuContentView.swift | 157 +++--------------- Sources/UniFiBar/Views/PreferencesView.swift | 72 ++++++++ 5 files changed, 165 insertions(+), 163 deletions(-) diff --git a/Sources/UniFiBar/Models/MonitoringDTO.swift b/Sources/UniFiBar/Models/MonitoringDTO.swift index c71ba0d..c46003b 100644 --- a/Sources/UniFiBar/Models/MonitoringDTO.swift +++ b/Sources/UniFiBar/Models/MonitoringDTO.swift @@ -52,7 +52,62 @@ struct AlarmDTO: Decodable, Sendable, Identifiable { } } -// MARK: - DPI (Deep Packet Inspection) Stats +// MARK: - DPI (Deep Packet Inspection) Stats — V2 Traffic API + +struct V2TrafficResponse: Decodable, Sendable { + let clientUsageByApp: [ClientAppUsage]? + + enum CodingKeys: String, CodingKey { + case clientUsageByApp = "client_usage_by_app" + } + + func toCategories() -> [DPICategoryDTO] { + guard let entries = clientUsageByApp else { return [] } + var categoryTotals: [Int: (rx: Int, tx: Int)] = [:] + for entry in entries { + for usage in entry.usageByApp { + let cat = usage.category + categoryTotals[cat, default: (0, 0)].rx += usage.rxBytes + categoryTotals[cat, default: (0, 0)].tx += usage.txBytes + } + } + return categoryTotals.map { cat, totals in + DPICategoryDTO( + id: cat, + name: DPIStatsResponse.categoryName(cat), + rxBytes: totals.rx, + txBytes: totals.tx + ) + } + .filter { $0.totalBytes > 0 } + .sorted { $0.totalBytes > $1.totalBytes } + } +} + +struct ClientAppUsage: Decodable, Sendable { + let usageByApp: [AppUsage] + + enum CodingKeys: String, CodingKey { + case usageByApp = "usage_by_app" + } +} + +struct AppUsage: Decodable, Sendable { + let category: Int + let application: Int? + let rxBytes: Int + let txBytes: Int + let totalBytes: Int? + + enum CodingKeys: String, CodingKey { + case category, application + case rxBytes = "bytes_received" + case txBytes = "bytes_transmitted" + case totalBytes = "total_bytes" + } +} + +// MARK: - DPI Legacy Stats (kept for reference, unused) struct DPICategoryDTO: Sendable, Identifiable { let id: Int diff --git a/Sources/UniFiBar/Network/UniFiClient.swift b/Sources/UniFiBar/Network/UniFiClient.swift index 41bbacc..dea9271 100644 --- a/Sources/UniFiBar/Network/UniFiClient.swift +++ b/Sources/UniFiBar/Network/UniFiClient.swift @@ -254,12 +254,18 @@ actor UniFiClient { // MARK: - DPI Stats - func fetchDPIStats() async -> [DPICategoryDTO]? { + func fetchDPIStats(siteId: String) async -> [DPICategoryDTO]? { + // Use v2 traffic API (legacy stat/sitedpi returns empty data on Network 9.1+) do { - let body: [String: Any] = ["type": "by_cat"] - let data = try await post("/proxy/network/api/s/default/stat/sitedpi", body: body) - let response = try JSONDecoder().decode(DPIStatsResponse.self, from: data) + let now = Int(Date().timeIntervalSince1970 * 1000) + let start = now - 3_600_000 // last hour + let path = "/proxy/network/v2/api/site/\(siteId)/traffic?start=\(start)&end=\(now)&includeUnidentified=true" + let data = try await request(path) + let response = try JSONDecoder().decode(V2TrafficResponse.self, from: data) let categories = response.toCategories() + if categories.isEmpty { + Self.logger.warning("DPI v2 returned 0 categories from \(response.clientUsageByApp?.count ?? 0) client entries") + } return categories.isEmpty ? nil : Array(categories.prefix(8)) } catch { Self.logger.error("Failed to fetch DPI stats: \(Self.safeErrorDescription(error))") diff --git a/Sources/UniFiBar/StatusBar/StatusBarController.swift b/Sources/UniFiBar/StatusBar/StatusBarController.swift index 7b25017..d0e86d7 100644 --- a/Sources/UniFiBar/StatusBar/StatusBarController.swift +++ b/Sources/UniFiBar/StatusBar/StatusBarController.swift @@ -309,7 +309,7 @@ final class StatusBarController { wifiStatus.updateGateway(gwStats, device: gwDevice) // Parallel batch 3: monitoring data (only fetch enabled sections) - await fetchMonitoringData(client: client) + await fetchMonitoringData(client: client, siteId: siteId) } /// Maps network errors to human-readable reasons for the UI. @@ -336,7 +336,7 @@ final class StatusBarController { /// Fetches optional monitoring data based on which sections are enabled in preferences. /// Each call is independent and fails silently — monitoring data is best-effort. - private func fetchMonitoringData(client: UniFiClient) async { + private func fetchMonitoringData(client: UniFiClient, siteId: String) async { // Evaluate section visibility on @MainActor before spawning child tasks let wantAlarms = preferences.isSectionEnabled(.alerts) let wantTraffic = preferences.isSectionEnabled(.traffic) @@ -346,7 +346,7 @@ final class StatusBarController { let wantRogue = preferences.isSectionEnabled(.nearbyAPs) async let alarmsTask: [AlarmDTO]? = wantAlarms ? await client.fetchAlarms() : nil - async let dpiTask: [DPICategoryDTO]? = wantTraffic ? await client.fetchDPIStats() : nil + async let dpiTask: [DPICategoryDTO]? = wantTraffic ? await client.fetchDPIStats(siteId: siteId) : nil async let ipsTask: [IPSEventDTO]? = wantSecurity ? await client.fetchIPSEvents() : nil async let anomaliesTask: [AnomalyDTO]? = wantSecurity ? await client.fetchAnomalies() : nil async let ddnsTask: [DDNSStatusDTO]? = wantDDNS ? await client.fetchDDNSStatus() : nil @@ -361,28 +361,6 @@ final class StatusBarController { let pf = await pfTask let rogue = await rogueTask - if wantAlarms && alarms == nil { - diagnosticsLog.record(.monitoring, level: .warning, message: "Failed to fetch alarms") - } - if wantTraffic && dpi == nil { - diagnosticsLog.record(.monitoring, level: .warning, message: "Failed to fetch DPI stats") - } - if wantSecurity && ips == nil { - diagnosticsLog.record(.monitoring, level: .warning, message: "Failed to fetch IPS events") - } - if wantSecurity && anomalies == nil { - diagnosticsLog.record(.monitoring, level: .warning, message: "Failed to fetch anomalies") - } - if wantDDNS && ddns == nil { - diagnosticsLog.record(.monitoring, level: .warning, message: "Failed to fetch DDNS status") - } - if wantPF && pf == nil { - diagnosticsLog.record(.monitoring, level: .warning, message: "Failed to fetch port forwards") - } - if wantRogue && rogue == nil { - diagnosticsLog.record(.monitoring, level: .warning, message: "Failed to fetch nearby APs") - } - wifiStatus.updateMonitoring( alarms: alarms, dpi: dpi, diff --git a/Sources/UniFiBar/Views/MenuContentView.swift b/Sources/UniFiBar/Views/MenuContentView.swift index 626ba22..bfe44f0 100644 --- a/Sources/UniFiBar/Views/MenuContentView.swift +++ b/Sources/UniFiBar/Views/MenuContentView.swift @@ -4,7 +4,6 @@ struct MenuContentView: View { let controller: StatusBarController @Environment(\.openWindow) private var openWindow - @State private var showDiagnostics = false var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -140,38 +139,12 @@ struct MenuContentView: View { @ViewBuilder private var footerTimestamp: some View { - VStack(alignment: .leading, spacing: 2) { - if let lastUpdated = status.lastUpdated { - Text("Last updated: \(lastUpdated.formatted(date: .omitted, time: .standard))") - .font(.caption2) - .foregroundStyle(.tertiary) - } - versionLine - } - .padding(.horizontal, 16) - .padding(.top, 8) - } - - @ViewBuilder - private var versionLine: some View { - let version = controller.updateChecker.currentVersion - if controller.updateChecker.updateAvailable, let latest = controller.updateChecker.latestVersion { - HStack(spacing: 4) { - Text("UniFiBar v\(version)") - Text("(v\(latest) available)") - .foregroundStyle(.blue) - } - .font(.caption2) - .foregroundStyle(.quaternary) - .onTapGesture { - if let url = controller.updateChecker.releaseURL { - NSWorkspace.shared.open(url) - } - } - } else { - Text("UniFiBar v\(version)") + if let lastUpdated = status.lastUpdated { + Text("Last updated: \(lastUpdated.formatted(date: .omitted, time: .standard))") .font(.caption2) - .foregroundStyle(.quaternary) + .foregroundStyle(.tertiary) + .padding(.horizontal, 16) + .padding(.top, 8) } } @@ -259,116 +232,34 @@ struct MenuContentView: View { .padding() } - // MARK: - Diagnostics Section - - @ViewBuilder - private var diagnosticsSection: some View { - VStack(alignment: .leading, spacing: 6) { - let version = controller.updateChecker.currentVersion - Text("UniFiBar v\(version)") - .font(.caption) - .foregroundStyle(.secondary) - - if controller.updateChecker.updateAvailable, let latest = controller.updateChecker.latestVersion { - Button { - if let url = controller.updateChecker.releaseURL { - NSWorkspace.shared.open(url) - } - } label: { - Label("v\(latest) available", systemImage: "arrow.down.circle") - .font(.caption) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - - let events = controller.diagnosticsLog.recentEvents - if events.isEmpty { - Text("No events recorded") - .font(.caption2) - .foregroundStyle(.tertiary) - } else { - ForEach(events.prefix(10)) { event in - HStack(spacing: 4) { - Circle() - .fill(event.level == .error ? Color.red : event.level == .warning ? Color.orange : Color.green) - .frame(width: 6, height: 6) - Text(event.timestamp.formatted(date: .omitted, time: .shortened)) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.tertiary) - Text(event.message) - .font(.caption2) - .lineLimit(1) - } - } - } - - HStack(spacing: 8) { - Button { - copyDiagnostics() - } label: { - Label("Copy Report", systemImage: "doc.on.doc") - .font(.caption2) - } - .buttonStyle(.bordered) - .controlSize(.small) - - Button { - controller.diagnosticsLog.clear() - } label: { - Label("Clear", systemImage: "trash") - .font(.caption2) - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 4) - } - // MARK: - Footer @ViewBuilder private var footerActions: some View { - VStack(spacing: 4) { - if showDiagnostics { - diagnosticsSection - Divider() + HStack(spacing: 8) { + Button { + controller.refreshNow() + } label: { + Image(systemName: "arrow.clockwise") + .frame(maxWidth: .infinity) } - HStack(spacing: 8) { - Button { - controller.refreshNow() - } label: { - Image(systemName: "arrow.clockwise") - .frame(maxWidth: .infinity) - } - - Button { - withAnimation { showDiagnostics.toggle() } - } label: { - Image(systemName: "stethoscope") - .frame(maxWidth: .infinity) - } - - Button { - activateAndOpenWindow("preferences") - } label: { - Image(systemName: "gearshape") - .frame(maxWidth: .infinity) - } + Button { + activateAndOpenWindow("preferences") + } label: { + Image(systemName: "gearshape") + .frame(maxWidth: .infinity) + } - Button { - NSApplication.shared.terminate(nil) - } label: { - Image(systemName: "xmark") - .frame(maxWidth: .infinity) - } + Button { + NSApplication.shared.terminate(nil) + } label: { + Image(systemName: "xmark") + .frame(maxWidth: .infinity) } - .padding(.horizontal, 16) - .padding(.vertical, 2) } + .padding(.horizontal, 16) + .padding(.vertical, 2) } // MARK: - Actions diff --git a/Sources/UniFiBar/Views/PreferencesView.swift b/Sources/UniFiBar/Views/PreferencesView.swift index 9e1fc8a..e811cf6 100644 --- a/Sources/UniFiBar/Views/PreferencesView.swift +++ b/Sources/UniFiBar/Views/PreferencesView.swift @@ -26,6 +26,7 @@ struct PreferencesView: View { siteSection behaviorSection visibilitySection + diagnosticsSection resetSection } .formStyle(.grouped) @@ -136,6 +137,77 @@ struct PreferencesView: View { } } + // MARK: - Diagnostics + + private var diagnosticsSection: some View { + Section { + let log = controller.diagnosticsLog + let events = log.recentEvents + + LabeledContent("Version") { + VStack(alignment: .trailing, spacing: 2) { + Text("v\(controller.updateChecker.currentVersion)") + if controller.updateChecker.updateAvailable, let latest = controller.updateChecker.latestVersion { + Button("v\(latest) available") { + if let url = controller.updateChecker.releaseURL { + NSWorkspace.shared.open(url) + } + } + .buttonStyle(.borderless) + .foregroundStyle(.blue) + } + } + } + + LabeledContent("Consecutive Errors") { + Text("\(controller.consecutiveErrorCount)") + } + + LabeledContent("Poll Interval") { + Text("\(controller.currentPollInterval)s") + } + + if !events.isEmpty { + DisclosureGroup("Recent Events (\(events.count))") { + ForEach(events.prefix(20)) { event in + HStack(spacing: 6) { + Circle() + .fill(event.level == .error ? Color.red : event.level == .warning ? Color.orange : Color.green) + .frame(width: 6, height: 6) + Text(event.timestamp.formatted(date: .omitted, time: .shortened)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + Text(event.message) + .font(.caption) + .lineLimit(1) + Spacer() + } + } + } + } + + HStack { + Button("Copy Report") { + let report = log.exportText( + errorState: controller.wifiStatus.errorState, + consecutiveErrors: controller.consecutiveErrorCount, + pollInterval: controller.currentPollInterval, + controllerHost: controller.preferences.controllerURL?.host, + allowSelfSignedCerts: controller.preferences.allowSelfSignedCerts + ) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(report, forType: .string) + } + Spacer() + Button("Clear Log") { + log.clear() + } + } + } header: { + Text("Diagnostics") + } + } + // MARK: - Reset private var resetSection: some View { From 966ebda31c54ede7f499fa1a0b9acbea95b12eeb Mon Sep 17 00:00:00 2001 From: Dario Mader Date: Thu, 16 Apr 2026 13:47:03 +0200 Subject: [PATCH 14/21] Remove broken monitoring endpoints and dead code - Remove DPI/Traffic section (v2 API returns 404 on UCG Fiber) - Remove Anomalies (decode fails on current API response format) - Remove all related DTOs: V2TrafficResponse, ClientAppUsage, AppUsage, DPICategoryDTO, DPIStatsResponse, AnomalyDTO - Remove fetchDPIStats, fetchAnomalies methods from UniFiClient - Remove .traffic from MenuSection enum - Remove anomalies from SecuritySection and WiFiStatus - Make monitoring fetches return error details for diagnostics - Add flexible array decoding (handles both wrapped and bare formats) - Enable security section by default - Clean up probe script (remove broken endpoints) AI assisted to create this change --- Scripts/probe_endpoints.sh | 56 ++++++ Sources/UniFiBar/Models/MonitoringDTO.swift | 174 ------------------ Sources/UniFiBar/Models/WiFiStatus.swift | 18 +- Sources/UniFiBar/Network/UniFiClient.swift | 118 ++++++------ .../Preferences/PreferencesManager.swift | 9 +- .../StatusBar/StatusBarController.swift | 55 +++--- Sources/UniFiBar/Views/MenuContentView.swift | 8 +- .../Views/Sections/SecuritySection.swift | 17 +- .../Views/Sections/TrafficSection.swift | 54 ------ 9 files changed, 154 insertions(+), 355 deletions(-) create mode 100755 Scripts/probe_endpoints.sh delete mode 100644 Sources/UniFiBar/Views/Sections/TrafficSection.swift diff --git a/Scripts/probe_endpoints.sh b/Scripts/probe_endpoints.sh new file mode 100755 index 0000000..a2894ae --- /dev/null +++ b/Scripts/probe_endpoints.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# UniFi API Endpoint Probe +# Usage: bash probe_endpoints.sh +# Example: bash probe_endpoints.sh https://192.168.2.1 f81179df... + +CONTROLLER="$1" +API_KEY="$2" + +if [ -z "$CONTROLLER" ] || [ -z "$API_KEY" ]; then + echo "Usage: bash probe_endpoints.sh " + exit 1 +fi + +# Strip trailing slash +CONTROLLER="${CONTROLLER%/}" + +HEADER="X-API-KEY: $API_KEY" +NOW_MS=$(($(date +%s) * 1000)) +HOUR_AGO_MS=$((NOW_MS - 3600000)) + +endpoints=( + "GET|/proxy/network/api/s/default/rest/alarm|alarms_rest" + "GET|/proxy/network/api/s/default/list/alarm|alarms_list" + "GET|/proxy/network/api/s/default/stat/ips/event|ips_events" + "GET|/proxy/network/api/s/default/rest/dynamicdns|ddns" + "GET|/proxy/network/api/s/default/rest/portforward|portforwards" + "GET|/proxy/network/api/s/default/stat/rogueap|rogueaps" +) + +echo "=== UniFi API Endpoint Probe ===" +echo "Controller: $CONTROLLER" +echo "Time: $(date -u)" +echo "" + +for entry in "${endpoints[@]}"; do + IFS='|' read -r method path label <<< "$entry" + url="${CONTROLLER}${path}" + echo "--- $label ---" + echo "$method $path" + + if [ "$method" = "POST" ]; then + response=$(curl -sk -w "\n__HTTP_CODE__%{http_code}" \ + -X POST -H "$HEADER" -H "Content-Type: application/json" \ + -d '{"type":"by_cat"}' "$url" 2>/dev/null) + else + response=$(curl -sk -w "\n__HTTP_CODE__%{http_code}" "$url" -H "$HEADER" 2>/dev/null) + fi + + http_code=$(echo "$response" | grep "__HTTP_CODE__" | sed 's/__HTTP_CODE__//') + body=$(echo "$response" | grep -v "__HTTP_CODE__") + + echo "HTTP $http_code" + echo "$body" | head -c 800 + echo "" + echo "" +done \ No newline at end of file diff --git a/Sources/UniFiBar/Models/MonitoringDTO.swift b/Sources/UniFiBar/Models/MonitoringDTO.swift index c46003b..68a2570 100644 --- a/Sources/UniFiBar/Models/MonitoringDTO.swift +++ b/Sources/UniFiBar/Models/MonitoringDTO.swift @@ -52,165 +52,6 @@ struct AlarmDTO: Decodable, Sendable, Identifiable { } } -// MARK: - DPI (Deep Packet Inspection) Stats — V2 Traffic API - -struct V2TrafficResponse: Decodable, Sendable { - let clientUsageByApp: [ClientAppUsage]? - - enum CodingKeys: String, CodingKey { - case clientUsageByApp = "client_usage_by_app" - } - - func toCategories() -> [DPICategoryDTO] { - guard let entries = clientUsageByApp else { return [] } - var categoryTotals: [Int: (rx: Int, tx: Int)] = [:] - for entry in entries { - for usage in entry.usageByApp { - let cat = usage.category - categoryTotals[cat, default: (0, 0)].rx += usage.rxBytes - categoryTotals[cat, default: (0, 0)].tx += usage.txBytes - } - } - return categoryTotals.map { cat, totals in - DPICategoryDTO( - id: cat, - name: DPIStatsResponse.categoryName(cat), - rxBytes: totals.rx, - txBytes: totals.tx - ) - } - .filter { $0.totalBytes > 0 } - .sorted { $0.totalBytes > $1.totalBytes } - } -} - -struct ClientAppUsage: Decodable, Sendable { - let usageByApp: [AppUsage] - - enum CodingKeys: String, CodingKey { - case usageByApp = "usage_by_app" - } -} - -struct AppUsage: Decodable, Sendable { - let category: Int - let application: Int? - let rxBytes: Int - let txBytes: Int - let totalBytes: Int? - - enum CodingKeys: String, CodingKey { - case category, application - case rxBytes = "bytes_received" - case txBytes = "bytes_transmitted" - case totalBytes = "total_bytes" - } -} - -// MARK: - DPI Legacy Stats (kept for reference, unused) - -struct DPICategoryDTO: Sendable, Identifiable { - let id: Int - let name: String - let rxBytes: Int - let txBytes: Int - - var totalBytes: Int { rxBytes + txBytes } - - var formattedTotal: String { - formatBytes(totalBytes) - } - - private func formatBytes(_ bytes: Int) -> String { - let gb = Double(bytes) / 1_073_741_824.0 - if gb >= 1.0 { return String(format: "%.1f GB", gb) } - let mb = Double(bytes) / 1_048_576.0 - if mb >= 1.0 { return String(format: "%.0f MB", mb) } - let kb = Double(bytes) / 1_024.0 - return String(format: "%.0f KB", kb) - } -} - -struct DPIStatsResponse: Decodable, Sendable { - let data: [DPIEntry] - - struct DPIEntry: Decodable, Sendable { - let byCat: [DPICategoryRaw]? - - enum CodingKeys: String, CodingKey { - case byCat = "by_cat" - } - } - - struct DPICategoryRaw: Decodable, Sendable { - let cat: Int? - let rxBytes: Int? - let txBytes: Int? - let apps: [DPIAppRaw]? - - enum CodingKeys: String, CodingKey { - case cat - case rxBytes = "rx_bytes" - case txBytes = "tx_bytes" - case apps - } - } - - struct DPIAppRaw: Decodable, Sendable { - let app: Int? - let cat: Int? - let rxBytes: Int? - let txBytes: Int? - - enum CodingKeys: String, CodingKey { - case app, cat - case rxBytes = "rx_bytes" - case txBytes = "tx_bytes" - } - } - - func toCategories() -> [DPICategoryDTO] { - guard let entry = data.first, let cats = entry.byCat else { return [] } - return cats.compactMap { raw in - guard let cat = raw.cat else { return nil } - return DPICategoryDTO( - id: cat, - name: Self.categoryName(cat), - rxBytes: raw.rxBytes ?? 0, - txBytes: raw.txBytes ?? 0 - ) - } - .filter { $0.totalBytes > 0 } - .sorted { $0.totalBytes > $1.totalBytes } - } - - // UniFi DPI category mapping - static func categoryName(_ cat: Int) -> String { - switch cat { - case 0: return "Instant Messaging" - case 1: return "P2P" - case 3: return "File Transfer" - case 4: return "Streaming" - case 5: return "Email" - case 6: return "Network Protocols" - case 7: return "Web" - case 8: return "Gaming" - case 9: return "Security" - case 10: return "Database" - case 13: return "Social" - case 14: return "Apple" - case 15: return "Microsoft" - case 17: return "VPN/Tunnel" - case 18: return "Video" - case 19: return "IoT" - case 20: return "Shopping" - case 24: return "Productivity" - case 25: return "Health" - default: return "Category \(cat)" - } - } -} - // MARK: - IDS/IPS Events struct IPSEventDTO: Decodable, Sendable, Identifiable { @@ -247,21 +88,6 @@ struct IPSEventDTO: Decodable, Sendable, Identifiable { } } -// MARK: - Anomalies - -struct AnomalyDTO: Decodable, Sendable, Identifiable { - let id: String - let anomaly: String? - let datetime: String? - let deviceMac: String? - - enum CodingKeys: String, CodingKey { - case id = "_id" - case anomaly, datetime - case deviceMac = "device_mac" - } -} - // MARK: - Dynamic DNS struct DDNSStatusDTO: Decodable, Sendable, Identifiable { diff --git a/Sources/UniFiBar/Models/WiFiStatus.swift b/Sources/UniFiBar/Models/WiFiStatus.swift index bddb3c7..b33794c 100644 --- a/Sources/UniFiBar/Models/WiFiStatus.swift +++ b/Sources/UniFiBar/Models/WiFiStatus.swift @@ -87,9 +87,7 @@ final class WiFiStatus { // Monitoring data var activeAlarms: [AlarmDTO]? = nil - var dpiCategories: [DPICategoryDTO]? = nil var ipsEvents: [IPSEventDTO]? = nil - var anomalies: [AnomalyDTO]? = nil var ddnsStatuses: [DDNSStatusDTO]? = nil var portForwards: [PortForwardDTO]? = nil var nearbyAPs: [RogueAPDTO]? = nil @@ -266,18 +264,10 @@ final class WiFiStatus { ipsEvents?.count ?? 0 } - var anomalyCount: Int { - anomalies?.count ?? 0 - } - var securitySummary: String? { let threats = ipsEventCount - let anomalyN = anomalyCount - guard threats > 0 || anomalyN > 0 else { return nil } - var parts: [String] = [] - if threats > 0 { parts.append("\(threats) threat\(threats == 1 ? "" : "s")") } - if anomalyN > 0 { parts.append("\(anomalyN) anomal\(anomalyN == 1 ? "y" : "ies")") } - return parts.joined(separator: " · ") + guard threats > 0 else { return nil } + return "\(threats) threat\(threats == 1 ? "" : "s")" } var nearbyAPCount: Int { @@ -464,17 +454,13 @@ final class WiFiStatus { func updateMonitoring( alarms: [AlarmDTO]?, - dpi: [DPICategoryDTO]?, ips: [IPSEventDTO]?, - anomalies: [AnomalyDTO]?, ddns: [DDNSStatusDTO]?, portForwards: [PortForwardDTO]?, rogueAPs: [RogueAPDTO]? ) { self.activeAlarms = alarms - self.dpiCategories = dpi self.ipsEvents = ips - self.anomalies = anomalies self.ddnsStatuses = ddns self.portForwards = portForwards self.nearbyAPs = rogueAPs diff --git a/Sources/UniFiBar/Network/UniFiClient.swift b/Sources/UniFiBar/Network/UniFiClient.swift index dea9271..b534448 100644 --- a/Sources/UniFiBar/Network/UniFiClient.swift +++ b/Sources/UniFiBar/Network/UniFiClient.swift @@ -164,6 +164,27 @@ actor UniFiClient { let data: [T] } + /// Decodes a UniFi API response that may be wrapped in `{ "data": [...] }` + /// or may be a bare array `[...]`. Tries LegacyResponse first, then direct array. + private static func decodeFlexibleArray( + _ type: T.Type, + from data: Data, + endpoint: String + ) -> [T]? { + // Try wrapped format first: { "data": [...] } + if let response = try? JSONDecoder().decode(LegacyResponse.self, from: data) { + return response.data + } + // Try bare array: [...] + if let array = try? JSONDecoder().decode([T].self, from: data) { + return array + } + // Log diagnostic info about the response + let preview = String(data: data.prefix(500), encoding: .utf8) ?? "(non-UTF8)" + Self.logger.error("Failed to decode \(endpoint): both formats failed. Response size: \(data.count) bytes, preview: \(preview)") + return nil + } + func fetchSessionHistory(mac: String) async -> [SessionDTO]? { let oneDayAgo = Int(Date.now.timeIntervalSince1970) - 86400 do { @@ -240,110 +261,82 @@ actor UniFiClient { // MARK: - Alarms - func fetchAlarms() async -> [AlarmDTO]? { + /// Returns (data, errorDetail). errorDetail is set when the fetch fails or decode fails. + func fetchAlarms() async -> (data: [AlarmDTO]?, errorDetail: String?) { do { let data = try await request("/proxy/network/api/s/default/rest/alarm") - let response = try JSONDecoder().decode(LegacyResponse.self, from: data) - let active = response.data.filter { $0.archived != true } - return active.isEmpty ? nil : Array(active.prefix(10)) - } catch { - Self.logger.error("Failed to fetch alarms: \(Self.safeErrorDescription(error))") - return nil - } - } - - // MARK: - DPI Stats - - func fetchDPIStats(siteId: String) async -> [DPICategoryDTO]? { - // Use v2 traffic API (legacy stat/sitedpi returns empty data on Network 9.1+) - do { - let now = Int(Date().timeIntervalSince1970 * 1000) - let start = now - 3_600_000 // last hour - let path = "/proxy/network/v2/api/site/\(siteId)/traffic?start=\(start)&end=\(now)&includeUnidentified=true" - let data = try await request(path) - let response = try JSONDecoder().decode(V2TrafficResponse.self, from: data) - let categories = response.toCategories() - if categories.isEmpty { - Self.logger.warning("DPI v2 returned 0 categories from \(response.clientUsageByApp?.count ?? 0) client entries") + guard let results = Self.decodeFlexibleArray(AlarmDTO.self, from: data, endpoint: "alarms") else { + return (nil, "decode failed, \(data.count) bytes") } - return categories.isEmpty ? nil : Array(categories.prefix(8)) + let active = results.filter { $0.archived != true } + return (active.isEmpty ? nil : Array(active.prefix(10)), nil) } catch { - Self.logger.error("Failed to fetch DPI stats: \(Self.safeErrorDescription(error))") - return nil + return (nil, Self.safeErrorDescription(error)) } } // MARK: - IDS/IPS Events - func fetchIPSEvents() async -> [IPSEventDTO]? { + func fetchIPSEvents() async -> (data: [IPSEventDTO]?, errorDetail: String?) { do { let data = try await request("/proxy/network/api/s/default/stat/ips/event") - let response = try JSONDecoder().decode(LegacyResponse.self, from: data) - return response.data.isEmpty ? nil : Array(response.data.prefix(10)) - } catch { - Self.logger.error("Failed to fetch IPS events: \(Self.safeErrorDescription(error))") - return nil - } - } - - // MARK: - Anomalies - - func fetchAnomalies() async -> [AnomalyDTO]? { - do { - let data = try await request("/proxy/network/api/s/default/stat/anomalies") - let response = try JSONDecoder().decode(LegacyResponse.self, from: data) - return response.data.isEmpty ? nil : Array(response.data.prefix(10)) + guard let results = Self.decodeFlexibleArray(IPSEventDTO.self, from: data, endpoint: "ips_events") else { + return (nil, "decode failed, \(data.count) bytes") + } + return (results.isEmpty ? nil : Array(results.prefix(10)), nil) } catch { - Self.logger.error("Failed to fetch anomalies: \(Self.safeErrorDescription(error))") - return nil + return (nil, Self.safeErrorDescription(error)) } } // MARK: - Dynamic DNS - func fetchDDNSStatus() async -> [DDNSStatusDTO]? { + func fetchDDNSStatus() async -> (data: [DDNSStatusDTO]?, errorDetail: String?) { do { let data = try await request("/proxy/network/api/s/default/rest/dynamicdns") - let response = try JSONDecoder().decode(LegacyResponse.self, from: data) - return response.data.isEmpty ? nil : Array(response.data.prefix(10)) + guard let results = Self.decodeFlexibleArray(DDNSStatusDTO.self, from: data, endpoint: "ddns") else { + return (nil, "decode failed, \(data.count) bytes") + } + return (results.isEmpty ? nil : Array(results.prefix(10)), nil) } catch { - Self.logger.error("Failed to fetch DDNS status: \(Self.safeErrorDescription(error))") - return nil + return (nil, Self.safeErrorDescription(error)) } } // MARK: - Port Forwards - func fetchPortForwards() async -> [PortForwardDTO]? { + func fetchPortForwards() async -> (data: [PortForwardDTO]?, errorDetail: String?) { do { let data = try await request("/proxy/network/api/s/default/stat/portforward") - let response = try JSONDecoder().decode(LegacyResponse.self, from: data) - let active = response.data.filter { $0.enabled == true } - return active.isEmpty ? nil : Array(active.prefix(50)) + guard let results = Self.decodeFlexibleArray(PortForwardDTO.self, from: data, endpoint: "portforwards") else { + return (nil, "decode failed, \(data.count) bytes") + } + let active = results.filter { $0.enabled == true } + return (active.isEmpty ? nil : Array(active.prefix(50)), nil) } catch { - Self.logger.error("Failed to fetch port forwards: \(Self.safeErrorDescription(error))") - return nil + return (nil, Self.safeErrorDescription(error)) } } // MARK: - Rogue / Neighboring APs - func fetchRogueAPs() async -> [RogueAPDTO]? { + func fetchRogueAPs() async -> (data: [RogueAPDTO]?, errorDetail: String?) { do { let body: [String: Any] = ["within": 24] let data = try await post("/proxy/network/api/s/default/stat/rogueap", body: body) - let response = try JSONDecoder().decode(LegacyResponse.self, from: data) - guard !response.data.isEmpty else { return nil } + guard let results = Self.decodeFlexibleArray(RogueAPDTO.self, from: data, endpoint: "rogueaps") else { + return (nil, "decode failed, \(data.count) bytes") + } + guard !results.isEmpty else { return (nil, nil) } // Return top 10 by signal strength (prefer dBm signal, fall back to rssi) - let sorted = response.data.sorted { + let sorted = results.sorted { let lhs = $0.signal ?? ($0.rssi.map { $0 - 95 } ?? -200) let rhs = $1.signal ?? ($1.rssi.map { $0 - 95 } ?? -200) return lhs > rhs } - return Array(sorted.prefix(10)) + return (Array(sorted.prefix(10)), nil) } catch { - Self.logger.error("Failed to fetch rogue APs: \(Self.safeErrorDescription(error))") - return nil + return (nil, Self.safeErrorDescription(error)) } } @@ -369,6 +362,7 @@ actor UniFiClient { let nsError = error as NSError return "\(nsError.domain) code=\(nsError.code)" } + } // MARK: - Certificate Pinning Delegate (Trust-On-First-Use) diff --git a/Sources/UniFiBar/Preferences/PreferencesManager.swift b/Sources/UniFiBar/Preferences/PreferencesManager.swift index 34e6e6f..b10b3bc 100644 --- a/Sources/UniFiBar/Preferences/PreferencesManager.swift +++ b/Sources/UniFiBar/Preferences/PreferencesManager.swift @@ -10,7 +10,6 @@ enum MenuSection: String, CaseIterable, Sendable { case sessionHistory = "sessionHistory" case alerts = "alerts" case security = "security" - case traffic = "traffic" case ddns = "ddns" case portForwards = "portForwards" case nearbyAPs = "nearbyAPs" @@ -24,7 +23,6 @@ enum MenuSection: String, CaseIterable, Sendable { case .sessionHistory: return "Session History" case .alerts: return "Alerts" case .security: return "Security (IPS)" - case .traffic: return "Traffic (DPI)" case .ddns: return "Dynamic DNS" case .portForwards: return "Port Forwards" case .nearbyAPs: return "Nearby APs" @@ -40,7 +38,6 @@ enum MenuSection: String, CaseIterable, Sendable { case .sessionHistory: return "clock" case .alerts: return "bell.badge" case .security: return "shield.lefthalf.filled" - case .traffic: return "chart.pie" case .ddns: return "link" case .portForwards: return "arrow.right.arrow.left" case .nearbyAPs: return "antenna.radiowaves.left.and.right" @@ -50,9 +47,9 @@ enum MenuSection: String, CaseIterable, Sendable { /// Whether this section is shown by default var defaultEnabled: Bool { switch self { - case .internet, .vpn, .wifi, .network, .sessionHistory, .alerts: + case .internet, .vpn, .wifi, .network, .sessionHistory, .alerts, .security: return true - case .security, .traffic, .ddns, .portForwards, .nearbyAPs: + case .ddns, .portForwards, .nearbyAPs: return false } } @@ -105,7 +102,7 @@ final class PreferencesManager { /// Returns true if any optional monitoring section is enabled (requiring extra API calls) var hasMonitoringSectionsEnabled: Bool { - let monitoringSections: [MenuSection] = [.alerts, .security, .traffic, .ddns, .portForwards, .nearbyAPs] + let monitoringSections: [MenuSection] = [.alerts, .security, .ddns, .portForwards, .nearbyAPs] return monitoringSections.contains { isSectionEnabled($0) } } diff --git a/Sources/UniFiBar/StatusBar/StatusBarController.swift b/Sources/UniFiBar/StatusBar/StatusBarController.swift index d0e86d7..08077e6 100644 --- a/Sources/UniFiBar/StatusBar/StatusBarController.swift +++ b/Sources/UniFiBar/StatusBar/StatusBarController.swift @@ -339,36 +339,45 @@ final class StatusBarController { private func fetchMonitoringData(client: UniFiClient, siteId: String) async { // Evaluate section visibility on @MainActor before spawning child tasks let wantAlarms = preferences.isSectionEnabled(.alerts) - let wantTraffic = preferences.isSectionEnabled(.traffic) let wantSecurity = preferences.isSectionEnabled(.security) let wantDDNS = preferences.isSectionEnabled(.ddns) let wantPF = preferences.isSectionEnabled(.portForwards) let wantRogue = preferences.isSectionEnabled(.nearbyAPs) - async let alarmsTask: [AlarmDTO]? = wantAlarms ? await client.fetchAlarms() : nil - async let dpiTask: [DPICategoryDTO]? = wantTraffic ? await client.fetchDPIStats(siteId: siteId) : nil - async let ipsTask: [IPSEventDTO]? = wantSecurity ? await client.fetchIPSEvents() : nil - async let anomaliesTask: [AnomalyDTO]? = wantSecurity ? await client.fetchAnomalies() : nil - async let ddnsTask: [DDNSStatusDTO]? = wantDDNS ? await client.fetchDDNSStatus() : nil - async let pfTask: [PortForwardDTO]? = wantPF ? await client.fetchPortForwards() : nil - async let rogueTask: [RogueAPDTO]? = wantRogue ? await client.fetchRogueAPs() : nil - - let alarms = await alarmsTask - let dpi = await dpiTask - let ips = await ipsTask - let anomalies = await anomaliesTask - let ddns = await ddnsTask - let pf = await pfTask - let rogue = await rogueTask + async let alarmsResult = wantAlarms ? await client.fetchAlarms() : (data: nil as [AlarmDTO]?, errorDetail: nil) + async let ipsResult = wantSecurity ? await client.fetchIPSEvents() : (data: nil as [IPSEventDTO]?, errorDetail: nil) + async let ddnsResult = wantDDNS ? await client.fetchDDNSStatus() : (data: nil as [DDNSStatusDTO]?, errorDetail: nil) + async let pfResult = wantPF ? await client.fetchPortForwards() : (data: nil as [PortForwardDTO]?, errorDetail: nil) + async let rogueResult = wantRogue ? await client.fetchRogueAPs() : (data: nil as [RogueAPDTO]?, errorDetail: nil) + + let alarms = await alarmsResult + let ips = await ipsResult + let ddns = await ddnsResult + let pf = await pfResult + let rogue = await rogueResult + + if wantAlarms, let error = alarms.errorDetail { + diagnosticsLog.record(.monitoring, level: .error, message: "Alarms fetch failed", detail: error) + } + if wantSecurity, let error = ips.errorDetail { + diagnosticsLog.record(.monitoring, level: .error, message: "IPS events fetch failed", detail: error) + } + if wantDDNS, let error = ddns.errorDetail { + diagnosticsLog.record(.monitoring, level: .error, message: "DDNS fetch failed", detail: error) + } + if wantPF, let error = pf.errorDetail { + diagnosticsLog.record(.monitoring, level: .error, message: "Port forwards fetch failed", detail: error) + } + if wantRogue, let error = rogue.errorDetail { + diagnosticsLog.record(.monitoring, level: .error, message: "Rogue APs fetch failed", detail: error) + } wifiStatus.updateMonitoring( - alarms: alarms, - dpi: dpi, - ips: ips, - anomalies: anomalies, - ddns: ddns, - portForwards: pf, - rogueAPs: rogue + alarms: alarms.data, + ips: ips.data, + ddns: ddns.data, + portForwards: pf.data, + rogueAPs: rogue.data ) } } diff --git a/Sources/UniFiBar/Views/MenuContentView.swift b/Sources/UniFiBar/Views/MenuContentView.swift index bfe44f0..f72125a 100644 --- a/Sources/UniFiBar/Views/MenuContentView.swift +++ b/Sources/UniFiBar/Views/MenuContentView.swift @@ -116,12 +116,8 @@ struct MenuContentView: View { } if prefs.isSectionEnabled(.security), - (status.ipsEvents != nil) || (status.anomalies != nil) { - SecuritySection(ipsEvents: status.ipsEvents, anomalies: status.anomalies) - } - - if prefs.isSectionEnabled(.traffic), let categories = status.dpiCategories { - TrafficSection(categories: categories) + status.ipsEvents != nil { + SecuritySection(ipsEvents: status.ipsEvents) } if prefs.isSectionEnabled(.ddns), let ddns = status.ddnsStatuses { diff --git a/Sources/UniFiBar/Views/Sections/SecuritySection.swift b/Sources/UniFiBar/Views/Sections/SecuritySection.swift index f3acdfd..095083d 100644 --- a/Sources/UniFiBar/Views/Sections/SecuritySection.swift +++ b/Sources/UniFiBar/Views/Sections/SecuritySection.swift @@ -2,16 +2,13 @@ import SwiftUI struct SecuritySection: View { let ipsEvents: [IPSEventDTO]? - let anomalies: [AnomalyDTO]? private var totalThreats: Int { ipsEvents?.count ?? 0 } - private var totalAnomalies: Int { anomalies?.count ?? 0 } - private var totalBadge: Int { totalThreats + totalAnomalies } var body: some View { CollapsibleSectionWithBadge( title: "Security", - badge: totalBadge, + badge: totalThreats, badgeColor: totalThreats > 0 ? .red : .yellow, defaultExpanded: false ) { @@ -23,14 +20,6 @@ struct SecuritySection: View { ) } - if totalAnomalies > 0 { - MetricRow( - label: "Anomalies", - value: "\(totalAnomalies)", - systemImage: "waveform.path.ecg" - ) - } - if let events = ipsEvents, !events.isEmpty { SubSectionHeader(title: "Recent Threats") ForEach(events.prefix(3)) { event in @@ -60,7 +49,7 @@ struct SecuritySection: View { } } - if totalThreats == 0 && totalAnomalies == 0 { + if totalThreats == 0 { HStack(spacing: 6) { Image(systemName: "checkmark.shield.fill") .foregroundStyle(.green) @@ -74,4 +63,4 @@ struct SecuritySection: View { } } } -} +} \ No newline at end of file diff --git a/Sources/UniFiBar/Views/Sections/TrafficSection.swift b/Sources/UniFiBar/Views/Sections/TrafficSection.swift deleted file mode 100644 index 6932286..0000000 --- a/Sources/UniFiBar/Views/Sections/TrafficSection.swift +++ /dev/null @@ -1,54 +0,0 @@ -import SwiftUI - -struct TrafficSection: View { - let categories: [DPICategoryDTO] - - var body: some View { - CollapsibleSection(title: "Traffic", defaultExpanded: false) { - ForEach(categories.prefix(6)) { cat in - HStack(spacing: 6) { - Image(systemName: iconForCategory(cat.name)) - .foregroundStyle(.secondary) - .frame(width: 20, alignment: .center) - Text(cat.name) - .foregroundStyle(.primary) - Spacer() - Text(cat.formattedTotal) - .foregroundStyle(.secondary) - .monospacedDigit() - } - .font(.callout) - .padding(.horizontal, 16) - .padding(.vertical, 1) - } - - if categories.count > 6 { - Text("+\(categories.count - 6) more categories") - .font(.caption2) - .foregroundStyle(.tertiary) - .padding(.horizontal, 16) - .padding(.top, 2) - } - } - } - - private func iconForCategory(_ name: String) -> String { - switch name { - case "Web": return "safari" - case "Streaming", "Video": return "tv" - case "Gaming": return "gamecontroller" - case "Social": return "person.2" - case "Email": return "envelope" - case "File Transfer": return "doc.on.doc" - case "VPN/Tunnel": return "lock.shield" - case "Instant Messaging": return "message" - case "P2P": return "arrow.triangle.swap" - case "Shopping": return "cart" - case "Productivity": return "doc.text" - case "IoT": return "house" - case "Apple": return "app.badge" - case "Microsoft": return "desktopcomputer" - default: return "chart.pie" - } - } -} From 0f3532ff1ea2bd05d63999d8e5a3380eeb82b638 Mon Sep 17 00:00:00 2001 From: Dario Mader Date: Thu, 16 Apr 2026 14:09:17 +0200 Subject: [PATCH 15/21] Add configurable poll interval and enhance diagnostics - Add poll interval picker in Preferences (10s-300s, default 30s) - Persist poll interval in UserDefaults, clamp 10-300s - Error backoff now uses configured base interval - Enhance diagnostics report with structured sections: System, Connection, WiFi, WAN, Gateway, VPN, Network - Show WiFi details (AP, experience, signal, IP) in Preferences Diagnostics section - Show event detail on second line in monospaced font AI assisted to create this change --- .../Preferences/PreferencesManager.swift | 11 +++ .../StatusBar/StatusBarController.swift | 6 +- Sources/UniFiBar/Utils/DiagnosticsLog.swift | 71 +++++++++++++++++-- Sources/UniFiBar/Views/MenuContentView.swift | 3 +- Sources/UniFiBar/Views/PreferencesView.swift | 57 +++++++++++++-- 5 files changed, 133 insertions(+), 15 deletions(-) diff --git a/Sources/UniFiBar/Preferences/PreferencesManager.swift b/Sources/UniFiBar/Preferences/PreferencesManager.swift index b10b3bc..0b7aafb 100644 --- a/Sources/UniFiBar/Preferences/PreferencesManager.swift +++ b/Sources/UniFiBar/Preferences/PreferencesManager.swift @@ -61,6 +61,7 @@ final class PreferencesManager { var isConfigured: Bool = false var allowSelfSignedCerts: Bool = false var compactMode: Bool = false + var pollIntervalSeconds: Int = 30 // Section visibility private var sectionVisibility: [String: Bool] = [:] @@ -73,6 +74,7 @@ final class PreferencesManager { private let selfSignedKey = "com.unifbar.allowSelfSigned" private let sectionVisibilityKey = "com.unifbar.sectionVisibility" private let compactModeKey = "com.unifbar.compactMode" + private let pollIntervalKey = "com.unifbar.pollInterval" var siteId: String? { get { UserDefaults.standard.string(forKey: siteIdKey) } @@ -86,6 +88,7 @@ final class PreferencesManager { init() { allowSelfSignedCerts = UserDefaults.standard.bool(forKey: selfSignedKey) compactMode = UserDefaults.standard.object(forKey: compactModeKey) as? Bool ?? false + pollIntervalSeconds = UserDefaults.standard.object(forKey: pollIntervalKey) as? Int ?? 30 if let saved = UserDefaults.standard.dictionary(forKey: sectionVisibilityKey) as? [String: Bool] { sectionVisibility = saved } @@ -147,6 +150,12 @@ final class PreferencesManager { isConfigured = true } + func setPollInterval(_ seconds: Int) { + let clamped = max(10, min(300, seconds)) + pollIntervalSeconds = clamped + UserDefaults.standard.set(clamped, forKey: pollIntervalKey) + } + func resetAll() async { // Clear certificate pin for the current controller host from Keychain if let urlString = cachedURL, let url = URL(string: urlString), let host = url.host() { @@ -162,7 +171,9 @@ final class PreferencesManager { UserDefaults.standard.removeObject(forKey: selfSignedKey) UserDefaults.standard.removeObject(forKey: sectionVisibilityKey) UserDefaults.standard.removeObject(forKey: compactModeKey) + UserDefaults.standard.removeObject(forKey: pollIntervalKey) sectionVisibility = [:] + pollIntervalSeconds = 30 allowSelfSignedCerts = false compactMode = false isConfigured = false diff --git a/Sources/UniFiBar/StatusBar/StatusBarController.swift b/Sources/UniFiBar/StatusBar/StatusBarController.swift index 08077e6..ffb560a 100644 --- a/Sources/UniFiBar/StatusBar/StatusBarController.swift +++ b/Sources/UniFiBar/StatusBar/StatusBarController.swift @@ -112,11 +112,11 @@ final class StatusBarController { } } - /// Returns poll interval: 30s normally, backs off up to 5 minutes on transient errors. + /// Returns poll interval: user-configured normally, backs off up to 5 minutes on transient errors. /// Auth failures don't back off — they stop polling entirely. private var pollInterval: Int { - guard consecutiveErrors > 0 else { return 30 } - return min(30 * (1 << min(consecutiveErrors, 4)), 300) + guard consecutiveErrors > 0 else { return preferences.pollIntervalSeconds } + return min(preferences.pollIntervalSeconds * (1 << min(consecutiveErrors, 4)), 300) } func stopPolling() { diff --git a/Sources/UniFiBar/Utils/DiagnosticsLog.swift b/Sources/UniFiBar/Utils/DiagnosticsLog.swift index 0d98d76..8b29beb 100644 --- a/Sources/UniFiBar/Utils/DiagnosticsLog.swift +++ b/Sources/UniFiBar/Utils/DiagnosticsLog.swift @@ -64,7 +64,8 @@ final class DiagnosticsLog { consecutiveErrors: Int, pollInterval: Int, controllerHost: String?, - allowSelfSignedCerts: Bool + allowSelfSignedCerts: Bool, + wifiStatus: WiFiStatus ) -> String { var lines: [String] = [] let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" @@ -73,25 +74,85 @@ final class DiagnosticsLog { lines.append("UniFiBar Diagnostics") lines.append("====================") + + // System info + lines.append("") + lines.append("[System]") lines.append("Version: \(version) (build \(build))") lines.append("macOS: \(macOS)") + + // Connection info + lines.append("") + lines.append("[Connection]") lines.append("Controller: \(controllerHost ?? "not configured")") lines.append("Self-signed certs: \(allowSelfSignedCerts ? "allowed" : "not allowed")") - if let errorState { lines.append("Status: \(errorState.displayTitle) \(errorState.displayReason.map { "(\($0))" } ?? "")") } else { lines.append("Status: connected") } - lines.append("Consecutive errors: \(consecutiveErrors)") lines.append("Poll interval: \(pollInterval)s") + lines.append("Last updated: \(wifiStatus.lastUpdated.map { $0.formatted() } ?? "never")") + + // WiFi details + lines.append("") + lines.append("[WiFi]") + lines.append("Connected: \(wifiStatus.isConnected)") + lines.append("Wired: \(wifiStatus.isWired)") + if let ip = wifiStatus.ip { lines.append("IP: \(ip)") } + if let essid = wifiStatus.essid { lines.append("Network: \(essid)") } + if let ap = wifiStatus.apName { lines.append("AP: \(ap)") } + if let satisfaction = wifiStatus.satisfaction { lines.append("WiFi Experience: \(satisfaction)%") } + if let signal = wifiStatus.signal { lines.append("Signal: \(signal) dBm") } + if let noise = wifiStatus.noiseFloor { lines.append("Noise Floor: \(noise) dBm") } + if let channel = wifiStatus.channel { lines.append("Channel: \(channel)\(wifiStatus.channelWidth.map { " / \($0) MHz" } ?? "")") } + if let standard = wifiStatus.wifiStandard { lines.append("Standard: \(standard)") } + if let mimo = wifiStatus.mimoDescription { lines.append("MIMO: \(mimo)") } + if wifiStatus.uptime != nil { lines.append("Uptime: \(wifiStatus.formattedUptime)") } + if let txRetries = wifiStatus.formattedTxRetries { lines.append("TX Retries: \(txRetries)") } + + // WAN lines.append("") + lines.append("[WAN]") + if let wanUp = wifiStatus.wanIsUp { lines.append("WAN Up: \(wanUp)") } + if let wanIP = wifiStatus.wanIP { lines.append("WAN IP: \(wanIP)") } + if let isp = wifiStatus.wanISP { lines.append("ISP: \(isp)") } + if let latency = wifiStatus.wanLatencyMs { lines.append("Latency: \(latency) ms") } + if let avail = wifiStatus.formattedWANAvailability { lines.append("Availability: \(avail)") } + if let throughput = wifiStatus.formattedWANThroughput { lines.append("Throughput: \(throughput)") } + // Gateway + if wifiStatus.gatewayName != nil || wifiStatus.gatewayCPU != nil { + lines.append("") + lines.append("[Gateway]") + if let name = wifiStatus.gatewayName { lines.append("Name: \(name)") } + if let load = wifiStatus.formattedGatewayLoad { lines.append("Load: \(load)") } + if let uptime = wifiStatus.formattedGatewayUptime { lines.append("Uptime: \(uptime)") } + } + + // VPN + if let tunnels = wifiStatus.vpnTunnels, !tunnels.isEmpty { + lines.append("") + lines.append("[VPN]") + for tunnel in tunnels { + lines.append(" \(tunnel.name ?? "Unknown"): \(tunnel.isConnected ? "connected" : tunnel.status?.lowercased() ?? "unknown")") + } + } + + // Network overview + lines.append("") + lines.append("[Network]") + if let overview = wifiStatus.formattedNetworkOverview { lines.append("Clients: \(overview)") } + if let overview = wifiStatus.formattedDeviceOverview { lines.append("Devices: \(overview)") } + if let firmware = wifiStatus.firmwareBadge { lines.append("Firmware: \(firmware)") } + + // Events + lines.append("") if events.isEmpty { - lines.append("No events recorded.") + lines.append("[Events] None recorded.") } else { - lines.append("Recent Events (newest first):") + lines.append("[Events] (newest first, \(events.count) total):") let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss" for event in events.reversed() { diff --git a/Sources/UniFiBar/Views/MenuContentView.swift b/Sources/UniFiBar/Views/MenuContentView.swift index f72125a..61448c2 100644 --- a/Sources/UniFiBar/Views/MenuContentView.swift +++ b/Sources/UniFiBar/Views/MenuContentView.swift @@ -266,7 +266,8 @@ struct MenuContentView: View { consecutiveErrors: controller.consecutiveErrorCount, pollInterval: controller.currentPollInterval, controllerHost: controller.preferences.controllerURL?.host, - allowSelfSignedCerts: controller.preferences.allowSelfSignedCerts + allowSelfSignedCerts: controller.preferences.allowSelfSignedCerts, + wifiStatus: controller.wifiStatus ) NSPasteboard.general.clearContents() NSPasteboard.general.setString(report, forType: .string) diff --git a/Sources/UniFiBar/Views/PreferencesView.swift b/Sources/UniFiBar/Views/PreferencesView.swift index e811cf6..0991b6a 100644 --- a/Sources/UniFiBar/Views/PreferencesView.swift +++ b/Sources/UniFiBar/Views/PreferencesView.swift @@ -10,6 +10,7 @@ struct PreferencesView: View { @State private var allowSelfSigned = false @State private var isEditingCredentials = false @State private var compactMode = true + @State private var pollInterval: Int = 30 @State private var launchAtLogin = false @State private var isLoading = true @State private var showResetConfirmation = false @@ -116,6 +117,17 @@ struct PreferencesView: View { controller.preferences.compactMode = newValue UserDefaults.standard.set(newValue, forKey: "com.unifbar.compactMode") } + Picker("Poll interval", selection: $pollInterval) { + Text("10s").tag(10) + Text("15s").tag(15) + Text("30s").tag(30) + Text("60s").tag(60) + Text("120s").tag(120) + Text("300s").tag(300) + } + .onChange(of: pollInterval) { _, newValue in + controller.preferences.setPollInterval(newValue) + } Toggle("Launch at login", isOn: $launchAtLogin) .onChange(of: launchAtLogin) { _, newValue in setLaunchAtLogin(newValue) @@ -143,6 +155,7 @@ struct PreferencesView: View { Section { let log = controller.diagnosticsLog let events = log.recentEvents + let status = controller.wifiStatus LabeledContent("Version") { VStack(alignment: .trailing, spacing: 2) { @@ -159,16 +172,38 @@ struct PreferencesView: View { } } + LabeledContent("Status") { + if let error = status.errorState { + Text(error.displayTitle).foregroundStyle(.red) + } else if status.isConnected { + Text(status.isWired ? "Wired" : "WiFi").foregroundStyle(.green) + } else { + Text("—").foregroundStyle(.secondary) + } + } + + if let ap = status.apName { + LabeledContent("AP", value: ap) + } + if let satisfaction = status.satisfaction { + LabeledContent("Experience", value: "\(satisfaction)%") + } + if let signal = status.signal { + LabeledContent("Signal", value: "\(signal) dBm") + } + if let ip = status.ip { + LabeledContent("IP", value: ip) + } + LabeledContent("Consecutive Errors") { Text("\(controller.consecutiveErrorCount)") } - LabeledContent("Poll Interval") { Text("\(controller.currentPollInterval)s") } if !events.isEmpty { - DisclosureGroup("Recent Events (\(events.count))") { + DisclosureGroup("Events (\(events.count))") { ForEach(events.prefix(20)) { event in HStack(spacing: 6) { Circle() @@ -177,9 +212,17 @@ struct PreferencesView: View { Text(event.timestamp.formatted(date: .omitted, time: .shortened)) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) - Text(event.message) - .font(.caption) - .lineLimit(1) + VStack(alignment: .leading, spacing: 0) { + Text(event.message) + .font(.caption) + .lineLimit(1) + if let detail = event.detail { + Text(detail) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + .lineLimit(2) + } + } Spacer() } } @@ -193,7 +236,8 @@ struct PreferencesView: View { consecutiveErrors: controller.consecutiveErrorCount, pollInterval: controller.currentPollInterval, controllerHost: controller.preferences.controllerURL?.host, - allowSelfSignedCerts: controller.preferences.allowSelfSignedCerts + allowSelfSignedCerts: controller.preferences.allowSelfSignedCerts, + wifiStatus: controller.wifiStatus ) NSPasteboard.general.clearContents() NSPasteboard.general.setString(report, forType: .string) @@ -231,6 +275,7 @@ struct PreferencesView: View { apiKey = controller.preferences.cachedAPIKey ?? "" allowSelfSigned = controller.preferences.allowSelfSignedCerts compactMode = controller.preferences.compactMode + pollInterval = controller.preferences.pollIntervalSeconds launchAtLogin = SMAppService.mainApp.status == .enabled isLoading = false } From d084ca9ac153f3140280303dd4cb51410c8f7730 Mon Sep 17 00:00:00 2001 From: Dario Mader Date: Thu, 16 Apr 2026 14:56:13 +0200 Subject: [PATCH 16/21] Remove live status fields from diagnostics, keep only logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostics section now shows: version + update indicator, consecutive errors, poll interval, and event log with detail. Live WiFi status (AP, signal, experience, IP) removed — that info belongs in the menu popover, not in debug logs. AI assisted to create this change --- Sources/UniFiBar/Views/PreferencesView.swift | 24 -------------------- 1 file changed, 24 deletions(-) diff --git a/Sources/UniFiBar/Views/PreferencesView.swift b/Sources/UniFiBar/Views/PreferencesView.swift index 0991b6a..b8fc091 100644 --- a/Sources/UniFiBar/Views/PreferencesView.swift +++ b/Sources/UniFiBar/Views/PreferencesView.swift @@ -155,7 +155,6 @@ struct PreferencesView: View { Section { let log = controller.diagnosticsLog let events = log.recentEvents - let status = controller.wifiStatus LabeledContent("Version") { VStack(alignment: .trailing, spacing: 2) { @@ -172,29 +171,6 @@ struct PreferencesView: View { } } - LabeledContent("Status") { - if let error = status.errorState { - Text(error.displayTitle).foregroundStyle(.red) - } else if status.isConnected { - Text(status.isWired ? "Wired" : "WiFi").foregroundStyle(.green) - } else { - Text("—").foregroundStyle(.secondary) - } - } - - if let ap = status.apName { - LabeledContent("AP", value: ap) - } - if let satisfaction = status.satisfaction { - LabeledContent("Experience", value: "\(satisfaction)%") - } - if let signal = status.signal { - LabeledContent("Signal", value: "\(signal) dBm") - } - if let ip = status.ip { - LabeledContent("IP", value: ip) - } - LabeledContent("Consecutive Errors") { Text("\(controller.consecutiveErrorCount)") } From 94f87aa9f27a1b71e76f54bf78ab3422cb6c92f9 Mon Sep 17 00:00:00 2001 From: Dario Mader Date: Thu, 16 Apr 2026 15:01:54 +0200 Subject: [PATCH 17/21] Remove WAN IP from diagnostics report AI assisted to create this change --- Sources/UniFiBar/Utils/DiagnosticsLog.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/UniFiBar/Utils/DiagnosticsLog.swift b/Sources/UniFiBar/Utils/DiagnosticsLog.swift index 8b29beb..d1c13b5 100644 --- a/Sources/UniFiBar/Utils/DiagnosticsLog.swift +++ b/Sources/UniFiBar/Utils/DiagnosticsLog.swift @@ -116,7 +116,6 @@ final class DiagnosticsLog { lines.append("") lines.append("[WAN]") if let wanUp = wifiStatus.wanIsUp { lines.append("WAN Up: \(wanUp)") } - if let wanIP = wifiStatus.wanIP { lines.append("WAN IP: \(wanIP)") } if let isp = wifiStatus.wanISP { lines.append("ISP: \(isp)") } if let latency = wifiStatus.wanLatencyMs { lines.append("Latency: \(latency) ms") } if let avail = wifiStatus.formattedWANAvailability { lines.append("Availability: \(avail)") } From ccce4345cfe47630bec062127e37e7dce260e692 Mon Sep 17 00:00:00 2001 From: Dario Mader Date: Thu, 16 Apr 2026 15:20:38 +0200 Subject: [PATCH 18/21] Show update available in menu footer below last updated timestamp Blue 'v1.x.x available' link with download arrow icon, clickable to open the GitHub release page. Only shown when an update exists. AI assisted to create this change --- Sources/UniFiBar/Views/MenuContentView.swift | 26 +++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/Sources/UniFiBar/Views/MenuContentView.swift b/Sources/UniFiBar/Views/MenuContentView.swift index 61448c2..cc1eb21 100644 --- a/Sources/UniFiBar/Views/MenuContentView.swift +++ b/Sources/UniFiBar/Views/MenuContentView.swift @@ -135,13 +135,27 @@ struct MenuContentView: View { @ViewBuilder private var footerTimestamp: some View { - if let lastUpdated = status.lastUpdated { - Text("Last updated: \(lastUpdated.formatted(date: .omitted, time: .standard))") - .font(.caption2) - .foregroundStyle(.tertiary) - .padding(.horizontal, 16) - .padding(.top, 8) + VStack(alignment: .leading, spacing: 2) { + if let lastUpdated = status.lastUpdated { + Text("Last updated: \(lastUpdated.formatted(date: .omitted, time: .standard))") + .font(.caption2) + .foregroundStyle(.tertiary) + } + if controller.updateChecker.updateAvailable, let latest = controller.updateChecker.latestVersion { + Button { + if let url = controller.updateChecker.releaseURL { + NSWorkspace.shared.open(url) + } + } label: { + Label("v\(latest) available", systemImage: "arrow.down.circle") + .font(.caption2) + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + } } + .padding(.horizontal, 16) + .padding(.top, 8) } // MARK: - Error Views From d2fb3e9c68607a7704456c9a79b151793fcfdc88 Mon Sep 17 00:00:00 2001 From: Dario Mader Date: Thu, 16 Apr 2026 15:33:34 +0200 Subject: [PATCH 19/21] Add 5-tap debug mode on version to toggle fake update indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tap the version number 5 times in Preferences → Diagnostics to toggle a fake update (v99.99.99). This shows the update indicator in the menu footer for UI testing. Tap 5 times again to dismiss. AI assisted to create this change --- Sources/UniFiBar/Utils/UpdateChecker.swift | 14 ++++++++++++++ Sources/UniFiBar/Views/PreferencesView.swift | 12 ++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Sources/UniFiBar/Utils/UpdateChecker.swift b/Sources/UniFiBar/Utils/UpdateChecker.swift index 90f8232..3cdd19e 100644 --- a/Sources/UniFiBar/Utils/UpdateChecker.swift +++ b/Sources/UniFiBar/Utils/UpdateChecker.swift @@ -13,6 +13,20 @@ final class UpdateChecker { private let checkInterval: TimeInterval = 86_400 // 24 hours private let lastCheckKey = "com.unifbar.lastUpdateCheck" + /// Debug: toggle a fake update indicator for UI testing. Tap version 5 times in Preferences to trigger. + func toggleDebugUpdate() { + if updateAvailable && latestVersion == "99.99.99" { + // Already in debug mode — turn it off + updateAvailable = false + latestVersion = nil + releaseURL = nil + } else { + updateAvailable = true + latestVersion = "99.99.99" + releaseURL = URL(string: "https://github.com/darox/UniFiBar/releases") + } + } + var currentVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" } diff --git a/Sources/UniFiBar/Views/PreferencesView.swift b/Sources/UniFiBar/Views/PreferencesView.swift index b8fc091..ce674d1 100644 --- a/Sources/UniFiBar/Views/PreferencesView.swift +++ b/Sources/UniFiBar/Views/PreferencesView.swift @@ -12,6 +12,7 @@ struct PreferencesView: View { @State private var compactMode = true @State private var pollInterval: Int = 30 @State private var launchAtLogin = false + @State private var versionTapCount = 0 @State private var isLoading = true @State private var showResetConfirmation = false @State private var errorMessage: String? @@ -159,10 +160,17 @@ struct PreferencesView: View { LabeledContent("Version") { VStack(alignment: .trailing, spacing: 2) { Text("v\(controller.updateChecker.currentVersion)") + .onTapGesture { + versionTapCount += 1 + if versionTapCount >= 5 { + versionTapCount = 0 + controller.updateChecker.toggleDebugUpdate() + } + } if controller.updateChecker.updateAvailable, let latest = controller.updateChecker.latestVersion { Button("v\(latest) available") { - if let url = controller.updateChecker.releaseURL { - NSWorkspace.shared.open(url) + if controller.updateChecker.releaseURL != nil { + NSWorkspace.shared.open(controller.updateChecker.releaseURL!) } } .buttonStyle(.borderless) From 093d5b931743959f4a2ec83ccfbb0f8ae8787814 Mon Sep 17 00:00:00 2001 From: Dario Mader Date: Thu, 16 Apr 2026 15:51:19 +0200 Subject: [PATCH 20/21] Add Fake Update toggle in Diagnostics debug mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visible toggle switch in Preferences → Diagnostics → Debug Mode. Also still accessible via 5-tap on version number. AI assisted to create this change --- Sources/UniFiBar/Views/PreferencesView.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Sources/UniFiBar/Views/PreferencesView.swift b/Sources/UniFiBar/Views/PreferencesView.swift index ce674d1..1d781cb 100644 --- a/Sources/UniFiBar/Views/PreferencesView.swift +++ b/Sources/UniFiBar/Views/PreferencesView.swift @@ -186,6 +186,17 @@ struct PreferencesView: View { Text("\(controller.currentPollInterval)s") } + LabeledContent("Debug Mode") { + HStack(spacing: 8) { + Toggle("Fake Update", isOn: Binding( + get: { controller.updateChecker.updateAvailable && controller.updateChecker.latestVersion == "99.99.99" }, + set: { _ in controller.updateChecker.toggleDebugUpdate() } + )) + .toggleStyle(.switch) + .controlSize(.small) + } + } + if !events.isEmpty { DisclosureGroup("Events (\(events.count))") { ForEach(events.prefix(20)) { event in From 5a176aa540e8ac67c6dd6449e403d1562e440254 Mon Sep 17 00:00:00 2001 From: Dario Mader Date: Thu, 16 Apr 2026 15:58:25 +0200 Subject: [PATCH 21/21] Remove label from debug mode toggle AI assisted to create this change --- Sources/UniFiBar/Views/PreferencesView.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Sources/UniFiBar/Views/PreferencesView.swift b/Sources/UniFiBar/Views/PreferencesView.swift index 1d781cb..fa0963c 100644 --- a/Sources/UniFiBar/Views/PreferencesView.swift +++ b/Sources/UniFiBar/Views/PreferencesView.swift @@ -187,14 +187,15 @@ struct PreferencesView: View { } LabeledContent("Debug Mode") { - HStack(spacing: 8) { - Toggle("Fake Update", isOn: Binding( - get: { controller.updateChecker.updateAvailable && controller.updateChecker.latestVersion == "99.99.99" }, - set: { _ in controller.updateChecker.toggleDebugUpdate() } - )) - .toggleStyle(.switch) - .controlSize(.small) + Toggle(isOn: Binding( + get: { controller.updateChecker.updateAvailable && controller.updateChecker.latestVersion == "99.99.99" }, + set: { _ in controller.updateChecker.toggleDebugUpdate() } + )) { + EmptyView() } + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() } if !events.isEmpty {