WallpaperManager.swift

5.0 KB · raw

1import AppKit23@MainActor4protocol WallpaperManaging {5  func download(from url: URL) async throws -> URL6  func setDesktopImage(from fileURL: URL, screen: NSScreen?) throws7  func saveToFolder(from sourceURL: URL, to folderURL: URL) throws8}910@MainActor11protocol DesktopImageSetting {12  func setDesktopImageURL(13    _ url: URL, for screen: NSScreen, options: [NSWorkspace.DesktopImageOptionKey: Any]14  ) throws15}1617extension NSWorkspace: DesktopImageSetting {}1819class WallpaperManager: WallpaperManaging {20  static let shared = WallpaperManager()2122  private let session: URLSession23  private let desktopSetter: @MainActor (URL, NSScreen) throws -> Void24  private let mainScreen: () -> NSScreen?25  private let firstScreen: () -> NSScreen?2627  init(28    session: URLSession = .shared,29    desktopSetter: (@MainActor (URL, NSScreen) throws -> Void)? = nil,30    workspace: DesktopImageSetting = NSWorkspace.shared,31    mainScreen: @escaping () -> NSScreen? = { NSScreen.main },32    firstScreen: @escaping () -> NSScreen? = { NSScreen.screens.first }33  ) {34    self.session = session35    self.desktopSetter =36      desktopSetter ?? { url, screen in37        try workspace.setDesktopImageURL(38          url, for: screen, options: [:]39        )40      }41    self.mainScreen = mainScreen42    self.firstScreen = firstScreen43  }4445  /// Downloads the file to a temporary location and returns the local URL.46  ///47  /// Each call writes to a fresh, unique temp filename so that distinct48  /// wallpapers sharing a `lastPathComponent` cannot overwrite one another49  /// before they are consumed (e.g. before `setDesktopImage` or50  /// `saveToFolder` reads the temp file). The caller owns the returned file51  /// and is responsible for removing it when no longer needed.52  func download(from url: URL) async throws -> URL {53    let (data, response) = try await session.data(from: url)54    guard let response = response as? HTTPURLResponse,55      (200...299).contains(response.statusCode)56    else {57      throw WallhavenError.apiError("Wallpaper download failed")58    }5960    let fileURL = Self.makeTempURL(for: url)61    try data.write(to: fileURL)62    return fileURL63  }6465  /// Returns a temp URL unique to this call. The original `url.pathExtension`66  /// is preserved when present so downstream consumers can identify the kind67  /// of file, but the stem is a fresh UUID — never derived from the input68  /// filename — to guarantee no two concurrent or sequential downloads of69  /// distinct wallpapers collide in the shared temp directory.70  static func makeTempURL(for url: URL) -> URL {71    let fileExtension = url.pathExtension.isEmpty ? "img" : url.pathExtension72    let stem = UUID().uuidString73    let filename = "haven-\(stem).\(fileExtension)"74    return FileManager.default.temporaryDirectory.appendingPathComponent(filename)75  }7677  /// Sets the desktop image from a local file URL.78  @MainActor79  func setDesktopImage(from fileURL: URL, screen: NSScreen? = nil) throws {80    if let screen = screen {81      try desktopSetter(fileURL, screen)82      return83    }84    // Pick the screen we should target when the caller didn't pass one.85    // The previous fallback force-unwrapped `NSScreen.screens.first!`, which86    // would trap on a headless launch (no screens at all) or a transient87    // screen-disconnect window. Throw a real error instead so callers can88    // react.89    let target: NSScreen90    if let main = mainScreen() {91      target = main92    } else if let first = firstScreen() {93      target = first94    } else {95      throw WallhavenError.apiError("No screen is available to set the desktop image")96    }97    try desktopSetter(fileURL, target)98  }99100  /// Copies the file to the specified destination directory.101  ///102  /// Policy for an existing destination with the same name:103  /// - if its contents match the source, leave it untouched (idempotent104  ///   re-save of the same logical wallpaper, preserves modification time);105  /// - if its contents differ, replace it with the source bytes.106  /// Either branch avoids the prior behavior of unconditionally deleting the107  /// destination before copying, which would destroy the source if the caller108  /// passed the same path for both arguments.109  func saveToFolder(from sourceURL: URL, to folderURL: URL) throws {110    // Ensure folder exists111    try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true)112113    let destinationURL = folderURL.appendingPathComponent(sourceURL.lastPathComponent)114115    if FileManager.default.fileExists(atPath: destinationURL.path) {116      let sourceData = try Data(contentsOf: sourceURL)117      let existingData = try Data(contentsOf: destinationURL)118      if sourceData == existingData {119        return120      }121      try FileManager.default.removeItem(at: destinationURL)122    }123124    try FileManager.default.copyItem(at: sourceURL, to: destinationURL)125  }126127  // Legacy support wrapper (optional, but cleaner to update call sites)128  @MainActor129  func setWallpaper(from url: URL, screen: NSScreen? = nil) async throws {130    let localURL = try await download(from: url)131    try setDesktopImage(from: localURL, screen: screen)132  }133}