WallpaperManager.swift

7.7 KB · raw

1import AppKit23@MainActor4protocol WallpaperManaging {5  func download(from url: URL) async throws -> URL6  func cachedLocalFile(for url: URL) async throws -> URL7  func setDesktopImage(from fileURL: URL, screen: NSScreen?) throws8  func saveToFolder(from sourceURL: URL, to folderURL: URL) throws9}1011@MainActor12protocol DesktopImageSetting {13  func setDesktopImageURL(14    _ url: URL, for screen: NSScreen, options: [NSWorkspace.DesktopImageOptionKey: Any]15  ) throws16}1718extension NSWorkspace: DesktopImageSetting {}1920class WallpaperManager: WallpaperManaging {21  static let shared = WallpaperManager()2223  private let session: URLSession24  private let desktopSetter: @MainActor (URL, NSScreen) throws -> Void25  private let mainScreen: () -> NSScreen?26  private let firstScreen: () -> NSScreen?27  private let cacheDirectoryProvider: @MainActor () -> URL?2829  init(30    session: URLSession = .shared,31    desktopSetter: (@MainActor (URL, NSScreen) throws -> Void)? = nil,32    workspace: DesktopImageSetting = NSWorkspace.shared,33    mainScreen: @escaping () -> NSScreen? = { NSScreen.main },34    firstScreen: @escaping () -> NSScreen? = { NSScreen.screens.first },35    cacheDirectoryProvider: @escaping @MainActor () -> URL? = WallpaperManager36      .defaultCacheDirectory37  ) {38    self.session = session39    self.desktopSetter =40      desktopSetter ?? { url, screen in41        try workspace.setDesktopImageURL(42          url, for: screen, options: [:]43        )44      }45    self.mainScreen = mainScreen46    self.firstScreen = firstScreen47    self.cacheDirectoryProvider = cacheDirectoryProvider48  }4950  private static func defaultCacheDirectory() -> URL? {51    guard52      let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first53    else { return nil }54    let bundleID = Bundle.main.bundleIdentifier ?? "Haven"55    return base.appendingPathComponent(bundleID).appendingPathComponent("WallpaperCache")56  }5758  /// Downloads the file to a temporary location and returns the local URL.59  ///60  /// Each call writes to a fresh, unique temp filename so that distinct61  /// wallpapers sharing a `lastPathComponent` cannot overwrite one another62  /// before they are consumed (e.g. before `setDesktopImage` or63  /// `saveToFolder` reads the temp file). The caller owns the returned file64  /// and is responsible for removing it when no longer needed.65  func download(from url: URL) async throws -> URL {66    let (data, response) = try await session.data(from: url)67    guard let response = response as? HTTPURLResponse,68      (200...299).contains(response.statusCode)69    else {70      throw WallhavenError.apiError("Wallpaper download failed")71    }7273    let fileURL = Self.makeTempURL(for: url)74    try data.write(to: fileURL)75    return fileURL76  }7778  /// Returns a persistent, on-disk copy of `url`, downloading it only if no79  /// cached copy already exists.80  ///81  /// Setting the desktop picture via `NSWorkspace.setDesktopImageURL` does82  /// not copy the image data — AppKit re-reads the file from the path we83  /// hand it (e.g. on Space switches, wake, or Dock restarts), so the source84  /// file must keep living at that path. Deleting it right after the call85  /// returns (as a "clean up my temp file" pass once did) raced AppKit's own86  /// read and made wallpapers silently fail to apply. Caching under a stable,87  /// content-addressed filename fixes that permanently and, as a side88  /// benefit, means re-setting a wallpaper you've already applied doesn't89  /// re-download it.90  func cachedLocalFile(for url: URL) async throws -> URL {91    guard let cacheDirectory = cacheDirectoryProvider() else {92      return try await download(from: url)93    }94    let destination = cacheDirectory.appendingPathComponent(Self.cacheFilename(for: url))95    if FileManager.default.fileExists(atPath: destination.path) {96      return destination97    }9899    let downloadedURL = try await download(from: url)100    try FileManager.default.createDirectory(101      at: cacheDirectory, withIntermediateDirectories: true)102    if FileManager.default.fileExists(atPath: destination.path) {103      try? FileManager.default.removeItem(at: destination)104    }105    try FileManager.default.moveItem(at: downloadedURL, to: destination)106    return destination107  }108109  /// Stable, deterministic cache filename for `url`. Wallhaven's direct110  /// image URLs already carry a unique per-wallpaper filename111  /// (`wallhaven-<id>.<ext>`), so reusing `lastPathComponent` is enough to112  /// dedupe; the percent-encoded fallback only matters for malformed/local113  /// URLs without a path component.114  static func cacheFilename(for url: URL) -> String {115    let name = url.lastPathComponent116    if !name.isEmpty { return name }117    return url.absoluteString.addingPercentEncoding(withAllowedCharacters: .alphanumerics)118      ?? "wallpaper"119  }120121  /// Returns a temp URL unique to this call. The original `url.pathExtension`122  /// is preserved when present so downstream consumers can identify the kind123  /// of file, but the stem is a fresh UUID — never derived from the input124  /// filename — to guarantee no two concurrent or sequential downloads of125  /// distinct wallpapers collide in the shared temp directory.126  static func makeTempURL(for url: URL) -> URL {127    let fileExtension = url.pathExtension.isEmpty ? "img" : url.pathExtension128    let stem = UUID().uuidString129    let filename = "haven-\(stem).\(fileExtension)"130    return FileManager.default.temporaryDirectory.appendingPathComponent(filename)131  }132133  /// Sets the desktop image from a local file URL.134  @MainActor135  func setDesktopImage(from fileURL: URL, screen: NSScreen? = nil) throws {136    if let screen = screen {137      try desktopSetter(fileURL, screen)138      return139    }140    // Pick the screen we should target when the caller didn't pass one.141    // The previous fallback force-unwrapped `NSScreen.screens.first!`, which142    // would trap on a headless launch (no screens at all) or a transient143    // screen-disconnect window. Throw a real error instead so callers can144    // react.145    let target: NSScreen146    if let main = mainScreen() {147      target = main148    } else if let first = firstScreen() {149      target = first150    } else {151      throw WallhavenError.apiError("No screen is available to set the desktop image")152    }153    try desktopSetter(fileURL, target)154  }155156  /// Copies the file to the specified destination directory.157  ///158  /// Policy for an existing destination with the same name:159  /// - if its contents match the source, leave it untouched (idempotent160  ///   re-save of the same logical wallpaper, preserves modification time);161  /// - if its contents differ, replace it with the source bytes.162  /// Either branch avoids the prior behavior of unconditionally deleting the163  /// destination before copying, which would destroy the source if the caller164  /// passed the same path for both arguments.165  func saveToFolder(from sourceURL: URL, to folderURL: URL) throws {166    // Ensure folder exists167    try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true)168169    let destinationURL = folderURL.appendingPathComponent(sourceURL.lastPathComponent)170171    if FileManager.default.fileExists(atPath: destinationURL.path) {172      let sourceData = try Data(contentsOf: sourceURL)173      let existingData = try Data(contentsOf: destinationURL)174      if sourceData == existingData {175        return176      }177      try FileManager.default.removeItem(at: destinationURL)178    }179180    try FileManager.default.copyItem(at: sourceURL, to: destinationURL)181  }182183  // Legacy support wrapper (optional, but cleaner to update call sites)184  @MainActor185  func setWallpaper(from url: URL, screen: NSScreen? = nil) async throws {186    let localURL = try await download(from: url)187    try setDesktopImage(from: localURL, screen: screen)188  }189}