diff --git a/.github/workflows/pr-agent.yml b/.github/workflows/pr-agent.yml index 8b18594..c133f67 100644 --- a/.github/workflows/pr-agent.yml +++ b/.github/workflows/pr-agent.yml @@ -2,7 +2,7 @@ name: PR Agent on: pull_request: - types: [opened, ready_for_review, reopened] + types: [opened, ready_for_review, reopened, synchronize] issue_comment: types: [created] @@ -17,12 +17,20 @@ jobs: (github.event_name == 'pull_request' && ( github.event.action == 'opened' || github.event.action == 'ready_for_review' || - github.event.action == 'reopened' + github.event.action == 'reopened' || + github.event.action == 'synchronize' )) || (github.event_name == 'issue_comment' && - startsWith(github.event.comment.body, '/pr-agent')) + github.event.sender.type != 'Bot' && ( + startsWith(github.event.comment.body, '/review') || + startsWith(github.event.comment.body, '/improve') || + startsWith(github.event.comment.body, '/describe') || + startsWith(github.event.comment.body, '/update_changelog') || + startsWith(github.event.comment.body, '/add_docs') || + startsWith(github.event.comment.body, '/pr-agent') + )) runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 30 steps: - name: PR Agent uses: qodo-ai/pr-agent@d82f7d3e696cd00822694aaa3096265d3889f3f1 @@ -30,6 +38,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} OPENAI_KEY: ${{ secrets.OPENAI_KEY }} OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }} - CONFIG__MODEL: "glm-5.1:cloud" + CONFIG__MODEL: "openai/glm-5.1:cloud" + GITHUB_ACTION_CONFIG__PR_ACTIONS: '["opened","reopened","ready_for_review","synchronize"]' PR_REVIEWER__ENABLE_AUTO_REVIEW: "true" PR_CODE_SUGGESTIONS__ENABLE_AUTO_CODE_SUGGESTIONS: "true" \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8ea027..0497512 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false diff --git a/.gitignore b/.gitignore index dfb8037..6247595 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ DerivedData/ .swiftpm/ /tmp/ unifbar-debug.log +.env # Debug scripts with credential access Scripts/api_debug.swift diff --git a/.pr_agent.toml b/.pr_agent.toml index 3da9a5a..c8abe4f 100644 --- a/.pr_agent.toml +++ b/.pr_agent.toml @@ -1,9 +1,15 @@ [config] -model = "glm-5.1:cloud" +model = "openai/glm-5.1:cloud" fallback_models = [] custom_model_max_tokens = 32768 +custom_reasoning_model = true + +[github_app] +handle_push_trigger = true +push_commands = ["/review"] [pr_reviewer] +enable_auto_review = true extra_instructions = """\ Focus on security, correctness, and Swift 6 strict concurrency. Flag any use of unsafe concurrency patterns, unguarded mutable state, or missing Sendable conformance. @@ -14,4 +20,5 @@ Check for force unwraps, unbounded arrays, and credential leaks in logs. extra_instructions = "Include a security impact assessment in the description." [pr_code_suggestions] +enable_auto_code_suggestions = true max_suggestions = 4 \ No newline at end of file diff --git a/Package.swift b/Package.swift index a2c965b..dfede82 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,11 @@ let package = Package( resources: [ .copy("../../Resources/Assets.xcassets") ] + ), + .testTarget( + name: "UniFiBarTests", + dependencies: ["UniFiBar"], + path: "Tests/UniFiBarTests" ) ] ) diff --git a/Scripts/compile_and_run.sh b/Scripts/compile_and_run.sh index 6cfe899..7211d96 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 @@ -60,6 +64,8 @@ cat > "$APP_BUNDLE/Contents/Info.plist" << PLIST NSAllowsLocalNetworking + NSLocalNetworkUsageDescription + UniFiBar needs access to your local network to connect to your UniFi controller. PLIST @@ -69,26 +75,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..bce5770 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" @@ -56,30 +60,41 @@ cat > "$APP_BUNDLE/Contents/Info.plist" << PLIST NSAllowsLocalNetworking + NSLocalNetworkUsageDescription + UniFiBar needs access to your local network to connect to your UniFi controller. 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..cc6da6a --- /dev/null +++ b/Scripts/probe_endpoints.sh @@ -0,0 +1,53 @@ +#!/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/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..6e364b6 100644 --- a/Sources/UniFiBar/App/UniFiBarApp.swift +++ b/Sources/UniFiBar/App/UniFiBarApp.swift @@ -21,6 +21,7 @@ struct UniFiBarApp: App { SetupView(controller: controller) } .windowResizability(.contentSize) + .defaultSize(width: 380, height: 440) Window("Preferences", id: "preferences") { PreferencesView(controller: controller) diff --git a/Sources/UniFiBar/Models/ClientDTO.swift b/Sources/UniFiBar/Models/ClientDTO.swift index 5961335..dffa379 100644 --- a/Sources/UniFiBar/Models/ClientDTO.swift +++ b/Sources/UniFiBar/Models/ClientDTO.swift @@ -48,6 +48,38 @@ struct V2ClientDTO: Decodable, Sendable { let ccq: Int? let gwMac: String? + /// Minimal init for wired connections not found in UniFi client list. + /// Only the IP is known; all other fields are nil. + init(ip: String, mac: String? = nil, hostname: String? = nil, displayName: String? = nil, signal: Int? = nil, rssi: Int? = nil, noise: Int? = nil, satisfaction: Int? = nil, wifiExperienceScore: Int? = nil, wifiExperienceAverage: Int? = nil, wifiTxRetriesPercentage: Double? = nil, channel: Int? = nil, channelWidth: Int? = nil, radioProto: String? = nil, radio: String? = nil, essid: String? = nil, apMac: String? = nil, lastUplinkName: String? = nil, rxRate: Int? = nil, txRate: Int? = nil, rxBytes: Int? = nil, txBytes: Int? = nil, uptime: Int? = nil, mimo: String? = nil, roamCount: Int? = nil, ccq: Int? = nil, gwMac: String? = nil) { + self.mac = mac + self.ip = ip + self.hostname = hostname + self.displayName = displayName + self.signal = signal + self.rssi = rssi + self.noise = noise + self.satisfaction = satisfaction + self.wifiExperienceScore = wifiExperienceScore + self.wifiExperienceAverage = wifiExperienceAverage + self.wifiTxRetriesPercentage = wifiTxRetriesPercentage + self.channel = channel + self.channelWidth = channelWidth + self.radioProto = radioProto + self.radio = radio + self.essid = essid + self.apMac = apMac + self.lastUplinkName = lastUplinkName + self.rxRate = rxRate + self.txRate = txRate + self.rxBytes = rxBytes + self.txBytes = txBytes + self.uptime = uptime + self.mimo = mimo + self.roamCount = roamCount + self.ccq = ccq + self.gwMac = gwMac + } + enum CodingKeys: String, CodingKey { case mac, ip, hostname, signal, rssi, noise, satisfaction, channel, radio, essid, uptime, ccq case displayName = "display_name" diff --git a/Sources/UniFiBar/Models/DeviceDTO.swift b/Sources/UniFiBar/Models/DeviceDTO.swift index 10156b4..4d5f890 100644 --- a/Sources/UniFiBar/Models/DeviceDTO.swift +++ b/Sources/UniFiBar/Models/DeviceDTO.swift @@ -17,10 +17,13 @@ struct DeviceDTO: Decodable, Sendable { var isOnline: Bool { state == "CONNECTED" || state == "ONLINE" } /// Gateway models: UCG Fiber, UDM, UDM Pro, UDR, UXG, etc. + /// Also matches devices with gateway-like features (wan role, routing capability). var isGateway: Bool { guard let model = model?.lowercased() else { return false } return model.contains("ucg") || model.contains("udm") || model.contains("udr") || model.contains("uxg") || model.contains("gateway") || model.contains("dream") + || model.contains("router") || model.contains("usg") + || features?.contains("wan") == true } } @@ -39,6 +42,7 @@ struct WANHealth: Sendable { let drops: Int? let rxBytesRate: Double? let txBytesRate: Double? + let speedTest: SpeedTestResult? } struct WANHealthResponse: Decodable, Sendable { @@ -57,6 +61,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 +76,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 +103,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 +124,8 @@ struct WANHealthResponse: Decodable, Sendable { availability: wan?.uptimeStats?.WAN?.availability, drops: www?.drops, rxBytesRate: wan?.rxBytesR, - txBytesRate: wan?.txBytesR + txBytesRate: wan?.txBytesR, + speedTest: speedTest ) } } @@ -106,14 +136,30 @@ struct VPNTunnelResponse: Decodable, Sendable { let data: [VPNTunnelDTO] } -struct VPNTunnelDTO: Decodable, Sendable { - let id: String +struct VPNTunnelDTO: Decodable, Sendable, Identifiable { + private let _id: String? let name: String? let status: String? let remoteNetworkCidr: String? let type: String? + var id: String { _id ?? name ?? UUID().uuidString } var isConnected: Bool { status == "CONNECTED" || status == "UP" } + + init(_id: String? = nil, name: String? = nil, status: String? = nil, remoteNetworkCidr: String? = nil, type: String? = nil) { + self._id = _id + self.name = name + self.status = status + self.remoteNetworkCidr = remoteNetworkCidr + self.type = type + } + + enum CodingKeys: String, CodingKey { + case _id + case name, status + case remoteNetworkCidr = "remote_network_cidr" + case type + } } // MARK: - Gateway Statistics @@ -154,7 +200,6 @@ struct APStats: Sendable { let uptimeSec: Int? let cpuUtilizationPct: Double? let memoryUtilizationPct: Double? - let txRetriesPct: Double? } struct APStatsResponse: Decodable, Sendable { @@ -166,8 +211,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..db06114 --- /dev/null +++ b/Sources/UniFiBar/Models/MonitoringDTO.swift @@ -0,0 +1,175 @@ +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: - 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 { + let h = hostName ?? "unknown" + let s = service ?? "ddns" + return "\(h)-\(s)" + } + + 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 { + private 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? + + var id: String { _id ?? "\(proto ?? ""):\(dstPort ?? "")->\(fwd ?? ""):\(fwdPort ?? "")" } + + init(_id: String? = nil, name: String? = nil, enabled: Bool? = nil, dstPort: String? = nil, fwd: String? = nil, fwdPort: String? = nil, proto: String? = nil, pfwd_interface: String? = nil) { + self._id = _id + self.name = name + self.enabled = enabled + self.dstPort = dstPort + self.fwd = fwd + self.fwdPort = fwdPort + self.proto = proto + self.pfwd_interface = pfwd_interface + } + + enum CodingKeys: String, CodingKey { + case _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" + } +} + +// MARK: - Speed test display (MainActor for RelativeDateTimeFormatter safety) + +@MainActor +extension SpeedTestResult { + var formattedLastRun: String? { + guard let date = lastRun else { return nil } + return Formatters.relativeTime(from: date) + } +} diff --git a/Sources/UniFiBar/Models/WiFiStatus.swift b/Sources/UniFiBar/Models/WiFiStatus.swift index 4200183..a5cccfd 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,14 @@ final class WiFiStatus { var onlineDevices: Int? = nil var offlineDeviceNames: [String]? = nil + // Speed test + var speedTest: SpeedTestResult? = nil + + // Monitoring data + var ddnsStatuses: [DDNSStatusDTO]? = nil + var portForwards: [PortForwardDTO]? = nil + var nearbyAPs: [RogueAPDTO]? = nil + // Metadata var lastUpdated: Date? = nil @@ -103,10 +112,28 @@ final class WiFiStatus { let fraction: Double } - enum ErrorState: Sendable { - case controllerUnreachable - case invalidAPIKey + enum ErrorState: Sendable, Equatable { + 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 +149,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 +166,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 +202,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 +254,10 @@ final class WiFiStatus { return "\(online) online · \(offline) offline" } + var nearbyAPCount: Int { + nearbyAPs?.count ?? 0 + } + var firmwareBadge: String? { guard let names = devicesWithUpdates, !names.isEmpty else { return nil } let count = names.count @@ -252,6 +302,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 +366,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 +376,7 @@ final class WiFiStatus { wanDrops = nil wanTxBytesRate = nil wanRxBytesRate = nil + speedTest = nil } } @@ -373,7 +426,7 @@ final class WiFiStatus { apDurations[name, default: 0] += duration } - let maxDuration = apDurations.values.max() ?? 1 + let maxDuration = max(apDurations.values.max() ?? 1, 1) sessions = apDurations .sorted { $0.value > $1.value } .map { SessionEntry( @@ -383,24 +436,89 @@ final class WiFiStatus { )} } - func markDisconnected() { + func updateMonitoring( + ddns: [DDNSStatusDTO]?, + portForwards: [PortForwardDTO]?, + rogueAPs: [RogueAPDTO]? + ) { + self.ddnsStatuses = ddns + self.portForwards = portForwards + self.nearbyAPs = rogueAPs + } + + /// Resets all display state to defaults. Called on disconnect, error, and reset. + func clearState() { isConnected = false isWired = false - errorState = .notConnected + errorState = nil satisfaction = nil + wifiExperienceAverage = nil signal = nil - lastUpdated = Date() + noiseFloor = nil + apName = nil + essid = nil + channel = nil + channelWidth = nil + wifiStandard = nil + mimoDescription = nil + rxRate = nil + txRate = nil + txRetriesPct = nil + rxBytes = nil + txBytes = nil + ip = nil + uptime = nil + roamCount = nil + totalClients = nil + clientsOnSameAP = nil + apCPU = nil + apMemory = nil + recentlyRoamed = false + roamedFrom = nil + roamCyclesRemaining = 0 + previousAPName = nil + previousSignal = nil + previousSatisfaction = nil + signalTrend = .stable + satisfactionTrend = .stable + sessions = nil + wanIsUp = nil + wanIP = nil + wanISP = nil + wanLatencyMs = nil + wanAvailability = nil + wanDrops = nil + wanTxBytesRate = nil + wanRxBytesRate = nil + gatewayCPU = nil + gatewayMemory = nil + gatewayUptime = nil + gatewayName = nil + vpnTunnels = nil + devicesWithUpdates = nil + totalDevices = nil + onlineDevices = nil + offlineDeviceNames = nil + speedTest = nil + ddnsStatuses = nil + portForwards = nil + nearbyAPs = nil + lastUpdated = nil + } + + func markDisconnected() { + clearState() + errorState = .notConnected } func markError(_ error: ErrorState) { - isConnected = false + clearState() errorState = error - lastUpdated = Date() } // MARK: - Helpers - private func formatRate(_ rateKbps: Int?) -> String { + func formatRate(_ rateKbps: Int?) -> String { guard let rate = rateKbps else { return "—" } let mbps = Double(rate) / 1000.0 if mbps >= 1000 { @@ -410,22 +528,29 @@ final class WiFiStatus { } private func formatBytesPerSec(_ bytesPerSec: Double) -> String { - if bytesPerSec >= 1_073_741_824 { - return String(format: "%.1f GB/s", bytesPerSec / 1_073_741_824) - } else if bytesPerSec >= 1_048_576 { - return String(format: "%.1f MB/s", bytesPerSec / 1_048_576) - } else if bytesPerSec >= 1_024 { - return String(format: "%.0f KB/s", bytesPerSec / 1_024) + if bytesPerSec >= 1_000_000_000 { + return String(format: "%.1f GB/s", bytesPerSec / 1_000_000_000) + } else if bytesPerSec >= 1_000_000 { + return String(format: "%.1f MB/s", bytesPerSec / 1_000_000) + } else if bytesPerSec >= 1_000 { + return String(format: "%.0f KB/s", bytesPerSec / 1_000) } return "0 B/s" } - private func formatBytes(_ bytes: Int) -> String { - let gb = Double(bytes) / 1_073_741_824.0 + func formatBytes(_ bytes: Int) -> String { + let gb = Double(bytes) / 1_000_000_000.0 if gb >= 1.0 { return String(format: "%.1f GB", gb) } - let mb = Double(bytes) / 1_048_576.0 - return String(format: "%.0f MB", mb) + let mb = Double(bytes) / 1_000_000.0 + if mb >= 1.0 { + return String(format: "%.1f MB", mb) + } + let kb = Double(bytes) / 1_000.0 + if kb >= 1.0 { + return String(format: "%.0f KB", kb) + } + return "\(bytes) B" } } diff --git a/Sources/UniFiBar/Network/UniFiClient.swift b/Sources/UniFiBar/Network/UniFiClient.swift index e362ddc..89e9670 100644 --- a/Sources/UniFiBar/Network/UniFiClient.swift +++ b/Sources/UniFiBar/Network/UniFiClient.swift @@ -1,35 +1,66 @@ 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? + /// Clears the cached site ID so the next fetchSiteId() will rediscover it. + func resetSiteCache() { + siteId = nil + } + 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) + } + } + + /// Invalidate the URLSession so in-flight requests are cancelled and the delegate is released. + func invalidate() { + session.invalidateAndCancel() + } + // MARK: - Core Requests private func request(_ path: String) async throws -> Data { @@ -71,7 +102,7 @@ actor UniFiClient { if let cached = siteId { return cached } struct SitesResponse: Decodable, Sendable { - let data: [SiteEntry] + let data: [SiteEntry]? struct SiteEntry: Decodable, Sendable { let id: String let name: String? @@ -80,7 +111,7 @@ actor UniFiClient { let data = try await request("/proxy/network/integrations/v1/sites") let response = try JSONDecoder().decode(SitesResponse.self, from: data) - guard let site = response.data.first else { + guard let site = response.data?.first else { throw UniFiError.noSitesFound } siteId = site.id @@ -91,35 +122,50 @@ 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) - - guard let myIP = DeviceDetector.activeIPv4Address() else { - throw UniFiError.selfNotFound - } - - guard let me = clients.first(where: { $0.ip == myIP }) else { - throw UniFiError.selfNotFound + 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 + + // Try each active interface IP against the UniFi client list. + // WiFi IPs (en0) are tried first since they're more likely to be in UniFi. + let myIPs = DeviceDetector.activeIPv4Addresses() + for ip in myIPs { + if let me = clients.first(where: { $0.ip == ip }) { + let totalClients = clients.count + let clientsOnSameAP: Int + if let myAP = me.apMac { + clientsOnSameAP = clients.filter { $0.apMac == myAP }.count + } else { + clientsOnSameAP = 1 + } + return SelfInfo( + client: me, + totalClients: totalClients, + clientsOnSameAP: clientsOnSameAP + ) + } } - let totalClients = clients.count - let clientsOnSameAP: Int - if let myAP = me.apMac { - clientsOnSameAP = clients.filter { $0.apMac == myAP }.count - } else { - clientsOnSameAP = 1 + // No matching client found — Mac is on a network not managed by UniFi + // (e.g., wired through a non-UniFi router). Show limited wired connection view. + if let wiredIP = myIPs.first { + return SelfInfo( + client: V2ClientDTO(ip: wiredIP), + totalClients: clients.count, + clientsOnSameAP: 1 + ) } - return SelfInfo( - client: me, - totalClients: totalClients, - clientsOnSameAP: clientsOnSameAP - ) + throw UniFiError.selfNotFound } // 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 +173,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 } } @@ -134,7 +181,33 @@ actor UniFiClient { // MARK: - Session History (POST-based) struct LegacyResponse: Decodable, Sendable { - let data: [T] + let data: [T]? + + init(data: [T]?) { + self.data = data + } + } + + /// Decodes a UniFi API response that may be wrapped in `{ "data": [...] }` + /// or `{ "data": null }`, or may be a bare array `[...]`. + /// Tries LegacyResponse first, then direct array. + static func decodeFlexibleArray( + _ type: T.Type, + from data: Data, + endpoint: String + ) -> [T]? { + // Try wrapped format first: { "data": [...] } or { "data": null } + 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]? { @@ -142,9 +215,10 @@ actor UniFiClient { 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 = Self.decodeFlexibleArray(SessionDTO.self, from: data, endpoint: "session_history") + return (sessions?.isEmpty == true) ? nil : sessions?.prefix(1_000).map { $0 } } catch { + Self.logger.error("Failed to fetch session history: \(Self.safeErrorDescription(error))") return nil } } @@ -154,10 +228,8 @@ 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") - if let response = try? JSONDecoder().decode(DeviceListResponse.self, from: data) { - return response.data - } - return try JSONDecoder().decode([DeviceDTO].self, from: data) + let devices = Self.decodeFlexibleArray(DeviceDTO.self, from: data, endpoint: "devices") ?? [] + return devices.count > 500 ? Array(devices.prefix(500)) : devices } // MARK: - VPN Tunnels @@ -169,6 +241,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 +254,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 +262,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 +273,118 @@ 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: - 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 +397,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 +492,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..534efa3 100644 --- a/Sources/UniFiBar/Preferences/PreferencesManager.swift +++ b/Sources/UniFiBar/Preferences/PreferencesManager.swift @@ -1,25 +1,106 @@ 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 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 .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 .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: + 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] = [.ddns, .portForwards, .nearbyAPs] + return monitoringSections.contains { isSectionEnabled($0) } } /// Reads Keychain once and caches. Subsequent calls use cache. @@ -51,6 +132,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 +144,37 @@ 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 setCompactMode(_ value: Bool) { + compactMode = value + UserDefaults.standard.set(value, forKey: compactModeKey) + } + 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..0c46fe0 100644 --- a/Sources/UniFiBar/StatusBar/StatusBarController.swift +++ b/Sources/UniFiBar/StatusBar/StatusBarController.swift @@ -22,17 +22,46 @@ 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 + private var isRefreshing = false + + var consecutiveErrorCount: Int { consecutiveErrors } + var currentPollInterval: Int { pollInterval } + + /// Tears down observers. Must be called on @MainActor before the object is released, + /// since @Observable types have nonisolated deinit which cannot 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() @@ -42,6 +71,13 @@ final class StatusBarController { func reconfigure() async { stopPolling() + // Reset error state so new credentials get a clean start + authFailed = false + consecutiveErrors = 0 + // Invalidate old URLSession to release delegate and cancel in-flight requests + if let oldClient = client { + await oldClient.invalidate() + } client = await preferences.loadClient() if client != nil { await refresh() @@ -54,29 +90,84 @@ 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() + } + + /// Resets all controller and WiFi state (called after preferences resetAll). + func resetState() { + stopPolling() + if let observer = wakeObserver { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + wakeObserver = nil + } + pathMonitor?.cancel() + pathMonitor = nil + // Invalidate URLSession to release delegate and cancel in-flight requests + let oldClient = client + client = nil + if let oldClient { + Task { await oldClient.invalidate() } + } + authFailed = false + consecutiveErrors = 0 + successCount = 0 + hasStarted = false + wifiStatus.clearState() + } + 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 { + // Clear the stale reference so wake/network handlers can restart polling + // after credentials are fixed via reconfigure() + self.pollTask = nil + 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 } + /// Restarts the polling loop so a new poll interval takes effect immediately. + func restartPolling() { + guard pollTask != nil else { return } + stopPolling() + startPolling() + } + 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 +175,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 +193,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")) @@ -104,8 +206,13 @@ final class StatusBarController { } private func refresh() async { + // Guard against overlapping refresh calls from poll loop + wake/network events + guard !isRefreshing else { return } + isRefreshing = true + defer { isRefreshing = false } + guard let client else { - wifiStatus.markError(.controllerUnreachable) + wifiStatus.markError(.controllerUnreachable(reason: "Not configured")) return } @@ -116,8 +223,37 @@ 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))) + } + await client.resetSiteCache() + 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) + await client.resetSiteCache() + 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))) + await client.resetSiteCache() return } @@ -128,67 +264,159 @@ 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) - async let sessionsTask: [SessionDTO]? = { - if let mac = me.mac { - return await client.fetchSessionHistory(mac: mac) - } - return nil - }() + let myMac = me.mac + let sessionsTask: Task<[SessionDTO]?, Never> + if let mac = myMac { + sessionsTask = Task { await client.fetchSessionHistory(mac: mac) } + } else { + sessionsTask = Task { nil } + } let devices = (try? await devicesTask) ?? [] let wanHealth = await wanHealthTask let tunnels = await vpnTask - let sessions = await sessionsTask + let sessions = await sessionsTask.value wifiStatus.updateDevices(devices) wifiStatus.updateWANHealth(wanHealth) 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() }) } let gwDevice = devices.first(where: \.isGateway) ?? devices.first(where: { $0.mac?.lowercased() == me.gwMac?.lowercased() }) - async let apStatsTask: APStats? = { - if let deviceId = apDevice?.id { - return await client.fetchAPStats(deviceId: deviceId, siteId: siteId) - } - return nil - }() - async let gwStatsTask: GatewayStats? = { - if let gwId = gwDevice?.id { - return await client.fetchGatewayStats(deviceId: gwId, siteId: siteId) - } - return nil - }() + let apId = apDevice?.id + let gwId = gwDevice?.id + let apStatsTask: Task + let gwStatsTask: Task + if let apId { + apStatsTask = Task { await client.fetchAPStats(deviceId: apId, siteId: siteId) } + } else { + apStatsTask = Task { nil } + } + if let gwId { + gwStatsTask = Task { await client.fetchGatewayStats(deviceId: gwId, siteId: siteId) } + } else { + gwStatsTask = Task { nil } + } - let apStats = await apStatsTask - let gwStats = await gwStatsTask + let apStats = await apStatsTask.value + let gwStats = await gwStatsTask.value wifiStatus.updateAPStats(apStats) wifiStatus.updateGateway(gwStats, device: gwDevice) + + // Parallel batch 3: monitoring data (only fetch enabled sections) + await fetchMonitoringData(client: client) + } + + /// Maps network errors to human-readable reasons for the UI. + private static func reasonFromError(_ error: Error) -> String? { + let nsError = error as NSError + guard nsError.domain == NSURLErrorDomain else { + return "\(nsError.domain) code=\(nsError.code)" + } + switch nsError.code { + case NSURLErrorCannotFindHost: return "DNS lookup failed" + case NSURLErrorDNSLookupFailed: return "DNS lookup failed" + case NSURLErrorTimedOut: return "Connection timed out" + case NSURLErrorCannotConnectToHost: return "Connection refused" + case NSURLErrorNetworkConnectionLost: return "Network connection lost" + case NSURLErrorNotConnectedToInternet: return "No internet connection" + case NSURLErrorSecureConnectionFailed: return "TLS handshake failed" + case NSURLErrorServerCertificateHasBadDate: return "Server certificate expired" + case NSURLErrorServerCertificateUntrusted: return "Server certificate untrusted" + case NSURLErrorServerCertificateHasUnknownRoot: return "Self-signed certificate" + case NSURLErrorClientCertificateRejected: return "Client certificate rejected" + default: return "Network error (\(nsError.code))" + } + } + + /// Fetches optional monitoring data based on which sections are enabled in preferences. + /// Each call is independent and fails silently — monitoring data is best-effort. + private func fetchMonitoringData(client: UniFiClient) async { + // Evaluate section visibility on @MainActor before spawning child tasks + let wantDDNS = preferences.isSectionEnabled(.ddns) + let wantPF = preferences.isSectionEnabled(.portForwards) + let wantRogue = preferences.isSectionEnabled(.nearbyAPs) + + 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 ddns = await ddnsResult + let pf = await pfResult + let rogue = await rogueResult + + 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( + ddns: ddns.data, + portForwards: pf.data, + rogueAPs: rogue.data + ) } } diff --git a/Sources/UniFiBar/Utils/DeviceDetector.swift b/Sources/UniFiBar/Utils/DeviceDetector.swift index 2295c10..94f3ec5 100644 --- a/Sources/UniFiBar/Utils/DeviceDetector.swift +++ b/Sources/UniFiBar/Utils/DeviceDetector.swift @@ -1,43 +1,60 @@ import Darwin enum DeviceDetector { - /// Returns the IPv4 address of the first active `en*` interface (WiFi or Ethernet). - static func activeIPv4Address() -> String? { + /// Returns all active IPv4 addresses on `en*` interfaces, ordered by interface name. + /// WiFi interfaces (en0) typically come first, followed by Thunderbolt/Ethernet (en1+). + static func activeIPv4Addresses() -> [String] { var ifaddr: UnsafeMutablePointer? guard getifaddrs(&ifaddr) == 0, let firstAddr = ifaddr else { - return nil + return [] } defer { freeifaddrs(ifaddr) } + var results: [String] = [] var current: UnsafeMutablePointer? = firstAddr while let ifa = current { let name = String(cString: ifa.pointee.ifa_name) - let family = ifa.pointee.ifa_addr.pointee.sa_family - - // Match any en* interface (en0 = WiFi, en1+ = Ethernet/Thunderbolt) - if name.hasPrefix("en") && family == UInt8(AF_INET) { - var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - let result = getnameinfo( - ifa.pointee.ifa_addr, - socklen_t(ifa.pointee.ifa_addr.pointee.sa_len), - &hostname, - socklen_t(hostname.count), - nil, 0, - NI_NUMERICHOST - ) - if result == 0 { - let length = hostname.firstIndex(of: 0).map { Int($0) } ?? hostname.count - let ip = String(decoding: hostname.prefix(length).map { UInt8(bitPattern: $0) }, as: UTF8.self) - // Skip link-local addresses (169.254.x.x) - if !ip.hasPrefix("169.254") { - return ip - } + guard name.hasPrefix("en") else { + current = ifa.pointee.ifa_next + continue + } + + guard let addr = ifa.pointee.ifa_addr else { + current = ifa.pointee.ifa_next + continue + } + + let family = addr.pointee.sa_family + guard family == UInt8(AF_INET) else { + current = ifa.pointee.ifa_next + continue + } + + var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + addr, + socklen_t(addr.pointee.sa_len), + &hostname, + socklen_t(hostname.count), + nil, 0, + NI_NUMERICHOST + ) + if result == 0 { + let length = hostname.firstIndex(of: 0).map { Int($0) } ?? hostname.count + let ip = String(decoding: hostname.prefix(length).map { UInt8(bitPattern: $0) }, as: UTF8.self) + if !ip.hasPrefix("169.254") && !results.contains(ip) { + results.append(ip) } } current = ifa.pointee.ifa_next } - return nil + return results + } + + /// Returns the IPv4 address of the first active `en*` interface (WiFi or Ethernet). + static func activeIPv4Address() -> String? { + activeIPv4Addresses().first } -} +} \ No newline at end of file diff --git a/Sources/UniFiBar/Utils/DiagnosticsLog.swift b/Sources/UniFiBar/Utils/DiagnosticsLog.swift new file mode 100644 index 0000000..fe39fd1 --- /dev/null +++ b/Sources/UniFiBar/Utils/DiagnosticsLog.swift @@ -0,0 +1,173 @@ +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 + private let timestampFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss" + return f + }() + + 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):") + for event in events.reversed() { + let time = timestampFormatter.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/Formatters.swift b/Sources/UniFiBar/Utils/Formatters.swift new file mode 100644 index 0000000..f24949f --- /dev/null +++ b/Sources/UniFiBar/Utils/Formatters.swift @@ -0,0 +1,18 @@ +import Foundation + +/// @MainActor-isolated formatting helpers. +/// RelativeDateTimeFormatter is not Sendable, so shared instances must not +/// escape the MainActor. Views call these from the UI layer where +/// MainActor isolation is guaranteed. +@MainActor +enum Formatters { + private static let relativeTime: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f + }() + + static func relativeTime(from date: Date) -> String { + relativeTime.localizedString(for: date, relativeTo: Date()) + } +} \ 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..4f84a67 --- /dev/null +++ b/Sources/UniFiBar/Utils/UpdateChecker.swift @@ -0,0 +1,110 @@ +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 + } + } + + /// Semver comparison: "1.2.3" vs "1.3.0". Strips pre-release suffixes + /// like "-beta1" from each segment before parsing. + static func isNewer(current: String, remote: String) -> Bool { + let currentParts = current.split(separator: ".").map { parseVersionSegment(String($0)) } + let remoteParts = remote.split(separator: ".").map { parseVersionSegment(String($0)) } + let count = max(currentParts.count, remoteParts.count) + for i in 0.. c { return true } + if r < c { return false } + } + return false + } + + /// Parse a version segment, stripping pre-release suffixes (e.g. "0-beta1" → 0). + static func parseVersionSegment(_ segment: String) -> Int { + // Strip anything after a hyphen (pre-release suffix like "-beta1") + let numericPart = segment.split(separator: "-").first.map(String.init) ?? segment + return Int(numericPart) ?? 0 + } +} + +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/Components/ProgressBarView.swift b/Sources/UniFiBar/Views/Components/ProgressBarView.swift index 3109882..f9e8d21 100644 --- a/Sources/UniFiBar/Views/Components/ProgressBarView.swift +++ b/Sources/UniFiBar/Views/Components/ProgressBarView.swift @@ -15,5 +15,6 @@ struct ProgressBarView: View { } } .frame(height: 6) + .accessibilityValue("\(Int(min(max(fraction, 0), 1) * 100))%") } } diff --git a/Sources/UniFiBar/Views/MenuContentView.swift b/Sources/UniFiBar/Views/MenuContentView.swift index e18ff84..c095b14 100644 --- a/Sources/UniFiBar/Views/MenuContentView.swift +++ b/Sources/UniFiBar/Views/MenuContentView.swift @@ -8,9 +8,16 @@ struct MenuContentView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { if !controller.preferences.isConfigured { - notConfiguredView + NotConfiguredView(onSetup: { activateAndOpenWindow("setup") }) } else if let errorState = controller.wifiStatus.errorState { - errorView(errorState) + MenuErrorView( + errorState: errorState, + consecutiveErrors: controller.consecutiveErrorCount, + pollInterval: controller.currentPollInterval, + onOpenPreferences: { activateAndOpenWindow("preferences") }, + onResetCertPin: { Task { await controller.resetCertPin() } }, + onCopyDiagnostics: { copyDiagnostics() } + ) } else if controller.wifiStatus.isConnected { connectedView } else { @@ -22,32 +29,81 @@ struct MenuContentView: View { Divider() .padding(.vertical, 4) - footerActions + MenuFooterView(controller: controller, onRefresh: { controller.refreshNow() }, onPreferences: { activateAndOpenWindow("preferences") }) } .padding(.vertical, 8) + .frame(height: controller.preferences.compactMode ? nil : screenUsableHeight) } - private func activateAndOpenWindow(_ id: String) { - NSApplication.shared.activate() - openWindow(id: id) + // MARK: - Computed Properties + + private var screenUsableHeight: CGFloat { + guard let screen = NSScreen.main else { return 600 } + return screen.visibleFrame.height - 40 } + private var prefs: PreferencesManager { controller.preferences } + private var status: WiFiStatus { controller.wifiStatus } + // MARK: - Connected View @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 + + @ViewBuilder + private var coreSections: some View { + if prefs.isSectionEnabled(.internet), status.wanIsUp != nil { + InternetSection( + wanIsUp: status.wanIsUp, + wanIP: status.wanIP, + wanISP: status.wanISP, + formattedWANLatency: status.formattedWANLatency, + formattedWANAvailability: status.formattedWANAvailability, + wanDrops: status.wanDrops, + formattedWANThroughput: status.formattedWANThroughput, + speedTest: status.speedTest, + gatewayName: status.gatewayName, + formattedGatewayLoad: status.formattedGatewayLoad, + formattedGatewayUptime: status.formattedGatewayUptime + ) } - // 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( + formattedNetworkOverview: status.formattedNetworkOverview, + formattedDeviceOverview: status.formattedDeviceOverview, + offlineDeviceNames: status.offlineDeviceNames, + firmwareBadge: status.firmwareBadge + ) + } + } + + @ViewBuilder + private var connectionContent: some View { + if status.isWired { SectionHeader(title: "Connection") HStack(spacing: 6) { Image(systemName: "cable.connector.horizontal") @@ -60,37 +116,117 @@ 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( + qualityLabel: status.qualityLabel, + satisfaction: status.satisfaction, + satisfactionTrend: status.satisfactionTrend, + wifiExperienceAverage: status.wifiExperienceAverage, + accentColor: status.statusBarColor + ) + SignalSection( + signalTrend: status.signalTrend, + signalDescription: status.signalDescription, + noiseFloor: status.noiseFloor + ) + AccessPointSection( + apName: status.apName, + essid: status.essid, + formattedAPLoad: status.formattedAPLoad, + channel: status.channel, + formattedChannelWidth: status.formattedChannelWidth, + wifiStandard: status.wifiStandard, + mimoDescription: status.mimoDescription, + recentlyRoamed: status.recentlyRoamed, + roamedFrom: status.roamedFrom + ) + LinkSection( + formattedRxRate: status.formattedRxRate, + formattedTxRate: status.formattedTxRate, + formattedTxRetries: status.formattedTxRetries, + formattedSessionData: status.formattedSessionData + ) + SessionSection( + ip: status.ip, + uptime: status.uptime, + formattedUptime: status.formattedUptime, + formattedRoamCount: status.formattedRoamCount + ) } + } - // Network — clients, devices, firmware - NetworkSection(wifiStatus: controller.wifiStatus) + // MARK: - Monitoring Sections - 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 monitoringSections: some View { + if prefs.isSectionEnabled(.ddns), let ddns = status.ddnsStatuses { + DDNSSection(statuses: ddns) } - } - // MARK: - Error Views + if prefs.isSectionEnabled(.portForwards), let pf = status.portForwards { + PortForwardsSection(portForwards: pf) + } + + if prefs.isSectionEnabled(.nearbyAPs), let aps = status.nearbyAPs { + NearbyAPsSection(rogueAPs: aps) + } + } @ViewBuilder - private var notConfiguredView: some View { + 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: - Actions + + private func activateAndOpenWindow(_ id: String) { + NSApplication.shared.activate(ignoringOtherApps: true) + openWindow(id: id) + } + + 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) + } +} + +// MARK: - Not Configured View + +private struct NotConfiguredView: View { + let onSetup: () -> Void + + var body: some View { VStack(spacing: 8) { Image(systemName: "wifi.slash") .font(.largeTitle) @@ -100,32 +236,59 @@ struct MenuContentView: View { Text("Set up your UniFi controller to get started.") .font(.caption) .foregroundStyle(.secondary) - Button("Open Setup") { - activateAndOpenWindow("setup") - } - .buttonStyle(.borderedProminent) + Button("Open Setup", action: onSetup) + .buttonStyle(.borderedProminent) } .frame(maxWidth: .infinity) .padding() } +} - @ViewBuilder - private func errorView(_ state: WiFiStatus.ErrorState) -> some View { +// MARK: - Error View + +private struct MenuErrorView: View { + let errorState: WiFiStatus.ErrorState + let consecutiveErrors: Int + let pollInterval: Int + let onOpenPreferences: () -> Void + let onResetCertPin: () -> Void + let onCopyDiagnostics: () -> Void + + var body: some View { VStack(spacing: 8) { - switch state { - case .controllerUnreachable: + switch errorState { + 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 consecutiveErrors > 0 { + Text("Retry in \(pollInterval)s · \(consecutiveErrors) error\(consecutiveErrors == 1 ? "" : "s")") + .font(.caption2) + .foregroundStyle(.tertiary) + } + case .invalidAPIKey(let httpCode): Label("Invalid API Key", systemImage: "key.slash") .foregroundStyle(.red) - Button("Open Preferences") { - activateAndOpenWindow("preferences") + if let code = httpCode { + Text("Server returned HTTP \(code)") + .font(.caption) + .foregroundStyle(.secondary) } - .buttonStyle(.borderedProminent) + Button("Open Preferences", action: onOpenPreferences) + .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", action: onResetCertPin) + .buttonStyle(.borderedProminent) case .notConnected: Label("Not Connected", systemImage: "wifi.slash") .foregroundStyle(.secondary) @@ -133,38 +296,48 @@ struct MenuContentView: View { .font(.caption) .foregroundStyle(.secondary) } + + Button(action: onCopyDiagnostics) { + Label("Copy Diagnostics", systemImage: "doc.on.doc") + .font(.caption) + } + .buttonStyle(.bordered) } .frame(maxWidth: .infinity) .padding() } +} - // MARK: - Footer +// MARK: - Footer View - @ViewBuilder - private var footerActions: some View { +private struct MenuFooterView: View { + let controller: StatusBarController + let onRefresh: () -> Void + let onPreferences: () -> Void + + var body: some View { HStack(spacing: 8) { - Button { - controller.refreshNow() - } label: { - Label("Refresh", systemImage: "arrow.clockwise") + Button(action: onRefresh) { + Image(systemName: "arrow.clockwise") .frame(maxWidth: .infinity) } + .accessibilityLabel("Refresh") - Button { - activateAndOpenWindow("preferences") - } label: { - Label("Preferences", systemImage: "gearshape") + Button(action: onPreferences) { + Image(systemName: "gearshape") .frame(maxWidth: .infinity) } + .accessibilityLabel("Preferences") Button { NSApplication.shared.terminate(nil) } label: { - Label("Quit", systemImage: "xmark") + Image(systemName: "xmark") .frame(maxWidth: .infinity) } + .accessibilityLabel("Quit") } .padding(.horizontal, 16) .padding(.vertical, 2) } -} +} \ No newline at end of file diff --git a/Sources/UniFiBar/Views/PreferencesView.swift b/Sources/UniFiBar/Views/PreferencesView.swift index 4fbb6c7..6a49377 100644 --- a/Sources/UniFiBar/Views/PreferencesView.swift +++ b/Sources/UniFiBar/Views/PreferencesView.swift @@ -3,118 +3,198 @@ import SwiftUI struct PreferencesView: View { let controller: StatusBarController + @Environment(\.dismiss) private var dismiss @State private var controllerURL = "" @State private var apiKey = "" @State private var allowSelfSigned = false + @State private var isEditingCredentials = false + @State private var compactMode = false + @State private var pollInterval: Int = 30 @State private var launchAtLogin = false + @State private var launchAtLoginInitialized = false + @State private var versionTapCount = 0 @State private var isLoading = true + @State private var isSaving = false @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) - - Toggle("Launch at login", isOn: $launchAtLogin) - .font(.callout) - .onChange(of: launchAtLogin) { _, newValue in - setLaunchAtLogin(newValue) - } - - if let siteId = controller.preferences.siteId { - HStack { - Text("Site ID") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Text(siteId) - .font(.caption) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - } - } + Form { + connectionSection + siteSection + behaviorSection + visibilitySection + DiagnosticsSection(controller: controller) + resetSection } + .formStyle(.grouped) + } + } + .frame(width: 480) + .task { await loadExisting() } + .confirmationDialog("Reset UniFiBar?", isPresented: $showResetConfirmation) { + Button("Reset & Forget", role: .destructive) { + Task { await reset() } + } + } message: { + Text("This will remove all saved credentials and settings. You will need to set up again.") + } + } - if let errorMessage { - Text(errorMessage) - .font(.caption) - .foregroundStyle(.red) - } + // MARK: - Connection + private var connectionSection: some View { + Section { + if isEditingCredentials { + TextField("Controller URL", text: $controllerURL, prompt: Text("https://192.168.1.1")) + SecureField("API Key", text: $apiKey, prompt: Text("Paste your API key")) + Toggle("Allow self-signed certificates", isOn: $allowSelfSigned) HStack { - Button("Reset & Forget", role: .destructive) { - showResetConfirmation = true - } - - Spacer() - Button("Cancel") { - dismiss() + revertCredentials() } - .keyboardShortcut(.cancelAction) - - Button("Save") { + Spacer() + Button("Update") { Task { await save() } } .buttonStyle(.borderedProminent) - .disabled(controllerURL.isEmpty || apiKey.isEmpty) - .keyboardShortcut(.defaultAction) + .disabled(controllerURL.isEmpty || apiKey.isEmpty || isSaving) + } + } else { + LabeledContent("Controller URL", value: controllerURL) + LabeledContent("API Key") { + Button("Change\u{2026}") { + apiKey = "" + isEditingCredentials = true + } + .buttonStyle(.borderless) + } + LabeledContent("Self-signed certificates") { + Text(allowSelfSigned ? "Allowed" : "Blocked") + } + Button("Edit Connection\u{2026}") { + isEditingCredentials = true } + if allowSelfSigned { + Button("Reset Certificate Pin") { + Task { + await controller.resetCertPin() + } + } + } + } + } header: { + Text("Connection") + } footer: { + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) } } - .padding(24) - .frame(width: 380) - .task { await loadExisting() } - .confirmationDialog("Reset UniFiBar?", isPresented: $showResetConfirmation) { - Button("Reset & Forget", role: .destructive) { - Task { await reset() } + } + + private var siteSection: some View { + Group { + if let siteId = controller.preferences.siteId { + Section { + LabeledContent("Site ID", value: siteId) + } header: { + Text("Site") + } } - } message: { - Text("This will remove all saved credentials and settings. You will need to set up again.") } } - private func loadExisting() async { - if let url = await KeychainHelper.shared.read(.controllerURL) { - controllerURL = url + // MARK: - Behavior + + private var behaviorSection: some View { + Section { + Toggle("Compact mode", isOn: $compactMode) + .onChange(of: compactMode) { _, newValue in + controller.preferences.setCompactMode(newValue) + } + 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) + controller.restartPolling() + } + Toggle("Launch at login", isOn: $launchAtLogin) + .onChange(of: launchAtLogin) { _, newValue in + guard launchAtLoginInitialized else { return } + setLaunchAtLogin(newValue) + } + } header: { + Text("Behavior") + } + } + + // MARK: - Visibility + + private var visibilitySection: some View { + Section { + ForEach(MenuSection.allCases, id: \.rawValue) { section in + SectionToggleRow(section: section, preferences: controller.preferences) + } + } header: { + Text("Visible Sections") } - if let key = await KeychainHelper.shared.read(.apiKey) { - apiKey = key + } + + // MARK: - Reset + + private var resetSection: some View { + Section { + Button("Reset & Forget All Settings\u{2026}", role: .destructive) { + showResetConfirmation = true + } + } footer: { + if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + Text("UniFiBar v\(version)") + } } + } + + // MARK: - Actions + + private func loadExisting() async { + 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 + launchAtLoginInitialized = true isLoading = false } + 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 + isSaving = true + defer { isSaving = false } + var urlString = controllerURL.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) @@ -126,30 +206,51 @@ 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 + + let testClient = UniFiClient( + baseURL: url, + apiKey: trimmedKey, + allowSelfSigned: allowSelfSigned + ) do { + let siteId = try await testClient.fetchSiteId() try await controller.preferences.save( controllerURL: urlString, apiKey: trimmedKey, allowSelfSigned: allowSelfSigned ) + controller.preferences.siteId = siteId await controller.reconfigure() - dismiss() + isEditingCredentials = false + } catch let error as UniFiError { + switch error { + case .httpError(let code) where code == 401 || code == 403: + errorMessage = "Authentication failed. Check your API key." + 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 and certificate settings." + } + } catch is URLError { + errorMessage = "Could not reach the controller. Check the URL and your network connection." } catch { - errorMessage = "Failed to save credentials. Please try again." + errorMessage = "Connection failed. Check your URL and network settings." } } private func reset() async { await controller.preferences.resetAll() - controller.stopPolling() + controller.resetState() dismiss() } @@ -161,7 +262,129 @@ struct PreferencesView: View { try SMAppService.mainApp.unregister() } } catch { + launchAtLoginInitialized = false launchAtLogin = SMAppService.mainApp.status == .enabled + launchAtLoginInitialized = true } } } + +// MARK: - Diagnostics Section + +private struct DiagnosticsSection: View { + let controller: StatusBarController + + @State private var versionTapCount = 0 + + var body: 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 let url = controller.updateChecker.releaseURL { + NSWorkspace.shared.open(url) + } + } + .buttonStyle(.borderless) + .foregroundStyle(.blue) + } + } + } + + LabeledContent("Consecutive Errors") { + Text("\(controller.consecutiveErrorCount)") + } + LabeledContent("Poll Interval") { + Text("\(controller.currentPollInterval)s") + } + + 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() + } + } + } + } + + 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") + } + } +} + +// MARK: - Section Toggle Row + +private struct SectionToggleRow: View { + let section: MenuSection + let preferences: PreferencesManager + + var body: some View { + Toggle(isOn: Binding( + get: { preferences.isSectionEnabled(section) }, + set: { preferences.setSectionEnabled(section, enabled: $0) } + )) { + Label(section.displayName, systemImage: section.icon) + } + } +} \ No newline at end of file diff --git a/Sources/UniFiBar/Views/Sections/ConnectionSection.swift b/Sources/UniFiBar/Views/Sections/ConnectionSection.swift index cbe3197..07daefc 100644 --- a/Sources/UniFiBar/Views/Sections/ConnectionSection.swift +++ b/Sources/UniFiBar/Views/Sections/ConnectionSection.swift @@ -1,24 +1,25 @@ import SwiftUI struct SignalSection: View { - let wifiStatus: WiFiStatus + let signalTrend: WiFiStatus.TrendDirection + let signalDescription: String + let noiseFloor: Int? var body: some View { SubSectionHeader(title: "Signal") - // RSSI with trend HStack(spacing: 6) { Image(systemName: "antenna.radiowaves.left.and.right") .foregroundStyle(.secondary) .frame(width: 20, alignment: .center) Text("RSSI") .foregroundStyle(.primary) - if wifiStatus.signalTrend != .stable { - Text(wifiStatus.signalTrend.symbol) - .foregroundStyle(wifiStatus.signalTrend == .up ? .green : .red) + if signalTrend != .stable { + Text(signalTrend.symbol) + .foregroundStyle(signalTrend == .up ? .green : .red) } Spacer() - Text(wifiStatus.signalDescription) + Text(signalDescription) .foregroundStyle(.secondary) .monospacedDigit() } @@ -26,39 +27,46 @@ struct SignalSection: View { .padding(.horizontal, 16) .padding(.vertical, 1) - if let noise = wifiStatus.noiseFloor { - MetricRow(label: "Noise Floor", value: "\(noise) dBm", systemImage: "waveform.path") + if let noiseFloor { + MetricRow(label: "Noise Floor", value: "\(noiseFloor) dBm", systemImage: "waveform.path") } } } struct AccessPointSection: View { - let wifiStatus: WiFiStatus + let apName: String? + let essid: String? + let formattedAPLoad: String? + let channel: Int? + let formattedChannelWidth: String? + let wifiStandard: String? + let mimoDescription: String? + let recentlyRoamed: Bool + let roamedFrom: String? var body: some View { SubSectionHeader(title: "Access Point") - if let apName = wifiStatus.apName { - let networkSuffix = wifiStatus.essid.map { " (\($0))" } ?? "" + if let apName { + let networkSuffix = essid.map { " (\($0))" } ?? "" MetricRow(label: "AP", value: apName + networkSuffix, systemImage: "wifi.router") - } else if let essid = wifiStatus.essid { + } else if let essid { MetricRow(label: "Network", value: essid, systemImage: "wifi.router") } - if let apLoad = wifiStatus.formattedAPLoad { + if let apLoad = formattedAPLoad { MetricRow(label: "Load", value: apLoad, systemImage: "cpu") } - if let channel = wifiStatus.channel { + if let channel { MetricRow(label: "Channel", value: channelDescription(channel), systemImage: "dot.radiowaves.right") } - if let standard = wifiStatus.wifiStandard { + if let standard = wifiStandard { MetricRow(label: "Standard", value: standardDescription(standard), systemImage: "cellularbars") } - // Roam indicator - if wifiStatus.recentlyRoamed, let from = wifiStatus.roamedFrom { + if recentlyRoamed, let from = roamedFrom { MetricRow(label: "Roamed from", value: from, systemImage: "arrow.triangle.swap") } } @@ -66,14 +74,14 @@ struct AccessPointSection: View { private func channelDescription(_ channel: Int) -> String { let band = channel > 14 ? "5 GHz" : "2.4 GHz" let base = "\(channel) · \(band)" - if let width = wifiStatus.formattedChannelWidth { + if let width = formattedChannelWidth { return "\(base) · \(width)" } return base } private func standardDescription(_ standard: String) -> String { - if let mimo = wifiStatus.mimoDescription { + if let mimo = mimoDescription { return "\(standard) · \(mimo) MIMO" } return standard @@ -81,62 +89,75 @@ struct AccessPointSection: View { } struct LinkSection: View { - let wifiStatus: WiFiStatus + let formattedRxRate: String + let formattedTxRate: String + let formattedTxRetries: String? + let formattedSessionData: String? var body: some View { SubSectionHeader(title: "Link") - MetricRow(label: "Rx", value: wifiStatus.formattedRxRate, systemImage: "arrow.down") - MetricRow(label: "Tx", value: wifiStatus.formattedTxRate, systemImage: "arrow.up") + MetricRow(label: "Rx", value: formattedRxRate, systemImage: "arrow.down") + MetricRow(label: "Tx", value: formattedTxRate, systemImage: "arrow.up") - if let sessionData = wifiStatus.formattedSessionData { + if let retries = formattedTxRetries { + MetricRow(label: "Tx Retries", value: retries, systemImage: "arrow.counterclockwise") + } + + if let sessionData = formattedSessionData { MetricRow(label: "Data", value: sessionData, systemImage: "chart.bar") } } } struct SessionSection: View { - let wifiStatus: WiFiStatus + let ip: String? + let uptime: Int? + let formattedUptime: String + let formattedRoamCount: String? var body: some View { SubSectionHeader(title: "Session") - if let ip = wifiStatus.ip { + if let ip { MetricRow(label: "IP", value: ip, systemImage: "network") } - if let uptime = wifiStatus.uptime, uptime > 0 { - MetricRow(label: "Uptime", value: wifiStatus.formattedUptime, systemImage: "timer") + if let uptime, uptime > 0 { + MetricRow(label: "Uptime", value: formattedUptime, systemImage: "timer") } - if let roamCountText = wifiStatus.formattedRoamCount { + if let roamCountText = formattedRoamCount { MetricRow(label: "Roams", value: roamCountText, systemImage: "repeat") } } } struct NetworkSection: View { - let wifiStatus: WiFiStatus + let formattedNetworkOverview: String? + let formattedDeviceOverview: String? + let offlineDeviceNames: [String]? + let firmwareBadge: String? var body: some View { SectionHeader(title: "Network") - if let overview = wifiStatus.formattedNetworkOverview { + if let overview = formattedNetworkOverview { MetricRow(label: "Clients", value: overview, systemImage: "person.2") } - if let deviceOverview = wifiStatus.formattedDeviceOverview { + if let deviceOverview = formattedDeviceOverview { MetricRow(label: "Devices", value: deviceOverview, systemImage: "desktopcomputer") } - if let offlineNames = wifiStatus.offlineDeviceNames { + if let offlineNames = offlineDeviceNames { ForEach(offlineNames, id: \.self) { name in MetricRow(label: name, value: "offline", systemImage: "exclamationmark.circle") } } - if let badge = wifiStatus.firmwareBadge { + if let badge = firmwareBadge { MetricRow(label: "Firmware", value: badge, systemImage: "arrow.down.circle") } } -} +} \ No newline at end of file diff --git a/Sources/UniFiBar/Views/Sections/DDNSSection.swift b/Sources/UniFiBar/Views/Sections/DDNSSection.swift new file mode 100644 index 0000000..36b7962 --- /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, !service.isEmpty { + 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..1760b69 100644 --- a/Sources/UniFiBar/Views/Sections/InternetSection.swift +++ b/Sources/UniFiBar/Views/Sections/InternetSection.swift @@ -1,12 +1,29 @@ import SwiftUI struct InternetSection: View { - let wifiStatus: WiFiStatus + let wanIsUp: Bool? + let wanIP: String? + let wanISP: String? + let formattedWANLatency: String? + let formattedWANAvailability: String? + let wanDrops: Int? + let formattedWANThroughput: String? + let speedTest: SpeedTestResult? + let gatewayName: String? + let formattedGatewayLoad: String? + let formattedGatewayUptime: String? var body: some View { SectionHeader(title: "Internet", showDivider: false) - if let isUp = wifiStatus.wanIsUp { + wanStatusGroup + speedTestGroup + gatewayGroup + } + + @ViewBuilder + private var wanStatusGroup: some View { + if let isUp = wanIsUp { HStack(spacing: 6) { Image(systemName: isUp ? "globe" : "globe.badge.chevron.backward") .foregroundStyle(isUp ? .green : .red) @@ -14,7 +31,7 @@ struct InternetSection: View { Text(isUp ? "Connected" : "Disconnected") .foregroundStyle(.primary) Spacer() - if let wanIP = wifiStatus.wanIP { + if let wanIP { Text(wanIP) .foregroundStyle(.secondary) .monospacedDigit() @@ -25,38 +42,76 @@ struct InternetSection: View { .padding(.vertical, 1) } - if let isp = wifiStatus.wanISP { + if let isp = wanISP { MetricRow(label: "ISP", value: isp, systemImage: "building.2") } - if let latency = wifiStatus.formattedWANLatency { + if let latency = formattedWANLatency { MetricRow(label: "Latency", value: latency, systemImage: "stopwatch") } - if let availability = wifiStatus.formattedWANAvailability { + if let availability = formattedWANAvailability { MetricRow(label: "Availability", value: availability, systemImage: "checkmark.shield") } - if let drops = wifiStatus.wanDrops { - MetricRow(label: "Drops", value: "\(drops)", systemImage: "exclamationmark.triangle") + if let wanDrops { + MetricRow(label: "Drops", value: "\(wanDrops)", systemImage: "exclamationmark.triangle") } - if let throughput = wifiStatus.formattedWANThroughput { + if let throughput = formattedWANThroughput { MetricRow(label: "Throughput", value: throughput, systemImage: "arrow.up.arrow.down") } + } + + @ViewBuilder + private var speedTestGroup: some View { + if let 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, 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 - if let gwName = wifiStatus.gatewayName { + @ViewBuilder + private var gatewayGroup: some View { + let hasGateway = gatewayName != nil || formattedGatewayLoad != nil || formattedGatewayUptime != nil + if hasGateway { SubSectionHeader(title: "Gateway") + } + + if let gwName = gatewayName { MetricRow(label: "Device", value: gwName, systemImage: "server.rack") } - if let load = wifiStatus.formattedGatewayLoad { + if let load = formattedGatewayLoad { MetricRow(label: "Load", value: load, systemImage: "cpu") } - if let uptime = wifiStatus.formattedGatewayUptime { + if let uptime = formattedGatewayUptime { MetricRow(label: "Uptime", value: uptime, systemImage: "timer") } } -} +} \ No newline at end of file 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/SessionTimeSection.swift b/Sources/UniFiBar/Views/Sections/SessionTimeSection.swift index 9330fdd..a5d30c4 100644 --- a/Sources/UniFiBar/Views/Sections/SessionTimeSection.swift +++ b/Sources/UniFiBar/Views/Sections/SessionTimeSection.swift @@ -4,7 +4,7 @@ struct SessionTimeSection: View { let sessions: [WiFiStatus.SessionEntry] var body: some View { - SubSectionHeader(title: "Session Time (Today)") + SubSectionHeader(title: "Session Time") ForEach(sessions) { session in HStack(spacing: 8) { @@ -12,6 +12,7 @@ struct SessionTimeSection: View { .font(.callout) .frame(width: 90, alignment: .leading) .lineLimit(1) + .truncationMode(.tail) ProgressBarView(fraction: session.fraction, color: .accentColor) @@ -29,6 +30,14 @@ struct SessionTimeSection: View { private func formattedDuration(_ seconds: Int) -> String { let hours = seconds / 3600 let minutes = (seconds % 3600) / 60 + if hours >= 24 { + let days = hours / 24 + let remainingHours = hours % 24 + if remainingHours > 0 { + return "\(days)d \(remainingHours)h" + } + return "\(days)d" + } if hours > 0 { return "\(hours)h \(minutes)m" } diff --git a/Sources/UniFiBar/Views/Sections/VPNSection.swift b/Sources/UniFiBar/Views/Sections/VPNSection.swift index 733c4e7..e073a12 100644 --- a/Sources/UniFiBar/Views/Sections/VPNSection.swift +++ b/Sources/UniFiBar/Views/Sections/VPNSection.swift @@ -6,13 +6,13 @@ struct VPNSection: View { var body: some View { SectionHeader(title: "VPN") - ForEach(Array(tunnels.enumerated()), id: \.offset) { _, tunnel in + ForEach(tunnels) { tunnel in let connected = tunnel.isConnected HStack(spacing: 6) { 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?.isEmpty == true ? nil : tunnel.name) ?? tunnel.id).prefix(128))) .foregroundStyle(.primary) .lineLimit(1) Spacer() diff --git a/Sources/UniFiBar/Views/Sections/WiFiExperienceSection.swift b/Sources/UniFiBar/Views/Sections/WiFiExperienceSection.swift index 88cd508..023b84b 100644 --- a/Sources/UniFiBar/Views/Sections/WiFiExperienceSection.swift +++ b/Sources/UniFiBar/Views/Sections/WiFiExperienceSection.swift @@ -1,29 +1,33 @@ import SwiftUI struct WiFiExperienceSection: View { - let wifiStatus: WiFiStatus + let qualityLabel: String + let satisfaction: Int? + let satisfactionTrend: WiFiStatus.TrendDirection + let wifiExperienceAverage: Int? + let accentColor: Color var body: some View { SectionHeader(title: "WiFi") VStack(alignment: .leading, spacing: 6) { HStack { - Text(wifiStatus.qualityLabel) + Text(qualityLabel) .font(.title3) .fontWeight(.medium) - if let satisfaction = wifiStatus.satisfaction { + if let satisfaction { Text("· \(satisfaction)%") .font(.title3) .foregroundStyle(.secondary) .monospacedDigit() - if wifiStatus.satisfactionTrend != .stable { - Text(wifiStatus.satisfactionTrend.symbol) + if satisfactionTrend != .stable { + Text(satisfactionTrend.symbol) .font(.title3) - .foregroundStyle(wifiStatus.satisfactionTrend == .up ? .green : .red) + .foregroundStyle(satisfactionTrend == .up ? .green : .red) } - if let avg = wifiStatus.wifiExperienceAverage { + if let avg = wifiExperienceAverage { Text("(avg \(avg)%)") .font(.callout) .foregroundStyle(.tertiary) @@ -32,14 +36,14 @@ struct WiFiExperienceSection: View { } } - if let satisfaction = wifiStatus.satisfaction { + if let satisfaction { ProgressBarView( fraction: Double(satisfaction) / 100.0, - color: wifiStatus.statusBarColor + color: accentColor ) } } .padding(.horizontal, 16) .padding(.vertical, 4) } -} +} \ No newline at end of file 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/Tests/UniFiBarTests/DecodeFlexibleArrayTests.swift b/Tests/UniFiBarTests/DecodeFlexibleArrayTests.swift new file mode 100644 index 0000000..36ae5ac --- /dev/null +++ b/Tests/UniFiBarTests/DecodeFlexibleArrayTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Testing +@testable import UniFiBar + +struct DecodeFlexibleArrayTests { + + // Simple DTO for testing decode + private struct TestItem: Decodable, Sendable { + let id: String + let name: String + } + + // MARK: - Wrapped Array { "data": [...] } + + @Test func testWrappedArray() async { + let json = """ + { + "data": [ + {"id": "1", "name": "Alpha"}, + {"id": "2", "name": "Beta"} + ] + } + """.data(using: .utf8)! + + let result = await UniFiClient.decodeFlexibleArray(TestItem.self, from: json, endpoint: "test") + #expect(result != nil) + #expect(result?.count == 2) + #expect(result?.first?.name == "Alpha") + } + + // MARK: - Bare Array [...] + + @Test func testBareArray() async { + let json = """ + [ + {"id": "1", "name": "Alpha"}, + {"id": "2", "name": "Beta"} + ] + """.data(using: .utf8)! + + let result = await UniFiClient.decodeFlexibleArray(TestItem.self, from: json, endpoint: "test") + #expect(result != nil) + #expect(result?.count == 2) + #expect(result?.first?.name == "Alpha") + } + + // MARK: - Null Data { "data": null } + + @Test func testNullData() async { + let json = """ + { "data": null } + """.data(using: .utf8)! + + let result = await UniFiClient.decodeFlexibleArray(TestItem.self, from: json, endpoint: "test") + #expect(result != nil) + #expect(result?.isEmpty == true) + } + + // MARK: - Empty Data { "data": [] } + + @Test func testEmptyWrappedArray() async { + let json = """ + { "data": [] } + """.data(using: .utf8)! + + let result = await UniFiClient.decodeFlexibleArray(TestItem.self, from: json, endpoint: "test") + #expect(result != nil) + #expect(result?.isEmpty == true) + } + + // MARK: - Bare Empty Array [] + + @Test func testBareEmptyArray() async { + let json = "[]".data(using: .utf8)! + + let result = await UniFiClient.decodeFlexibleArray(TestItem.self, from: json, endpoint: "test") + #expect(result != nil) + #expect(result?.isEmpty == true) + } + + // MARK: - Invalid JSON + + @Test func testInvalidJSON() async { + let json = "not json at all".data(using: .utf8)! + + let result = await UniFiClient.decodeFlexibleArray(TestItem.self, from: json, endpoint: "test") + #expect(result == nil) + } + + // MARK: - Empty Data + + @Test func testEmptyData() async { + let json = Data() + + let result = await UniFiClient.decodeFlexibleArray(TestItem.self, from: json, endpoint: "test") + #expect(result == nil) + } +} \ No newline at end of file diff --git a/Tests/UniFiBarTests/PreferencesManagerTests.swift b/Tests/UniFiBarTests/PreferencesManagerTests.swift new file mode 100644 index 0000000..6c3c563 --- /dev/null +++ b/Tests/UniFiBarTests/PreferencesManagerTests.swift @@ -0,0 +1,96 @@ +import Testing +@testable import UniFiBar + +@MainActor +struct PreferencesManagerTests { + + // MARK: - Default Section Visibility + + @Test func testDefaultSectionVisibility() async { + // Use a fresh instance with isolated UserDefaults + let prefs = PreferencesManager() + // Clear any stale section visibility from previous app runs + await prefs.resetAll() + // Re-create to get fresh defaults after reset + let fresh = PreferencesManager() + // Enabled by default + #expect(fresh.isSectionEnabled(.internet) == true) + #expect(fresh.isSectionEnabled(.vpn) == true) + #expect(fresh.isSectionEnabled(.wifi) == true) + #expect(fresh.isSectionEnabled(.network) == true) + #expect(fresh.isSectionEnabled(.sessionHistory) == true) + // Disabled by default + #expect(fresh.isSectionEnabled(.ddns) == false) + #expect(fresh.isSectionEnabled(.portForwards) == false) + #expect(fresh.isSectionEnabled(.nearbyAPs) == false) + } + + @Test func testSetSectionEnabled() async { + let prefs = PreferencesManager() + await prefs.resetAll() + let fresh = PreferencesManager() + #expect(fresh.isSectionEnabled(.ddns) == false) + fresh.setSectionEnabled(.ddns, enabled: true) + #expect(fresh.isSectionEnabled(.ddns) == true) + fresh.setSectionEnabled(.ddns, enabled: false) + #expect(fresh.isSectionEnabled(.ddns) == false) + } + + // MARK: - Poll Interval Clamping + + @Test func testPollIntervalClamping_min() { + let prefs = PreferencesManager() + prefs.setPollInterval(5) + #expect(prefs.pollIntervalSeconds == 10) + } + + @Test func testPollIntervalClamping_max() { + let prefs = PreferencesManager() + prefs.setPollInterval(500) + #expect(prefs.pollIntervalSeconds == 300) + } + + @Test func testPollIntervalClamping_valid() { + let prefs = PreferencesManager() + prefs.setPollInterval(30) + #expect(prefs.pollIntervalSeconds == 30) + } + + @Test func testPollIntervalClamping_boundaryMin() { + let prefs = PreferencesManager() + prefs.setPollInterval(10) + #expect(prefs.pollIntervalSeconds == 10) + } + + @Test func testPollIntervalClamping_boundaryMax() { + let prefs = PreferencesManager() + prefs.setPollInterval(300) + #expect(prefs.pollIntervalSeconds == 300) + } + + // MARK: - hasMonitoringSectionsEnabled + + @Test func testHasMonitoringSectionsEnabled_withDefaults() { + let prefs = PreferencesManager() + // ddns, portForwards, nearbyAPs are disabled by default, but UserDefaults may override + let result = prefs.hasMonitoringSectionsEnabled + #expect(result == true || result == false) + } + + @Test func testHasMonitoringSectionsEnabled_allDisabled() { + let prefs = PreferencesManager() + for section in [MenuSection.ddns, .portForwards, .nearbyAPs] { + prefs.setSectionEnabled(section, enabled: false) + } + #expect(prefs.hasMonitoringSectionsEnabled == false) + } + + @Test func testHasMonitoringSectionsEnabled_oneEnabled() { + let prefs = PreferencesManager() + for section in [MenuSection.ddns, .portForwards, .nearbyAPs] { + prefs.setSectionEnabled(section, enabled: false) + } + prefs.setSectionEnabled(.ddns, enabled: true) + #expect(prefs.hasMonitoringSectionsEnabled == true) + } +} \ No newline at end of file diff --git a/Tests/UniFiBarTests/UniFiErrorTests.swift b/Tests/UniFiBarTests/UniFiErrorTests.swift new file mode 100644 index 0000000..1527763 --- /dev/null +++ b/Tests/UniFiBarTests/UniFiErrorTests.swift @@ -0,0 +1,34 @@ +import Testing +@testable import UniFiBar + +struct UniFiErrorTests { + + @Test func testHTTPErrorCode() { + let error = UniFiError.httpError(statusCode: 401) + if case .httpError(let code) = error { + #expect(code == 401) + } + } + + @Test func testHTTPErrorCodes_various() { + let codes = [400, 401, 403, 404, 500] + for code in codes { + let error = UniFiError.httpError(statusCode: code) + if case .httpError(let stored) = error { + #expect(stored == code) + } + } + } + + @Test func testAllCases() { + // Verify all cases can be constructed + let errors: [UniFiError] = [ + .httpError(statusCode: 500), + .noSitesFound, + .selfNotFound, + .invalidURL, + .notConfigured, + ] + #expect(errors.count == 5) + } +} \ No newline at end of file diff --git a/Tests/UniFiBarTests/UpdateCheckerTests.swift b/Tests/UniFiBarTests/UpdateCheckerTests.swift new file mode 100644 index 0000000..ea05fef --- /dev/null +++ b/Tests/UniFiBarTests/UpdateCheckerTests.swift @@ -0,0 +1,64 @@ +import Testing +@testable import UniFiBar + +@MainActor +struct UpdateCheckerTests { + + // MARK: - Version Comparison + + @Test func testIsNewer_major() { + #expect(UpdateChecker.isNewer(current: "2.0.0", remote: "3.0.0") == true) + #expect(UpdateChecker.isNewer(current: "3.0.0", remote: "2.0.0") == false) + } + + @Test func testIsNewer_minor() { + #expect(UpdateChecker.isNewer(current: "2.0.0", remote: "2.1.0") == true) + #expect(UpdateChecker.isNewer(current: "2.1.0", remote: "2.0.0") == false) + } + + @Test func testIsNewer_patch() { + #expect(UpdateChecker.isNewer(current: "2.0.0", remote: "2.0.1") == true) + #expect(UpdateChecker.isNewer(current: "2.0.1", remote: "2.0.0") == false) + } + + @Test func testIsNewer_equal() { + #expect(UpdateChecker.isNewer(current: "2.0.0", remote: "2.0.0") == false) + #expect(UpdateChecker.isNewer(current: "1.5.3", remote: "1.5.3") == false) + } + + @Test func testIsNewer_older() { + #expect(UpdateChecker.isNewer(current: "3.0.0", remote: "2.9.9") == false) + } + + @Test func testIsNewer_unevenLengths() { + #expect(UpdateChecker.isNewer(current: "2.0", remote: "2.0.1") == true) + #expect(UpdateChecker.isNewer(current: "2.0.0", remote: "2.0") == false) + } + + // MARK: - Pre-release version parsing + + @Test func testIsNewer_preRelease() { + // "2.0.0-beta1" should parse as [2, 0, 0] — not [2, 0] + #expect(UpdateChecker.isNewer(current: "2.0.0", remote: "2.0.0-beta1") == false) + // Pre-release is NOT newer than the same stable version + #expect(UpdateChecker.isNewer(current: "1.9.9", remote: "2.0.0-beta1") == true) + } + + // MARK: - Version segment parsing + + @Test func testParseVersionSegment_numeric() { + #expect(UpdateChecker.parseVersionSegment("3") == 3) + #expect(UpdateChecker.parseVersionSegment("0") == 0) + #expect(UpdateChecker.parseVersionSegment("15") == 15) + } + + @Test func testParseVersionSegment_preRelease() { + #expect(UpdateChecker.parseVersionSegment("0-beta1") == 0) + #expect(UpdateChecker.parseVersionSegment("3-rc2") == 3) + #expect(UpdateChecker.parseVersionSegment("1-alpha") == 1) + } + + @Test func testParseVersionSegment_nonNumeric() { + #expect(UpdateChecker.parseVersionSegment("abc") == 0) + } +} \ No newline at end of file diff --git a/Tests/UniFiBarTests/WiFiStatusTests.swift b/Tests/UniFiBarTests/WiFiStatusTests.swift new file mode 100644 index 0000000..819e8e6 --- /dev/null +++ b/Tests/UniFiBarTests/WiFiStatusTests.swift @@ -0,0 +1,170 @@ +import Testing +@testable import UniFiBar + +@MainActor +struct WiFiStatusTests { + + // MARK: - Quality Label + + @Test func testQualityLabel() { + let status = WiFiStatus() + status.satisfaction = 95 + #expect(status.qualityLabel == "Excellent") + status.satisfaction = 80 + #expect(status.qualityLabel == "Excellent") + status.satisfaction = 79 + #expect(status.qualityLabel == "Good") + status.satisfaction = 50 + #expect(status.qualityLabel == "Good") + status.satisfaction = 49 + #expect(status.qualityLabel == "Fair") + status.satisfaction = 20 + #expect(status.qualityLabel == "Fair") + status.satisfaction = 19 + #expect(status.qualityLabel == "Poor") + status.satisfaction = 0 + #expect(status.qualityLabel == "Poor") + status.satisfaction = nil + #expect(status.qualityLabel == "Unknown") + } + + // MARK: - Status Bar Colors + + @Test func testStatusBarColors_errorStates() { + let status = WiFiStatus() + status.errorState = .controllerUnreachable(reason: nil) + #expect(status.statusBarColor == .orange) + status.errorState = .invalidAPIKey(httpCode: 401) + #expect(status.statusBarColor == .red) + status.errorState = .notConnected + #expect(status.statusBarColor == .gray) + status.errorState = .certChanged + #expect(status.statusBarColor == .orange) + } + + @Test func testStatusBarColors_connected() { + let status = WiFiStatus() + status.isConnected = true + status.satisfaction = 90 + #expect(status.statusBarColor == .green) + status.satisfaction = 60 + #expect(status.statusBarColor == .yellow) + status.satisfaction = 30 + #expect(status.statusBarColor == .red) + } + + @Test func testStatusBarColors_wired() { + let status = WiFiStatus() + status.isConnected = true + status.isWired = true + #expect(status.statusBarColor == .blue) + } + + // MARK: - Status Bar Symbols + + @Test func testStatusBarSymbols_errorStates() { + let status = WiFiStatus() + status.errorState = .controllerUnreachable(reason: nil) + #expect(status.statusBarSymbol == "wifi.exclamationmark") + status.errorState = .invalidAPIKey(httpCode: 403) + #expect(status.statusBarSymbol == "lock.shield") + status.errorState = .notConnected + #expect(status.statusBarSymbol == "wifi.slash") + status.errorState = .certChanged + #expect(status.statusBarSymbol == "lock.shield") + } + + @Test func testStatusBarSymbols_connected() { + let status = WiFiStatus() + status.isConnected = true + status.satisfaction = 50 + #expect(status.statusBarSymbol == "wifi") + status.satisfaction = 30 + #expect(status.statusBarSymbol == "wifi.exclamationmark") + } + + @Test func testStatusBarSymbols_disconnected() { + let status = WiFiStatus() + #expect(status.statusBarSymbol == "wifi.slash") + } + + @Test func testStatusBarSymbols_wired() { + let status = WiFiStatus() + status.isConnected = true + status.isWired = true + #expect(status.statusBarSymbol == "cable.connector.horizontal") + } + + // MARK: - clearState / markDisconnected / markError + + @Test func testMarkDisconnectedClearsAllState() { + let status = WiFiStatus() + // Populate all fields + status.isConnected = true + status.isWired = false + status.satisfaction = 95 + status.signal = -50 + status.apName = "U7 Pro" + status.essid = "MyNetwork" + status.channel = 36 + status.ip = "192.168.1.5" + status.uptime = 3600 + status.wanIsUp = true + status.vpnTunnels = [VPNTunnelDTO(_id: "1", name: "Tunnel", status: "CONNECTED", remoteNetworkCidr: nil, type: nil)] + + status.markDisconnected() + + #expect(status.isConnected == false) + #expect(status.isWired == false) + #expect(status.errorState == .notConnected) + #expect(status.satisfaction == nil) + #expect(status.signal == nil) + #expect(status.apName == nil) + #expect(status.essid == nil) + #expect(status.channel == nil) + #expect(status.ip == nil) + #expect(status.uptime == nil) + #expect(status.wanIsUp == nil) + #expect(status.vpnTunnels == nil) + } + + @Test func testMarkErrorClearsAllState() { + let status = WiFiStatus() + status.isConnected = true + status.satisfaction = 90 + status.signal = -55 + status.apName = "U7 In-Wall" + + status.markError(.invalidAPIKey(httpCode: 401)) + + #expect(status.isConnected == false) + #expect(status.errorState == .invalidAPIKey(httpCode: 401)) + #expect(status.satisfaction == nil) + #expect(status.signal == nil) + #expect(status.apName == nil) + } + + // MARK: - Format Rate + + @Test func testFormatRate() { + let status = WiFiStatus() + // Input is Kbps — 500000 Kbps = 500 Mbps + #expect(status.formatRate(500_000) == "500 Mbps") + #expect(status.formatRate(1_000_000) == "1.00 Gbps") + #expect(status.formatRate(1_500_000) == "1.50 Gbps") + #expect(status.formatRate(1_200_000) == "1.20 Gbps") + #expect(status.formatRate(54_000) == "54 Mbps") + // nil + #expect(status.formatRate(nil) == "—") + } + + // MARK: - Format Bytes + + @Test func testFormatBytes() { + let status = WiFiStatus() + #expect(status.formatBytes(500_000) == "500 KB") + #expect(status.formatBytes(1_500_000) == "1.5 MB") + #expect(status.formatBytes(1_000_000_000) == "1.0 GB") + #expect(status.formatBytes(2_000_000_000) == "2.0 GB") + } +} \ No newline at end of file