Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 129 additions & 19 deletions comp/core/gui/guiimpl/systray/Sources/WiFiDataProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,28 +46,21 @@ class WiFiDataProvider: NSObject, CLLocationManagerDelegate {
private var permissionPromptProcess: Process? = nil
private var sessionPromptAttempted: Bool = false
private var firstWiFiRequestMade: Bool = false
private var tccLoadingCompleted: Bool = false // Tracks if TCC polling has finished

override init() {
self.locationManager = CLLocationManager()
super.init()
// Keep delegate to monitor permission status changes
self.locationManager.delegate = self

let status = getAuthorizationStatus()
Logger.info("Initialized with authorization status: \(authorizationStatusString())", context: "WiFiDataProvider")
Logger.info("WiFiDataProvider initialized, waiting for TCC to load...", context: "WiFiDataProvider")

// Log messages if permission not granted
if status != .authorizedAlways {
Logger.info("Location permission not granted - SSID/BSSID will be unavailable", context: "WiFiDataProvider")
Logger.info("To enable: System Settings → Privacy & Security → Location Services → Datadog Agent", context: "WiFiDataProvider")

// Only attempt prompt if GUI environment is available (preserves retry opportunities in headless mode)
if isGUIAvailable() {
Logger.info("GUI environment detected, attempting permission prompt...", context: "WiFiDataProvider")
attemptPermissionPrompt()
} else {
Logger.info("Headless environment detected, skipping permission prompt (will retry when GUI available)", context: "WiFiDataProvider")
}
// Poll for TCC database to load on background thread (non-blocking)
// This provides fast permission prompts (~200ms) while avoiding the TCC timing race condition
// The first WiFi request check serves as a safety net and second-chance mechanism
DispatchQueue.global(qos: .utility).async { [weak self] in
self?.waitForTCCLoad(timeout: 2.0)
}
}

Expand Down Expand Up @@ -112,11 +105,21 @@ class WiFiDataProvider: NSObject, CLLocationManagerDelegate {
private func attemptPermissionPrompt() {
let authStatus = getAuthorizationStatus()

// Only attempt if permission not granted
// Skip if permission already granted
guard authStatus != .authorizedAlways else {
return
}

// Skip if permission is restricted by policy (MDM, parental controls, etc.)
// User cannot override policy restrictions
if authStatus == .restricted {
Logger.info("Location permission restricted by device policy - cannot prompt", context: "WiFiDataProvider")
Logger.info("Contact your system administrator to enable location access", context: "WiFiDataProvider")
return
}

// For .notDetermined and .denied: Allow up to 2 prompt attempts
// These are user-controllable states (unlike .restricted)
// Only show once per session (prevents repeated prompts after dismissal)
guard !sessionPromptAttempted else {
Logger.debug("Permission prompt already attempted this session, skipping", context: "WiFiDataProvider")
Expand Down Expand Up @@ -170,17 +173,124 @@ class WiFiDataProvider: NSObject, CLLocationManagerDelegate {
}
}

/// Wait for TCC (Transparency, Consent, and Control) database to load
/// TCC loads asynchronously when CLLocationManager is created, typically taking 50-500ms
/// This method polls the authorization status until it becomes definitive (not .notDetermined)
/// or until the timeout is reached
private func waitForTCCLoad(timeout: TimeInterval = 2.0) {
let startTime = Date()
let pollInterval: TimeInterval = 0.05 // 50ms - balance between responsiveness and CPU usage

// Early exit optimization: check immediately first
// On warm start, TCC might already be cached and ready
let initialStatus = getAuthorizationStatus()
if initialStatus != .notDetermined {
let elapsed = Int(Date().timeIntervalSince(startTime) * 1000)
Logger.info("TCC already loaded (immediate check after \(elapsed)ms), status: \(authorizationStatusString())", context: "WiFiDataProvider")

DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.handleTCCLoadedStatus(initialStatus)
}
return
}

// TCC not ready yet, start polling
Logger.info("TCC not ready (initial status: notDetermined), polling for up to \(Int(timeout * 1000))ms...", context: "WiFiDataProvider")

while Date().timeIntervalSince(startTime) < timeout {
let status = getAuthorizationStatus()

// Stop polling if we have a definitive answer
if status != .notDetermined {
let elapsed = Int(Date().timeIntervalSince(startTime) * 1000)
Logger.info("TCC loaded after \(elapsed)ms, status: \(authorizationStatusString())", context: "WiFiDataProvider")

// Handle the loaded status on main thread
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.handleTCCLoadedStatus(status)
}
return
}

Thread.sleep(forTimeInterval: pollInterval)
}

// Timeout - status still notDetermined after 2 seconds
let elapsed = Int(timeout * 1000)
Logger.info("TCC poll timeout after \(elapsed)ms, status: notDetermined", context: "WiFiDataProvider")
Logger.info("Permission likely not set - will attempt prompt", context: "WiFiDataProvider")

// Permission truly not set - handle on main thread
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.handleTCCLoadedStatus(.notDetermined)
}
}

/// Handle TCC authorization status after it has been loaded
/// This is called from waitForTCCLoad() on the main thread
private func handleTCCLoadedStatus(_ status: CLAuthorizationStatus) {
// Mark TCC loading as complete - this allows Check #2 to proceed with real status
self.tccLoadingCompleted = true

switch status {
case .notDetermined:
// After 2 seconds, still notDetermined = permission truly not set
Logger.info("Location permission not granted - SSID/BSSID will be unavailable", context: "WiFiDataProvider")
Logger.info("To enable: System Settings → Privacy & Security → Location Services → Datadog Agent", context: "WiFiDataProvider")

if isGUIAvailable() {
Logger.info("GUI environment detected, attempting permission prompt...", context: "WiFiDataProvider")
attemptPermissionPrompt()
} else {
Logger.info("Headless environment detected, skipping permission prompt (will retry at first WiFi request)", context: "WiFiDataProvider")
}

case .authorizedAlways:
Logger.info("Location permission already granted - WiFi SSID/BSSID will be available", context: "WiFiDataProvider")

case .denied:
// User explicitly denied - but they can change their mind
// Give them an opportunity to reconsider (Check #1)
Logger.info("Location permission previously denied - SSID/BSSID will be unavailable", context: "WiFiDataProvider")
Logger.info("To enable: System Settings → Privacy & Security → Location Services → Datadog Agent", context: "WiFiDataProvider")

if isGUIAvailable() {
Logger.info("Attempting to prompt (user may have changed mind)...", context: "WiFiDataProvider")
attemptPermissionPrompt()
} else {
Logger.info("Headless environment detected, will retry at first WiFi request", context: "WiFiDataProvider")
}

case .restricted:
// Policy restriction - cannot override, don't prompt
Logger.info("Location permission restricted by device policy - SSID/BSSID will be unavailable", context: "WiFiDataProvider")
Logger.info("Contact your system administrator to enable location access", context: "WiFiDataProvider")

@unknown default:
Logger.error("Unknown authorization status: \(status.rawValue)", context: "WiFiDataProvider")
}
}

/// Get current WiFi information for the system
func getWiFiInfo() -> WiFiData {
// Check current authorization status (read-only, no prompt attempt)
let authStatus = getAuthorizationStatus()
let isAuthorized = (authStatus == .authorizedAlways)

// One more time, on first WiFi request with no permission, try prompting
// for location permission (if GUI available)
if !isAuthorized && !firstWiFiRequestMade {
// Check #2 (Safety Net): On first WiFi request without permission, try prompting
// NOTE: Only check AFTER TCC loading completes (tccLoadingCompleted flag)
// This prevents acting on stale "notDetermined" status during the first ~2 seconds
// The first WiFi requests that arrive before TCC loads will still work (returning RSSI, noise, etc.)
// Only SSID/BSSID will be empty without location permission - this is acceptable
// This serves two purposes:
// 1. Safety net: Catches edge cases where TCC polling at startup failed/timed out
// 2. Second chance: Allows recovery if user accidentally denied permission at startup
if !isAuthorized && !firstWiFiRequestMade && tccLoadingCompleted {
if isGUIAvailable() {
Logger.info("First WiFi data request without permission, attempting prompt...", context: "WiFiDataProvider")
Logger.info("First WiFi data request without permission (after TCC load), attempting prompt...", context: "WiFiDataProvider")
attemptPermissionPrompt()
} else {
Logger.info("First WiFi data request without permission, but headless environment - skipping prompt", context: "WiFiDataProvider")
Expand Down
17 changes: 16 additions & 1 deletion comp/core/gui/guiimpl/systray/Sources/WiFiIPCServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,22 @@ class WiFiIPCServer {

data.withUnsafeBytes { ptr in
let bytesPtr = ptr.baseAddress?.assumingMemoryBound(to: UInt8.self)
_ = write(clientFD, bytesPtr, data.count)
let bytesWritten = write(clientFD, bytesPtr, data.count)

// Handle write errors gracefully (client may disconnect before response is fully sent)
// With SIGPIPE ignored in main.swift, write() returns -1 instead of crashing the app
if bytesWritten < 0 {
if errno == EPIPE {
// Client disconnected before response fully sent - this is normal for short-lived connections
Logger.debug("Client disconnected before response sent (EPIPE)", context: "WiFiIPCServer")
} else {
Logger.error("Write failed: \(String(cString: strerror(errno)))", context: "WiFiIPCServer")
}
} else if bytesWritten < data.count {
// Partial write - rare but possible if socket buffer is full
Logger.info("Partial write: \(bytesWritten)/\(data.count) bytes sent", context: "WiFiIPCServer")
}
// Success case (bytesWritten == data.count) - no logging needed for normal operation
}
}

Expand Down
63 changes: 45 additions & 18 deletions omnibus/package-scripts/agent-dmg/postinst
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,51 @@ fi
# Get the user home directory (needed for both installation modes)
USER_HOME=$(sudo -Hu "$INSTALL_USER" sh -c 'echo $HOME')

# Create agent plist unconditionally (both modes need it)
# ============================================================================
# Check for conflicts BEFORE creating any files
# ============================================================================

# For per-user installations (no marker file), check if system-wide exists
if [ ! -f "/tmp/install-ddagent/system-wide" ]; then
# This is a per-user installation attempt
if [ -f "/Library/LaunchDaemons/com.datadoghq.agent.plist" ]; then
show_installation_error "System-wide agent installation detected at:
/Library/LaunchDaemons/com.datadoghq.agent.plist

Cannot proceed with per-user installation.

To proceed, remove the system-wide components:
sudo launchctl bootout system/com.datadoghq.agent 2>/dev/null || true
sudo rm -f /Library/LaunchDaemons/com.datadoghq.agent.plist
sudo rm -f /Library/LaunchAgents/com.datadoghq.gui.plist

Then rerun this installation."
exit 1
fi

# Also check for orphaned system-wide GUI plist
if [ -f "/Library/LaunchAgents/com.datadoghq.gui.plist" ]; then
show_installation_error "System-wide GUI installation detected at:
/Library/LaunchAgents/com.datadoghq.gui.plist

This may be from a partial or incomplete system-wide installation.
Cannot proceed with per-user installation.

To proceed, remove the system-wide components:
sudo launchctl bootout system/com.datadoghq.agent 2>/dev/null || true
sudo rm -f /Library/LaunchDaemons/com.datadoghq.agent.plist
sudo rm -f /Library/LaunchAgents/com.datadoghq.gui.plist

Then rerun this installation."
exit 1
fi
fi

# ============================================================================
# Validation passed - safe to create files now
# ============================================================================

# Create agent plist (both modes need it)
# For system-wide: install_mac_os.sh will move it to /Library/LaunchDaemons/
# For per-user: it stays in ~/Library/LaunchAgents/
echo "# Configuring the agent as a launchd service"
Expand Down Expand Up @@ -193,24 +237,7 @@ else
# Per-user installation: Install GUI for current user
echo "# Configuring GUI for per-user installation"

# Check if system-wide installation exists
if [ -f "/Library/LaunchDaemons/com.datadoghq.agent.plist" ]; then
show_installation_error "System-wide agent installation detected at:
/Library/LaunchDaemons/com.datadoghq.agent.plist

Cannot proceed with per-user installation.

To proceed, remove the system-wide components:
sudo launchctl bootout system/com.datadoghq.agent 2>/dev/null || true
sudo rm -f /Library/LaunchDaemons/com.datadoghq.agent.plist
sudo rm -f /Library/LaunchAgents/com.datadoghq.gui.plist

Then rerun this installation."
exit 1
fi

# Clean up stale system-wide marker if present (prevents headless mode in per-user install)
# Only reached if no system-wide installation detected above
rm -rf /tmp/install-ddagent

# Error if app not properly installed or root
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Each section from every release note are combined when the
# CHANGELOG.rst is rendered. So the text needs to be worded so that
# it does not depend on any information only available in another
# section. This may mean repeating some details, but each section
# must be readable independently of the other.
#
# Each section note must be formatted as reStructuredText.
---
fixes:
- |
Refined location permission checks to avoid unnecessary system prompt.
Added prevention for possible installation conflict between per-user and system-wide installations.
Loading