AutostartManager.swift

3.6 KB · raw

1import Foundation2import ServiceManagement34@MainActor5protocol AutostartManaging: AnyObject {6  var isEnabled: Bool { get set }7  /// Latest failure observed by the manager. `nil` when the launch-agent8  /// plist is in its expected state (or has never been touched). Callers9  /// can surface this through UI/alerts without having to inspect logs.10  /// The flag is reset to `nil` whenever the next operation succeeds.11  var lastError: String? { get }12}1314class AutostartManager: AutostartManaging {15  static let shared = AutostartManager()1617  private let launchAgentURL: URL18  private let bundleIdentifier = "com.june.Haven"19  private let executablePath: () -> String?20  private let writePlist: (Data, URL) throws -> Void21  private let removePlist: (URL) throws -> Void22  private let ensureLaunchAgentsDirectory: (URL) throws -> Void23  private let fileExists: (String) -> Bool2425  private(set) var lastError: String?2627  init(28    libraryDirectory: URL? = nil,29    executablePath: @escaping () -> String? = { Bundle.main.executablePath },30    writePlist: @escaping (Data, URL) throws -> Void = { data, url in try data.write(to: url) },31    removePlist: @escaping (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) },32    ensureLaunchAgentsDirectory: @escaping (URL) throws -> Void = { url in33      try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)34    },35    fileExists: @escaping (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }36  ) {37    let libraryDir =38      libraryDirectory39      ?? FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!40    let launchAgentsDir = libraryDir.appendingPathComponent("LaunchAgents")4142    // Capture directory-creation failures instead of dropping them with43    // `try?`. The most common cause in practice is a parent path that is a44    // regular file (the test suite exercises this) or an unwritable parent;45    // either way the manager must continue to function (it's still safe to46    // query `isEnabled`) but the cause needs to be reachable for callers.47    do {48      try ensureLaunchAgentsDirectory(launchAgentsDir)49    } catch {50      lastError = "Failed to prepare LaunchAgents directory: \(error.localizedDescription)"51    }5253    self.launchAgentURL = launchAgentsDir.appendingPathComponent("\(bundleIdentifier).plist")54    self.executablePath = executablePath55    self.writePlist = writePlist56    self.removePlist = removePlist57    self.ensureLaunchAgentsDirectory = ensureLaunchAgentsDirectory58    self.fileExists = fileExists59  }6061  var isEnabled: Bool {62    get {63      return fileExists(launchAgentURL.path)64    }65    set {66      if newValue {67        enable()68      } else {69        disable()70      }71    }72  }7374  private func enable() {75    guard !isEnabled else { return }7677    // Point to the actual executable inside the bundle78    guard let appPath = executablePath() else { return }7980    let plistContent: [String: Any] = [81      "Label": bundleIdentifier,82      "ProgramArguments": [appPath],83      "RunAtLoad": true,84      "ProcessType": "Interactive",85    ]8687    do {88      let data = try PropertyListSerialization.data(89        fromPropertyList: plistContent, format: .xml, options: 0)90      try writePlist(data, launchAgentURL)91      lastError = nil92    } catch {93      lastError = "Failed to enable autostart: \(error.localizedDescription)"94    }95  }9697  private func disable() {98    guard isEnabled else { return }99100    do {101      try removePlist(launchAgentURL)102      lastError = nil103    } catch {104      lastError = "Failed to disable autostart: \(error.localizedDescription)"105    }106  }107}