import Foundation import ServiceManagement import AppKit @MainActor final class AppPreferences: ObservableObject { @Published var port: Int { didSet { UserDefaults.standard.set(port, forKey: "daemonPort") } } @Published var showInDock: Bool { didSet { UserDefaults.standard.set(showInDock, forKey: "showInDock") applyDockPolicy() } } @Published var developmentTeam: String { didSet { UserDefaults.standard.set(developmentTeam, forKey: "developmentTeam") } } @Published var allowProvisioningUpdates: Bool { didSet { UserDefaults.standard.set(allowProvisioningUpdates, forKey: "allowProvisioningUpdates") } } @Published var buildConfiguration: String { didSet { UserDefaults.standard.set(buildConfiguration, forKey: "buildConfiguration") } } var launchAtLogin: Bool { get { SMAppService.mainApp.status == .enabled } set { do { if newValue { try SMAppService.mainApp.register() } else { try SMAppService.mainApp.unregister() } objectWillChange.send() } catch { print("SMAppService error: \(error)") } } } // Cached to avoid blocking the main thread on every view render. // Refreshed once at init and when explicitly requested. @Published private(set) var tailscaleIP: String = "Not detected" func refreshTailscaleIP() { DispatchQueue.global(qos: .utility).async { let ip = TailscaleDetector.currentIP() ?? "Not detected" DispatchQueue.main.async { self.tailscaleIP = ip } } } init() { let d = UserDefaults.standard self.port = d.integer(forKey: "daemonPort").nonZero ?? 8080 self.showInDock = d.bool(forKey: "showInDock") self.developmentTeam = d.string(forKey: "developmentTeam") ?? "" self.allowProvisioningUpdates = d.object(forKey: "allowProvisioningUpdates") as? Bool ?? true self.buildConfiguration = d.string(forKey: "buildConfiguration") ?? "Debug" applyDockPolicy() Task { refreshTailscaleIP() } } private func applyDockPolicy() { NSApp.setActivationPolicy(showInDock ? .regular : .accessory) } } private extension Int { var nonZero: Int? { self == 0 ? nil : self } } enum TailscaleDetector { static func currentIP() -> String? { let paths = [ "/Applications/Tailscale.app/Contents/MacOS/Tailscale", "/usr/local/bin/tailscale", "/opt/homebrew/bin/tailscale" ] guard let exe = paths.first(where: { FileManager.default.fileExists(atPath: $0) }) else { return nil } let p = Process() p.executableURL = URL(fileURLWithPath: exe) p.arguments = ["ip", "--4"] let pipe = Pipe() p.standardOutput = pipe p.standardError = Pipe() try? p.run(); p.waitUntilExit() return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) .components(separatedBy: "\n").first } }