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