import AppKit @MainActor protocol WallpaperManaging { func download(from url: URL) async throws -> URL func cachedLocalFile(for url: URL) async throws -> URL func setDesktopImage(from fileURL: URL, screen: NSScreen?) throws func saveToFolder(from sourceURL: URL, to folderURL: URL) throws } @MainActor protocol DesktopImageSetting { func setDesktopImageURL( _ url: URL, for screen: NSScreen, options: [NSWorkspace.DesktopImageOptionKey: Any] ) throws } extension NSWorkspace: DesktopImageSetting {} class WallpaperManager: WallpaperManaging { static let shared = WallpaperManager() private let session: URLSession private let desktopSetter: @MainActor (URL, NSScreen) throws -> Void private let mainScreen: () -> NSScreen? private let firstScreen: () -> NSScreen? private let cacheDirectoryProvider: @MainActor () -> URL? init( session: URLSession = .shared, desktopSetter: (@MainActor (URL, NSScreen) throws -> Void)? = nil, workspace: DesktopImageSetting = NSWorkspace.shared, mainScreen: @escaping () -> NSScreen? = { NSScreen.main }, firstScreen: @escaping () -> NSScreen? = { NSScreen.screens.first }, cacheDirectoryProvider: @escaping @MainActor () -> URL? = WallpaperManager .defaultCacheDirectory ) { self.session = session self.desktopSetter = desktopSetter ?? { url, screen in try workspace.setDesktopImageURL( url, for: screen, options: [:] ) } self.mainScreen = mainScreen self.firstScreen = firstScreen self.cacheDirectoryProvider = cacheDirectoryProvider } private static func defaultCacheDirectory() -> URL? { guard let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil } let bundleID = Bundle.main.bundleIdentifier ?? "Haven" return base.appendingPathComponent(bundleID).appendingPathComponent("WallpaperCache") } /// Downloads the file to a temporary location and returns the local URL. /// /// Each call writes to a fresh, unique temp filename so that distinct /// wallpapers sharing a `lastPathComponent` cannot overwrite one another /// before they are consumed (e.g. before `setDesktopImage` or /// `saveToFolder` reads the temp file). The caller owns the returned file /// and is responsible for removing it when no longer needed. func download(from url: URL) async throws -> URL { let (data, response) = try await session.data(from: url) guard let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode) else { throw WallhavenError.apiError("Wallpaper download failed") } let fileURL = Self.makeTempURL(for: url) try data.write(to: fileURL) return fileURL } /// Returns a persistent, on-disk copy of `url`, downloading it only if no /// cached copy already exists. /// /// Setting the desktop picture via `NSWorkspace.setDesktopImageURL` does /// not copy the image data — AppKit re-reads the file from the path we /// hand it (e.g. on Space switches, wake, or Dock restarts), so the source /// file must keep living at that path. Deleting it right after the call /// returns (as a "clean up my temp file" pass once did) raced AppKit's own /// read and made wallpapers silently fail to apply. Caching under a stable, /// content-addressed filename fixes that permanently and, as a side /// benefit, means re-setting a wallpaper you've already applied doesn't /// re-download it. func cachedLocalFile(for url: URL) async throws -> URL { guard let cacheDirectory = cacheDirectoryProvider() else { return try await download(from: url) } let destination = cacheDirectory.appendingPathComponent(Self.cacheFilename(for: url)) if FileManager.default.fileExists(atPath: destination.path) { return destination } let downloadedURL = try await download(from: url) try FileManager.default.createDirectory( at: cacheDirectory, withIntermediateDirectories: true) if FileManager.default.fileExists(atPath: destination.path) { try? FileManager.default.removeItem(at: destination) } try FileManager.default.moveItem(at: downloadedURL, to: destination) return destination } /// Stable, deterministic cache filename for `url`. Wallhaven's direct /// image URLs already carry a unique per-wallpaper filename /// (`wallhaven-.`), so reusing `lastPathComponent` is enough to /// dedupe; the percent-encoded fallback only matters for malformed/local /// URLs without a path component. static func cacheFilename(for url: URL) -> String { let name = url.lastPathComponent if !name.isEmpty { return name } return url.absoluteString.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "wallpaper" } /// Returns a temp URL unique to this call. The original `url.pathExtension` /// is preserved when present so downstream consumers can identify the kind /// of file, but the stem is a fresh UUID — never derived from the input /// filename — to guarantee no two concurrent or sequential downloads of /// distinct wallpapers collide in the shared temp directory. static func makeTempURL(for url: URL) -> URL { let fileExtension = url.pathExtension.isEmpty ? "img" : url.pathExtension let stem = UUID().uuidString let filename = "haven-\(stem).\(fileExtension)" return FileManager.default.temporaryDirectory.appendingPathComponent(filename) } /// Sets the desktop image from a local file URL. @MainActor func setDesktopImage(from fileURL: URL, screen: NSScreen? = nil) throws { if let screen = screen { try desktopSetter(fileURL, screen) return } // Pick the screen we should target when the caller didn't pass one. // The previous fallback force-unwrapped `NSScreen.screens.first!`, which // would trap on a headless launch (no screens at all) or a transient // screen-disconnect window. Throw a real error instead so callers can // react. let target: NSScreen if let main = mainScreen() { target = main } else if let first = firstScreen() { target = first } else { throw WallhavenError.apiError("No screen is available to set the desktop image") } try desktopSetter(fileURL, target) } /// Copies the file to the specified destination directory. /// /// Policy for an existing destination with the same name: /// - if its contents match the source, leave it untouched (idempotent /// re-save of the same logical wallpaper, preserves modification time); /// - if its contents differ, replace it with the source bytes. /// Either branch avoids the prior behavior of unconditionally deleting the /// destination before copying, which would destroy the source if the caller /// passed the same path for both arguments. func saveToFolder(from sourceURL: URL, to folderURL: URL) throws { // Ensure folder exists try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) let destinationURL = folderURL.appendingPathComponent(sourceURL.lastPathComponent) if FileManager.default.fileExists(atPath: destinationURL.path) { let sourceData = try Data(contentsOf: sourceURL) let existingData = try Data(contentsOf: destinationURL) if sourceData == existingData { return } try FileManager.default.removeItem(at: destinationURL) } try FileManager.default.copyItem(at: sourceURL, to: destinationURL) } // Legacy support wrapper (optional, but cleaner to update call sites) @MainActor func setWallpaper(from url: URL, screen: NSScreen? = nil) async throws { let localURL = try await download(from: url) try setDesktopImage(from: localURL, screen: screen) } }