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/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/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/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/DeviceDTO.swift b/Sources/UniFiBar/Models/DeviceDTO.swift index 10156b4..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 ) } } @@ -154,7 +181,6 @@ struct APStats: Sendable { let uptimeSec: Int? let cpuUtilizationPct: Double? let memoryUtilizationPct: Double? - let txRetriesPct: Double? } struct APStatsResponse: Decodable, Sendable { @@ -166,8 +192,7 @@ struct APStatsResponse: Decodable, Sendable { APStats( uptimeSec: uptimeSec, cpuUtilizationPct: cpuUtilizationPct, - memoryUtilizationPct: memoryUtilizationPct, - txRetriesPct: nil + memoryUtilizationPct: memoryUtilizationPct ) } } diff --git a/Sources/UniFiBar/Models/MonitoringDTO.swift b/Sources/UniFiBar/Models/MonitoringDTO.swift new file mode 100644 index 0000000..68a2570 --- /dev/null +++ b/Sources/UniFiBar/Models/MonitoringDTO.swift @@ -0,0 +1,235 @@ +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 { + 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 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 } + // 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 { + guard let date else { return "" } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } +} + +// 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 { + truncated(msg ?? catname ?? "IPS Event") + } + + var relativeTime: String { + guard let timestamp else { return "" } + 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()) + } +} + +// MARK: - Dynamic DNS + +struct DDNSStatusDTO: Decodable, Sendable, Identifiable { + let status: String? + let service: String? + let hostName: String? + let login: String? + let interface: String? + + enum CodingKeys: String, CodingKey { + case status, service, login, interface + 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 { + return status == "good" || status == "nochg" + } + return service != nil + } + + var displayStatus: String { + switch status { + case "good", "nochg": return "Active" + 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) + } + } +} + +// 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 { + truncated(name ?? "\(proto?.uppercased() ?? ""):\(dstPort ?? "?")", maxLength: 64) + } + + var summary: String { + let p = proto?.uppercased() ?? "TCP" + return truncated("\(p) :\(dstPort ?? "?") → \(fwd ?? "?"):\(fwdPort ?? dstPort ?? "?")", maxLength: 64) + } +} + +// MARK: - Rogue / Neighboring APs + +struct RogueAPDTO: Decodable, Sendable, Identifiable { + 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 ?? "\(essid ?? "")-\(channel ?? 0)-\(apMac ?? "")" } + + enum CodingKeys: String, CodingKey { + case _id + case bssid, essid, rssi, signal, channel, age + case isRogue = "is_rogue" + case apMac = "ap_mac" + } + + var signalDescription: String { + // 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" + } + + var displayName: String { + truncated(essid ?? bssid ?? "Hidden", maxLength: 64) + } +} + +// 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 4200183..b33794c 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 @@ -81,6 +82,16 @@ final class WiFiStatus { var onlineDevices: Int? = nil var offlineDeviceNames: [String]? = nil + // Speed test + var speedTest: SpeedTestResult? = nil + + // Monitoring data + var activeAlarms: [AlarmDTO]? = nil + var ipsEvents: [IPSEventDTO]? = nil + var ddnsStatuses: [DDNSStatusDTO]? = nil + var portForwards: [PortForwardDTO]? = nil + var nearbyAPs: [RogueAPDTO]? = nil + // Metadata var lastUpdated: Date? = nil @@ -104,9 +115,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 @@ -122,6 +151,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 { @@ -132,6 +168,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" } @@ -161,6 +204,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))%" @@ -208,6 +256,24 @@ final class WiFiStatus { return "\(online) online · \(offline) offline" } + var activeAlarmCount: Int { + activeAlarms?.count ?? 0 + } + + var ipsEventCount: Int { + ipsEvents?.count ?? 0 + } + + var securitySummary: String? { + let threats = ipsEventCount + guard threats > 0 else { return nil } + return "\(threats) threat\(threats == 1 ? "" : "s")" + } + + var nearbyAPCount: Int { + nearbyAPs?.count ?? 0 + } + var firmwareBadge: String? { guard let names = devicesWithUpdates, !names.isEmpty else { return nil } let count = names.count @@ -252,6 +318,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 @@ -315,6 +382,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 @@ -324,6 +392,7 @@ final class WiFiStatus { wanDrops = nil wanTxBytesRate = nil wanRxBytesRate = nil + speedTest = nil } } @@ -383,6 +452,20 @@ final class WiFiStatus { )} } + func updateMonitoring( + alarms: [AlarmDTO]?, + ips: [IPSEventDTO]?, + ddns: [DDNSStatusDTO]?, + portForwards: [PortForwardDTO]?, + rogueAPs: [RogueAPDTO]? + ) { + self.activeAlarms = alarms + self.ipsEvents = ips + self.ddnsStatuses = ddns + self.portForwards = portForwards + self.nearbyAPs = rogueAPs + } + func markDisconnected() { isConnected = false isWired = false @@ -395,6 +478,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 e362ddc..b534448 100644 --- a/Sources/UniFiBar/Network/UniFiClient.swift +++ b/Sources/UniFiBar/Network/UniFiClient.swift @@ -1,35 +1,56 @@ 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 + private static let logger = Logger(subsystem: "com.unifbar.app", category: "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 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 { @@ -91,7 +112,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 @@ -119,7 +142,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 request") + return nil + } do { let data = try await request( "/proxy/network/integrations/v1/sites/\(siteId)/devices/\(deviceId)/statistics/latest" @@ -127,6 +153,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(APStatsResponse.self, from: data) return response.toAPStats } catch { + Self.logger.error("Failed to fetch AP stats: \(Self.safeErrorDescription(error))") return nil } } @@ -137,14 +164,37 @@ 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 { 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 } } @@ -154,10 +204,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 @@ -169,6 +222,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: \(Self.safeErrorDescription(error))") return nil } } @@ -181,6 +235,7 @@ actor UniFiClient { let response = try JSONDecoder().decode(WANHealthResponse.self, from: data) return response.toWANHealth() } catch { + Self.logger.error("Failed to fetch WAN health: \(Self.safeErrorDescription(error))") return nil } } @@ -188,7 +243,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 request") + return nil + } do { let data = try await request( "/proxy/network/integrations/v1/sites/\(siteId)/devices/\(deviceId)/statistics/latest" @@ -196,33 +254,148 @@ actor UniFiClient { let response = try JSONDecoder().decode(GatewayStatsResponse.self, from: data) return response.toGatewayStats } catch { + Self.logger.error("Failed to fetch gateway stats: \(Self.safeErrorDescription(error))") return nil } } + // MARK: - Alarms + + /// 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") + guard let results = Self.decodeFlexibleArray(AlarmDTO.self, from: data, endpoint: "alarms") else { + return (nil, "decode failed, \(data.count) bytes") + } + let active = results.filter { $0.archived != true } + return (active.isEmpty ? nil : Array(active.prefix(10)), nil) + } catch { + return (nil, Self.safeErrorDescription(error)) + } + } + + // MARK: - IDS/IPS Events + + func fetchIPSEvents() async -> (data: [IPSEventDTO]?, errorDetail: String?) { + do { + let data = try await request("/proxy/network/api/s/default/stat/ips/event") + 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 { + return (nil, Self.safeErrorDescription(error)) + } + } + + // MARK: - Dynamic DNS + + func fetchDDNSStatus() async -> (data: [DDNSStatusDTO]?, errorDetail: String?) { + do { + let data = try await request("/proxy/network/api/s/default/rest/dynamicdns") + 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 { + return (nil, Self.safeErrorDescription(error)) + } + } + + // MARK: - Port Forwards + + func fetchPortForwards() async -> (data: [PortForwardDTO]?, errorDetail: String?) { + do { + let data = try await request("/proxy/network/api/s/default/stat/portforward") + 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 { + return (nil, Self.safeErrorDescription(error)) + } + } + + // MARK: - Rogue / Neighboring APs + + 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) + 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 = 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)), nil) + } catch { + return (nil, Self.safeErrorDescription(error)) + } + } + // MARK: - Validation /// Validates that an identifier is safe for URL path interpolation (alphanumeric, hyphens, colons). 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. +/// 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 pinnedKeyKey: String - private let state: OSAllocatedUnfairLock + private let keychainKey: String + private let state: OSAllocatedUnfairLock + + enum PinState: Sendable { + case unpinned + case pinned(Data) + 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.pinnedKeyKey = "com.unifbar.cert-pin.\(host)" - self.state = OSAllocatedUnfairLock( - initialState: UserDefaults.standard.data(forKey: pinnedKeyKey) - ) + self.keychainKey = "com.unifbar.cert-pin.\(host)" + 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( @@ -235,33 +408,87 @@ 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, still allow connection (user opted into self-signed) guard let serverKeyHash = Self.publicKeyHash(from: serverTrust) else { - return (.useCredential, URLCredential(trust: serverTrust)) + return (.cancelAuthenticationChallenge, nil) } - let storedHash = state.withLock { $0 } + // 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) + } - 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 (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 { + // Cert changed — could be renewal or MITM. Reject and flag it. + currentState = .certChanged + return ((.cancelAuthenticationChallenge, nil), false) + } + + case .unpinned: + currentState = .pinned(serverKeyHash) + return ((.useCredential, URLCredential(trust: serverTrust)), true) + + case .certChanged: + return ((.cancelAuthenticationChallenge, nil), false) } - } else { - // Trust-on-first-use: pin the key - state.withLock { $0 = serverKeyHash } - UserDefaults.standard.set(serverKeyHash, forKey: pinnedKeyKey) } - return (.useCredential, URLCredential(trust: serverTrust)) + if shouldPersist { + Self.savePinToKeychain(key: keychainKey, data: serverKeyHash) + } + + return decision + } + + /// 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) + var error: CFError? + let valid = SecTrustEvaluateWithError(trust, &error) + 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. @@ -276,4 +503,49 @@ 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) { + // 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, + ] + 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 12fcdbe..0b7aafb 100644 --- a/Sources/UniFiBar/Preferences/PreferencesManager.swift +++ b/Sources/UniFiBar/Preferences/PreferencesManager.swift @@ -1,25 +1,112 @@ 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 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 .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 .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, .security: + return true + case .ddns, .portForwards, .nearbyAPs: + return false + } + } +} + @MainActor @Observable 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] = [:] // 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 compactModeKey = "com.unifbar.compactMode" + private let pollIntervalKey = "com.unifbar.pollInterval" var siteId: String? { get { UserDefaults.standard.string(forKey: siteIdKey) } set { UserDefaults.standard.set(newValue, forKey: siteIdKey) } } + var controllerURL: URL? { + cachedURL.flatMap { URL(string: $0) } + } + 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 + } + } + + 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, .ddns, .portForwards, .nearbyAPs] + return monitoringSections.contains { isSectionEnabled($0) } } /// Reads Keychain once and caches. Subsequent calls use cache. @@ -51,6 +138,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) @@ -62,14 +150,32 @@ 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() { + 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) await KeychainHelper.shared.delete(.apiKey) cachedURL = nil cachedAPIKey = nil UserDefaults.standard.removeObject(forKey: siteIdKey) 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 bf371ba..ffb560a 100644 --- a/Sources/UniFiBar/StatusBar/StatusBarController.swift +++ b/Sources/UniFiBar/StatusBar/StatusBarController.swift @@ -22,17 +22,45 @@ 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") private var pollTask: Task? private var client: UniFiClient? private var pathMonitor: NWPathMonitor? private var wakeObserver: NSObjectProtocol? private var hasStarted = false + 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. + func tearDown() { + if let observer = wakeObserver { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + wakeObserver = nil + } + pathMonitor?.cancel() + pathMonitor = nil + pollTask?.cancel() + pollTask = nil + } func start() async { 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() @@ -54,29 +82,50 @@ final class StatusBarController { } func refreshNow() { + 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() - try? await Task.sleep(for: .seconds(30)) + // 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: 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 preferences.pollIntervalSeconds } + return min(preferences.pollIntervalSeconds * (1 << min(consecutiveErrors, 4)), 300) + } + func stopPolling() { pollTask?.cancel() pollTask = nil } 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, @@ -84,8 +133,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() + } } } @@ -96,7 +151,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")) @@ -105,7 +165,7 @@ final class StatusBarController { private func refresh() async { guard let client else { - wifiStatus.markError(.controllerUnreachable) + wifiStatus.markError(.controllerUnreachable(reason: "Not configured")) return } @@ -116,8 +176,34 @@ 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))") + diagnosticsLog.record(.authentication, level: .error, message: "Auth failed during site discovery", detail: "HTTP \(code)") + authFailed = true + consecutiveErrors = 0 + 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(reason: Self.reasonFromError(error))) + } + return } catch { - wifiStatus.markError(.controllerUnreachable) + 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 + } + 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(reason: Self.reasonFromError(error))) return } @@ -128,23 +214,54 @@ final class StatusBarController { } catch let error as UniFiError { switch error { case .httpError(let code) where code == 401 || code == 403: - wifiStatus.markError(.invalidAPIKey) + 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(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: - wifiStatus.markError(.controllerUnreachable) + 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(reason: Self.reasonFromError(error))) } return } catch { - wifiStatus.markError(.controllerUnreachable) + 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) + 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 } + consecutiveErrors = 0 + 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: 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) @@ -165,7 +282,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() }) } @@ -190,5 +307,77 @@ final class StatusBarController { wifiStatus.updateAPStats(apStats) wifiStatus.updateGateway(gwStats, device: gwDevice) + + // Parallel batch 3: monitoring data (only fetch enabled sections) + await fetchMonitoringData(client: client, siteId: siteId) + } + + /// 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, siteId: String) async { + // Evaluate section visibility on @MainActor before spawning child tasks + let wantAlarms = preferences.isSectionEnabled(.alerts) + let wantSecurity = preferences.isSectionEnabled(.security) + let wantDDNS = preferences.isSectionEnabled(.ddns) + let wantPF = preferences.isSectionEnabled(.portForwards) + let wantRogue = preferences.isSectionEnabled(.nearbyAPs) + + 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.data, + ips: ips.data, + ddns: ddns.data, + portForwards: pf.data, + rogueAPs: rogue.data + ) } } diff --git a/Sources/UniFiBar/Utils/DiagnosticsLog.swift b/Sources/UniFiBar/Utils/DiagnosticsLog.swift new file mode 100644 index 0000000..d1c13b5 --- /dev/null +++ b/Sources/UniFiBar/Utils/DiagnosticsLog.swift @@ -0,0 +1,170 @@ +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, + wifiStatus: WiFiStatus + ) -> 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("====================") + + // 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 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("[Events] None recorded.") + } else { + lines.append("[Events] (newest first, \(events.count) total):") + 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..3cdd19e --- /dev/null +++ b/Sources/UniFiBar/Utils/UpdateChecker.swift @@ -0,0 +1,102 @@ +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" + + /// 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" + } + + 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/Components/CollapsibleSection.swift b/Sources/UniFiBar/Views/Components/CollapsibleSection.swift new file mode 100644 index 0000000..b521f46 --- /dev/null +++ b/Sources/UniFiBar/Views/Components/CollapsibleSection.swift @@ -0,0 +1,130 @@ +import SwiftUI + +struct CollapsibleSection: View { + let title: String + let showDivider: Bool + let defaultExpanded: Bool + @ViewBuilder let content: () -> Content + + @State private var isExpanded: Bool + + 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._isExpanded = State(initialValue: 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() + } + } 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 + let defaultExpanded: Bool + @ViewBuilder let content: () -> Content + + @State private var isExpanded: Bool + + 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._isExpanded = State(initialValue: 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() + } + } 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() + } + } + } +} \ No newline at end of file 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/MenuContentView.swift b/Sources/UniFiBar/Views/MenuContentView.swift index e18ff84..cc1eb21 100644 --- a/Sources/UniFiBar/Views/MenuContentView.swift +++ b/Sources/UniFiBar/Views/MenuContentView.swift @@ -25,6 +25,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) { @@ -34,20 +40,49 @@ 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) + ScrollView { + VStack(alignment: .leading, spacing: 0) { + 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,31 +95,67 @@ 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, 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 { + SecuritySection(ipsEvents: status.ipsEvents) + } + + if prefs.isSectionEnabled(.ddns), let ddns = status.ddnsStatuses { + DDNSSection(statuses: ddns) } - // Network — clients, devices, firmware - NetworkSection(wifiStatus: controller.wifiStatus) + if prefs.isSectionEnabled(.portForwards), let pf = status.portForwards { + PortForwardsSection(portForwards: pf) + } + + if prefs.isSectionEnabled(.nearbyAPs), let aps = status.nearbyAPs { + NearbyAPsSection(rogueAPs: aps) + } + } - if let lastUpdated = controller.wifiStatus.lastUpdated { - Text("Last updated: \(lastUpdated.formatted(date: .omitted, time: .standard))") - .font(.caption2) - .foregroundStyle(.tertiary) - .padding(.horizontal, 16) - .padding(.top, 8) + @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) + } + 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 @@ -113,19 +184,44 @@ 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") } .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) @@ -133,6 +229,14 @@ 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() @@ -146,25 +250,40 @@ struct MenuContentView: View { Button { controller.refreshNow() } label: { - Label("Refresh", systemImage: "arrow.clockwise") + Image(systemName: "arrow.clockwise") .frame(maxWidth: .infinity) } Button { activateAndOpenWindow("preferences") } label: { - Label("Preferences", systemImage: "gearshape") + Image(systemName: "gearshape") .frame(maxWidth: .infinity) } Button { NSApplication.shared.terminate(nil) } label: { - Label("Quit", systemImage: "xmark") + 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, + wifiStatus: controller.wifiStatus + ) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(report, forType: .string) + } +} \ No newline at end of file diff --git a/Sources/UniFiBar/Views/PreferencesView.swift b/Sources/UniFiBar/Views/PreferencesView.swift index 4fbb6c7..fa0963c 100644 --- a/Sources/UniFiBar/Views/PreferencesView.swift +++ b/Sources/UniFiBar/Views/PreferencesView.swift @@ -8,111 +8,283 @@ struct PreferencesView: View { @State private var controllerURL = "" @State private var apiKey = "" @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 versionTapCount = 0 @State private var isLoading = true @State private var showResetConfirmation = false @State private var errorMessage: String? 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) + Form { + connectionSection + siteSection + behaviorSection + visibilitySection + diagnosticsSection + 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.") + } + } - Toggle("Launch at login", isOn: $launchAtLogin) - .font(.callout) - .onChange(of: launchAtLogin) { _, newValue in - setLaunchAtLogin(newValue) - } + // MARK: - Connection - if let siteId = controller.preferences.siteId { - HStack { - Text("Site ID") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Text(siteId) - .font(.caption) - .foregroundStyle(.tertiary) - .textSelection(.enabled) + 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("Cancel") { + revertCredentials() + } + Spacer() + Button("Update") { + Task { await save() } + } + .buttonStyle(.borderedProminent) + .disabled(controllerURL.isEmpty || apiKey.isEmpty) + } + } 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) + } + } + } - if let errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundStyle(.red) + private var siteSection: some View { + Group { + if let siteId = controller.preferences.siteId { + Section { + LabeledContent("Site ID", value: siteId) + } header: { + Text("Site") } + } + } + } - HStack { - Button("Reset & Forget", role: .destructive) { - showResetConfirmation = true - } + // MARK: - Behavior - Spacer() + private var behaviorSection: some View { + Section { + Toggle("Compact mode", isOn: $compactMode) + .onChange(of: compactMode) { _, newValue in + 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) + } + } header: { + Text("Behavior") + } + } - Button("Cancel") { - dismiss() + // 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") + } + } + + // 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)") + .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 controller.updateChecker.releaseURL != nil { + NSWorkspace.shared.open(controller.updateChecker.releaseURL!) + } + } + .buttonStyle(.borderless) + .foregroundStyle(.blue) } - .keyboardShortcut(.cancelAction) + } + } - Button("Save") { - Task { await save() } + LabeledContent("Consecutive Errors") { + Text("\(controller.consecutiveErrorCount)") + } + LabeledContent("Poll Interval") { + Text("\(controller.currentPollInterval)s") + } + + LabeledContent("Debug Mode") { + 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 { + DisclosureGroup("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) + 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() + } } - .buttonStyle(.borderedProminent) - .disabled(controllerURL.isEmpty || apiKey.isEmpty) - .keyboardShortcut(.defaultAction) } } + + 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, + wifiStatus: controller.wifiStatus + ) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(report, forType: .string) + } + Spacer() + Button("Clear Log") { + log.clear() + } + } + } header: { + Text("Diagnostics") } - .padding(24) - .frame(width: 380) - .task { await loadExisting() } - .confirmationDialog("Reset UniFiBar?", isPresented: $showResetConfirmation) { - Button("Reset & Forget", role: .destructive) { - Task { await reset() } + } + + // 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)") } - } message: { - Text("This will remove all saved credentials and settings. You will need to set up again.") } } + // MARK: - Actions + + /// Load credentials from Keychain (first load only). private func loadExisting() async { - if let url = await KeychainHelper.shared.read(.controllerURL) { - controllerURL = url - } - if let key = await KeychainHelper.shared.read(.apiKey) { - apiKey = key - } + await controller.preferences.checkConfiguration() + controllerURL = controller.preferences.cachedURL ?? "" + apiKey = controller.preferences.cachedAPIKey ?? "" allowSelfSigned = controller.preferences.allowSelfSignedCerts + compactMode = controller.preferences.compactMode + pollInterval = controller.preferences.pollIntervalSeconds 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) @@ -126,13 +298,14 @@ struct PreferencesView: View { } guard let url = URL(string: urlString), - let scheme = url.scheme, scheme == "http" || scheme == "https", - let host = url.host(), !host.isEmpty + let scheme = url.scheme, scheme == "https", + let host = url.host(), !host.isEmpty, + url.query == nil, url.fragment == nil 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 + _ = url do { try await controller.preferences.save( @@ -141,7 +314,7 @@ struct PreferencesView: View { allowSelfSigned: allowSelfSigned ) await controller.reconfigure() - dismiss() + isEditingCredentials = false } catch { errorMessage = "Failed to save credentials. Please try again." } @@ -165,3 +338,27 @@ 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) + } + .onChange(of: isEnabled) { _, newValue in + preferences.setSectionEnabled(section, enabled: newValue) + } + } +} \ No newline at end of file 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/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/Sections/DDNSSection.swift b/Sources/UniFiBar/Views/Sections/DDNSSection.swift new file mode 100644 index 0000000..cf5fd95 --- /dev/null +++ b/Sources/UniFiBar/Views/Sections/DDNSSection.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct DDNSSection: View { + let statuses: [DDNSStatusDTO] + + var body: some View { + CollapsibleSection(title: "Dynamic DNS", defaultExpanded: false) { + ForEach(statuses) { 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) { + 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) + } + } + Spacer() + Text(ddns.displayStatus) + .font(.callout) + .foregroundStyle(ddns.isActive ? Color.secondary : Color.red) + } + .padding(.horizontal, 16) + .padding(.vertical, 1) + } + } + } +} 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..ac87581 --- /dev/null +++ b/Sources/UniFiBar/Views/Sections/NearbyAPsSection.swift @@ -0,0 +1,40 @@ +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) { 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) + } + } + } +} 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..095083d --- /dev/null +++ b/Sources/UniFiBar/Views/Sections/SecuritySection.swift @@ -0,0 +1,66 @@ +import SwiftUI + +struct SecuritySection: View { + let ipsEvents: [IPSEventDTO]? + + private var totalThreats: Int { ipsEvents?.count ?? 0 } + + var body: some View { + CollapsibleSectionWithBadge( + title: "Security", + badge: totalThreats, + badgeColor: totalThreats > 0 ? .red : .yellow, + defaultExpanded: false + ) { + if totalThreats > 0 { + MetricRow( + label: "Threats Blocked", + value: "\(totalThreats)", + systemImage: "shield.lefthalf.filled.slash" + ) + } + + 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 { + 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) + } + } + } +} \ No newline at end of file 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 2040950..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 { @@ -94,10 +99,11 @@ struct SetupView: View { } guard let url = URL(string: urlString), - let scheme = url.scheme, scheme == "http" || scheme == "https", - let host = url.host(), !host.isEmpty + let scheme = url.scheme, scheme == "https", + let host = url.host(), !host.isEmpty, + url.query == nil, url.fragment == nil 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 } @@ -123,9 +129,8 @@ 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 { let delay = min(pow(2.0, Double(failedAttempts - 4)), 30.0) retryAvailableAt = Date().addingTimeInterval(delay) @@ -135,13 +140,19 @@ 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 = "Connection failed. Check your URL and network settings." } isValidating = false } -} +} \ No newline at end of file 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()