1e8dc98e1ecf

resolve phase 3 low-priority findings (v0.6.0)

time
2026-07-16 21:23 UTC · 3 months ago
machine
koh2
agent
claude-sonnet-5
session
resolve audited findings
parent
deb6c7320fc3
15 files Sources/Haven/AppBootstrap.swift+24 −2Sources/Haven/AppDelegate.swift+31 −5Sources/Haven/Managers/AutostartManager.swift+31 −6Sources/Haven/Managers/ImageCache.swift+39 −2Sources/Haven/Managers/LocalWallpapersManager.swift+51 −8Sources/Haven/Managers/WallpaperManager.swift+21 −6Sources/Haven/UI/AppState.swift+21 −6Sources/Haven/UI/Views/Components/WallpaperItem.swift+31 −2Sources/Haven/UI/Views/MainView.swift+0 −2Sources/Haven/Utils/Configuration.swift+0 −1Tests/HavenTests/AppStateTests.swift+41 −0Tests/HavenTests/ManagerTests.swift+146 −1Tests/HavenTests/PlatformCoverageTests.swift+27 −3Tests/HavenTests/ViewCoverageTests.swift+14 −0bundle_app.sh+6 −2
Sources/Haven/AppBootstrap.swift+24 −2
@@ -1,11 +1,33 @@11 import AppKit22 33 enum HavenApplication {4+  /// Boots the app. Returns a tiny tuple-style struct so callers (and5+  /// tests) can introspect the activation policy the bootstrap installed6+  /// without poking at NSApp.shared directly.7+  @discardableResult48   static func start(59     delegate: AppDelegate, shouldRun: Bool,6-    setDelegate: (AppDelegate) -> Void, run: () -> Void7-  ) {10+    setDelegate: (AppDelegate) -> Void, run: () -> Void,11+    /// Defaults to setting `NSApp.setActivationPolicy(.accessory)` so an12+    /// unbundled `swift run` launch still behaves like a menu-bar app and13+    /// doesn't grow a Dock icon. The bundled `Info.plist` keeps the same14+    /// behaviour via `LSUIElement`, so both paths land on `.accessory`.15+    setActivationPolicy: (NSApplication.ActivationPolicy) -> Void =16+      { NSApp.setActivationPolicy($0) }17+  ) -> ActivationChoice {18+    // `.accessory` keeps Haven out of the Dock and out of the Cmd+Tab19+    // switcher, matching the bundled `LSUIElement` Info.plist key. The20+    // menu-bar status item still shows in both launch modes.21+    setActivationPolicy(.accessory)822     setDelegate(delegate)923     if shouldRun { run() }24+    return ActivationChoice(policy: .accessory)25+  }26+27+  /// Recorded activation policy chosen by `start`. Exposed so tests can28+  /// assert that the bootstrap honours the menu-bar contract regardless29+  /// of whether the executable was launched from a bundle.30+  struct ActivationChoice: Equatable {31+    let policy: NSApplication.ActivationPolicy1032   }1133 }

view file

Sources/Haven/AppDelegate.swift+31 −5
@@ -1,20 +1,46 @@11 import AppKit22 33 class AppDelegate: NSObject, NSApplicationDelegate {4-  var statusItem: NSStatusItem!5-  var menuController: MenuController!4+  // Optional backing storage rather than implicitly-unwrapped optionals:5+  // before `applicationDidFinishLaunching` runs (or after `invalidate`),6+  // there is genuinely no status item or menu controller to hand out, and7+  // reading them through a normal optional forces the lifecycle through8+  // a single funnel instead of letting any caller trap on a half-built9+  // delegate.10+  private var statusItem: NSStatusItem?11+  private var menuController: MenuController?612 713   func applicationDidFinishLaunching(_ notification: Notification) {814     // Create the status item in the menu bar9-    statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)15+    let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)1016 11-    if let button = statusItem.button {17+    if let button = item.button {1218       // Use a system symbol for the icon1319       button.image = NSImage(1420         systemSymbolName: "photo.on.rectangle.angled", accessibilityDescription: "Haven")1521     }1622 1723     // Initialize the menu controller18-    menuController = MenuController(statusItem: statusItem)24+    menuController = MenuController(statusItem: item)25+    statusItem = item26+  }27+28+  /// Read-only access to the status item created during launch. Returns29+  /// `nil` when the delegate has not finished launching yet (or after30+  /// `invalidate()` has been called). Callers that need the item to be31+  /// present must consult this guard and fail gracefully otherwise.32+  var currentStatusItem: NSStatusItem? { statusItem }33+34+  /// Read-only access to the menu controller created during launch.35+  /// Returns `nil` before launch finishes so callers do not accidentally36+  /// observe a half-built controller.37+  var currentMenuController: MenuController? { menuController }38+39+  /// Tear down status-item and menu-controller state. Useful for tests40+  /// that want to recreate the delegate cleanly and for any future code41+  /// path that needs to dispose of the menu bar item explicitly.42+  func invalidate() {43+    statusItem = nil44+    menuController = nil1945   }2046 }

view file

Sources/Haven/Managers/AutostartManager.swift+31 −6
@@ -4,6 +4,11 @@44 @MainActor55 protocol AutostartManaging: AnyObject {66   var isEnabled: Bool { get set }7+  /// Latest failure observed by the manager. `nil` when the launch-agent8+  /// plist is in its expected state (or has never been touched). Callers9+  /// can surface this through UI/alerts without having to inspect logs.10+  /// The flag is reset to `nil` whenever the next operation succeeds.11+  var lastError: String? { get }712 }813 914 class AutostartManager: AutostartManaging {@@ -14,30 +19,48 @@1419   private let executablePath: () -> String?1520   private let writePlist: (Data, URL) throws -> Void1621   private let removePlist: (URL) throws -> Void22+  private let ensureLaunchAgentsDirectory: (URL) throws -> Void23+  private let fileExists: (String) -> Bool1724 25+  private(set) var lastError: String?26+1827   init(1928     libraryDirectory: URL? = nil,2029     executablePath: @escaping () -> String? = { Bundle.main.executablePath },2130     writePlist: @escaping (Data, URL) throws -> Void = { data, url in try data.write(to: url) },22-    removePlist: @escaping (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) }31+    removePlist: @escaping (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) },32+    ensureLaunchAgentsDirectory: @escaping (URL) throws -> Void = { url in33+      try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)34+    },35+    fileExists: @escaping (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }2336   ) {2437     let libraryDir =2538       libraryDirectory2639       ?? FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!2740     let launchAgentsDir = libraryDir.appendingPathComponent("LaunchAgents")2841 29-    // Ensure directory exists30-    try? FileManager.default.createDirectory(at: launchAgentsDir, withIntermediateDirectories: true)42+    // Capture directory-creation failures instead of dropping them with43+    // `try?`. The most common cause in practice is a parent path that is a44+    // regular file (the test suite exercises this) or an unwritable parent;45+    // either way the manager must continue to function (it's still safe to46+    // query `isEnabled`) but the cause needs to be reachable for callers.47+    do {48+      try ensureLaunchAgentsDirectory(launchAgentsDir)49+    } catch {50+      lastError = "Failed to prepare LaunchAgents directory: \(error.localizedDescription)"51+    }3152 3253     self.launchAgentURL = launchAgentsDir.appendingPathComponent("\(bundleIdentifier).plist")3354     self.executablePath = executablePath3455     self.writePlist = writePlist3556     self.removePlist = removePlist57+    self.ensureLaunchAgentsDirectory = ensureLaunchAgentsDirectory58+    self.fileExists = fileExists3659   }3760 3861   var isEnabled: Bool {3962     get {40-      return FileManager.default.fileExists(atPath: launchAgentURL.path)63+      return fileExists(launchAgentURL.path)4164     }4265     set {4366       if newValue {@@ -65,8 +88,9 @@6588       let data = try PropertyListSerialization.data(6689         fromPropertyList: plistContent, format: .xml, options: 0)6790       try writePlist(data, launchAgentURL)91+      lastError = nil6892     } catch {69-      print("Failed to enable autostart: \(error)")93+      lastError = "Failed to enable autostart: \(error.localizedDescription)"7094     }7195   }7296 @@ -75,8 +99,9 @@7599 76100     do {77101       try removePlist(launchAgentURL)102+      lastError = nil78103     } catch {79-      print("Failed to disable autostart: \(error)")104+      lastError = "Failed to disable autostart: \(error.localizedDescription)"80105     }81106   }82107 }

view file

Sources/Haven/Managers/ImageCache.swift+39 −2
@@ -1,12 +1,37 @@11 import AppKit22 3+/// In-memory thumbnail cache backed by `NSCache`. NSCache already evicts4+/// entries under memory pressure; the explicit `countLimit` and5+/// `totalCostLimit` here are the deterministic upper bounds the manager6+/// enforces so a runaway explore session can't grow the cache without7+/// limit. Both are configurable through `init` so tests can shrink the8+/// budget without depending on system-wide memory signals.39 class ImageCache {410   static let shared = ImageCache()11+12+  /// Default cap on the number of cached images. Sixty-four entries is13+  /// enough to cover an explore screen of thumbnails plus a screen of14+  /// pre-fetched neighbours without keeping multiple megabytes of decoded15+  /// pixel data resident indefinitely.16+  static let defaultCountLimit = 6417+18+  /// Default cost budget in bytes. `NSImage` does not expose a precise19+  /// pixel cost, so we estimate `width * height * 4` (RGBA) when callers20+  /// know the source dimensions. The default of ~64 MiB keeps the cache21+  /// under a tenth of a typical app's compressed memory allowance.22+  static let defaultTotalCostLimit = 64 * 1024 * 102423+524   private let cache = NSCache<NSString, NSImage>()625   private let session: URLSession726 8-  init(session: URLSession = .shared) {27+  init(28+    session: URLSession = .shared,29+    countLimit: Int = ImageCache.defaultCountLimit,30+    totalCostLimit: Int = ImageCache.defaultTotalCostLimit31+  ) {932     self.session = session33+    self.cache.countLimit = countLimit34+    self.cache.totalCostLimit = totalCostLimit1035   }1136 1237   func image(for url: URL) -> NSImage? {@@ -14,7 +39,7 @@1439   }1540 1641   func insertImage(_ image: NSImage, for url: URL) {17-    cache.setObject(image, forKey: url.absoluteString as NSString)42+    cache.setObject(image, forKey: url.absoluteString as NSString, cost: estimatedCost(of: image))1843   }1944 2045   func fetchImage(url: URL) async throws -> NSImage {@@ -34,5 +59,17 @@3459 3560     insertImage(image, for: url)3661     return image62+  }63+64+  /// Estimated RGBA byte cost of `image`. Used by NSCache so eviction65+  /// respects a real budget instead of just the entry count. Returns zero66+  /// for images without pixel dimensions (e.g. vector PDFs) so the count67+  /// limit alone bounds them.68+  private func estimatedCost(of image: NSImage) -> Int {69+    let size = image.size70+    let width = Int(size.width.rounded())71+    let height = Int(size.height.rounded())72+    guard width > 0, height > 0 else { return 0 }73+    return width * height * 43774   }3875 }

view file

Sources/Haven/Managers/LocalWallpapersManager.swift+51 −8
@@ -39,14 +39,28 @@3939     }4040   }4141 42+  /// Begin watching `folderPath` for filesystem changes. If monitoring could43+  /// not be installed (missing folder, permission denied, descriptor open44+  /// failure), surfaces the failure through `errorMessage` so the UI is not45+  /// silently out of date. The first scan still runs so the user sees the46+  /// current state even when live updates are unavailable.4247   func startMonitoring(folderPath: String) {4348     folderMonitor?.stop()44-    folderMonitor = FolderMonitor(folderPath: folderPath) { [weak self] in49+    // Reset prior state: the previous monitor (if any) is gone, the50+    // previous scan result no longer applies. Both completion paths51+    // (monitor failure here, scan failure inside scanFolder) repopulate52+    // this field.53+    errorMessage = nil54+    let monitor = FolderMonitor(folderPath: folderPath) { [weak self] in4555       Task { @MainActor in4656         self?.scanFolder(path: folderPath)4757       }4858     }49-    folderMonitor?.start()59+    folderMonitor = monitor60+    monitor.start()61+    if let message = monitor.openFailureMessage {62+      errorMessage = message63+    }5064     scanFolder(path: folderPath)5165   }5266 @@ -66,7 +80,11 @@6680     scanGeneration &+= 16781     let requestGeneration = scanGeneration6882     isLoading = true69-    errorMessage = nil83+    // Note: errorMessage is *not* cleared here so an unrelated failure84+    // (e.g. FolderMonitor could not install its watch) survives the scan85+    // attempt and stays visible to the UI. The success/failure cases below86+    // own the message: success clears it, failure overwrites it with the87+    // scan-specific cause.7088 7189     // Snapshot all immutable configuration the detached task needs into a7290     // Sendable value. This keeps `Task.detached` from capturing `self` (and@@ -87,7 +105,9 @@87105       switch result {88106       case .success(let wallpapers):89107         self.wallpapers = wallpapers90-        self.errorMessage = nil108+      // errorMessage intentionally not cleared: a previously surfaced109+      // monitor open failure must stay visible even when the scan itself110+      // completes successfully.91111       case .failure(let message):92112         self.errorMessage = message93113       }@@ -191,16 +211,38 @@191211   private var fileDescriptor: Int32 = -1192212   private var source: DispatchSourceFileSystemObject?193213   private let queue = DispatchQueue(label: "com.haven.folderMonitor", qos: .utility)214+  private let openPath: (String, Int32) -> Int32194215 195-  init(folderPath: String, fileDescriptor: Int32 = -1, callback: @escaping () -> Void) {216+  /// Human-readable description of why `start()` could not install a live217+  /// monitor; `nil` when monitoring is healthy (or has not yet been218+  /// attempted). Surfaced through the manager's `errorMessage` so the UI219+  /// shows the cause instead of silently dropping the watch.220+  private(set) var openFailureMessage: String?221+222+  init(223+    folderPath: String, fileDescriptor: Int32 = -1, callback: @escaping () -> Void,224+    openPath: @escaping (String, Int32) -> Int32 = { path, flags in225+      Darwin.open(path, flags)226+    }227+  ) {196228     self.folderPath = folderPath197229     self.fileDescriptor = fileDescriptor198230     self.callback = callback231+    self.openPath = openPath199232   }200233 201-  func start() {202-    fileDescriptor = open(folderPath, O_EVTONLY)203-    guard fileDescriptor >= 0 else { return }234+  /// Returns `true` when the monitor is now actively watching the folder.235+  /// `false` means `open()` rejected the path; callers should surface the236+  /// stored `openFailureMessage` instead of pretending monitoring succeeded.237+  @discardableResult238+  func start() -> Bool {239+    fileDescriptor = openPath(folderPath, O_EVTONLY)240+    guard fileDescriptor >= 0 else {241+      openFailureMessage =242+        "Could not watch \(folderPath): \(String(cString: strerror(errno)))"243+      return false244+    }245+    openFailureMessage = nil204246 205247     source = DispatchSource.makeFileSystemObjectSource(206248       fileDescriptor: fileDescriptor,@@ -217,6 +259,7 @@217259     source?.setCancelHandler { [weak self] in self?.closeFileDescriptor() }218260 219261     source?.resume()262+    return true220263   }221264 222265   func stop() {

view file

Sources/Haven/Managers/WallpaperManager.swift+21 −6
@@ -21,11 +21,15 @@2121 2222   private let session: URLSession2323   private let desktopSetter: @MainActor (URL, NSScreen) throws -> Void24+  private let mainScreen: () -> NSScreen?25+  private let firstScreen: () -> NSScreen?2426 2527   init(2628     session: URLSession = .shared,2729     desktopSetter: (@MainActor (URL, NSScreen) throws -> Void)? = nil,28-    workspace: DesktopImageSetting = NSWorkspace.shared30+    workspace: DesktopImageSetting = NSWorkspace.shared,31+    mainScreen: @escaping () -> NSScreen? = { NSScreen.main },32+    firstScreen: @escaping () -> NSScreen? = { NSScreen.screens.first }2933   ) {3034     self.session = session3135     self.desktopSetter =@@ -34,6 +38,8 @@3438           url, for: screen, options: [:]3539         )3640       }41+    self.mainScreen = mainScreen42+    self.firstScreen = firstScreen3743   }3844 3945   /// Downloads the file to a temporary location and returns the local URL.@@ -73,13 +79,22 @@7379   func setDesktopImage(from fileURL: URL, screen: NSScreen? = nil) throws {7480     if let screen = screen {7581       try desktopSetter(fileURL, screen)82+      return83+    }84+    // Pick the screen we should target when the caller didn't pass one.85+    // The previous fallback force-unwrapped `NSScreen.screens.first!`, which86+    // would trap on a headless launch (no screens at all) or a transient87+    // screen-disconnect window. Throw a real error instead so callers can88+    // react.89+    let target: NSScreen90+    if let main = mainScreen() {91+      target = main92+    } else if let first = firstScreen() {93+      target = first7694     } else {77-      // Set for all screens or just main? Let's default to main for safety,78-      // but the UI will likely drive this.79-      // If no screen specified, maybe set for the 'active' one.80-      let targetScreen = NSScreen.main ?? NSScreen.screens.first!81-      try desktopSetter(fileURL, targetScreen)95+      throw WallhavenError.apiError("No screen is available to set the desktop image")8296     }97+    try desktopSetter(fileURL, target)8398   }8499 85100   /// Copies the file to the specified destination directory.

view file

Sources/Haven/UI/AppState.swift+21 −6
@@ -144,6 +144,21 @@144144       .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }145145       .store(in: &cancellables)146146 147+    // Resolution and ratio pickers fire one value per menu interaction.148+    // Debouncing them keeps a single user gesture producing a single149+    // network request, matching the categories/purity/searchText flow.150+    $selectedResolution151+      .dropFirst()152+      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)153+      .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }154+      .store(in: &cancellables)155+156+    $selectedRatio157+      .dropFirst()158+      .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)159+      .sink { [weak self] _ in Task { await self?.fetchWallpapers() } }160+      .store(in: &cancellables)161+147162     $feedMode148163       .dropFirst()149164       .sink { [weak self] _ in@@ -274,9 +289,9 @@274289     do {275290       let localURL = try await wallpaperManager.download(from: remoteURL)276291 277-      try await MainActor.run {278-        try wallpaperManager.setDesktopImage(from: localURL, screen: screen)279-      }292+      // AppState is @MainActor isolated; the prior MainActor.run hop was293+      // redundant because the call site already runs on the main actor.294+      try wallpaperManager.setDesktopImage(from: localURL, screen: screen)280295 281296       // Add to history282297       historyManager.addToHistory(wallpaper)@@ -293,9 +308,9 @@293308 294309   func setLocalWallpaper(_ wallpaper: LocalWallpaper, on screen: NSScreen? = nil) async {295310     do {296-      try await MainActor.run {297-        try wallpaperManager.setDesktopImage(from: wallpaper.url, screen: screen)298-      }311+      // AppState is @MainActor isolated; the prior MainActor.run hop was312+      // redundant because the call site already runs on the main actor.313+      try wallpaperManager.setDesktopImage(from: wallpaper.url, screen: screen)299314 300315       let historyWallpaper = Wallpaper(301316         id: wallpaper.id,

view file

Sources/Haven/UI/Views/Components/WallpaperItem.swift+31 −2
@@ -79,14 +79,43 @@7979     return AnyView(Rectangle().fill(Color.gray.opacity(0.2)).overlay(ProgressView()))8080   }8181 82+  /// Exponential-backoff retry. Each successive failure doubles the wait83+  /// (`base * 2^attempt`) so a flaky host gets a chance to recover without84+  /// hammering it, while still bounding the total wait under85+  /// `maxAttempts * base` for the default parameters.86+  ///87+  /// Cancellation-safe: bails out immediately when the hosting task is88+  /// already cancelled, and exits early if `Task.sleep` itself is89+  /// interrupted mid-wait so a disappearing row never advances the90+  /// attempt counter or rebinds `retryID`.91+  /// Exposes the doubling backoff schedule for tests so callers can92+  /// assert the wait times without having to actually sleep.93+  func backoffDelay(forAttempt attempt: Int) -> UInt64 {94+    let safe = max(0, attempt)95+    let multiplier = UInt64(1) << safe96+    return multiplier &* retryDelayNanoseconds97+  }98+8299   func retryAfterFailure() async {83100     guard attempt < maxAttempts else { return }84-    let delay = UInt64(attempt + 1) * retryDelayNanoseconds85-    try? await Task.sleep(nanoseconds: delay)101+    if Task.isCancelled { return }102+103+    // Doubling schedule: 1x, 2x, 4x, 8x, ... `attempt` stays small104+    // (≤ maxAttempts), so the shift never overflows UInt64.105+    let multiplier = UInt64(1) << attempt106+    let delay = multiplier &* retryDelayNanoseconds107+    do {108+      try await Task.sleep(nanoseconds: delay)109+    } catch {110+      return111+    }86112     guard !Task.isCancelled else { return }87113     attempt += 188114     retryID = UUID()89115   }116+117+  /// Test seam: read-only access to the internal retry counter.118+  var exposedAttempt: Int { attempt }90119 91120   func toggleFavorite() { favoritesManager.toggleFavorite(wallpaper) }92121 }

view file

Sources/Haven/UI/Views/MainView.swift+0 −2
@@ -110,8 +110,6 @@110110         .frame(width: 30)111111       }112112       .padding()113-      .onChange(of: appState.selectedResolution) { _ in Task { await appState.fetchWallpapers() } }114-      .onChange(of: appState.selectedRatio) { _ in Task { await appState.fetchWallpapers() } }115113 116114       Divider()117115 

view file

Sources/Haven/Utils/Configuration.swift+0 −1
@@ -2,5 +2,4 @@22 33 struct Configuration {44   static let baseURL = URL(string: "https://wallhaven.cc/api/v1")!5-  static let defaultResolution = "1920x1080"65 }

view file

Tests/HavenTests/AppStateTests.swift+41 −0
@@ -94,6 +94,7 @@9494 @MainActor9595 final class AutostartStub: AutostartManaging {9696   var isEnabled = false97+  var lastError: String?9798 }9899 99100 @Suite("Explore feed configuration")@@ -351,6 +352,46 @@351352     state.saveFolderPath = FileManager.default.temporaryDirectory.path352353 353354     #expect(search.requests.count >= 5)355+  }356+357+  @Test("Resolution and ratio changes are debounced like other filters")358+  func debouncedResolutionAndRatio() async {359+    let search = SearchStub()360+    // Pre-load enough results for any plausible burst; the test asserts on361+    // request counts, not on which exact request wins.362+    search.results = Array(repeating: .success(([], nil)), count: 8)363+    let state = AppState(364+      api: search, chooseFolder: { _, completion in completion(nil) },365+      terminateApplication: {}, observeChanges: true)366+367+    // Let the initial-value debouncers from searchText/categories/purity368+    // settle before we measure the cost of our rapid filter changes.369+    try? await Task.sleep(nanoseconds: 1_000_000_000)370+    let baseline = search.requests.count371+372+    // Rapid-fire several changes per filter. With debounce these should373+    // coalesce into a single fetch per filter; without it, each assignment374+    // would produce its own request.375+    state.selectedResolution = "1920x1080"376+    state.selectedResolution = "2560x1440"377+    state.selectedResolution = "3840x2160"378+    state.selectedRatio = "16x9"379+    state.selectedRatio = "21x9"380+381+    // Wait long enough for every debouncer to settle (3x the 0.5s window).382+    try? await Task.sleep(nanoseconds: 1_500_000_000)383+384+    let newRequests = Array(search.requests.dropFirst(baseline))385+    // Each debounced filter (resolution, ratio) fires its own fetch, but386+    // every fetch reads the latest values of both filters at the time it387+    // runs. The debouncers coalesce the rapid changes into exactly one388+    // fetch per filter, so we expect two new requests rather than the five389+    // an unguarded publisher would have produced.390+    #expect(newRequests.count == 2)391+    for request in newRequests {392+      #expect(request.resolutions == ["3840x2160"])393+      #expect(request.ratios == ["21x9"])394+    }354395   }355396 356397   @Test("Stale reset responses do not overwrite a newer query")

view file

Tests/HavenTests/ManagerTests.swift+146 −1
@@ -255,6 +255,26 @@255255     await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: httpError) }256256     await #expect(throws: WallhavenError.self) { try await cache.fetchImage(url: invalid) }257257   }258+259+  @Test("Default cache limits are finite and configurable")260+  func defaultAndInjectedLimits() {261+    // Sanity: defaults are bounded so the cache cannot grow without limit.262+    #expect(ImageCache.defaultCountLimit > 0)263+    #expect(ImageCache.defaultTotalCostLimit > 0)264+265+    let tight = ImageCache(countLimit: 2, totalCostLimit: 1024)266+    let first = URL(string: "https://example.com/\(UUID().uuidString).png")!267+    let second = URL(string: "https://example.com/\(UUID().uuidString).png")!268+    let third = URL(string: "https://example.com/\(UUID().uuidString).png")!269+    tight.insertImage(NSImage(size: NSSize(width: 4, height: 4)), for: first)270+    tight.insertImage(NSImage(size: NSSize(width: 4, height: 4)), for: second)271+    tight.insertImage(NSImage(size: NSSize(width: 4, height: 4)), for: third)272+273+    // NSCache enforces the count limit deterministically enough to assert;274+    // the third insertion must have evicted at least one of the earlier two.275+    let survivors = [first, second, third].filter { tight.image(for: $0) != nil }276+    #expect(survivors.count <= 2)277+  }258278 }259279 260280 @Suite("Wallpaper file management", .serialized)@@ -438,6 +458,64 @@438458       WallpaperManager.makeTempURL(for: withExtension).path439459         != WallpaperManager.makeTempURL(for: withExtension).path)440460   }461+462+  @Test("setDesktopImage without screens throws instead of force-unwrapping")463+  func setDesktopImageWithoutScreensThrows() {464+    let manager = WallpaperManager(465+      desktopSetter: { _, _ in },466+      mainScreen: { nil }, firstScreen: { nil })467+    let url = URL(fileURLWithPath: "/tmp/no-screens.jpg")468+469+    #expect(throws: WallhavenError.self) {470+      try manager.setDesktopImage(from: url)471+    }472+  }473+474+  @Test("setDesktopImage prefers main over first screen when both are available")475+  func setDesktopImagePrefersMain() throws {476+    let mainScreen = NSScreen.main ?? NSScreen.screens.first477+    guard let mainScreen else { return }478+    let otherScreen = mainScreen479+    var captured: [NSScreen] = []480+    let manager = WallpaperManager(481+      desktopSetter: { _, target in captured.append(target) },482+      mainScreen: { mainScreen }, firstScreen: { otherScreen })483+    let url = URL(fileURLWithPath: "/tmp/main-preferred.jpg")484+485+    try manager.setDesktopImage(from: url)486+487+    #expect(captured == [mainScreen])488+  }489+490+  @Test("setDesktopImage falls back to first screen when main is missing")491+  func setDesktopImageFallsBackToFirst() throws {492+    guard let first = NSScreen.screens.first else { return }493+    var captured: [NSScreen] = []494+    let manager = WallpaperManager(495+      desktopSetter: { _, target in captured.append(target) },496+      mainScreen: { nil }, firstScreen: { first })497+    let url = URL(fileURLWithPath: "/tmp/fallback.jpg")498+499+    try manager.setDesktopImage(from: url)500+501+    #expect(captured == [first])502+  }503+504+  @Test("setDesktopImage default firstScreen closure calls NSScreen.screens.first")505+  func setDesktopImageDefaultFirstScreenClosure() throws {506+    guard let first = NSScreen.screens.first else { return }507+    var captured: [NSScreen] = []508+    // No main screen + default firstScreen closure exercises the509+    // { NSScreen.screens.first } default injected in the init signature.510+    let manager = WallpaperManager(511+      desktopSetter: { _, target in captured.append(target) },512+      mainScreen: { nil })513+    let url = URL(fileURLWithPath: "/tmp/default-first.jpg")514+515+    try manager.setDesktopImage(from: url)516+517+    #expect(captured == [first])518+  }441519 }442520 443521 @Suite("Autostart persistence", .serialized)@@ -512,6 +590,37 @@512590     manager.isEnabled = false513591     #expect(manager.isEnabled)514592   }593+594+  @Test("LaunchAgents directory-creation failures surface through lastError")595+  func launchAgentsDirectoryFailureIsSurfaced() {596+    let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)597+    defer { try? FileManager.default.removeItem(at: file) }598+    try? Data("not a directory".utf8).write(to: file)599+    let manager = AutostartManager(600+      libraryDirectory: file, executablePath: { "/tmp/Haven" })601+602+    // The directory-create error must be observable rather than swallowed.603+    #expect(manager.lastError?.contains("LaunchAgents") == true)604+  }605+606+  @Test("lastError is cleared by a successful enable/disable cycle")607+  func lastErrorClearsOnSuccess() throws {608+    let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)609+    defer { try? FileManager.default.removeItem(at: root) }610+    let manager = AutostartManager(611+      libraryDirectory: root, executablePath: { "/tmp/Haven" },612+      writePlist: { _, _ in throw CocoaError(.fileWriteNoPermission) })613+614+    manager.isEnabled = true615+    #expect(manager.lastError?.contains("Failed to enable autostart") == true)616+617+    // Swap in a working writer so the next cycle succeeds.618+    let successManager = AutostartManager(619+      libraryDirectory: root, executablePath: { "/tmp/Haven" })620+    successManager.isEnabled = true621+    successManager.isEnabled = false622+    #expect(successManager.lastError == nil)623+  }515624 }516625 517626 @Suite("Local wallpaper library", .serialized)@@ -676,13 +785,49 @@676785     try? await Task.sleep(nanoseconds: 500_000_000)677786 678787     let invalidMonitor = FolderMonitor(folderPath: root.appendingPathComponent("missing").path) {}679-    invalidMonitor.start()788+    #expect(invalidMonitor.start() == false)789+    #expect(invalidMonitor.openFailureMessage?.isEmpty == false)680790     invalidMonitor.closeFileDescriptor()681791 792+    // An injectable `openPath` returning -1 simulates open() failure so the793+    // error is reproducible in CI. The message must reach any caller that794+    // asks for it so the UI can react instead of silently dropping the watch.795+    let broken = FolderMonitor(796+      folderPath: root.path, callback: {},797+      openPath: { _, _ in -1 })798+    #expect(broken.start() == false)799+    #expect(broken.openFailureMessage?.localizedCaseInsensitiveContains("could not watch") == true)800+682801     let descriptor = open("/dev/null", O_RDONLY)683802     let descriptorMonitor = FolderMonitor(684803       folderPath: "/dev/null", fileDescriptor: descriptor, callback: {})685804     descriptorMonitor.closeFileDescriptor()686805     #expect(fcntl(descriptor, F_GETFD) == -1)806+  }807+808+  @Test("startMonitoring surfaces folder-monitor open failures through errorMessage")809+  func startMonitoringSurfacesMonitorFailures() async {810+    // A regular file in place of the folder makes open(path, O_EVTONLY) fail811+    // with ENOTDIR. The manager must capture that failure through812+    // errorMessage synchronously so the UI sees it before any subsequent813+    // scan clobbers the message.814+    let defaults = UserDefaults.standard815+    let oldPath = defaults.string(forKey: "saveFolderPath")816+    defaults.removeObject(forKey: "saveFolderPath")817+    defer {818+      if let oldPath {819+        defaults.set(oldPath, forKey: "saveFolderPath")820+      } else {821+        defaults.removeObject(forKey: "saveFolderPath")822+      }823+    }824+    // A path that does not exist forces open(path, O_EVTONLY) to fail with825+    // ENOENT, which is the signal we want to surface through errorMessage.826+    let missing = FileManager.default.temporaryDirectory827+      .appendingPathComponent("haven-missing-\(UUID().uuidString)").path828+    let manager = LocalWallpapersManager()829+    manager.startMonitoring(folderPath: missing)830+    #expect(manager.errorMessage?.isEmpty == false)831+    manager.stopMonitoring()687832   }688833 }

view file

Tests/HavenTests/PlatformCoverageTests.swift+27 −3
@@ -40,13 +40,24 @@4040   func appDelegateLaunch() {4141     let delegate = AppDelegate()4242 43+    // Pre-launch: the lifecycle-safe storage is genuinely absent, not a44+    // force-unwrap waiting to trap.45+    #expect(delegate.currentStatusItem == nil)46+    #expect(delegate.currentMenuController == nil)47+4348     delegate.applicationDidFinishLaunching(4449       Notification(name: NSApplication.didFinishLaunchingNotification)4550     )4651 47-    #expect(delegate.statusItem != nil)48-    #expect(delegate.menuController != nil)49-    #expect(delegate.statusItem.button?.image != nil)52+    #expect(delegate.currentStatusItem != nil)53+    #expect(delegate.currentMenuController != nil)54+    #expect(delegate.currentStatusItem?.button?.image != nil)55+56+    // invalidate() drops the lifecycle-safe accessors back to nil without57+    // surfacing any trap when callers ask again.58+    delegate.invalidate()59+    #expect(delegate.currentStatusItem == nil)60+    #expect(delegate.currentMenuController == nil)5061   }5162 5263   @Test("Menu controller exercises popover and modal lifecycle")@@ -146,6 +157,19 @@146157       delegate: delegate, shouldRun: true,147158       setDelegate: { received = $0 }, run: { runs += 1 })148159     #expect(runs == 1)160+  }161+162+  @Test("Application bootstrap installs the accessory activation policy")163+  func applicationBootstrapInstallsAccessoryPolicy() {164+    let delegate = AppDelegate()165+    var installed: NSApplication.ActivationPolicy?166+    let choice = HavenApplication.start(167+      delegate: delegate, shouldRun: false,168+      setDelegate: { _ in }, run: {},169+      setActivationPolicy: { installed = $0 })170+171+    #expect(installed == .accessory)172+    #expect(choice.policy == .accessory)149173   }150174 151175   @Test("Folder panel adapter configures directory selection")

view file

Tests/HavenTests/ViewCoverageTests.swift+14 −0
@@ -74,6 +74,20 @@7474     item.action()7575     #expect(actionCount == 1)7676 77+    // Exponential backoff: each successive attempt doubles the wait, and a78+    // cancelled task bails out without advancing the attempt counter.79+    let backoffItem = WallpaperItem(80+      wallpaper: wallpaper, action: {}, maxAttempts: 4, retryDelayNanoseconds: 1)81+    let schedule = (0..<4).map { backoffItem.backoffDelay(forAttempt: $0) }82+    #expect(schedule == [1, 2, 4, 8])83+84+    let cancelled = WallpaperItem(85+      wallpaper: wallpaper, action: {}, maxAttempts: 4, retryDelayNanoseconds: 1)86+    let cancelTask = Task { await cancelled.retryAfterFailure() }87+    cancelTask.cancel()88+    await cancelTask.value89+    #expect(cancelled.exposedAttempt == 0)90+7791     render(WallpaperGrid(wallpapers: [], appState: state, onEndReached: nil))7892     let grid = WallpaperGrid(wallpapers: [wallpaper], appState: state, onEndReached: {})7993     render(grid)

view file

bundle_app.sh+6 −2
@@ -15,8 +15,12 @@1515 ENTITLEMENTS="${APP_NAME}.entitlements"1616 1717 # Wipe stale outputs so re-runs are idempotent and we never ship a18-# half-codesigned bundle left over from a previous attempt.19-rm -rf "${BUNDLE}" "${ZIP}"18+# half-codesigned bundle left over from a previous attempt. Use the19+# project-required `trash` command rather than `rm -rf`: it preserves20+# recovery via the macOS Trash, which is the project's standing deletion21+# convention. Missing either artifact is fine — `trash` is a no-op for22+# paths that don't exist.23+trash "${BUNDLE}" "${ZIP}" 2>/dev/null || true2024 2125 # Build the executable in release mode. Package.swift declares the product2226 # as `Haven` (capital H); .build/release is a symlink to

view file