diff --git a/Sources/Wallpaper/BingAPI.swift b/Sources/Wallpaper/BingAPI.swift index ea12655..7f62e19 100644 --- a/Sources/Wallpaper/BingAPI.swift +++ b/Sources/Wallpaper/BingAPI.swift @@ -27,11 +27,13 @@ struct BingImage: Codable, Hashable, Identifiable { enum WallpaperError: LocalizedError { case noImages case downloadFailed + case invalidURL var errorDescription: String? { switch self { case .noImages: return "No images found" case .downloadFailed: return "Failed to download image" + case .invalidURL: return "Invalid image URL" } } } diff --git a/Sources/Wallpaper/PreferencesStore.swift b/Sources/Wallpaper/PreferencesStore.swift index e38b77b..4dc39db 100644 --- a/Sources/Wallpaper/PreferencesStore.swift +++ b/Sources/Wallpaper/PreferencesStore.swift @@ -9,6 +9,8 @@ struct WallpaperPreferences: Codable { final class PreferencesStore { static let shared = PreferencesStore() private(set) var preferences = WallpaperPreferences() + /// Favorites in newest-first order, kept in sync on mutation so callers never re-sort + private(set) var sortedFavorites: [BingImage] = [] let fileURL: URL init(fileURL: URL? = nil) { @@ -20,12 +22,17 @@ final class PreferencesStore { func load() { let fm = FileManager.default + defer { rebuildSortedFavorites() } guard fm.fileExists(atPath: fileURL.path) else { return } do { let data = try Data(contentsOf: fileURL) preferences = try JSONDecoder().decode(WallpaperPreferences.self, from: data) } catch { - // If corrupted, start fresh + // Start fresh, but move the unreadable file aside first: otherwise the next + // save() would overwrite it and destroy every favorite and dislike for good. + let backupURL = fileURL.appendingPathExtension("corrupt") + try? fm.removeItem(at: backupURL) + try? fm.moveItem(at: fileURL, to: backupURL) preferences = WallpaperPreferences() } } @@ -63,11 +70,13 @@ final class PreferencesStore { func addFavorite(_ image: BingImage) { guard !preferences.favorites.contains(where: { $0.startdate == image.startdate }) else { return } preferences.favorites.append(image) + rebuildSortedFavorites() save() } func removeFavorite(_ image: BingImage) { preferences.favorites.removeAll { $0.startdate == image.startdate } + rebuildSortedFavorites() save() } @@ -78,4 +87,8 @@ final class PreferencesStore { func favoriteDates() -> Set { Set(preferences.favorites.map(\.startdate)) } + + private func rebuildSortedFavorites() { + sortedFavorites = preferences.favorites.sorted { $0.startdate > $1.startdate } + } } diff --git a/Sources/Wallpaper/UpdateChecker.swift b/Sources/Wallpaper/UpdateChecker.swift index d3aae38..3833962 100644 --- a/Sources/Wallpaper/UpdateChecker.swift +++ b/Sources/Wallpaper/UpdateChecker.swift @@ -184,8 +184,11 @@ final class UpdateChecker { // MARK: - Semver Comparison nonisolated static func isNewer(remote: String, than current: String) -> Bool { - let remoteParts = remote.split(separator: ".").compactMap { Int($0) } - let currentParts = current.split(separator: ".").compactMap { Int($0) } + // Parse strictly: dropping unparseable components would shift the remaining ones + // into the wrong significance position (e.g. "1.beta.5" comparing 5 against minor). + guard let remoteParts = versionComponents(remote), + let currentParts = versionComponents(current) + else { return false } let count = max(remoteParts.count, currentParts.count) for i in 0.. [Int]? { + let parts = version.split(separator: ".") + guard !parts.isEmpty else { return nil } + + var numbers: [Int] = [] + for part in parts { + let digits = part.prefix { $0.isNumber } + guard let value = Int(digits) else { return nil } + numbers.append(value) + } + return numbers + } } // MARK: - Download Progress diff --git a/Sources/Wallpaper/WallpaperApp.swift b/Sources/Wallpaper/WallpaperApp.swift index 0c12a3b..96fb6c0 100644 --- a/Sources/Wallpaper/WallpaperApp.swift +++ b/Sources/Wallpaper/WallpaperApp.swift @@ -31,6 +31,9 @@ struct WallpaperApp: App { toolbar } .frame(width: 320) + // The login item can be toggled in System Settings while we run, so re-read + // it every time the window opens instead of trusting the launch-time snapshot + .onAppear { launchAtLogin = SMAppService.mainApp.status == .enabled } } .menuBarExtraStyle(.window) } @@ -330,11 +333,13 @@ struct WallpaperApp: App { Spacer() Button { - launchAtLogin.toggle() do { - if launchAtLogin { try SMAppService.mainApp.register() } - else { try SMAppService.mainApp.unregister() } - } catch { launchAtLogin.toggle() } + if launchAtLogin { try SMAppService.mainApp.unregister() } + else { try SMAppService.mainApp.register() } + } catch { + // Fall through — the status re-read below reports what actually happened + } + launchAtLogin = SMAppService.mainApp.status == .enabled } label: { Image(systemName: launchAtLogin ? "checkmark.circle.fill" : "circle") } diff --git a/Sources/Wallpaper/WallpaperManager.swift b/Sources/Wallpaper/WallpaperManager.swift index a7b35d2..d900a3c 100644 --- a/Sources/Wallpaper/WallpaperManager.swift +++ b/Sources/Wallpaper/WallpaperManager.swift @@ -19,8 +19,15 @@ class WallpaperManager { private var screenObserver: NSObjectProtocol? private var activityToken: NSObjectProtocol? + /// The image last written to the desktop, and the one that was there before the + /// favorites panel was opened, so browsing favorites can be undone. + private var desktopImage: BingImage? + private var preFavoritesDesktopImage: BingImage? + private let store = PreferencesStore.shared + private static let maxImages = 10 + var locale: String { Locale.current.identifier.replacingOccurrences(of: "_", with: "-") } @@ -45,28 +52,29 @@ class WallpaperManager { return store.isFavorited(images[currentIndex].startdate) } - var favoriteImages: [BingImage] { - store.preferences.favorites.sorted { $0.startdate > $1.startdate } - } + /// Newest-first; the store keeps this sorted so the view can read it every render pass + var favoriteImages: [BingImage] { store.sortedFavorites } var currentFavorite: BingImage? { let favs = favoriteImages - guard !favs.isEmpty, favoriteIndex >= 0, favoriteIndex < favs.count else { return nil } + guard favoriteIndex >= 0, favoriteIndex < favs.count else { return nil } return favs[favoriteIndex] } - var hasPreviousFavorite: Bool { !favoriteImages.isEmpty && favoriteIndex < favoriteImages.count - 1 } + var hasPreviousFavorite: Bool { favoriteIndex < favoriteImages.count - 1 } var hasNextFavorite: Bool { favoriteIndex > 0 } func showFavorites() async { showingFavorites = true favoriteIndex = 0 + preFavoritesDesktopImage = desktopImage await applyFavoriteAtIndex() } func hideFavorites() async { showingFavorites = false - await restoreNonDisliked() + favoritePreviewImage = nil + await restoreDesktopAfterFavorites() } func previousFavorite() async { @@ -86,21 +94,30 @@ class WallpaperManager { store.removeFavorite(fav) if favoriteImages.isEmpty { showingFavorites = false - await restoreNonDisliked() + favoritePreviewImage = nil + await restoreDesktopAfterFavorites() } else { favoriteIndex = min(favoriteIndex, favoriteImages.count - 1) await applyFavoriteAtIndex() } } - /// When returning from favorites, ensure we're showing a non-disliked wallpaper - private func restoreNonDisliked() async { - if currentIndex >= 0, currentIndex < images.count, store.isDisliked(images[currentIndex].startdate) { - if let idx = images.firstIndex(where: { !store.isDisliked($0.startdate) }) { + /// Put back the wallpaper that was on the desktop before favorites were browsed. + /// Falls back to the most recent non-disliked image if that one is gone or now disliked. + private func restoreDesktopAfterFavorites() async { + let saved = preFavoritesDesktopImage + preFavoritesDesktopImage = nil + + do { + if let saved, !store.isDisliked(saved.startdate) { + // Preview state already describes currentIndex, so only the desktop needs fixing + try await setDesktop(saved) + } else if let idx = images.firstIndex(where: { !store.isDisliked($0.startdate) }) { currentIndex = idx - do { try await applyWallpaper(at: currentIndex) } - catch { errorMessage = error.localizedDescription } + try await applyWallpaper(at: idx) } + } catch { + errorMessage = error.localizedDescription } } @@ -111,10 +128,7 @@ class WallpaperManager { return } do { - let localURL = try await downloadImage(fav) - for screen in NSScreen.screens { - try NSWorkspace.shared.setDesktopImageURL(localURL, for: screen) - } + let localURL = try await setDesktop(fav) favoritePreviewImage = NSImage(contentsOf: localURL) } catch { errorMessage = error.localizedDescription @@ -164,10 +178,11 @@ class WallpaperManager { private func loadAll() async { guard !isLoading else { return } isLoading = true + defer { isLoading = false } errorMessage = nil do { var allImages: [BingImage] = [] - for idx in stride(from: 0, to: 10, by: 5) { + for idx in stride(from: 0, to: Self.maxImages, by: 5) { let fetched = try await fetchImages(idx: idx, count: 5) allImages.append(contentsOf: fetched) } @@ -184,38 +199,55 @@ class WallpaperManager { } catch { errorMessage = error.localizedDescription } - isLoading = false } /// Refresh: check for the latest image only, insert if new, then apply it func refresh() async { guard !isLoading else { return } isLoading = true + defer { isLoading = false } errorMessage = nil do { - let fetched = try await fetchImages(idx: 0, count: 1) - if let latest = fetched.first { - if images.first?.startdate != latest.startdate { - images.insert(latest, at: 0) - if images.count > 10 { images.removeLast(images.count - 10) } - cleanOldCache() - } - // Only auto-apply the latest if it's not disliked - if !store.isDisliked(latest.startdate) { - currentIndex = 0 - try await applyWallpaper(at: 0) + guard let latest = try await fetchImages(idx: 0, count: 1).first else { return } + + // Only accept a strictly newer date — an older entry at the head would break + // the descending order that navigation and "first non-disliked" rely on. + let isNewer = images.first.map { latest.startdate > $0.startdate } ?? true + if isNewer { + images.insert(latest, at: 0) + // Every existing entry shifted down by one, so currentIndex must follow + currentIndex += 1 + if images.count > Self.maxImages { + images.removeLast(images.count - Self.maxImages) } + currentIndex = min(currentIndex, images.count - 1) + cleanOldCache() } + + // Don't yank the desktop out from under the favorites panel, and never + // auto-apply a disliked wallpaper + guard !showingFavorites, !store.isDisliked(latest.startdate), + let idx = images.firstIndex(where: { $0.startdate == latest.startdate }) + else { return } + currentIndex = idx + try await applyWallpaper(at: idx) } catch { errorMessage = error.localizedDescription } - isLoading = false } /// Fetch images from Bing API, bypassing HTTP cache private func fetchImages(idx: Int, count: Int) async throws -> [BingImage] { - let url = "https://www.bing.com/HPImageArchive.aspx?format=js&idx=\(idx)&n=\(count)&mkt=\(locale)" - var request = URLRequest(url: URL(string: url)!) + var components = URLComponents(string: "https://www.bing.com/HPImageArchive.aspx") + components?.queryItems = [ + URLQueryItem(name: "format", value: "js"), + URLQueryItem(name: "idx", value: String(idx)), + URLQueryItem(name: "n", value: String(count)), + URLQueryItem(name: "mkt", value: locale), + ] + guard let url = components?.url else { throw WallpaperError.invalidURL } + + var request = URLRequest(url: url) request.cachePolicy = .reloadIgnoringLocalCacheData let (data, _) = try await URLSession.shared.data(for: request) return try JSONDecoder().decode(BingResponse.self, from: data).images @@ -225,12 +257,16 @@ class WallpaperManager { func previous() async { guard !isLoading, hasPrevious else { return } + isLoading = true + defer { isLoading = false } currentIndex += 1 await showOrApply(at: currentIndex) } func next() async { guard !isLoading, hasNext else { return } + isLoading = true + defer { isLoading = false } currentIndex -= 1 await showOrApply(at: currentIndex) } @@ -239,12 +275,14 @@ class WallpaperManager { private func showOrApply(at index: Int) async { guard index >= 0, index < images.count else { return } let image = images[index] - if store.isDisliked(image.startdate) { - do { try await previewOnly(at: index) } - catch { errorMessage = error.localizedDescription } - } else { - do { try await applyWallpaper(at: index) } - catch { errorMessage = error.localizedDescription } + do { + if store.isDisliked(image.startdate) { + try await previewOnly(at: index) + } else { + try await applyWallpaper(at: index) + } + } catch { + errorMessage = error.localizedDescription } } @@ -288,13 +326,12 @@ class WallpaperManager { } func applyFavorite(_ image: BingImage) async { + guard !isLoading else { return } isLoading = true + defer { isLoading = false } errorMessage = nil do { - let localURL = try await downloadImage(image) - for screen in NSScreen.screens { - try NSWorkspace.shared.setDesktopImageURL(localURL, for: screen) - } + let localURL = try await setDesktop(image) currentTitle = image.title currentCopyright = image.copyright previewImage = NSImage(contentsOf: localURL) @@ -302,19 +339,19 @@ class WallpaperManager { if let idx = images.firstIndex(where: { $0.startdate == image.startdate }) { currentIndex = idx } + // Deliberate choice — nothing to restore when the panel closes + preFavoritesDesktopImage = nil + favoritePreviewImage = nil showingFavorites = false } catch { errorMessage = error.localizedDescription } - isLoading = false } func cachedImage(for image: BingImage) -> NSImage? { - let localURL = cacheDir.appendingPathComponent("\(image.startdate)_\(locale)_UHD.jpg") - return NSImage(contentsOf: localURL) + NSImage(contentsOf: cacheDir.appendingPathComponent(cacheFileName(for: image))) } - // MARK: - Wallpaper /// Download the image (if not cached) and set it as wallpaper on all screens @@ -326,25 +363,41 @@ class WallpaperManager { private func applyWallpaper(at index: Int) async throws { guard index >= 0, index < images.count else { return } let image = images[index] - let localURL = try await downloadImage(image) + let localURL = try await setDesktop(image) + + currentTitle = image.title + currentCopyright = image.copyright + previewImage = NSImage(contentsOf: localURL) + } + /// Download (if needed) and set the image on every screen. Leaves preview state alone. + @discardableResult + private func setDesktop(_ image: BingImage) async throws -> URL { + let localURL = try await downloadImage(image) for screen in NSScreen.screens { try NSWorkspace.shared.setDesktopImageURL(localURL, for: screen) } + desktopImage = image + return localURL + } - currentTitle = image.title - currentCopyright = image.copyright - previewImage = NSImage(contentsOf: localURL) + /// Keyed by the image's own URL rather than the current locale, so a region change + /// never orphans a cached favorite that was saved under a different market. + private func cacheFileName(for image: BingImage) -> String { + let token = String(image.urlbase.filter { $0.isLetter || $0.isNumber }.suffix(80)) + return "\(image.startdate)_\(token)_UHD.jpg" } /// Download UHD image to cache, skip if already exists private func downloadImage(_ image: BingImage) async throws -> URL { try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) - let localURL = cacheDir.appendingPathComponent("\(image.startdate)_\(locale)_UHD.jpg") + let localURL = cacheDir.appendingPathComponent(cacheFileName(for: image)) if FileManager.default.fileExists(atPath: localURL.path) { return localURL } - let url = URL(string: "https://www.bing.com\(image.urlbase)_UHD.jpg")! + guard let url = URL(string: "https://www.bing.com\(image.urlbase)_UHD.jpg") else { + throw WallpaperError.invalidURL + } let (data, _) = try await URLSession.shared.data(from: url) guard !data.isEmpty else { throw WallpaperError.downloadFailed } @@ -357,11 +410,19 @@ class WallpaperManager { /// Remove cached images older than 10 days, but keep favorites private func cleanOldCache() { let fm = FileManager.default + // Filenames are always Gregorian yyyyMMdd — never parse them with the user's calendar + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? calendar.timeZone let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone formatter.dateFormat = "yyyyMMdd" + let favDates = store.favoriteDates() - guard let cutoff = Calendar.current.date(byAdding: .day, value: -10, to: Date()), - let files = try? fm.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) else { return } + guard let cutoff = calendar.date(byAdding: .day, value: -Self.maxImages, to: Date()), + let files = try? fm.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) + else { return } for file in files { let name = file.lastPathComponent let dateString = String(name.prefix(8))