Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CleanMac/CleanMacApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -70,6 +71,7 @@ final class CleanMacAppDelegate: NSObject, NSApplicationDelegate {
CleanMacNotificationService.configure()
requestNotificationAuthorizationIfUseful()
autoScanScheduler.start()
lowDiskSpaceMonitor.start()
}

func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
Expand All @@ -87,6 +89,7 @@ final class CleanMacAppDelegate: NSObject, NSApplicationDelegate {

func applicationWillTerminate(_ notification: Notification) {
autoScanScheduler.stop()
lowDiskSpaceMonitor.stop()
}

private func configureDockIcon() {
Expand Down
36 changes: 36 additions & 0 deletions CleanMac/Support/CleanMacLowDiskSpaceMonitor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation

@MainActor
final class CleanMacLowDiskSpaceMonitor {
private let checkInterval: Duration
private var monitoringTask: Task<Void, Never>?

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
}
}
40 changes: 40 additions & 0 deletions CleanMac/Support/CleanMacNotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CleanMac/Support/CleanMacPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down
5 changes: 4 additions & 1 deletion CleanMac/Support/MainWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions CleanMac/Support/StatusSystemMetrics.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Darwin
import Foundation
import IOKit.ps
import CleanMacCore

struct StatusSystemSnapshot: Equatable {
let cpuFraction: Double
Expand Down Expand Up @@ -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)) ?? [:]
Expand Down
19 changes: 19 additions & 0 deletions CleanMac/Views/MainWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -75,6 +76,13 @@ struct MainWindowView: View {

restrictSelectionToSafeItems()
}
.task(id: requestedSectionID) {
guard !requestedSectionID.isEmpty else {
return
}
await Task.yield()
applyRequestedSection(requestedSectionID)
}
}

@ViewBuilder
Expand Down Expand Up @@ -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) }
}
Expand Down
50 changes: 50 additions & 0 deletions CleanMac/Views/StatusMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ struct StatusMenuView: View {
VStack(alignment: .leading, spacing: 12) {
header
metricGrid
if snapshot.disk.isLowSpace {
lowDiskWarning
}
networkStrip
systemPanel
actions
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions CleanMac/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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.";

Expand Down
5 changes: 5 additions & 0 deletions CleanMac/ru.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "Это тестовое уведомление. Автоскан сможет показывать такой баннер после завершения.";

Expand Down
36 changes: 36 additions & 0 deletions CleanMacCore/Sources/CleanMacCore/LowDiskSpaceWarningPolicy.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
))
}
}
Loading