diff --git a/CleanMac/CleanMacApp.swift b/CleanMac/CleanMacApp.swift index 659304d..01e36bf 100644 --- a/CleanMac/CleanMacApp.swift +++ b/CleanMac/CleanMacApp.swift @@ -62,6 +62,7 @@ struct CleanMacApp: App { final class CleanMacAppDelegate: NSObject, NSApplicationDelegate { private let autoScanScheduler = CleanMacAutoScanScheduler() + private let lowDiskSpaceMonitor = CleanMacLowDiskSpaceMonitor() func applicationDidFinishLaunching(_ notification: Notification) { MainWindowController.prepareForInitialPresentation(isBackgroundLaunch: !NSApp.isActive) @@ -70,6 +71,7 @@ final class CleanMacAppDelegate: NSObject, NSApplicationDelegate { CleanMacNotificationService.configure() requestNotificationAuthorizationIfUseful() autoScanScheduler.start() + lowDiskSpaceMonitor.start() } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -87,6 +89,7 @@ final class CleanMacAppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { autoScanScheduler.stop() + lowDiskSpaceMonitor.stop() } private func configureDockIcon() { diff --git a/CleanMac/Support/CleanMacLowDiskSpaceMonitor.swift b/CleanMac/Support/CleanMacLowDiskSpaceMonitor.swift new file mode 100644 index 0000000..858c49c --- /dev/null +++ b/CleanMac/Support/CleanMacLowDiskSpaceMonitor.swift @@ -0,0 +1,36 @@ +import Foundation + +@MainActor +final class CleanMacLowDiskSpaceMonitor { + private let checkInterval: Duration + private var monitoringTask: Task? + + init(checkInterval: Duration = .seconds(30 * 60)) { + self.checkInterval = checkInterval + } + + func start() { + guard monitoringTask == nil else { + return + } + + monitoringTask = Task { [weak self] in + guard let self else { return } + + while !Task.isCancelled { + await CleanMacNotificationService.notifyLowDiskSpaceIfNeeded(.current()) + + do { + try await Task.sleep(for: checkInterval) + } catch { + return + } + } + } + } + + func stop() { + monitoringTask?.cancel() + monitoringTask = nil + } +} diff --git a/CleanMac/Support/CleanMacNotificationService.swift b/CleanMac/Support/CleanMacNotificationService.swift index f2a02aa..fb2947e 100644 --- a/CleanMac/Support/CleanMacNotificationService.swift +++ b/CleanMac/Support/CleanMacNotificationService.swift @@ -64,6 +64,46 @@ enum CleanMacNotificationService { await add(request) } + static func notifyLowDiskSpaceIfNeeded( + _ disk: StatusDiskSnapshot, + defaults: UserDefaults = .standard, + now: Date = Date() + ) async { + let lastTimestamp = defaults.double(forKey: CleanMacPreferenceKeys.lowDiskSpaceLastNotificationTimestamp) + let lastNotificationDate = lastTimestamp > 0 ? Date(timeIntervalSince1970: lastTimestamp) : nil + guard LowDiskSpaceWarningPolicy.shouldNotify( + totalBytes: disk.totalBytes, + freeBytes: disk.freeBytes, + lastNotificationDate: lastNotificationDate, + now: now + ) else { + return + } + + let settings = await notificationSettings() + guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { + return + } + + let content = UNMutableNotificationContent() + content.title = L.t("notification.lowDisk.title") + content.body = L.f( + "notification.lowDisk.body", + CleanMacFormatters.bytes(disk.freeBytes), + Int(((disk.freeFraction ?? 0) * 100).rounded(.down)) + ) + content.sound = .default + + let request = UNNotificationRequest( + identifier: "CleanMac.lowDiskSpace", + content: content, + trigger: nil + ) + if await add(request) { + defaults.set(now.timeIntervalSince1970, forKey: CleanMacPreferenceKeys.lowDiskSpaceLastNotificationTimestamp) + } + } + static func sendTestNotification(defaults: UserDefaults = .standard) async -> CleanMacNotificationDeliveryResult { guard notificationsEnabled(defaults: defaults) else { return .disabled diff --git a/CleanMac/Support/CleanMacPreferences.swift b/CleanMac/Support/CleanMacPreferences.swift index cd23400..0bf6232 100644 --- a/CleanMac/Support/CleanMacPreferences.swift +++ b/CleanMac/Support/CleanMacPreferences.swift @@ -14,6 +14,8 @@ enum CleanMacPreferenceKeys { static let autoScanMinute = "CleanMac.autoScanMinute" static let autoScanLastRunKey = "CleanMac.autoScanLastRunKey" static let autoScanNotificationsEnabled = "CleanMac.autoScanNotificationsEnabled" + static let lowDiskSpaceLastNotificationTimestamp = "CleanMac.lowDiskSpaceLastNotificationTimestamp" + static let requestedSection = "CleanMac.requestedSection" static let scanInProgress = "CleanMac.scanInProgress" } diff --git a/CleanMac/Support/MainWindowController.swift b/CleanMac/Support/MainWindowController.swift index dbc8fe7..e71b2d5 100644 --- a/CleanMac/Support/MainWindowController.swift +++ b/CleanMac/Support/MainWindowController.swift @@ -49,8 +49,11 @@ enum MainWindowController { } @MainActor - static func show(openWindow: OpenWindowAction) { + static func show(openWindow: OpenWindowAction, section: CleanMacSection? = nil) { suppressInitialPresentation = false + if let section { + UserDefaults.standard.set(section.rawValue, forKey: CleanMacPreferenceKeys.requestedSection) + } if let existingWindow = NSApp.windows.first(where: { $0.identifier == identifier }) { existingWindow.deminiaturize(nil) diff --git a/CleanMac/Support/StatusSystemMetrics.swift b/CleanMac/Support/StatusSystemMetrics.swift index 9cd273c..8fbf281 100644 --- a/CleanMac/Support/StatusSystemMetrics.swift +++ b/CleanMac/Support/StatusSystemMetrics.swift @@ -1,6 +1,7 @@ import Darwin import Foundation import IOKit.ps +import CleanMacCore struct StatusSystemSnapshot: Equatable { let cpuFraction: Double @@ -42,6 +43,14 @@ struct StatusDiskSnapshot: Equatable { return min(max(Double(usedBytes) / Double(totalBytes), 0), 1) } + var freeFraction: Double? { + LowDiskSpaceWarningPolicy.freeFraction(totalBytes: totalBytes, freeBytes: freeBytes) + } + + var isLowSpace: Bool { + LowDiskSpaceWarningPolicy.isLowSpace(totalBytes: totalBytes, freeBytes: freeBytes) + } + static func current() -> StatusDiskSnapshot { let homeURL = FileManager.default.homeDirectoryForCurrentUser let attributes = (try? FileManager.default.attributesOfFileSystem(forPath: homeURL.path)) ?? [:] diff --git a/CleanMac/Views/MainWindowView.swift b/CleanMac/Views/MainWindowView.swift index 87e09b2..af8d75d 100644 --- a/CleanMac/Views/MainWindowView.swift +++ b/CleanMac/Views/MainWindowView.swift @@ -23,6 +23,7 @@ struct MainWindowView: View { @AppStorage("CleanMac.confirmBeforeCleanup") private var confirmBeforeCleanup = true @AppStorage("CleanMac.showMenuBarStatus") private var showMenuBarStatus = true @AppStorage(CleanMacPreferenceKeys.scanInProgress) private var scanInProgress = false + @AppStorage(CleanMacPreferenceKeys.requestedSection) private var requestedSectionID = "" private let minimumScanAnimationDuration: TimeInterval = 1.15 private let cleanupHistoryStore = CleanupHistoryStore() @@ -75,6 +76,13 @@ struct MainWindowView: View { restrictSelectionToSafeItems() } + .task(id: requestedSectionID) { + guard !requestedSectionID.isEmpty else { + return + } + await Task.yield() + applyRequestedSection(requestedSectionID) + } } @ViewBuilder @@ -141,6 +149,17 @@ struct MainWindowView: View { .map(\.category) } + private func applyRequestedSection(_ rawValue: String) { + guard !rawValue.isEmpty else { + return + } + defer { requestedSectionID = "" } + guard CleanMacSection(rawValue: rawValue) != nil else { + return + } + selectedSectionID = rawValue + } + private var selectedAreas: [CleanupArea] { CleanMacCatalog.cleanupAreas.filter { selectedAreaIDs.contains($0.id) } } diff --git a/CleanMac/Views/StatusMenuView.swift b/CleanMac/Views/StatusMenuView.swift index c876ba6..c6c5827 100644 --- a/CleanMac/Views/StatusMenuView.swift +++ b/CleanMac/Views/StatusMenuView.swift @@ -16,6 +16,9 @@ struct StatusMenuView: View { VStack(alignment: .leading, spacing: 12) { header metricGrid + if snapshot.disk.isLowSpace { + lowDiskWarning + } networkStrip systemPanel actions @@ -145,6 +148,53 @@ struct StatusMenuView: View { } } + private var lowDiskWarning: some View { + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 9) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + + Text(L.t("status.lowDisk.title")) + .font(.system(size: 13, weight: .bold)) + + Spacer() + + Text("\(Int(((snapshot.disk.freeFraction ?? 0) * 100).rounded(.down)))%") + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(.orange) + .monospacedDigit() + } + + Text(L.f( + "status.lowDisk.message", + CleanMacFormatters.bytes(snapshot.disk.freeBytes) + )) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Button { + MainWindowController.show(openWindow: openWindow, section: .diskAnalysis) + } label: { + Label(L.t("status.lowDisk.action"), systemImage: "chart.pie.fill") + .font(.system(size: 12, weight: .bold)) + .frame(maxWidth: .infinity, minHeight: 30) + } + .buttonStyle(.borderedProminent) + .tint(.orange) + } + .padding(12) + .background( + Color.orange.opacity(colorScheme == .dark ? 0.12 : 0.08), + in: RoundedRectangle(cornerRadius: 15, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 15, style: .continuous) + .strokeBorder(Color.orange.opacity(0.45)) + } + .accessibilityElement(children: .contain) + } + private var systemPanel: some View { VStack(spacing: 9) { HStack(spacing: 10) { diff --git a/CleanMac/en.lproj/Localizable.strings b/CleanMac/en.lproj/Localizable.strings index 6ccf523..eb186ae 100644 --- a/CleanMac/en.lproj/Localizable.strings +++ b/CleanMac/en.lproj/Localizable.strings @@ -55,8 +55,13 @@ "status.metrics.batteryPower" = "On battery"; "status.metrics.rate" = "%@/s"; "status.metrics.uptime" = "%dh %02dm"; +"status.lowDisk.title" = "Storage is running low"; +"status.lowDisk.message" = "%@ remains. Open Disk Analysis and run a scan to find what is using space."; +"status.lowDisk.action" = "Disk Analysis"; "notification.autoScan.title" = "Auto scan finished"; "notification.autoScan.body" = "%d items, %@ found. No cleanup was performed."; +"notification.lowDisk.title" = "Your disk is running low"; +"notification.lowDisk.body" = "%@ remains (%d%%). Open Disk Analysis and run a CleanMac scan."; "notification.test.title" = "CleanMac notifications work"; "notification.test.body" = "This is a test notification. Auto scan can show this kind of banner after finishing."; diff --git a/CleanMac/ru.lproj/Localizable.strings b/CleanMac/ru.lproj/Localizable.strings index d7c0420..4b6df2e 100644 --- a/CleanMac/ru.lproj/Localizable.strings +++ b/CleanMac/ru.lproj/Localizable.strings @@ -55,8 +55,13 @@ "status.metrics.batteryPower" = "От батареи"; "status.metrics.rate" = "%@/с"; "status.metrics.uptime" = "%dч %02dм"; +"status.lowDisk.title" = "Заканчивается место"; +"status.lowDisk.message" = "Свободно %@. Открой Анализ диска и запусти сканирование, чтобы найти, что занимает место."; +"status.lowDisk.action" = "Анализ диска"; "notification.autoScan.title" = "Автосканирование завершено"; "notification.autoScan.body" = "Найдено: %d, объём: %@. Очистка не выполнялась."; +"notification.lowDisk.title" = "На диске заканчивается место"; +"notification.lowDisk.body" = "Свободно %@ (%d%%). Открой Анализ диска и запусти сканирование CleanMac."; "notification.test.title" = "CleanMac уведомления работают"; "notification.test.body" = "Это тестовое уведомление. Автоскан сможет показывать такой баннер после завершения."; diff --git a/CleanMacCore/Sources/CleanMacCore/LowDiskSpaceWarningPolicy.swift b/CleanMacCore/Sources/CleanMacCore/LowDiskSpaceWarningPolicy.swift new file mode 100644 index 0000000..d5af991 --- /dev/null +++ b/CleanMacCore/Sources/CleanMacCore/LowDiskSpaceWarningPolicy.swift @@ -0,0 +1,36 @@ +import Foundation + +public enum LowDiskSpaceWarningPolicy { + public static let thresholdFraction = 0.10 + public static let notificationCooldown: TimeInterval = 24 * 60 * 60 + + public static func freeFraction(totalBytes: Int64, freeBytes: Int64) -> Double? { + guard totalBytes > 0 else { + return nil + } + + return min(max(Double(freeBytes) / Double(totalBytes), 0), 1) + } + + public static func isLowSpace(totalBytes: Int64, freeBytes: Int64) -> Bool { + guard let fraction = freeFraction(totalBytes: totalBytes, freeBytes: freeBytes) else { + return false + } + return fraction < thresholdFraction + } + + public static func shouldNotify( + totalBytes: Int64, + freeBytes: Int64, + lastNotificationDate: Date?, + now: Date = Date() + ) -> Bool { + guard isLowSpace(totalBytes: totalBytes, freeBytes: freeBytes) else { + return false + } + guard let lastNotificationDate else { + return true + } + return now.timeIntervalSince(lastNotificationDate) >= notificationCooldown + } +} diff --git a/CleanMacCore/Tests/CleanMacCoreTests/LowDiskSpaceWarningPolicyTests.swift b/CleanMacCore/Tests/CleanMacCoreTests/LowDiskSpaceWarningPolicyTests.swift new file mode 100644 index 0000000..23717d5 --- /dev/null +++ b/CleanMacCore/Tests/CleanMacCoreTests/LowDiskSpaceWarningPolicyTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import CleanMacCore + +final class LowDiskSpaceWarningPolicyTests: XCTestCase { + func testLowSpaceRequiresValidCapacityAndStrictlyLessThanTenPercentFree() { + XCTAssertFalse(LowDiskSpaceWarningPolicy.isLowSpace(totalBytes: 0, freeBytes: 0)) + XCTAssertFalse(LowDiskSpaceWarningPolicy.isLowSpace(totalBytes: 1_000, freeBytes: 100)) + XCTAssertTrue(LowDiskSpaceWarningPolicy.isLowSpace(totalBytes: 1_000, freeBytes: 99)) + } + + func testNotificationIsLimitedToOncePerTwentyFourHours() { + let now = Date(timeIntervalSince1970: 1_000_000) + + XCTAssertTrue(LowDiskSpaceWarningPolicy.shouldNotify( + totalBytes: 1_000, + freeBytes: 50, + lastNotificationDate: nil, + now: now + )) + XCTAssertFalse(LowDiskSpaceWarningPolicy.shouldNotify( + totalBytes: 1_000, + freeBytes: 50, + lastNotificationDate: now.addingTimeInterval(-23 * 60 * 60), + now: now + )) + XCTAssertTrue(LowDiskSpaceWarningPolicy.shouldNotify( + totalBytes: 1_000, + freeBytes: 50, + lastNotificationDate: now.addingTimeInterval(-24 * 60 * 60), + now: now + )) + } + + func testRecoveredSpaceSuppressesNotificationRegardlessOfCooldown() { + XCTAssertFalse(LowDiskSpaceWarningPolicy.shouldNotify( + totalBytes: 1_000, + freeBytes: 250, + lastNotificationDate: nil + )) + } +} diff --git a/contract.md b/contract.md index 8ce3e24..083645d 100644 --- a/contract.md +++ b/contract.md @@ -2,52 +2,64 @@ ## Task -- ID: TASK-043 -- Title: Fix custom-folder source selection +- ID: TASK-044 +- Title: Low disk space warning - Mode: continue ## Planner Notes -- Why this task now: the user reported that the Custom Folder source looked inactive in both read-only analysis workspaces. -- Expected value: reliable native folder selection without forcing a scan or losing the current built-in source on cancel. -- Main risk: opening a synchronous AppKit modal during a SwiftUI Picker state transaction can drop or visually revert the selection. -- Safety choice: intercept the custom value, defer the panel one main-actor turn, and change source state only after selection. +- Why this task now: the menu-bar dashboard already samples free disk capacity and can surface an actionable warning before storage is exhausted. +- Expected value: a clear under-10% warning, an immediate path to Disk Analysis, and a restrained local notification. +- Main risk: repeated one-second samples can spam notifications, and requesting notification permission automatically would be intrusive. +- Safety choice: keep the menu warning live, notify at most once per 24 hours only when macOS permission is already granted, and never start scanning automatically. ## Builder Scope - Allowed files: - - `CleanMac/Views/DiskAnalysisView.swift`; - - `CleanMac/Views/DuplicateFinderView.swift`; + - `CleanMacCore/Sources/CleanMacCore/LowDiskSpaceWarningPolicy.swift`; + - `CleanMacCore/Tests/CleanMacCoreTests/LowDiskSpaceWarningPolicyTests.swift`; + - `CleanMac/Support/StatusSystemMetrics.swift`; + - `CleanMac/Support/CleanMacLowDiskSpaceMonitor.swift`; + - `CleanMac/Support/CleanMacNotificationService.swift`; + - `CleanMac/Support/CleanMacPreferences.swift`; + - `CleanMac/Support/MainWindowController.swift`; + - `CleanMac/Views/StatusMenuView.swift`; + - `CleanMac/Views/MainWindowView.swift`; + - `CleanMac/CleanMacApp.swift`; + - RU/EN localization; - Loop documentation files. - Allowed commands: - source inspection and Debug build/launch; - - live native panel open/cancel/select checks without starting a scan; + - focused policy fixtures and full core tests; + - read-only menu navigation review without sending a real notification; - `swift test --package-path CleanMacCore`; - standard Git/PR/CI checks and merge after green status. - Out of scope: - - scanning user folders, cleanup, release/version/package changes, localization, dependencies, or architecture changes. + - automatic scanning/cleanup, notification permission prompts, Settings redesign, release/version/package changes, dependencies, or architecture changes. - Dependencies allowed: none - Destructive actions allowed: none ## Evaluator Checklist - Done criteria: - - Both Custom Folder segments open their localized native folder panel. - - Cancel keeps the previous built-in source and leaves controls active. - - Selecting a folder activates Custom Folder, shows the exact path, and exposes the change-folder button. - - No scan starts from source selection alone. + - Low space means valid total capacity with free fraction strictly below 10%. + - Notification eligibility is limited to once per 24 hours and persisted only after successful delivery. + - The monitor checks at launch and every 30 minutes without requesting permission. + - The menu warning recommends scanning and provides a direct Disk Analysis button. + - Navigation reveals the existing main window or opens one, then selects Disk Analysis without starting analysis. - Required verification: + - focused threshold/boundary/cooldown tests; - Debug build and signed launch verification; - - live cancel/select interactions in both sections; + - live menu-to-Disk-Analysis navigation without triggering a scan or notification; - `swift test --package-path CleanMacCore`; - `git diff --check`; - green GitHub PR checks. - Manual checks: - - Confirm the selected path is visible and no progress indicator appears. - - Do not start scanning or move any user file during verification. + - Confirm normal disk space keeps the warning hidden. + - Do not change notification permission, start scanning, or move any user file during verification. ## Result - Status: complete -- Verification result: Debug build and signed launch passed; both native panels opened; cancel preserved Downloads in Duplicate Finder; selecting `/Users/admin/Documents` activated Custom Folder and displayed the path in both sections; no scan started; core tests, diff checks, and PR CI passed. -- Notes: the fix changes only source-selection timing and does not expand folder access or scanner scope. +- Verification result: focused boundary/cooldown tests and all 43 core tests passed; localization lint/key parity, Debug build, ad-hoc signature/launch, normal-capacity hidden state, one-shot Disk Analysis routing, diff checks, and GitHub PR CI passed. +- Notes: the warning uses the same available-capacity value shown in the menu-bar disk card; it never requests notification permission or starts a scan automatically. APFS purgeable-space behavior remains represented by macOS capacity APIs. diff --git a/progress.md b/progress.md index e96887c..a690a9b 100644 --- a/progress.md +++ b/progress.md @@ -400,3 +400,13 @@ Append-only history. Do not erase previous entries. - Next step: Package a 0.3.1 bug-fix release only when explicitly requested. - Bottleneck: none; the known CoreSimulator warning remains unrelated and non-blocking for macOS builds. - Handoff: CleanMac is running from the current Debug product on Duplicate Finder with `/Users/admin/Documents` selected. No cleanup, duplicate move, or scan was triggered. + +## 2026-07-12 - TASK-044 - Low disk space warning + +- What changed: Added a shared low-space policy with a strict under-10% threshold and a 24-hour notification cooldown. CleanMac now checks capacity at launch and every 30 minutes, sends a localized warning only when macOS notifications are already authorized, and records the cooldown only after successful delivery. The menu-bar popover shows an adaptive warning card with remaining space, a scan recommendation, and a direct Disk Analysis action that reveals or opens the main window without starting analysis. +- Files touched: `CleanMacCore/Sources/CleanMacCore/LowDiskSpaceWarningPolicy.swift`, `CleanMacCore/Tests/CleanMacCoreTests/LowDiskSpaceWarningPolicyTests.swift`, `CleanMac/CleanMacApp.swift`, `CleanMac/Support/CleanMacLowDiskSpaceMonitor.swift`, `CleanMac/Support/CleanMacNotificationService.swift`, `CleanMac/Support/CleanMacPreferences.swift`, `CleanMac/Support/MainWindowController.swift`, `CleanMac/Support/StatusSystemMetrics.swift`, `CleanMac/Views/MainWindowView.swift`, `CleanMac/Views/StatusMenuView.swift`, `CleanMac/en.lproj/Localizable.strings`, `CleanMac/ru.lproj/Localizable.strings`, `contract.md`, `project-analysis.md`, `roadmap.md`, `progress.md`, `trace.md`, `verification.md`. +- Checks run: focused `LowDiskSpaceWarningPolicyTests` (3/3); full `swift test --package-path CleanMacCore` (43/43); localization plist lint and RU/EN key parity; `git diff --check`; `./script/build_and_run.sh --verify`; live one-shot route check to the Russian Disk Analysis screen; normal-capacity hidden-state check; GitHub PR core-test and macOS-build CI. +- Result: Passed. Exactly 10% does not warn, below 10% does; notifications are limited to one successful delivery per 24 hours; the action opens Disk Analysis without selecting files or starting a scan. No notification permission was changed and no real warning was sent during verification. +- Next step: Publish a feature release only when explicitly requested. +- Bottleneck: system notification delivery remains dependent on macOS permission; the known CoreSimulator version warning remains unrelated and non-blocking for macOS builds. +- Handoff: The current Debug app uses the normal live disk capacity, so the warning remains naturally hidden. No scan, cleanup, application removal, release, or user-file mutation occurred. diff --git a/project-analysis.md b/project-analysis.md index c1b5185..8398f8a 100644 --- a/project-analysis.md +++ b/project-analysis.md @@ -66,6 +66,7 @@ CleanMac is a macOS menu bar and windowed system cleanup utility. The project wa - The standard About panel is replaced by a centered singleton CleanMac window with live version/build metadata, local-first safety details, project links, and immediate RU/EN plus light/dark updates. - The selected language override applies through the app's localizer, and the selected appearance applies to both the main window and menu bar popover. - The menu bar popover is a compact live dashboard with CPU, memory, disk, battery, network, uptime, scan state, and last-scan summary. Its 2x2 gauge layout uses system materials and follows the light/dark appearance selected in CleanMac rather than a fixed reference color scheme. +- When free disk capacity falls strictly below 10%, the menu bar shows an adaptive warning with the remaining space, recommends a scan, and opens Disk Analysis directly without starting it. A background check runs at launch and every 30 minutes; when macOS notifications are already allowed, it can send at most one warning per 24 hours. - Settings can enable read-only auto scan while the app is running; it supports daily, hourly, and every-two-hours frequencies, uses the currently selected scan areas, and updates menu bar status. - Settings can register the main app through `SMAppService.mainApp` to launch at login, displays the authoritative macOS enabled/disabled/approval/unavailable status, reports registration failures, and links to Login Items when user action is required. Login-style background launch keeps the main window hidden until the user activates CleanMac or chooses Open from the menu bar. - Scheduled auto scan can show localized macOS completion notifications when the notification toggle is enabled and system permission allows it; Settings includes a test notification button to diagnose macOS permission/delivery state. Manual scans remain silent. @@ -80,7 +81,7 @@ CleanMac is a macOS menu bar and windowed system cleanup utility. The project wa - Cleanup is intentionally Trash-based; permanent deletion is still out of scope. - Duplicate scanning intentionally skips hidden files and package contents, stops at a documented file-count safety limit, and can take substantial time in the explicit large-file mode. Its results remain isolated from junk totals, normal cleanup history, and scheduled scans. - Scheduled auto scan still runs inside the CleanMac process rather than a privileged agent, but the optional Login Item can now start that process automatically after macOS login. -- Notification delivery depends on the macOS notification permission for CleanMac; if permission is denied, scheduled scans still complete silently. +- Notification delivery depends on the macOS notification permission for CleanMac; if permission is denied, scheduled scans still complete silently and the low-space warning remains available inside the menu-bar popover. - Root-owned or otherwise protected third-party apps may fail to move without administrator privileges; CleanMac reports the failure and does not escalate privileges or remove leftovers. - Whole-disk analysis intentionally attempts every path beneath `/`, including system and mounted-volume paths. macOS-protected locations remain unreadable without the required system access, while APFS firmlinks/mounted representations can make the measured total differ from Finder; the UI reports these limitations and never treats the result as junk. The analysis UI uses a custom Reduce Motion-aware progress indicator and an interactive radial map whose hovered sector enlarges and reports its exact size in GB. - Local Xcode emits a CoreSimulator warning; it does not currently block macOS builds. diff --git a/roadmap.md b/roadmap.md index 0fc7b58..c2c88fa 100644 --- a/roadmap.md +++ b/roadmap.md @@ -1,5 +1,19 @@ # Roadmap +- [x] ID: TASK-044 + Title: Low disk space warning + Goal: Warn when available disk space falls below 10% without spamming or starting cleanup automatically. + What to do: Add a testable threshold/cooldown policy, a background best-effort notification check, an adaptive warning card in the menu-bar popover, and direct navigation to Disk Analysis. + Files: low-space core policy/tests, status metrics/menu, notification/monitor/preferences/window routing, localization, Loop docs + Definition of done: below 10% shows the menu warning and may notify only when already authorized; notifications are limited to once per 24 hours; the button opens Disk Analysis; copy recommends a scan; no scan starts automatically. + Verification: focused policy tests; full SwiftPM tests; localization lint/key parity; Debug build/launch; menu navigation review; `git diff --check`; green GitHub CI + Priority: high + Impact: medium + Risk: low + Effort: medium + Confidence: high + Score: medium impact / low risk / medium + - [x] ID: TASK-043 Title: Fix custom-folder source selection Goal: Make the Custom Folder source reliably open the native folder picker in both Disk Analysis and Duplicate Finder. diff --git a/trace.md b/trace.md index 6ae96c8..794b22d 100644 --- a/trace.md +++ b/trace.md @@ -127,3 +127,10 @@ Append-only trace of failures, restarts, and judgment divergences. - Cause: both views called synchronous `NSOpenPanel.runModal()` directly from the segmented Picker's `onChange` callback while SwiftUI was still applying the source-state transaction. - Fix: use an intercepting source Binding, defer panel presentation to the next main-actor turn, and mutate the source only after a folder is selected; cancel leaves the previous source unchanged. - Status: resolved; live Russian checks opened both native panels, verified cancel fallback, selected `/Users/admin/Documents` in both sections, and confirmed that no scan started automatically. + +## 2026-07-12 - TASK-044 - Requested section was consumed before scene restoration + +- Symptom: the first live route check consumed the requested Disk Analysis section but the restored main window still displayed Overview. +- Cause: `onAppear` applied the external selection before SwiftUI restored the existing `@SceneStorage` sidebar value. +- Fix: consume the requested section from an ID-driven task after one main-actor yield, then clear the one-shot preference. +- Status: resolved; the second clean launch opened directly on the Russian Disk Analysis screen and did not start a scan. diff --git a/verification.md b/verification.md index 0954d3e..4b0e92d 100644 --- a/verification.md +++ b/verification.md @@ -47,6 +47,7 @@ | --- | --- | --- | --- | --- | | macOS app launch | `./script/build_and_run.sh --verify` | UI, app lifecycle, assets, or project changes | Build succeeds and `pgrep -x CleanMac` finds the app | Run the xcodebuild command from this file and inspect launch logs | | Menu bar dashboard | Open the status item in both CleanMac appearance modes and observe at least two refreshes | Menu-bar layout, metrics, localization, appearance, or actions change | Light selection renders a light popover, dark selection renders a dark popover, live metric values remain valid, and Open focuses the main window | Inspect the accessibility tree and capture light/dark screenshots; do not trigger scan, cleanup, or removal actions | +| Low disk space warning | Core threshold/cooldown fixtures plus a read-only main-window route check | Disk capacity policy, status monitor, notification delivery, menu warning, or requested-section routing changes | Exactly 10% stays normal; below 10% warns; successful notifications are limited to once per 24 hours; the menu action selects Disk Analysis without starting a scan | Run `LowDiskSpaceWarningPolicyTests`; on a normal disk confirm the warning stays hidden; exercise the requested-section route without changing notification permission or faking user disk capacity | | Settings and launch at login | Inspect `SMAppService.mainApp.status`, launch once normally and once with `open -g`, then restore through app activation and menu-bar Open | Settings navigation, permissions placement, Login Item, or app launch lifecycle changes | Permissions exists only inside Settings; system status is localized; background launch has zero main windows; activation/menu-bar Open reveals the existing window | Do not toggle Login Item during automated verification; use the current read-only status and inspect registration/error paths in code | | First-launch onboarding | Reset only `CleanMac.onboardingCompleted`, review four pages, finish, and relaunch | Onboarding content, persistence, language, appearance, permission guidance, or launch routing changes | First launch shows Welcome in system appearance; Back/Next/Skip work; settings open only explicitly; completion switches to Dashboard; relaunch skips onboarding | Inspect the accessibility tree and screenshots, then verify the single defaults key; leave it absent for the user's next launch | | About window | Open the app menu item, switch RU/EN and light/dark, then inspect the Window menu | About scene, metadata, localization, theme, or links change | One fixed-size About window shows the active language/theme, accurate bundle version/build, and GitHub/Releases/License links | Inspect the accessibility tree and capture a screenshot without activating external links |