diff --git a/Resources/Info.plist b/Resources/Info.plist index 4570db7..f90094b 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -9,9 +9,9 @@ CFBundleExecutable Wallpaper CFBundleVersion - 1.0.0 + 1.1.0 CFBundleShortVersionString - 1.0.0 + 1.1.0 CFBundlePackageType APPL CFBundleIconFile diff --git a/Sources/Wallpaper/BingAPI.swift b/Sources/Wallpaper/BingAPI.swift index 6fbd8c0..ea12655 100644 --- a/Sources/Wallpaper/BingAPI.swift +++ b/Sources/Wallpaper/BingAPI.swift @@ -6,11 +6,22 @@ struct BingResponse: Codable { } /// A single Bing daily wallpaper entry -struct BingImage: Codable { +struct BingImage: Codable, Hashable, Identifiable { let startdate: String // e.g. "20260218" let urlbase: String // path to build UHD image URL let copyright: String let title: String + + var id: String { startdate } + + /// Formats "20260218" as "2026-02-18" + var formattedDate: String { + guard startdate.count == 8 else { return startdate } + let y = startdate.prefix(4) + let m = startdate.dropFirst(4).prefix(2) + let d = startdate.dropFirst(6).prefix(2) + return "\(y)-\(m)-\(d)" + } } enum WallpaperError: LocalizedError { diff --git a/Sources/Wallpaper/PreferencesStore.swift b/Sources/Wallpaper/PreferencesStore.swift new file mode 100644 index 0000000..e38b77b --- /dev/null +++ b/Sources/Wallpaper/PreferencesStore.swift @@ -0,0 +1,81 @@ +import Foundation + +struct WallpaperPreferences: Codable { + var dislikedDates: Set = [] // startdate strings, e.g. "20260218" + var favorites: [BingImage] = [] // full metadata for re-download +} + +@MainActor @Observable +final class PreferencesStore { + static let shared = PreferencesStore() + private(set) var preferences = WallpaperPreferences() + let fileURL: URL + + init(fileURL: URL? = nil) { + self.fileURL = fileURL ?? FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("BingWallpaper") + .appendingPathComponent("preferences.json") + } + + func load() { + let fm = FileManager.default + 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 + preferences = WallpaperPreferences() + } + } + + func save() { + let fm = FileManager.default + let dir = fileURL.deletingLastPathComponent() + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + do { + let data = try JSONEncoder().encode(preferences) + try data.write(to: fileURL, options: .atomic) + } catch { + // Silent failure — preferences are non-critical + } + } + + // MARK: - Dislikes + + func addDislike(_ date: String) { + preferences.dislikedDates.insert(date) + save() + } + + func removeDislike(_ date: String) { + preferences.dislikedDates.remove(date) + save() + } + + func isDisliked(_ date: String) -> Bool { + preferences.dislikedDates.contains(date) + } + + // MARK: - Favorites + + func addFavorite(_ image: BingImage) { + guard !preferences.favorites.contains(where: { $0.startdate == image.startdate }) else { return } + preferences.favorites.append(image) + save() + } + + func removeFavorite(_ image: BingImage) { + preferences.favorites.removeAll { $0.startdate == image.startdate } + save() + } + + func isFavorited(_ date: String) -> Bool { + preferences.favorites.contains { $0.startdate == date } + } + + func favoriteDates() -> Set { + Set(preferences.favorites.map(\.startdate)) + } +} diff --git a/Sources/Wallpaper/WallpaperApp.swift b/Sources/Wallpaper/WallpaperApp.swift index fbe41cc..10b5e17 100644 --- a/Sources/Wallpaper/WallpaperApp.swift +++ b/Sources/Wallpaper/WallpaperApp.swift @@ -22,7 +22,11 @@ struct WallpaperApp: App { var body: some Scene { MenuBarExtra("Wallpaper", systemImage: "photo.on.rectangle") { VStack(spacing: 0) { - imageCard + if manager.showingFavorites { + favoritesPanel + } else { + imageCard + } Divider() toolbar } @@ -78,27 +82,75 @@ struct WallpaperApp: App { LinearGradient(colors: [.clear, .black.opacity(0.6)], startPoint: .top, endPoint: .bottom) ) + // Action buttons at top corners (visible on hover) + VStack { + HStack { + actionButton( + icon: manager.isCurrentDisliked ? "hand.thumbsdown.fill" : "hand.thumbsdown", + tint: manager.isCurrentDisliked ? .orange : nil, + enabled: !manager.images.isEmpty + ) { + if manager.isCurrentDisliked { + manager.undoDislike() + } else { + Task { await manager.dislike() } + } + } + + Spacer() + + actionButton( + icon: manager.isCurrentFavorited ? "hand.thumbsup.fill" : "hand.thumbsup", + tint: manager.isCurrentFavorited ? .blue : nil, + enabled: !manager.images.isEmpty + ) { manager.toggleFavorite() } + } + .padding(.horizontal, 6) + .padding(.top, 6) + + Spacer() + } + .opacity(isHoveringImage ? 1 : 0) + .animation(.easeInOut(duration: 0.2), value: isHoveringImage) + // Navigation arrows (visible on hover) HStack { - navArrow("chevron.left", enabled: manager.hasPrevious) { Task { await manager.previous() } } + actionButton( + icon: "chevron.left", + enabled: manager.hasPrevious + ) { Task { await manager.previous() } } + Spacer() - navArrow("chevron.right", enabled: manager.hasNext) { Task { await manager.next() } } + + actionButton( + icon: "chevron.right", + enabled: manager.hasNext + ) { Task { await manager.next() } } } .padding(.horizontal, 6) .opacity(isHoveringImage ? 1 : 0) .animation(.easeInOut(duration: 0.2), value: isHoveringImage) } .onHover { isHoveringImage = $0 } - .onTapGesture { Task { await manager.applyCurrentWallpaper() } } + .onTapGesture { + if !manager.isCurrentDisliked { + Task { await manager.applyCurrentWallpaper() } + } + } .clipShape(RoundedRectangle(cornerRadius: 8)) .padding(8) } - private func navArrow(_ icon: String, enabled: Bool, action: @escaping () -> Void) -> some View { + private func actionButton( + icon: String, + tint: Color? = nil, + enabled: Bool, + action: @escaping () -> Void + ) -> some View { Button(action: action) { Image(systemName: icon) .font(.system(size: 12, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(tint ?? .white) .frame(width: 28, height: 28) .background(.ultraThinMaterial.opacity(0.8)) .clipShape(Circle()) @@ -109,6 +161,97 @@ struct WallpaperApp: App { .disabled(!enabled) } + // MARK: - Favorites Panel + + private var favoritesPanel: some View { + ZStack(alignment: .bottom) { + if let fav = manager.currentFavorite { + if let image = manager.favoritePreviewImage { + Image(nsImage: image) + .resizable() + .aspectRatio(16 / 9, contentMode: .fill) + .frame(maxWidth: .infinity) + .clipped() + } else { + Rectangle() + .fill(.quaternary) + .aspectRatio(16 / 9, contentMode: .fit) + .overlay { + Image(systemName: "photo") + .font(.largeTitle) + .foregroundStyle(.secondary) + } + } + + // Title overlay + VStack(alignment: .leading, spacing: 2) { + Spacer() + Text(fav.title) + .font(.callout.weight(.medium)) + .lineLimit(2) + Text(fav.formattedDate) + .font(.caption2) + .opacity(0.8) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background( + LinearGradient(colors: [.clear, .black.opacity(0.6)], startPoint: .top, endPoint: .bottom) + ) + + // Top-right actions (visible on hover) + VStack { + HStack { + Spacer() + actionButton(icon: "desktopcomputer", enabled: true) { + Task { await manager.applyFavorite(fav) } + } + actionButton(icon: "heart.slash.fill", tint: .pink, enabled: true) { + Task { await manager.removeCurrentFavorite() } + } + } + .padding(.horizontal, 6) + .padding(.top, 6) + Spacer() + } + .opacity(isHoveringImage ? 1 : 0) + .animation(.easeInOut(duration: 0.2), value: isHoveringImage) + + // Navigation arrows (visible on hover) + HStack { + actionButton(icon: "chevron.left", enabled: manager.hasPreviousFavorite) { + manager.previousFavorite() + } + Spacer() + actionButton(icon: "chevron.right", enabled: manager.hasNextFavorite) { + manager.nextFavorite() + } + } + .padding(.horizontal, 6) + .opacity(isHoveringImage ? 1 : 0) + .animation(.easeInOut(duration: 0.2), value: isHoveringImage) + } else { + Rectangle() + .fill(.quaternary) + .aspectRatio(16 / 9, contentMode: .fit) + .overlay { + VStack(spacing: 8) { + Image(systemName: "heart.slash") + .font(.title) + .foregroundStyle(.secondary) + Text("No favorites yet") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + .onHover { isHoveringImage = $0 } + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(8) + } + // MARK: - Toolbar private var toolbar: some View { @@ -155,6 +298,20 @@ struct WallpaperApp: App { Spacer() + Button { + if manager.showingFavorites { + Task { await manager.hideFavorites() } + } else { + manager.showFavorites() + } + } label: { + Image(systemName: manager.showingFavorites ? "heart.fill" : "heart") + .foregroundStyle(manager.showingFavorites ? .pink : .primary) + } + .help("Favorites") + + Spacer() + Button { refreshTrigger += 1 Task { await manager.refresh() } diff --git a/Sources/Wallpaper/WallpaperManager.swift b/Sources/Wallpaper/WallpaperManager.swift index c99fe36..790f3d6 100644 --- a/Sources/Wallpaper/WallpaperManager.swift +++ b/Sources/Wallpaper/WallpaperManager.swift @@ -8,6 +8,9 @@ class WallpaperManager { var isLoading = false var currentIndex = 0 var previewImage: NSImage? + var showingFavorites = false + var favoriteIndex = 0 + var favoritePreviewImage: NSImage? private(set) var images: [BingImage] = [] private var timer: Timer? @@ -16,11 +19,13 @@ class WallpaperManager { private var screenObserver: NSObjectProtocol? private var activityToken: NSObjectProtocol? + private let store = PreferencesStore.shared + var locale: String { Locale.current.identifier.replacingOccurrences(of: "_", with: "-") } - private var cacheDir: URL { + var cacheDir: URL { FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] .appendingPathComponent("BingWallpaper") } @@ -28,11 +33,91 @@ class WallpaperManager { var hasPrevious: Bool { !images.isEmpty && currentIndex < images.count - 1 } var hasNext: Bool { currentIndex > 0 } + // MARK: - Like/Dislike/Favorite Computed Properties + + var isCurrentDisliked: Bool { + guard currentIndex >= 0, currentIndex < images.count else { return false } + return store.isDisliked(images[currentIndex].startdate) + } + + var isCurrentFavorited: Bool { + guard currentIndex >= 0, currentIndex < images.count else { return false } + return store.isFavorited(images[currentIndex].startdate) + } + + var favoriteImages: [BingImage] { + store.preferences.favorites + } + + var currentFavorite: BingImage? { + let favs = favoriteImages + guard !favs.isEmpty, favoriteIndex >= 0, favoriteIndex < favs.count else { return nil } + return favs[favoriteIndex] + } + + var hasPreviousFavorite: Bool { !favoriteImages.isEmpty && favoriteIndex < favoriteImages.count - 1 } + var hasNextFavorite: Bool { favoriteIndex > 0 } + + func showFavorites() { + showingFavorites = true + favoriteIndex = 0 + loadFavoritePreview() + } + + func hideFavorites() async { + showingFavorites = false + await restoreNonDisliked() + } + + func previousFavorite() { + guard hasPreviousFavorite else { return } + favoriteIndex += 1 + loadFavoritePreview() + } + + func nextFavorite() { + guard hasNextFavorite else { return } + favoriteIndex -= 1 + loadFavoritePreview() + } + + func removeCurrentFavorite() async { + guard let fav = currentFavorite else { return } + store.removeFavorite(fav) + if favoriteImages.isEmpty { + showingFavorites = false + await restoreNonDisliked() + } else { + favoriteIndex = min(favoriteIndex, favoriteImages.count - 1) + loadFavoritePreview() + } + } + + /// 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) }) { + currentIndex = idx + do { try await applyWallpaper(at: currentIndex) } + catch { errorMessage = error.localizedDescription } + } + } + } + + private func loadFavoritePreview() { + guard let fav = currentFavorite else { + favoritePreviewImage = nil + return + } + favoritePreviewImage = cachedImage(for: fav) + } + // MARK: - Lifecycle /// Start the manager: fetch all images and schedule refresh every 6 hours + on wake func start() { guard timer == nil else { return } + store.load() Task { await loadAll() } // Prevent App Nap from deferring the refresh timer @@ -78,8 +163,13 @@ class WallpaperManager { } guard !allImages.isEmpty else { throw WallpaperError.noImages } images = allImages - currentIndex = 0 - try await applyWallpaper(at: 0) + // Apply first non-disliked wallpaper + if let firstNonDisliked = allImages.firstIndex(where: { !store.isDisliked($0.startdate) }) { + currentIndex = firstNonDisliked + } else { + currentIndex = 0 + } + try await applyWallpaper(at: currentIndex) cleanOldCache() } catch { errorMessage = error.localizedDescription @@ -100,8 +190,11 @@ class WallpaperManager { if images.count > 10 { images.removeLast(images.count - 10) } cleanOldCache() } - currentIndex = 0 - try await applyWallpaper(at: 0) + // Only auto-apply the latest if it's not disliked + if !store.isDisliked(latest.startdate) { + currentIndex = 0 + try await applyWallpaper(at: 0) + } } } catch { errorMessage = error.localizedDescription @@ -123,15 +216,104 @@ class WallpaperManager { func previous() async { guard !isLoading, hasPrevious else { return } currentIndex += 1 - do { try await applyWallpaper(at: currentIndex) } - catch { errorMessage = error.localizedDescription } + await showOrApply(at: currentIndex) } func next() async { guard !isLoading, hasNext else { return } currentIndex -= 1 - do { try await applyWallpaper(at: currentIndex) } - catch { errorMessage = error.localizedDescription } + await showOrApply(at: currentIndex) + } + + /// Preview-only if disliked, otherwise apply as wallpaper + 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 } + } + } + + /// Download and show in preview without setting as desktop wallpaper + private func previewOnly(at index: Int) async throws { + guard index >= 0, index < images.count else { return } + let image = images[index] + let localURL = try await downloadImage(image) + currentTitle = image.title + currentCopyright = image.copyright + previewImage = NSImage(contentsOf: localURL) + } + + // MARK: - Like/Dislike/Favorite Actions + + func dislike() async { + guard currentIndex >= 0, currentIndex < images.count else { return } + let image = images[currentIndex] + store.addDislike(image.startdate) + // Remove from favorites if present + store.removeFavorite(image) + await navigateToNextNonDisliked() + } + + func undoDislike() { + guard currentIndex >= 0, currentIndex < images.count else { return } + store.removeDislike(images[currentIndex].startdate) + } + + func toggleFavorite() { + guard currentIndex >= 0, currentIndex < images.count else { return } + let image = images[currentIndex] + if store.isFavorited(image.startdate) { + store.removeFavorite(image) + } else { + store.addFavorite(image) + // Remove dislike if adding to favorites + store.removeDislike(image.startdate) + } + } + + func applyFavorite(_ image: BingImage) async { + isLoading = true + errorMessage = nil + do { + let localURL = try await downloadImage(image) + for screen in NSScreen.screens { + try NSWorkspace.shared.setDesktopImageURL(localURL, for: screen) + } + currentTitle = image.title + currentCopyright = image.copyright + previewImage = NSImage(contentsOf: localURL) + // If image is in our loaded list, update currentIndex + if let idx = images.firstIndex(where: { $0.startdate == image.startdate }) { + currentIndex = idx + } + 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) + } + + private func navigateToNextNonDisliked() async { + // Try newer images first (lower indices), then older images + let candidates = Array(0.. PreferencesStore { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent("preferences.json") + return PreferencesStore(fileURL: url) + } + + @Test("add and check dislike") + @MainActor func addDislike() { + let store = makeStore() + #expect(!store.isDisliked("20260218")) + store.addDislike("20260218") + #expect(store.isDisliked("20260218")) + } + + @Test("remove dislike") + @MainActor func removeDislike() { + let store = makeStore() + store.addDislike("20260218") + store.removeDislike("20260218") + #expect(!store.isDisliked("20260218")) + } + + @Test("add and check favorite") + @MainActor func addFavorite() { + let store = makeStore() + let image = BingImage(startdate: "20260218", urlbase: "/img", copyright: "©", title: "T") + #expect(!store.isFavorited("20260218")) + store.addFavorite(image) + #expect(store.isFavorited("20260218")) + #expect(store.preferences.favorites.count == 1) + } + + @Test("adding duplicate favorite is a no-op") + @MainActor func duplicateFavorite() { + let store = makeStore() + let image = BingImage(startdate: "20260218", urlbase: "/img", copyright: "©", title: "T") + store.addFavorite(image) + store.addFavorite(image) + #expect(store.preferences.favorites.count == 1) + } + + @Test("remove favorite") + @MainActor func removeFavorite() { + let store = makeStore() + let image = BingImage(startdate: "20260218", urlbase: "/img", copyright: "©", title: "T") + store.addFavorite(image) + store.removeFavorite(image) + #expect(!store.isFavorited("20260218")) + #expect(store.preferences.favorites.isEmpty) + } + + @Test("favoriteDates returns correct set") + @MainActor func favoriteDates() { + let store = makeStore() + let img1 = BingImage(startdate: "20260218", urlbase: "/1", copyright: "©", title: "A") + let img2 = BingImage(startdate: "20260219", urlbase: "/2", copyright: "©", title: "B") + store.addFavorite(img1) + store.addFavorite(img2) + #expect(store.favoriteDates() == Set(["20260218", "20260219"])) + } + + @Test("save and load round-trip persists data") + @MainActor func persistence() { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent("preferences.json") + + let store1 = PreferencesStore(fileURL: url) + store1.addDislike("20260218") + store1.addFavorite(BingImage(startdate: "20260220", urlbase: "/img", copyright: "©", title: "T")) + + let store2 = PreferencesStore(fileURL: url) + store2.load() + #expect(store2.isDisliked("20260218")) + #expect(store2.isFavorited("20260220")) + #expect(store2.preferences.favorites.count == 1) + + // Cleanup + try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) + } + + @Test("load with missing file keeps empty defaults") + @MainActor func loadMissing() { + let store = makeStore() + store.load() + #expect(store.preferences.dislikedDates.isEmpty) + #expect(store.preferences.favorites.isEmpty) + } + + @Test("load with corrupted file resets to defaults") + @MainActor func loadCorrupted() { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent("preferences.json") + + let dir = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try? "not-json".data(using: .utf8)?.write(to: url) + + let store = PreferencesStore(fileURL: url) + store.load() + #expect(store.preferences.dislikedDates.isEmpty) + #expect(store.preferences.favorites.isEmpty) + + // Cleanup + try? FileManager.default.removeItem(at: dir) + } +}