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)
+ }
+}