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
↓ haven-1e8dc98e.tar.gzatlas at this save
files changed15 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 −2Sources/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 }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 }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 }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 }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() {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.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,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 }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 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 }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")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 }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")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)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