diff --git a/app/EXO/EXO/EXOApp.swift b/app/EXO/EXO/EXOApp.swift index a5862d17..2ba2c9e8 100644 --- a/app/EXO/EXO/EXOApp.swift +++ b/app/EXO/EXO/EXOApp.swift @@ -45,8 +45,8 @@ struct EXOApp: App { let thunderboltBridge = ThunderboltBridgeService(clusterStateService: service) _thunderboltBridgeService = StateObject(wrappedValue: thunderboltBridge) enableLaunchAtLoginIfNeeded() - // Remove old LaunchDaemon components if they exist (from previous versions) - cleanupLegacyNetworkSetup() + // Install LaunchDaemon to disable Thunderbolt Bridge on startup (prevents network loops) + NetworkSetupHelper.promptAndInstallIfNeeded() // Check local network access periodically (warning disappears when user grants permission) localNetwork.startPeriodicChecking(interval: 10) controller.scheduleLaunch(after: 15) @@ -136,36 +136,6 @@ struct EXOApp: App { } } - private func cleanupLegacyNetworkSetup() { - guard NetworkSetupHelper.hasInstalledComponents() else { return } - // Dispatch async to ensure app is ready before showing alert - DispatchQueue.main.async { - let alert = NSAlert() - alert.messageText = "EXO Network Configuration" - alert.informativeText = - "EXO needs to configure local network discovery on your device. This requires granting permission once." - alert.alertStyle = .informational - alert.addButton(withTitle: "Continue") - alert.addButton(withTitle: "Later") - - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { - Logger().info("User deferred legacy network setup cleanup") - return - } - - do { - try NetworkSetupHelper.uninstall() - Logger().info("Cleaned up legacy network setup components") - } catch { - // Non-fatal: user may have cancelled admin prompt or cleanup may have - // partially succeeded. The app will continue normally. - Logger().warning( - "Could not clean up legacy network setup (non-fatal): \(error.localizedDescription)" - ) - } - } - } } /// Helper for managing EXO's launch-at-login registration diff --git a/app/EXO/EXO/Services/NetworkSetupHelper.swift b/app/EXO/EXO/Services/NetworkSetupHelper.swift index 2bb3ddc3..f5b8ad1c 100644 --- a/app/EXO/EXO/Services/NetworkSetupHelper.swift +++ b/app/EXO/EXO/Services/NetworkSetupHelper.swift @@ -11,6 +11,68 @@ enum NetworkSetupHelper { private static let legacyScriptDestination = "/Library/Application Support/EXO/disable_bridge_enable_dhcp.sh" private static let plistDestination = "/Library/LaunchDaemons/io.exo.networksetup.plist" + private static let requiredStartInterval: Int = 1786 + + private static let setupScript = """ + #!/usr/bin/env bash + + set -euo pipefail + + PREFS="/Library/Preferences/SystemConfiguration/preferences.plist" + + # Remove bridge0 interface + ifconfig bridge0 &>/dev/null && { + ifconfig bridge0 | grep -q 'member' && { + ifconfig bridge0 | awk '/member/ {print $2}' | xargs -n1 ifconfig bridge0 deletem 2>/dev/null || true + } + ifconfig bridge0 destroy 2>/dev/null || true + } + + # Remove Thunderbolt Bridge from VirtualNetworkInterfaces in preferences.plist + /usr/libexec/PlistBuddy -c "Delete :VirtualNetworkInterfaces:Bridge:bridge0" "$PREFS" 2>/dev/null || true + + networksetup -listnetworkservices | grep -q "Thunderbolt Bridge" && { + networksetup -setnetworkserviceenabled "Thunderbolt Bridge" off + } || true + """ + + /// Prompts user and installs the LaunchDaemon if not already installed. + /// Shows an alert explaining what will be installed before requesting admin privileges. + static func promptAndInstallIfNeeded() { + // Use .utility priority to match NSAppleScript's internal QoS and avoid priority inversion + Task.detached(priority: .utility) { + // If already correctly installed, skip + if daemonAlreadyInstalled() { + return + } + + // Show alert on main thread + let shouldInstall = await MainActor.run { + let alert = NSAlert() + alert.messageText = "EXO Network Configuration" + alert.informativeText = + "EXO needs to install a system service to automatically disable Thunderbolt Bridge on startup. This prevents network loops when connecting multiple Macs via Thunderbolt.\n\nYou will be prompted for your administrator password." + alert.alertStyle = .informational + alert.addButton(withTitle: "Install") + alert.addButton(withTitle: "Not Now") + return alert.runModal() == .alertFirstButtonReturn + } + + guard shouldInstall else { + logger.info("User deferred network setup daemon installation") + return + } + + do { + try installLaunchDaemon() + logger.info("Network setup launch daemon installed and started") + } catch { + logger.error( + "Network setup launch daemon failed: \(error.localizedDescription, privacy: .public)" + ) + } + } + } /// Removes all EXO network setup components from the system. /// This includes the LaunchDaemon, scripts, logs, and network location. @@ -30,6 +92,100 @@ enum NetworkSetupHelper { return scriptExists || legacyScriptExists || plistExists } + private static func daemonAlreadyInstalled() -> Bool { + let manager = FileManager.default + let scriptExists = manager.fileExists(atPath: scriptDestination) + let plistExists = manager.fileExists(atPath: plistDestination) + guard scriptExists, plistExists else { return false } + guard + let installedScript = try? String(contentsOfFile: scriptDestination, encoding: .utf8), + installedScript.trimmingCharacters(in: .whitespacesAndNewlines) + == setupScript.trimmingCharacters(in: .whitespacesAndNewlines) + else { + return false + } + guard + let data = try? Data(contentsOf: URL(fileURLWithPath: plistDestination)), + let plist = try? PropertyListSerialization.propertyList( + from: data, options: [], format: nil) as? [String: Any] + else { + return false + } + guard + let interval = plist["StartInterval"] as? Int, + interval == requiredStartInterval + else { + return false + } + if let programArgs = plist["ProgramArguments"] as? [String], + programArgs.contains(scriptDestination) == false + { + return false + } + return true + } + + private static func installLaunchDaemon() throws { + let installerScript = makeInstallerScript() + try runShellAsAdmin(installerScript) + } + + private static func makeInstallerScript() -> String { + """ + set -euo pipefail + + LABEL="\(daemonLabel)" + SCRIPT_DEST="\(scriptDestination)" + LEGACY_SCRIPT_DEST="\(legacyScriptDestination)" + PLIST_DEST="\(plistDestination)" + LOG_OUT="/var/log/\(daemonLabel).log" + LOG_ERR="/var/log/\(daemonLabel).err.log" + + # First, completely remove any existing installation + launchctl bootout system/"$LABEL" 2>/dev/null || true + rm -f "$PLIST_DEST" + rm -f "$SCRIPT_DEST" + rm -f "$LEGACY_SCRIPT_DEST" + rm -f "$LOG_OUT" "$LOG_ERR" + + # Install fresh + mkdir -p "$(dirname "$SCRIPT_DEST")" + + cat > "$SCRIPT_DEST" <<'EOF_SCRIPT' + \(setupScript) + EOF_SCRIPT + chmod 755 "$SCRIPT_DEST" + + cat > "$PLIST_DEST" <<'EOF_PLIST' + + + + + Label + \(daemonLabel) + ProgramArguments + + /bin/bash + \(scriptDestination) + + StartInterval + \(requiredStartInterval) + RunAtLoad + + StandardOutPath + /var/log/\(daemonLabel).log + StandardErrorPath + /var/log/\(daemonLabel).err.log + + + EOF_PLIST + + launchctl bootstrap system "$PLIST_DEST" + launchctl enable system/"$LABEL" + launchctl kickstart -k system/"$LABEL" + """ + } + private static func makeUninstallScript() -> String { """ set -euo pipefail