WallpaperManager.swift

4.5 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 -> Void2425  init(26    session: URLSession = .shared,27    desktopSetter: (@MainActor (URL, NSScreen) throws -> Void)? = nil,28    workspace: DesktopImageSetting = NSWorkspace.shared29  ) {30    self.session = session31    self.desktopSetter =32      desktopSetter ?? { url, screen in33        try workspace.setDesktopImageURL(34          url, for: screen, options: [:]35        )36      }37  }3839  /// Downloads the file to a temporary location and returns the local URL.40  ///41  /// Each call writes to a fresh, unique temp filename so that distinct42  /// wallpapers sharing a `lastPathComponent` cannot overwrite one another43  /// before they are consumed (e.g. before `setDesktopImage` or44  /// `saveToFolder` reads the temp file). The caller owns the returned file45  /// and is responsible for removing it when no longer needed.46  func download(from url: URL) async throws -> URL {47    let (data, response) = try await session.data(from: url)48    guard let response = response as? HTTPURLResponse,49      (200...299).contains(response.statusCode)50    else {51      throw WallhavenError.apiError("Wallpaper download failed")52    }5354    let fileURL = Self.makeTempURL(for: url)55    try data.write(to: fileURL)56    return fileURL57  }5859  /// Returns a temp URL unique to this call. The original `url.pathExtension`60  /// is preserved when present so downstream consumers can identify the kind61  /// of file, but the stem is a fresh UUID — never derived from the input62  /// filename — to guarantee no two concurrent or sequential downloads of63  /// distinct wallpapers collide in the shared temp directory.64  static func makeTempURL(for url: URL) -> URL {65    let fileExtension = url.pathExtension.isEmpty ? "img" : url.pathExtension66    let stem = UUID().uuidString67    let filename = "haven-\(stem).\(fileExtension)"68    return FileManager.default.temporaryDirectory.appendingPathComponent(filename)69  }7071  /// Sets the desktop image from a local file URL.72  @MainActor73  func setDesktopImage(from fileURL: URL, screen: NSScreen? = nil) throws {74    if let screen = screen {75      try desktopSetter(fileURL, screen)76    } else {77      // Set for all screens or just main? Let's default to main for safety,78      // but the UI will likely drive this.79      // If no screen specified, maybe set for the 'active' one.80      let targetScreen = NSScreen.main ?? NSScreen.screens.first!81      try desktopSetter(fileURL, targetScreen)82    }83  }8485  /// Copies the file to the specified destination directory.86  ///87  /// Policy for an existing destination with the same name:88  /// - if its contents match the source, leave it untouched (idempotent89  ///   re-save of the same logical wallpaper, preserves modification time);90  /// - if its contents differ, replace it with the source bytes.91  /// Either branch avoids the prior behavior of unconditionally deleting the92  /// destination before copying, which would destroy the source if the caller93  /// passed the same path for both arguments.94  func saveToFolder(from sourceURL: URL, to folderURL: URL) throws {95    // Ensure folder exists96    try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true)9798    let destinationURL = folderURL.appendingPathComponent(sourceURL.lastPathComponent)99100    if FileManager.default.fileExists(atPath: destinationURL.path) {101      let sourceData = try Data(contentsOf: sourceURL)102      let existingData = try Data(contentsOf: destinationURL)103      if sourceData == existingData {104        return105      }106      try FileManager.default.removeItem(at: destinationURL)107    }108109    try FileManager.default.copyItem(at: sourceURL, to: destinationURL)110  }111112  // Legacy support wrapper (optional, but cleaner to update call sites)113  @MainActor114  func setWallpaper(from url: URL, screen: NSScreen? = nil) async throws {115    let localURL = try await download(from: url)116    try setDesktopImage(from: localURL, screen: screen)117  }118}