From 3180147a72810f56d25e7e9761850bacbc24a709 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:16 -0300 Subject: [PATCH 01/23] feat(clean): utilitarios de filesystem com guarda de caminhos protegidos --- .../Core/Services/Clean/CleanSupport.swift | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 ClipFlow/Core/Services/Clean/CleanSupport.swift diff --git a/ClipFlow/Core/Services/Clean/CleanSupport.swift b/ClipFlow/Core/Services/Clean/CleanSupport.swift new file mode 100644 index 0000000..975d849 --- /dev/null +++ b/ClipFlow/Core/Services/Clean/CleanSupport.swift @@ -0,0 +1,150 @@ +import Foundation +import AppKit + +/// Item de arquivo/pasta encontrado por um scan de limpeza. +struct CleanFileItem: Identifiable, Hashable { + let url: URL + let bytes: UInt64 + let modifiedAt: Date? + + var id: URL { url } + var name: String { url.lastPathComponent } + var path: String { url.path } +} + +enum CleanFormat { + static func bytes(_ value: UInt64) -> String { + ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file) + } +} + +/// Utilitários de filesystem compartilhados pelos módulos de limpeza. +/// Todas as funções são síncronas e pensadas para rodar fora da main thread. +enum FileSweeper { + /// Caminhos que jamais podem ser removidos, independentemente da regra que + /// os produziu. Última linha de defesa contra bugs de scan. + static func isProtected(_ url: URL) -> Bool { + let home = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL + let path = url.standardizedFileURL.path + + // Nada fora da pasta pessoal (exceto o que já veio de /Applications + // via desinstalação, que valida por conta própria). + let protectedExact: Set = [ + "/", home.path, + home.appendingPathComponent("Desktop").path, + home.appendingPathComponent("Documents").path, + home.appendingPathComponent("Downloads").path, + home.appendingPathComponent("Pictures").path, + home.appendingPathComponent("Movies").path, + home.appendingPathComponent("Music").path, + home.appendingPathComponent("Applications").path, + home.appendingPathComponent("Library").path, + home.appendingPathComponent("Library/Application Support").path, + home.appendingPathComponent("Library/Caches").path, + home.appendingPathComponent("Library/Preferences").path, + home.appendingPathComponent("Library/Logs").path, + home.appendingPathComponent("Library/Containers").path, + home.appendingPathComponent("Library/Mobile Documents").path, + home.appendingPathComponent("Library/Keychains").path, + home.appendingPathComponent(".Trash").path + ] + if protectedExact.contains(path) { return true } + + // Chaves e documentos do iCloud nunca são alvo de limpeza. + let protectedPrefixes = [ + home.appendingPathComponent("Library/Keychains").path + "/", + home.appendingPathComponent("Library/Mobile Documents").path + "/" + ] + return protectedPrefixes.contains { path.hasPrefix($0) } + } + /// Tamanho total (alocado) de um arquivo ou diretório, recursivo. + static func allocatedSize(of url: URL) -> UInt64 { + let fm = FileManager.default + let keys: Set = [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileAllocatedSizeKey] + var isDirectory: ObjCBool = false + guard fm.fileExists(atPath: url.path, isDirectory: &isDirectory) else { return 0 } + + if !isDirectory.boolValue { + let values = try? url.resourceValues(forKeys: keys) + return UInt64(values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0) + } + + guard let enumerator = fm.enumerator( + at: url, + includingPropertiesForKeys: Array(keys), + options: [], + errorHandler: { _, _ in true } + ) else { return 0 } + + var total: UInt64 = 0 + for case let fileURL as URL in enumerator { + guard let values = try? fileURL.resourceValues(forKeys: keys), + values.isRegularFile == true else { continue } + total += UInt64(values.totalFileAllocatedSize ?? values.fileAllocatedSize ?? 0) + } + return total + } + + /// Filhos imediatos de um diretório como itens com tamanho. + static func children(of url: URL, includeHidden: Bool = false) -> [CleanFileItem] { + let fm = FileManager.default + var options: FileManager.DirectoryEnumerationOptions = [] + if !includeHidden { options.insert(.skipsHiddenFiles) } + guard let urls = try? fm.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.contentModificationDateKey], + options: options + ) else { return [] } + + return urls.map { child in + let modified = (try? child.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate + return CleanFileItem(url: child, bytes: allocatedSize(of: child), modifiedAt: modified) + } + } + + /// Move URLs para a Lixeira. Retorna bytes recuperados e falhas. + static func trash(urls: [URL]) -> (reclaimed: UInt64, failures: [String]) { + let fm = FileManager.default + var reclaimed: UInt64 = 0 + var failures: [String] = [] + for url in urls { + guard !isProtected(url) else { + failures.append("\(url.lastPathComponent): caminho protegido / protected path") + continue + } + let size = allocatedSize(of: url) + do { + try fm.trashItem(at: url, resultingItemURL: nil) + reclaimed += size + } catch { + failures.append("\(url.lastPathComponent): \(error.localizedDescription)") + } + } + return (reclaimed, failures) + } + + /// Remove URLs permanentemente (usado apenas para conteúdo da Lixeira). + static func removePermanently(urls: [URL]) -> (reclaimed: UInt64, failures: [String]) { + let fm = FileManager.default + var reclaimed: UInt64 = 0 + var failures: [String] = [] + for url in urls { + guard !isProtected(url) else { + failures.append("\(url.lastPathComponent): caminho protegido / protected path") + continue + } + let size = allocatedSize(of: url) + do { + try fm.removeItem(at: url) + reclaimed += size + } catch { + failures.append("\(url.lastPathComponent): \(error.localizedDescription)") + } + } + return (reclaimed, failures) + } + + static func revealInFinder(_ url: URL) { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } +} From 14847f4cfc9b619c9edc70f1057402c619cec0f3 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:37 -0300 Subject: [PATCH 02/23] feat(clean): catalogo com 31 regras curadas de limpeza segura --- .../Services/Clean/CleanupRuleCatalog.swift | 408 ++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 ClipFlow/Core/Services/Clean/CleanupRuleCatalog.swift diff --git a/ClipFlow/Core/Services/Clean/CleanupRuleCatalog.swift b/ClipFlow/Core/Services/Clean/CleanupRuleCatalog.swift new file mode 100644 index 0000000..dc0cac2 --- /dev/null +++ b/ClipFlow/Core/Services/Clean/CleanupRuleCatalog.swift @@ -0,0 +1,408 @@ +import Foundation + +/// Nível de segurança de uma regra de limpeza. +enum CleanupSafety { + /// Recriado automaticamente pelo sistema/app; remoção sem efeitos colaterais. + case safe + /// Remoção segura, mas com efeito perceptível (ex.: perder restauração de + /// janelas, backups). Exige revisão consciente do usuário. + case review +} + +enum CleanupSection: String, CaseIterable, Identifiable { + case system + case browsers + case developer + case apps + case bigItems + + var id: String { rawValue } + + var systemImage: String { + switch self { + case .system: return "gearshape" + case .browsers: return "safari" + case .developer: return "hammer" + case .apps: return "square.grid.2x2" + case .bigItems: return "shippingbox" + } + } + + func title(_ t: (String, String) -> String) -> String { + switch self { + case .system: return t("Sistema", "System") + case .browsers: return t("Navegadores", "Browsers") + case .developer: return t("Desenvolvedor", "Developer") + case .apps: return t("Aplicativos", "Applications") + case .bigItems: return t("Itens grandes", "Big items") + } + } +} + +/// Regra de limpeza curada: o que varrer, como e com quais salvaguardas. +struct CleanupRule: Identifiable { + enum Mode { + /// Cada filho imediato das raízes é um item removível. + case children + /// A raiz inteira é um único item. + case wholeFolder + /// Apenas arquivos com estas extensões (minúsculas) nas raízes. + case filesWithExtensions(Set) + } + + let id: String + let section: CleanupSection + let titlePT: String + let titleEN: String + let detailPT: String + let detailEN: String + let systemImage: String + let roots: [URL] + let mode: Mode + let safety: CleanupSafety + /// Entra na seleção padrão e na limpeza de 1 clique da Análise Inteligente. + let selectedByDefault: Bool + /// Ignora itens modificados há menos de N dias (proteção contra apps em uso). + let minAgeDays: Int? + /// Primeiro componente sob a raiz a ignorar (evita sobreposição entre regras). + let excludedFirstComponents: Set + let includeHidden: Bool + /// Apaga em definitivo em vez de mover para a Lixeira (apenas a própria Lixeira). + let deletesPermanently: Bool + + init( + id: String, + section: CleanupSection, + titlePT: String, titleEN: String, + detailPT: String, detailEN: String, + systemImage: String, + roots: [URL], + mode: Mode = .children, + safety: CleanupSafety = .safe, + selectedByDefault: Bool = true, + minAgeDays: Int? = nil, + excludedFirstComponents: Set = [], + includeHidden: Bool = false, + deletesPermanently: Bool = false + ) { + self.id = id + self.section = section + self.titlePT = titlePT + self.titleEN = titleEN + self.detailPT = detailPT + self.detailEN = detailEN + self.systemImage = systemImage + self.roots = roots + self.mode = mode + self.safety = safety + self.selectedByDefault = selectedByDefault + self.minAgeDays = minAgeDays + self.excludedFirstComponents = excludedFirstComponents + self.includeHidden = includeHidden + self.deletesPermanently = deletesPermanently + } +} + +/// Catálogo de regras no espírito da "Safety Database" do CleanMyMac: +/// caminhos conhecidos, seguros e recriáveis, curados por ferramenta. +enum CleanupRuleCatalog { + /// Caches de navegadores e apps cobertos por regras próprias — o cache + /// genérico ignora estes primeiros componentes para não contar em dobro. + private static let dedicatedCacheFolders: Set = [ + "com.apple.Safari", "Google", "com.google.Chrome", "Firefox", "Mozilla", + "company.thebrowser.Browser", "com.microsoft.edgemac", + "com.brave.Browser", "BraveSoftware", "Homebrew", "Yarn", "pip", + "CocoaPods", "go-build", "com.spotify.client" + ] + + static func rules() -> [CleanupRule] { + let home = FileManager.default.homeDirectoryForCurrentUser + let library = home.appendingPathComponent("Library", isDirectory: true) + let caches = library.appendingPathComponent("Caches", isDirectory: true) + let appSupport = library.appendingPathComponent("Application Support", isDirectory: true) + let xcode = library.appendingPathComponent("Developer/Xcode", isDirectory: true) + + func c(_ path: String) -> URL { caches.appendingPathComponent(path, isDirectory: true) } + func s(_ path: String) -> URL { appSupport.appendingPathComponent(path, isDirectory: true) } + + return [ + // MARK: Sistema + CleanupRule( + id: "system.appCaches", section: .system, + titlePT: "Caches de aplicativos", titleEN: "Application caches", + detailPT: "~/Library/Caches — apps recriam quando necessário (só itens com 3+ dias)", + detailEN: "~/Library/Caches — apps recreate on demand (only items 3+ days old)", + systemImage: "internaldrive", + roots: [caches], + minAgeDays: 3, + excludedFirstComponents: dedicatedCacheFolders + ), + CleanupRule( + id: "system.logs", section: .system, + titlePT: "Arquivos de log", titleEN: "Log files", + detailPT: "~/Library/Logs — registros de diagnóstico acumulados", + detailEN: "~/Library/Logs — accumulated diagnostic logs", + systemImage: "doc.text", + roots: [library.appendingPathComponent("Logs", isDirectory: true)] + ), + CleanupRule( + id: "system.mailDownloads", section: .system, + titlePT: "Anexos do Mail em cache", titleEN: "Cached Mail attachments", + detailPT: "Cópias locais de anexos — os originais continuam no e-mail", + detailEN: "Local attachment copies — originals stay in your email", + systemImage: "envelope.open", + roots: [library.appendingPathComponent( + "Containers/com.apple.mail/Data/Library/Mail Downloads", isDirectory: true)] + ), + CleanupRule( + id: "system.incompleteDownloads", section: .system, + titlePT: "Downloads incompletos", titleEN: "Incomplete downloads", + detailPT: "Arquivos .download, .crdownload e .part abandonados", + detailEN: "Abandoned .download, .crdownload and .part files", + systemImage: "xmark.icloud", + roots: [home.appendingPathComponent("Downloads", isDirectory: true)], + mode: .filesWithExtensions(["download", "crdownload", "part", "partial", "opdownload"]), + minAgeDays: 1 + ), + CleanupRule( + id: "system.savedState", section: .system, + titlePT: "Estados de janelas salvos", titleEN: "Saved window states", + detailPT: "Apps esquecem as janelas abertas da última sessão (30+ dias)", + detailEN: "Apps forget last session's open windows (30+ days old)", + systemImage: "macwindow.on.rectangle", + roots: [library.appendingPathComponent("Saved Application State", isDirectory: true)], + safety: .review, selectedByDefault: false, minAgeDays: 30 + ), + CleanupRule( + id: "system.tempFiles", section: .system, + titlePT: "Arquivos temporários antigos", titleEN: "Old temporary files", + detailPT: "Pasta temporária do usuário — só itens parados há 7+ dias", + detailEN: "User temp folder — only items untouched for 7+ days", + systemImage: "clock.badge.xmark", + roots: [FileManager.default.temporaryDirectory], + safety: .review, selectedByDefault: false, minAgeDays: 7 + ), + CleanupRule( + id: "system.oldDownloads", section: .system, + titlePT: "Downloads antigos", titleEN: "Old downloads", + detailPT: "Itens sem modificação há mais de 30 dias", + detailEN: "Items untouched for over 30 days", + systemImage: "arrow.down.circle", + roots: [home.appendingPathComponent("Downloads", isDirectory: true)], + safety: .review, selectedByDefault: false, minAgeDays: 30 + ), + CleanupRule( + id: "system.trash", section: .system, + titlePT: "Lixeira", titleEN: "Trash", + detailPT: "Esvazia em definitivo — não pode ser desfeito", + detailEN: "Empties permanently — cannot be undone", + systemImage: "trash", + roots: [home.appendingPathComponent(".Trash", isDirectory: true)], + safety: .review, selectedByDefault: false, + includeHidden: true, deletesPermanently: true + ), + + // MARK: Navegadores + CleanupRule( + id: "browser.safari", section: .browsers, + titlePT: "Cache do Safari", titleEN: "Safari cache", + detailPT: "Páginas e imagens em cache — histórico e senhas intactos", + detailEN: "Cached pages and images — history and passwords untouched", + systemImage: "safari", + roots: [c("com.apple.Safari")], mode: .wholeFolder + ), + CleanupRule( + id: "browser.chrome", section: .browsers, + titlePT: "Cache do Chrome", titleEN: "Chrome cache", + detailPT: "Somente cache — perfis, senhas e extensões intactos", + detailEN: "Cache only — profiles, passwords and extensions untouched", + systemImage: "globe", + roots: [c("Google/Chrome"), c("com.google.Chrome")], mode: .wholeFolder + ), + CleanupRule( + id: "browser.firefox", section: .browsers, + titlePT: "Cache do Firefox", titleEN: "Firefox cache", + detailPT: "Somente cache — perfis e dados intactos", + detailEN: "Cache only — profiles and data untouched", + systemImage: "flame", + roots: [c("Firefox"), c("Mozilla")], mode: .wholeFolder + ), + CleanupRule( + id: "browser.arc", section: .browsers, + titlePT: "Cache do Arc", titleEN: "Arc cache", + detailPT: "Somente cache do navegador Arc", + detailEN: "Arc browser cache only", + systemImage: "circle.grid.cross", + roots: [c("company.thebrowser.Browser")], mode: .wholeFolder + ), + CleanupRule( + id: "browser.edge", section: .browsers, + titlePT: "Cache do Edge", titleEN: "Edge cache", + detailPT: "Somente cache do Microsoft Edge", + detailEN: "Microsoft Edge cache only", + systemImage: "e.circle", + roots: [c("com.microsoft.edgemac")], mode: .wholeFolder + ), + CleanupRule( + id: "browser.brave", section: .browsers, + titlePT: "Cache do Brave", titleEN: "Brave cache", + detailPT: "Somente cache do Brave", + detailEN: "Brave cache only", + systemImage: "shield", + roots: [c("com.brave.Browser"), c("BraveSoftware")], mode: .wholeFolder + ), + + // MARK: Desenvolvedor + CleanupRule( + id: "dev.derivedData", section: .developer, + titlePT: "Xcode DerivedData", titleEN: "Xcode DerivedData", + detailPT: "Builds intermediários — recompilados no próximo build", + detailEN: "Intermediate builds — recompiled on next build", + systemImage: "hammer", + roots: [xcode.appendingPathComponent("DerivedData", isDirectory: true)] + ), + CleanupRule( + id: "dev.deviceSupport", section: .developer, + titlePT: "iOS DeviceSupport", titleEN: "iOS DeviceSupport", + detailPT: "Símbolos de versões antigas de iOS — rebaixados ao conectar o aparelho", + detailEN: "Old iOS version symbols — re-downloaded when device connects", + systemImage: "iphone", + roots: [xcode.appendingPathComponent("iOS DeviceSupport", isDirectory: true)] + ), + CleanupRule( + id: "dev.simulatorCaches", section: .developer, + titlePT: "Caches de simulador", titleEN: "Simulator caches", + detailPT: "CoreSimulator/Caches — recriados pelo Xcode", + detailEN: "CoreSimulator/Caches — recreated by Xcode", + systemImage: "ipad.and.iphone", + roots: [library.appendingPathComponent("Developer/CoreSimulator/Caches", isDirectory: true)], + mode: .wholeFolder + ), + CleanupRule( + id: "dev.xcodeArchives", section: .developer, + titlePT: "Xcode Archives", titleEN: "Xcode Archives", + detailPT: "Contêm dSYMs de builds publicados — remova só o que não precisa mais", + detailEN: "Contain dSYMs of shipped builds — remove only what you no longer need", + systemImage: "archivebox", + roots: [xcode.appendingPathComponent("Archives", isDirectory: true)], + safety: .review, selectedByDefault: false + ), + CleanupRule( + id: "dev.npm", section: .developer, + titlePT: "Cache do npm", titleEN: "npm cache", + detailPT: "~/.npm/_cacache — pacotes baixam de novo sob demanda", + detailEN: "~/.npm/_cacache — packages re-download on demand", + systemImage: "shippingbox", + roots: [home.appendingPathComponent(".npm/_cacache", isDirectory: true)], + mode: .wholeFolder + ), + CleanupRule( + id: "dev.yarn", section: .developer, + titlePT: "Cache do Yarn", titleEN: "Yarn cache", + detailPT: "~/Library/Caches/Yarn", + detailEN: "~/Library/Caches/Yarn", + systemImage: "shippingbox", + roots: [c("Yarn")], mode: .wholeFolder + ), + CleanupRule( + id: "dev.pip", section: .developer, + titlePT: "Cache do pip", titleEN: "pip cache", + detailPT: "~/Library/Caches/pip", + detailEN: "~/Library/Caches/pip", + systemImage: "shippingbox", + roots: [c("pip")], mode: .wholeFolder + ), + CleanupRule( + id: "dev.homebrew", section: .developer, + titlePT: "Cache do Homebrew", titleEN: "Homebrew cache", + detailPT: "Instaladores baixados — equivalente a brew cleanup", + detailEN: "Downloaded bottles — equivalent to brew cleanup", + systemImage: "mug", + roots: [c("Homebrew")] + ), + CleanupRule( + id: "dev.cocoapods", section: .developer, + titlePT: "Cache do CocoaPods", titleEN: "CocoaPods cache", + detailPT: "~/Library/Caches/CocoaPods", + detailEN: "~/Library/Caches/CocoaPods", + systemImage: "shippingbox", + roots: [c("CocoaPods")], mode: .wholeFolder + ), + CleanupRule( + id: "dev.gradle", section: .developer, + titlePT: "Cache do Gradle", titleEN: "Gradle cache", + detailPT: "~/.gradle/caches — dependências baixam de novo no próximo build", + detailEN: "~/.gradle/caches — dependencies re-download on next build", + systemImage: "shippingbox", + roots: [home.appendingPathComponent(".gradle/caches", isDirectory: true)], + mode: .wholeFolder + ), + CleanupRule( + id: "dev.goBuild", section: .developer, + titlePT: "Cache de build do Go", titleEN: "Go build cache", + detailPT: "~/Library/Caches/go-build", + detailEN: "~/Library/Caches/go-build", + systemImage: "shippingbox", + roots: [c("go-build")], mode: .wholeFolder + ), + CleanupRule( + id: "dev.cargo", section: .developer, + titlePT: "Cache do Cargo (Rust)", titleEN: "Cargo cache (Rust)", + detailPT: "~/.cargo/registry/cache — crates baixam de novo sob demanda", + detailEN: "~/.cargo/registry/cache — crates re-download on demand", + systemImage: "shippingbox", + roots: [home.appendingPathComponent(".cargo/registry/cache", isDirectory: true)], + mode: .wholeFolder + ), + + // MARK: Apps + CleanupRule( + id: "app.spotify", section: .apps, + titlePT: "Cache do Spotify", titleEN: "Spotify cache", + detailPT: "Músicas em cache — downloads offline não são afetados", + detailEN: "Streaming cache — offline downloads unaffected", + systemImage: "music.note", + roots: [c("com.spotify.client"), s("Spotify/PersistentCache")], + mode: .wholeFolder + ), + CleanupRule( + id: "app.slack", section: .apps, + titlePT: "Cache do Slack", titleEN: "Slack cache", + detailPT: "Somente cache — conversas ficam no servidor", + detailEN: "Cache only — conversations live on the server", + systemImage: "bubble.left.and.bubble.right", + roots: [s("Slack/Cache"), s("Slack/Service Worker/CacheStorage")], + mode: .wholeFolder + ), + CleanupRule( + id: "app.discord", section: .apps, + titlePT: "Cache do Discord", titleEN: "Discord cache", + detailPT: "Somente cache — mensagens ficam no servidor", + detailEN: "Cache only — messages live on the server", + systemImage: "gamecontroller", + roots: [s("discord/Cache"), s("discord/Code Cache")], + mode: .wholeFolder + ), + CleanupRule( + id: "app.vscode", section: .apps, + titlePT: "Cache do VS Code", titleEN: "VS Code cache", + detailPT: "Cache e dados temporários — extensões e ajustes intactos", + detailEN: "Cache and temp data — extensions and settings untouched", + systemImage: "chevron.left.forwardslash.chevron.right", + roots: [s("Code/Cache"), s("Code/CachedData"), s("Code/CachedExtensionVSIXs")], + mode: .wholeFolder + ), + + // MARK: Itens grandes + CleanupRule( + id: "big.iosBackups", section: .bigItems, + titlePT: "Backups de iPhone/iPad", titleEN: "iPhone/iPad backups", + detailPT: "Backups locais completos — confirme que há backup no iCloud antes", + detailEN: "Full local backups — confirm you have iCloud backups first", + systemImage: "externaldrive.badge.icloud", + roots: [s("MobileSync/Backup")], + safety: .review, selectedByDefault: false + ) + ] + } +} From e09244ab917de366c85712a68fafb9c19619f317 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:37 -0300 Subject: [PATCH 03/23] feat(clean): motor de limpeza com selecao por item e contador vitalicio --- .../Core/Services/Clean/JunkScanService.swift | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 ClipFlow/Core/Services/Clean/JunkScanService.swift diff --git a/ClipFlow/Core/Services/Clean/JunkScanService.swift b/ClipFlow/Core/Services/Clean/JunkScanService.swift new file mode 100644 index 0000000..efbaf16 --- /dev/null +++ b/ClipFlow/Core/Services/Clean/JunkScanService.swift @@ -0,0 +1,209 @@ +import Foundation + +/// Resultado do scan de uma regra de limpeza. +struct CleanupRuleResult { + let ruleID: String + let items: [CleanFileItem] + var bytes: UInt64 { items.reduce(0) { $0 + $1.bytes } } +} + +struct JunkCleanupSummary: Equatable { + let reclaimedBytes: UInt64 + let failures: [String] +} + +/// Motor de limpeza baseado no catálogo de regras curadas. +@MainActor +final class JunkScanService: ObservableObject { + let rules = CleanupRuleCatalog.rules() + + @Published private(set) var results: [String: CleanupRuleResult] = [:] + @Published private(set) var isScanning = false + @Published private(set) var isCleaning = false + @Published private(set) var lastCleanup: JunkCleanupSummary? + /// Total liberado pelo CleanFlow desde a instalação (persistido). + @Published private(set) var lifetimeReclaimedBytes: UInt64 + + private static let lifetimeKey = "cleanflow.lifetimeReclaimedBytes" + + init() { + let stored = UserDefaults.standard.object(forKey: Self.lifetimeKey) as? NSNumber + lifetimeReclaimedBytes = stored?.uint64Value ?? 0 + } + + var totalBytes: UInt64 { + results.values.reduce(0) { $0 + $1.bytes } + } + + /// Regras seguras e pré-selecionadas — usadas na limpeza de 1 clique. + var safeDefaultRuleIDs: Set { + Set(rules.filter { $0.safety == .safe && $0.selectedByDefault }.map(\.id)) + } + + /// Bytes recuperáveis apenas pelas regras seguras. + var safeBytes: UInt64 { + safeDefaultRuleIDs.reduce(0) { $0 + bytes(forRule: $1) } + } + + func bytes(forRule id: String) -> UInt64 { + results[id]?.bytes ?? 0 + } + + func itemCount(forRule id: String) -> Int { + results[id]?.items.count ?? 0 + } + + func rule(withID id: String) -> CleanupRule? { + rules.first { $0.id == id } + } + + func scan(completion: (() -> Void)? = nil) { + guard !isScanning, !isCleaning else { return } + isScanning = true + + let rules = self.rules + Task { + let scanned = await Task.detached(priority: .utility) { () -> [CleanupRuleResult] in + rules.map(Self.scanRule) + }.value + results = Dictionary(uniqueKeysWithValues: scanned.map { ($0.ruleID, $0) }) + isScanning = false + completion?() + } + } + + /// Limpa as regras selecionadas. Tudo vai para a Lixeira, exceto regras + /// marcadas como permanentes (apenas a própria Lixeira). + func clean(ruleIDs: Set, completion: (() -> Void)? = nil) { + guard !ruleIDs.isEmpty, !isScanning, !isCleaning else { return } + isCleaning = true + + let snapshot = results + let rulesByID = Dictionary(uniqueKeysWithValues: rules.map { ($0.id, $0) }) + Task { + let summary = await Task.detached(priority: .utility) { () -> JunkCleanupSummary in + var reclaimed: UInt64 = 0 + var failures: [String] = [] + for id in ruleIDs { + guard let result = snapshot[id], let rule = rulesByID[id] else { continue } + let urls = result.items.map(\.url) + let outcome = rule.deletesPermanently + ? FileSweeper.removePermanently(urls: urls) + : FileSweeper.trash(urls: urls) + reclaimed += outcome.reclaimed + failures.append(contentsOf: outcome.failures) + } + return JunkCleanupSummary(reclaimedBytes: reclaimed, failures: failures) + }.value + lastCleanup = summary + lifetimeReclaimedBytes += summary.reclaimedBytes + UserDefaults.standard.set(NSNumber(value: lifetimeReclaimedBytes), forKey: Self.lifetimeKey) + isCleaning = false + scan(completion: completion) + } + } + + /// Limpa itens selecionados individualmente, agrupados por regra + /// (usado pelo Gerenciador de Limpeza com seleção por item). + func clean(itemsByRule: [String: Set], completion: (() -> Void)? = nil) { + let nonEmpty = itemsByRule.filter { !$0.value.isEmpty } + guard !nonEmpty.isEmpty, !isScanning, !isCleaning else { return } + isCleaning = true + + let rulesByID = Dictionary(uniqueKeysWithValues: rules.map { ($0.id, $0) }) + Task { + let summary = await Task.detached(priority: .utility) { () -> JunkCleanupSummary in + var reclaimed: UInt64 = 0 + var failures: [String] = [] + for (ruleID, urls) in nonEmpty { + guard let rule = rulesByID[ruleID] else { continue } + let outcome = rule.deletesPermanently + ? FileSweeper.removePermanently(urls: Array(urls)) + : FileSweeper.trash(urls: Array(urls)) + reclaimed += outcome.reclaimed + failures.append(contentsOf: outcome.failures) + } + return JunkCleanupSummary(reclaimedBytes: reclaimed, failures: failures) + }.value + lastCleanup = summary + lifetimeReclaimedBytes += summary.reclaimedBytes + UserDefaults.standard.set(NSNumber(value: lifetimeReclaimedBytes), forKey: Self.lifetimeKey) + isCleaning = false + scan(completion: completion) + } + } + + /// Remove um item individual de uma regra (usado no gerenciador de revisão). + func trashSingleItem(ruleID: String, url: URL) { + guard let result = results[ruleID], let rule = rule(withID: ruleID) else { return } + let outcome = rule.deletesPermanently + ? FileSweeper.removePermanently(urls: [url]) + : FileSweeper.trash(urls: [url]) + guard outcome.failures.isEmpty else { return } + results[ruleID] = CleanupRuleResult( + ruleID: ruleID, + items: result.items.filter { $0.url != url } + ) + lifetimeReclaimedBytes += outcome.reclaimed + UserDefaults.standard.set(NSNumber(value: lifetimeReclaimedBytes), forKey: Self.lifetimeKey) + } + + /// Bytes das regras seguras de uma seção (para o botão "Limpar" dos cards). + func safeBytes(in section: CleanupSection) -> UInt64 { + rules + .filter { $0.section == section && $0.safety == .safe && $0.selectedByDefault } + .reduce(0) { $0 + bytes(forRule: $1.id) } + } + + func safeRuleIDs(in section: CleanupSection) -> Set { + Set(rules + .filter { $0.section == section && $0.safety == .safe && $0.selectedByDefault } + .map(\.id)) + } + + func bytes(in section: CleanupSection) -> UInt64 { + rules.filter { $0.section == section }.reduce(0) { $0 + bytes(forRule: $1.id) } + } + + // MARK: - Scan + + nonisolated static func scanRule(_ rule: CleanupRule) -> CleanupRuleResult { + let fm = FileManager.default + var items: [CleanFileItem] = [] + let cutoff = rule.minAgeDays.map { Date().addingTimeInterval(-Double($0) * 86_400) } + + for root in rule.roots { + guard fm.fileExists(atPath: root.path) else { continue } + switch rule.mode { + case .wholeFolder: + let size = FileSweeper.allocatedSize(of: root) + if size > 0 { + items.append(CleanFileItem(url: root, bytes: size, modifiedAt: nil)) + } + case .children: + var children = FileSweeper.children(of: root, includeHidden: rule.includeHidden) + if !rule.excludedFirstComponents.isEmpty { + children.removeAll { rule.excludedFirstComponents.contains($0.url.lastPathComponent) } + } + if let cutoff { + children.removeAll { item in + guard let modified = item.modifiedAt else { return true } + return modified >= cutoff + } + } + items += children + case .filesWithExtensions(let extensions): + let children = FileSweeper.children(of: root, includeHidden: rule.includeHidden) + items += children.filter { item in + guard extensions.contains(item.url.pathExtension.lowercased()) else { return false } + guard let cutoff else { return true } + guard let modified = item.modifiedAt else { return false } + return modified < cutoff + } + } + } + + let filtered = items.filter { $0.bytes > 0 }.sorted { $0.bytes > $1.bytes } + return CleanupRuleResult(ruleID: rule.id, items: filtered) + } +} From 80287dc39f10f6487429dbce86d2041288367bb0 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:37 -0300 Subject: [PATCH 04/23] feat(clean): inventario de launch agents e processos pesados --- .../Services/Clean/StartupItemsService.swift | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 ClipFlow/Core/Services/Clean/StartupItemsService.swift diff --git a/ClipFlow/Core/Services/Clean/StartupItemsService.swift b/ClipFlow/Core/Services/Clean/StartupItemsService.swift new file mode 100644 index 0000000..a5d3d19 --- /dev/null +++ b/ClipFlow/Core/Services/Clean/StartupItemsService.swift @@ -0,0 +1,115 @@ +import Foundation + +/// Agente/daemon de inicialização (plist em LaunchAgents/LaunchDaemons). +struct StartupItem: Identifiable, Hashable { + enum Domain: String, CaseIterable { + case userAgent + case globalAgent + case globalDaemon + } + + let url: URL + let label: String + let programPath: String? + let domain: Domain + + var id: URL { url } + /// Apenas itens do usuário podem ser removidos sem privilégios de admin. + var isRemovable: Bool { domain == .userAgent } +} + +/// Processo pesado (CPU/RAM) para o módulo de aceleração. +struct HeavyProcess: Identifiable, Hashable { + let pid: Int32 + let name: String + let cpuPercent: Double + let memoryPercent: Double + + var id: Int32 { pid } +} + +@MainActor +final class StartupItemsService: ObservableObject { + @Published private(set) var items: [StartupItem] = [] + @Published private(set) var processes: [HeavyProcess] = [] + @Published private(set) var isLoading = false + @Published private(set) var errorMessage: String? + + func refresh() { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + + Task { + async let loadedItems = Task.detached(priority: .utility) { Self.loadStartupItems() }.value + async let loadedProcesses = Task.detached(priority: .utility) { Self.loadHeavyProcesses() }.value + items = await loadedItems + processes = await loadedProcesses + isLoading = false + } + } + + /// Move o plist do agente para a Lixeira (efeito após logout/reboot). + func remove(_ item: StartupItem) { + guard item.isRemovable else { return } + let outcome = FileSweeper.trash(urls: [item.url]) + if !outcome.failures.isEmpty { + errorMessage = outcome.failures.joined(separator: "\n") + } + refresh() + } + + // MARK: - Carregamento + + nonisolated static func loadStartupItems() -> [StartupItem] { + let home = FileManager.default.homeDirectoryForCurrentUser + let roots: [(URL, StartupItem.Domain)] = [ + (home.appendingPathComponent("Library/LaunchAgents", isDirectory: true), .userAgent), + (URL(fileURLWithPath: "/Library/LaunchAgents", isDirectory: true), .globalAgent), + (URL(fileURLWithPath: "/Library/LaunchDaemons", isDirectory: true), .globalDaemon) + ] + + var found: [StartupItem] = [] + for (root, domain) in roots { + guard let urls = try? FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: nil + ) else { continue } + for url in urls where url.pathExtension == "plist" { + guard let data = try? Data(contentsOf: url), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), + let dict = plist as? [String: Any] else { continue } + let label = dict["Label"] as? String ?? url.deletingPathExtension().lastPathComponent + let program = dict["Program"] as? String + ?? (dict["ProgramArguments"] as? [String])?.first + found.append(StartupItem(url: url, label: label, programPath: program, domain: domain)) + } + } + return found.sorted { $0.label.localizedCaseInsensitiveCompare($1.label) == .orderedAscending } + } + + nonisolated static func loadHeavyProcesses(limit: Int = 12) -> [HeavyProcess] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/ps") + process.arguments = ["-Aceo", "pid=,pcpu=,pmem=,comm=", "-r"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + guard (try? process.run()) != nil else { return [] } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard let output = String(data: data, encoding: .utf8) else { return [] } + + var result: [HeavyProcess] = [] + for line in output.split(separator: "\n") { + let parts = line.split(separator: " ", maxSplits: 3, omittingEmptySubsequences: true) + guard parts.count == 4, + let pid = Int32(parts[0]), + let cpu = Double(parts[1]), + let mem = Double(parts[2]) else { continue } + result.append(HeavyProcess(pid: pid, name: String(parts[3]), cpuPercent: cpu, memoryPercent: mem)) + if result.count >= limit { break } + } + return result + } +} From 4094cdab47e2efa7eeb2169100e0b5765e720550 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:37 -0300 Subject: [PATCH 05/23] feat(clean): tarefas de manutencao com prompt admin nativo --- .../Services/Clean/MaintenanceService.swift | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 ClipFlow/Core/Services/Clean/MaintenanceService.swift diff --git a/ClipFlow/Core/Services/Clean/MaintenanceService.swift b/ClipFlow/Core/Services/Clean/MaintenanceService.swift new file mode 100644 index 0000000..ea06f93 --- /dev/null +++ b/ClipFlow/Core/Services/Clean/MaintenanceService.swift @@ -0,0 +1,137 @@ +import Foundation + +/// Tarefa de manutenção no estilo CleanMyMac. +struct MaintenanceTask: Identifiable { + let id: String + let titlePT: String + let titleEN: String + let detailPT: String + let detailEN: String + let systemImage: String + /// Comando shell executado; tarefas com `needsAdmin` rodam em um único + /// "do shell script ... with administrator privileges" (1 prompt só). + let command: String + let needsAdmin: Bool +} + +@MainActor +final class MaintenanceService: ObservableObject { + @Published private(set) var isRunning = false + @Published private(set) var lastOutcomePT: String? + @Published private(set) var lastOutcomeEN: String? + + let tasks: [MaintenanceTask] = [ + MaintenanceTask( + id: "flushDNS", + titlePT: "Limpar cache de DNS", titleEN: "Flush DNS cache", + detailPT: "Resolve sites que não carregam após trocas de rede", + detailEN: "Fixes sites that fail to load after network changes", + systemImage: "network", + command: "dscacheutil -flushcache; killall -HUP mDNSResponder", + needsAdmin: true + ), + MaintenanceTask( + id: "spotlight", + titlePT: "Reindexar Spotlight", titleEN: "Reindex Spotlight", + detailPT: "Reconstrói o índice de busca — a reindexação leva um tempo", + detailEN: "Rebuilds the search index — reindexing takes a while", + systemImage: "magnifyingglass", + command: "mdutil -E / >/dev/null 2>&1", + needsAdmin: true + ), + MaintenanceTask( + id: "purgeRAM", + titlePT: "Liberar memória inativa", titleEN: "Free up inactive memory", + detailPT: "Executa purge para liberar RAM em cache", + detailEN: "Runs purge to release cached RAM", + systemImage: "memorychip", + command: "purge", + needsAdmin: true + ), + MaintenanceTask( + id: "launchServices", + titlePT: "Reconstruir Launch Services", titleEN: "Rebuild Launch Services", + detailPT: "Corrige apps duplicados no menu \"Abrir com\"", + detailEN: "Fixes duplicate apps in the \"Open With\" menu", + systemImage: "menucard", + command: "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -kill -r -domain local -domain system -domain user >/dev/null 2>&1", + needsAdmin: false + ) + ] + + /// Executa as tarefas selecionadas. As que exigem admin disparam o prompt + /// de senha padrão do macOS (uma vez, com os comandos agrupados). + func run(taskIDs: Set) { + guard !taskIDs.isEmpty, !isRunning else { return } + isRunning = true + lastOutcomePT = nil + lastOutcomeEN = nil + + let selected = tasks.filter { taskIDs.contains($0.id) } + Task { + let failures = await Task.detached(priority: .userInitiated) { () -> [String] in + var failures: [String] = [] + + let userCommands = selected.filter { !$0.needsAdmin }.map(\.command) + for command in userCommands { + if !Self.runShell(command) { failures.append(command) } + } + + let adminCommands = selected.filter(\.needsAdmin).map(\.command) + if !adminCommands.isEmpty { + let joined = adminCommands.joined(separator: "; ") + if !Self.runShellAsAdmin(joined) { + failures.append("admin: \(joined)") + } + } + return failures + }.value + + if failures.isEmpty { + lastOutcomePT = "Tarefas concluídas com sucesso." + lastOutcomeEN = "Tasks completed successfully." + } else { + lastOutcomePT = "Algumas tarefas falharam ou foram canceladas." + lastOutcomeEN = "Some tasks failed or were cancelled." + } + isRunning = false + } + } + + // MARK: - Execução + + private nonisolated static func runShell(_ command: String) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-c", command] + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } + + /// Usa osascript para obter o prompt de autenticação nativo do macOS. + private nonisolated static func runShellAsAdmin(_ command: String) -> Bool { + let escaped = command + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let script = "do shell script \"\(escaped)\" with administrator privileges" + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } +} From 6dcff067fcfc0e6133d63294d35ac452b0d5f693 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:37 -0300 Subject: [PATCH 06/23] feat(clean): inventario de apps com deteccao de sobras --- .../Services/Clean/AppInventoryService.swift | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 ClipFlow/Core/Services/Clean/AppInventoryService.swift diff --git a/ClipFlow/Core/Services/Clean/AppInventoryService.swift b/ClipFlow/Core/Services/Clean/AppInventoryService.swift new file mode 100644 index 0000000..4347a75 --- /dev/null +++ b/ClipFlow/Core/Services/Clean/AppInventoryService.swift @@ -0,0 +1,146 @@ +import Foundation +import AppKit + +/// App instalado em /Applications ou ~/Applications. +struct InstalledApp: Identifiable, Hashable { + let url: URL + let name: String + let bundleIdentifier: String? + let version: String? + let bytes: UInt64 + + var id: URL { url } + var icon: NSImage { NSWorkspace.shared.icon(forFile: url.path) } +} + +/// Sobras de um app (caches, preferências, suporte etc.). +struct AppLeftovers { + let app: InstalledApp + let items: [CleanFileItem] + var bytes: UInt64 { items.reduce(0) { $0 + $1.bytes } } +} + +@MainActor +final class AppInventoryService: ObservableObject { + @Published private(set) var apps: [InstalledApp] = [] + @Published private(set) var isLoading = false + @Published private(set) var isUninstalling = false + @Published private(set) var leftovers: AppLeftovers? + @Published private(set) var errorMessage: String? + + func refresh() { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + + Task { + let loaded = await Task.detached(priority: .utility) { Self.loadApps() }.value + apps = loaded + isLoading = false + } + } + + /// Encontra sobras do app (não remove nada ainda). + func findLeftovers(for app: InstalledApp) { + Task { + let found = await Task.detached(priority: .utility) { Self.leftovers(for: app) }.value + leftovers = AppLeftovers(app: app, items: found) + } + } + + func dismissLeftovers() { + leftovers = nil + } + + /// Move o app e as sobras selecionadas para a Lixeira. + func uninstall(app: InstalledApp, leftoverURLs: [URL]) { + guard !isUninstalling else { return } + isUninstalling = true + errorMessage = nil + + Task { + let outcome = await Task.detached(priority: .utility) { + FileSweeper.trash(urls: [app.url] + leftoverURLs) + }.value + if !outcome.failures.isEmpty { + errorMessage = outcome.failures.joined(separator: "\n") + } + leftovers = nil + isUninstalling = false + refresh() + } + } + + // MARK: - Carregamento + + nonisolated static func loadApps() -> [InstalledApp] { + let home = FileManager.default.homeDirectoryForCurrentUser + let roots = [ + URL(fileURLWithPath: "/Applications", isDirectory: true), + home.appendingPathComponent("Applications", isDirectory: true) + ] + + var found: [InstalledApp] = [] + for root in roots { + guard let urls = try? FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] + ) else { continue } + for url in urls where url.pathExtension == "app" { + let bundle = Bundle(url: url) + let info = bundle?.infoDictionary + let name = (info?["CFBundleDisplayName"] as? String) + ?? (info?["CFBundleName"] as? String) + ?? url.deletingPathExtension().lastPathComponent + found.append(InstalledApp( + url: url, + name: name, + bundleIdentifier: bundle?.bundleIdentifier, + version: info?["CFBundleShortVersionString"] as? String, + bytes: FileSweeper.allocatedSize(of: url) + )) + } + } + return found.sorted { $0.bytes > $1.bytes } + } + + nonisolated static func leftovers(for app: InstalledApp) -> [CleanFileItem] { + let home = FileManager.default.homeDirectoryForCurrentUser + let library = home.appendingPathComponent("Library", isDirectory: true) + let containers = [ + "Application Support", + "Caches", + "Preferences", + "Logs", + "Saved Application State", + "Containers", + "HTTPStorages", + "WebKit" + ].map { library.appendingPathComponent($0, isDirectory: true) } + + var matches: [CleanFileItem] = [] + let bundleID = app.bundleIdentifier?.lowercased() + let appName = app.name.lowercased() + + for container in containers { + guard let urls = try? FileManager.default.contentsOfDirectory( + at: container, includingPropertiesForKeys: nil + ) else { continue } + for url in urls { + let candidate = url.lastPathComponent.lowercased() + let matchesBundle = bundleID.map { candidate.contains($0) } ?? false + // Nome exato evita falsos positivos com nomes genéricos. + let matchesName = appName.count >= 4 + && (candidate == appName || candidate == appName + ".plist") + guard matchesBundle || matchesName else { continue } + // Nunca sugere sobras do próprio ClipFlow. + if candidate.contains("clipflow") { continue } + matches.append(CleanFileItem( + url: url, + bytes: FileSweeper.allocatedSize(of: url), + modifiedAt: nil + )) + } + } + return matches.sorted { $0.bytes > $1.bytes } + } +} From d15813b885a7c409476bb77178f1937651fa8866 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:37 -0300 Subject: [PATCH 07/23] feat(clean): busca de duplicatas exatas por SHA-256 --- .../Clean/DuplicateFinderService.swift | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 ClipFlow/Core/Services/Clean/DuplicateFinderService.swift diff --git a/ClipFlow/Core/Services/Clean/DuplicateFinderService.swift b/ClipFlow/Core/Services/Clean/DuplicateFinderService.swift new file mode 100644 index 0000000..ef8dee5 --- /dev/null +++ b/ClipFlow/Core/Services/Clean/DuplicateFinderService.swift @@ -0,0 +1,125 @@ +import Foundation +import CryptoKit + +/// Limites do scan de duplicatas: ignora arquivos minúsculos (ruído) +/// e gigantes (hash muito caro). +private let duplicateScanMinFileSize: UInt64 = 1024 * 100 // 100 KB +private let duplicateScanMaxFileSize: UInt64 = 1024 * 1024 * 1024 // 1 GB + +/// Grupo de arquivos com conteúdo idêntico (mesmo hash). +struct DuplicateGroup: Identifiable { + let hash: String + let items: [CleanFileItem] + + var id: String { hash } + /// Espaço recuperável mantendo uma cópia. + var wastedBytes: UInt64 { + guard let first = items.first else { return 0 } + return first.bytes * UInt64(items.count - 1) + } +} + +@MainActor +final class DuplicateFinderService: ObservableObject { + @Published private(set) var groups: [DuplicateGroup] = [] + @Published private(set) var isScanning = false + @Published private(set) var scannedFileCount = 0 + @Published var scanRoots: [URL] + + init() { + let home = FileManager.default.homeDirectoryForCurrentUser + scanRoots = ["Downloads", "Documents", "Desktop"].map { + home.appendingPathComponent($0, isDirectory: true) + } + } + + var totalWastedBytes: UInt64 { + groups.reduce(0) { $0 + $1.wastedBytes } + } + + func scan() { + guard !isScanning else { return } + isScanning = true + groups = [] + scannedFileCount = 0 + + let roots = scanRoots + Task { + let outcome = await Task.detached(priority: .utility) { () -> (groups: [DuplicateGroup], count: Int) in + Self.findDuplicates(in: roots) + }.value + groups = outcome.groups + scannedFileCount = outcome.count + isScanning = false + } + } + + /// Move os arquivos escolhidos para a Lixeira e remove-os dos grupos. + func trash(urls: Set) { + guard !urls.isEmpty else { return } + _ = FileSweeper.trash(urls: Array(urls)) + groups = groups.compactMap { group in + let remaining = group.items.filter { !urls.contains($0.url) } + guard remaining.count > 1 else { return nil } + return DuplicateGroup(hash: group.hash, items: remaining) + } + } + + // MARK: - Busca + + nonisolated static func findDuplicates(in roots: [URL]) -> (groups: [DuplicateGroup], count: Int) { + let fm = FileManager.default + let keys: Set = [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey] + + // Passo 1: agrupa por tamanho (barato). + var bySize: [UInt64: [CleanFileItem]] = [:] + var count = 0 + for root in roots { + guard let enumerator = fm.enumerator( + at: root, + includingPropertiesForKeys: Array(keys), + options: [.skipsHiddenFiles, .skipsPackageDescendants], + errorHandler: { _, _ in true } + ) else { continue } + for case let url as URL in enumerator { + guard let values = try? url.resourceValues(forKeys: keys), + values.isRegularFile == true, + let size = values.fileSize.map(UInt64.init), + size >= duplicateScanMinFileSize, size <= duplicateScanMaxFileSize else { continue } + count += 1 + bySize[size, default: []].append( + CleanFileItem(url: url, bytes: size, modifiedAt: values.contentModificationDate) + ) + } + } + + // Passo 2: hash SHA-256 apenas de candidatos com tamanho repetido. + var byHash: [String: [CleanFileItem]] = [:] + for (_, candidates) in bySize where candidates.count > 1 { + for item in candidates { + guard let digest = sha256(of: item.url) else { continue } + byHash[digest, default: []].append(item) + } + } + + let groups = byHash + .filter { $0.value.count > 1 } + .map { DuplicateGroup(hash: $0.key, items: $0.value.sorted { lhs, rhs in + (lhs.modifiedAt ?? .distantPast) > (rhs.modifiedAt ?? .distantPast) + }) } + .sorted { $0.wastedBytes > $1.wastedBytes } + return (groups, count) + } + + private nonisolated static func sha256(of url: URL) -> String? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + var hasher = SHA256() + while autoreleasepool(invoking: { + guard let chunk = try? handle.read(upToCount: 1024 * 1024), !chunk.isEmpty else { return false } + hasher.update(data: chunk) + return true + }) {} + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } +} From 9b85940811880c817d8b13a9d7d75725c3f69457 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:37 -0300 Subject: [PATCH 08/23] feat(clean): imagens similares via dHash perceptual --- .../Services/Clean/SimilarImagesService.swift | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 ClipFlow/Core/Services/Clean/SimilarImagesService.swift diff --git a/ClipFlow/Core/Services/Clean/SimilarImagesService.swift b/ClipFlow/Core/Services/Clean/SimilarImagesService.swift new file mode 100644 index 0000000..7556467 --- /dev/null +++ b/ClipFlow/Core/Services/Clean/SimilarImagesService.swift @@ -0,0 +1,154 @@ +import Foundation +import CoreGraphics +import ImageIO + +/// Grupo de imagens visualmente parecidas (dHash com distância de Hamming baixa). +struct SimilarImageGroup: Identifiable { + let id = UUID() + let items: [CleanFileItem] + + /// Espaço recuperável mantendo a maior/mais recente. + var wastedBytes: UInt64 { + items.dropFirst().reduce(0) { $0 + $1.bytes } + } +} + +/// Detecção de imagens similares (não idênticas) via perceptual hash. +/// Varre imagens soltas em Mesa, Downloads e Imagens — não toca na +/// biblioteca do Photos. +@MainActor +final class SimilarImagesService: ObservableObject { + @Published private(set) var groups: [SimilarImageGroup] = [] + @Published private(set) var isScanning = false + @Published private(set) var scannedCount = 0 + + var totalWastedBytes: UInt64 { + groups.reduce(0) { $0 + $1.wastedBytes } + } + + func scan() { + guard !isScanning else { return } + isScanning = true + groups = [] + scannedCount = 0 + + Task { + let outcome = await Task.detached(priority: .utility) { () -> (groups: [SimilarImageGroup], count: Int) in + Self.findSimilarImages() + }.value + groups = outcome.groups + scannedCount = outcome.count + isScanning = false + } + } + + func trash(urls: Set) { + guard !urls.isEmpty else { return } + _ = FileSweeper.trash(urls: Array(urls)) + groups = groups.compactMap { group in + let remaining = group.items.filter { !urls.contains($0.url) } + guard remaining.count > 1 else { return nil } + return SimilarImageGroup(items: remaining) + } + } + + // MARK: - Busca + + private nonisolated static let imageExtensions: Set = [ + "jpg", "jpeg", "png", "heic", "heif", "tiff", "bmp", "webp" + ] + /// Distância de Hamming máxima (de 64 bits) para considerar "similar". + private nonisolated static let maxHammingDistance = 8 + private nonisolated static let maxScannedImages = 3000 + + nonisolated static func findSimilarImages() -> (groups: [SimilarImageGroup], count: Int) { + let home = FileManager.default.homeDirectoryForCurrentUser + let roots = ["Desktop", "Downloads", "Pictures"].map { + home.appendingPathComponent($0, isDirectory: true) + } + + // Coleta imagens (fora de pacotes como .photoslibrary). + var images: [(item: CleanFileItem, hash: UInt64)] = [] + var count = 0 + let keys: Set = [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey] + + for root in roots { + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: Array(keys), + options: [.skipsHiddenFiles, .skipsPackageDescendants], + errorHandler: { _, _ in true } + ) else { continue } + for case let url as URL in enumerator { + guard imageExtensions.contains(url.pathExtension.lowercased()), + let values = try? url.resourceValues(forKeys: keys), + values.isRegularFile == true, + let size = values.fileSize.map(UInt64.init), + size > 50_000 else { continue } + count += 1 + guard count <= maxScannedImages else { break } + guard let hash = dHash(of: url) else { continue } + images.append(( + CleanFileItem(url: url, bytes: size, modifiedAt: values.contentModificationDate), + hash + )) + } + } + + // Agrupamento guloso por distância de Hamming. + var used = Set() + var groups: [SimilarImageGroup] = [] + for i in images.indices where !used.contains(i) { + var members = [images[i].item] + for j in images.indices where j > i && !used.contains(j) { + if (images[i].hash ^ images[j].hash).nonzeroBitCount <= maxHammingDistance { + members.append(images[j].item) + used.insert(j) + } + } + if members.count > 1 { + used.insert(i) + // Maior primeiro: sugerimos manter a de melhor qualidade. + groups.append(SimilarImageGroup(items: members.sorted { $0.bytes > $1.bytes })) + } + } + return (groups.sorted { $0.wastedBytes > $1.wastedBytes }, min(count, maxScannedImages)) + } + + /// dHash 8x8: reduz para 9x8 em tons de cinza e compara pixels vizinhos. + nonisolated static func dHash(of url: URL) -> UInt64? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: 32, + kCGImageSourceCreateThumbnailWithTransform: true + ] + guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + + let width = 9, height = 8 + var pixels = [UInt8](repeating: 0, count: width * height) + guard let context = CGContext( + data: &pixels, + width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGImageAlphaInfo.none.rawValue + ) else { return nil } + context.interpolationQuality = .low + context.draw(thumbnail, in: CGRect(x: 0, y: 0, width: width, height: height)) + + var hash: UInt64 = 0 + var bit = 0 + for row in 0.. pixels[row * width + col + 1] { + hash |= 1 << UInt64(bit) + } + bit += 1 + } + } + return hash + } +} From d02ecd3c56f3c634dd2a056d46b8af7ff6b90a6c Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:37 -0300 Subject: [PATCH 09/23] feat(clean): varredura de arquivos grandes e antigos --- .../Services/Clean/LargeOldFilesService.swift | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 ClipFlow/Core/Services/Clean/LargeOldFilesService.swift diff --git a/ClipFlow/Core/Services/Clean/LargeOldFilesService.swift b/ClipFlow/Core/Services/Clean/LargeOldFilesService.swift new file mode 100644 index 0000000..7c56d38 --- /dev/null +++ b/ClipFlow/Core/Services/Clean/LargeOldFilesService.swift @@ -0,0 +1,72 @@ +import Foundation + +@MainActor +final class LargeOldFilesService: ObservableObject { + enum SizeThreshold: UInt64, CaseIterable, Identifiable { + case mb50 = 52_428_800 + case mb100 = 104_857_600 + case mb500 = 524_288_000 + case gb1 = 1_073_741_824 + + var id: UInt64 { rawValue } + var label: String { CleanFormat.bytes(rawValue) } + } + + @Published private(set) var files: [CleanFileItem] = [] + @Published private(set) var isScanning = false + @Published var threshold: SizeThreshold = .mb100 + + var totalBytes: UInt64 { + files.reduce(0) { $0 + $1.bytes } + } + + func scan() { + guard !isScanning else { return } + isScanning = true + files = [] + + let minSize = threshold.rawValue + Task { + let found = await Task.detached(priority: .utility) { () -> [CleanFileItem] in + Self.findLargeFiles(minSize: minSize) + }.value + files = found + isScanning = false + } + } + + func trash(urls: Set) { + guard !urls.isEmpty else { return } + _ = FileSweeper.trash(urls: Array(urls)) + files.removeAll { urls.contains($0.url) } + } + + nonisolated static func findLargeFiles(minSize: UInt64, limit: Int = 300) -> [CleanFileItem] { + let fm = FileManager.default + let home = fm.homeDirectoryForCurrentUser + let keys: Set = [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey] + // Library fica de fora: caches são cobertos pelo módulo de limpeza. + let skipped = home.appendingPathComponent("Library", isDirectory: true).path + + guard let enumerator = fm.enumerator( + at: home, + includingPropertiesForKeys: Array(keys), + options: [.skipsHiddenFiles, .skipsPackageDescendants], + errorHandler: { _, _ in true } + ) else { return [] } + + var found: [CleanFileItem] = [] + for case let url as URL in enumerator { + if url.path.hasPrefix(skipped) { + enumerator.skipDescendants() + continue + } + guard let values = try? url.resourceValues(forKeys: keys), + values.isRegularFile == true, + let size = values.fileSize.map(UInt64.init), + size >= minSize else { continue } + found.append(CleanFileItem(url: url, bytes: size, modifiedAt: values.contentModificationDate)) + } + return Array(found.sorted { $0.bytes > $1.bytes }.prefix(limit)) + } +} From 7d88ff980348a892e5327710051530fe90600129 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:37 -0300 Subject: [PATCH 10/23] feat(clean): arvore de tamanhos para a lupa de espaco --- .../Core/Services/Clean/DiskMapService.swift | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 ClipFlow/Core/Services/Clean/DiskMapService.swift diff --git a/ClipFlow/Core/Services/Clean/DiskMapService.swift b/ClipFlow/Core/Services/Clean/DiskMapService.swift new file mode 100644 index 0000000..5a72fd5 --- /dev/null +++ b/ClipFlow/Core/Services/Clean/DiskMapService.swift @@ -0,0 +1,92 @@ +import Foundation + +/// Nó da árvore de pastas para o mapa de disco (estilo Space Lens). +final class DiskNode: Identifiable { + let url: URL + let name: String + let bytes: UInt64 + let isDirectory: Bool + let children: [DiskNode] + + var id: URL { url } + + init(url: URL, name: String, bytes: UInt64, isDirectory: Bool, children: [DiskNode]) { + self.url = url + self.name = name + self.bytes = bytes + self.isDirectory = isDirectory + self.children = children + } +} + +@MainActor +final class DiskMapService: ObservableObject { + @Published private(set) var root: DiskNode? + @Published private(set) var isScanning = false + @Published private(set) var currentURL: URL + + init() { + currentURL = FileManager.default.homeDirectoryForCurrentUser + } + + func scan(url: URL? = nil) { + guard !isScanning else { return } + if let url { currentURL = url } + isScanning = true + root = nil + + let target = currentURL + Task { + let node = await Task.detached(priority: .utility) { () -> DiskNode in + Self.buildTree(at: target, depth: 2) + }.value + root = node + isScanning = false + } + } + + /// Constrói a árvore com profundidade limitada; abaixo disso agrega tamanhos. + nonisolated static func buildTree(at url: URL, depth: Int) -> DiskNode { + let fm = FileManager.default + var isDirectory: ObjCBool = false + fm.fileExists(atPath: url.path, isDirectory: &isDirectory) + + guard isDirectory.boolValue else { + return DiskNode( + url: url, + name: url.lastPathComponent, + bytes: FileSweeper.allocatedSize(of: url), + isDirectory: false, + children: [] + ) + } + + guard depth > 0 else { + return DiskNode( + url: url, + name: url.lastPathComponent, + bytes: FileSweeper.allocatedSize(of: url), + isDirectory: true, + children: [] + ) + } + + let childURLs = (try? fm.contentsOfDirectory( + at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] + )) ?? [] + + let children = childURLs + .map { buildTree(at: $0, depth: depth - 1) } + .filter { $0.bytes > 0 } + .sorted { $0.bytes > $1.bytes } + + let total = children.reduce(UInt64(0)) { $0 + $1.bytes } + return DiskNode( + url: url, + name: url.lastPathComponent, + bytes: total, + isDirectory: true, + children: children + ) + } +} From 3532dce47b98979d4ac209c31755c95752a52df9 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:37 -0300 Subject: [PATCH 11/23] feat(clean): revisao heuristica de itens de inicializacao --- .../Services/Clean/ProtectionService.swift | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 ClipFlow/Core/Services/Clean/ProtectionService.swift diff --git a/ClipFlow/Core/Services/Clean/ProtectionService.swift b/ClipFlow/Core/Services/Clean/ProtectionService.swift new file mode 100644 index 0000000..2fb4152 --- /dev/null +++ b/ClipFlow/Core/Services/Clean/ProtectionService.swift @@ -0,0 +1,104 @@ +import Foundation + +/// Locais de execução tipicamente usados por adware. +private let suspiciousPathFragments = [ + "/tmp/", "/private/tmp/", "/var/folders/", "/.hidden", "/downloads/" +] + +/// Prefixos de labels de fornecedores conhecidos (lista básica, não exaustiva). +private let knownVendorPrefixes = [ + "com.apple.", "com.google.", "com.microsoft.", "com.adobe.", + "com.docker.", "com.spotify.", "org.mozilla.", "com.dropbox.", + "com.logi.", "com.jetbrains.", "com.macpaw.", "com.1password." +] + +/// Achado da revisão de proteção. Não é um antivírus: apenas sinaliza +/// itens de inicialização com características típicas de adware para revisão manual. +struct ProtectionFinding: Identifiable { + enum Severity: Int, Comparable { + case info = 0 + case warning = 1 + + static func < (lhs: Severity, rhs: Severity) -> Bool { lhs.rawValue < rhs.rawValue } + } + + let item: StartupItem + let severity: Severity + let reasonPT: String + let reasonEN: String + + var id: URL { item.url } +} + +@MainActor +final class ProtectionService: ObservableObject { + @Published private(set) var findings: [ProtectionFinding] = [] + @Published private(set) var reviewedCount = 0 + @Published private(set) var isScanning = false + + var warningCount: Int { + findings.filter { $0.severity == .warning }.count + } + + func scan(completion: (() -> Void)? = nil) { + guard !isScanning else { return } + isScanning = true + + Task { + let outcome = await Task.detached(priority: .utility) { () -> (findings: [ProtectionFinding], total: Int) in + let items = StartupItemsService.loadStartupItems() + let findings = items.compactMap(Self.evaluate) + return (findings, items.count) + }.value + findings = outcome.findings.sorted { $0.severity > $1.severity } + reviewedCount = outcome.total + isScanning = false + completion?() + } + } + + /// Move o plist sinalizado para a Lixeira (somente agentes do usuário). + func quarantine(_ finding: ProtectionFinding) { + guard finding.item.isRemovable else { return } + _ = FileSweeper.trash(urls: [finding.item.url]) + findings.removeAll { $0.id == finding.id } + } + + // MARK: - Heurísticas + + nonisolated static func evaluate(_ item: StartupItem) -> ProtectionFinding? { + // Binário rodando de local temporário/oculto: sinal clássico de adware. + if let program = item.programPath?.lowercased(), + suspiciousPathFragments.contains(where: { program.contains($0) }) { + return ProtectionFinding( + item: item, + severity: .warning, + reasonPT: "Executável em local temporário ou incomum", + reasonEN: "Executable in a temporary or unusual location" + ) + } + + // Programa referenciado não existe mais: item quebrado. + if let program = item.programPath, !FileManager.default.fileExists(atPath: program) { + return ProtectionFinding( + item: item, + severity: .info, + reasonPT: "Item quebrado: o programa referenciado não existe", + reasonEN: "Broken item: referenced program no longer exists" + ) + } + + // Terceiro desconhecido: apenas informativo, para revisão. + let label = item.label.lowercased() + if !knownVendorPrefixes.contains(where: { label.hasPrefix($0) }) && item.domain != .userAgent { + return ProtectionFinding( + item: item, + severity: .info, + reasonPT: "Item de terceiros fora da lista de fornecedores conhecidos", + reasonEN: "Third-party item not in the known vendor list" + ) + } + + return nil + } +} From 2e225b21833bbd8498c24c612197795e1bea08f9 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:51 -0300 Subject: [PATCH 12/23] feat(clean-ui): shell do CleanFlow com sidebar, temas por modulo e componentes --- ClipFlow/UI/Views/Clean/CleanCenterView.swift | 487 ++++++++++++++++++ 1 file changed, 487 insertions(+) create mode 100644 ClipFlow/UI/Views/Clean/CleanCenterView.swift diff --git a/ClipFlow/UI/Views/Clean/CleanCenterView.swift b/ClipFlow/UI/Views/Clean/CleanCenterView.swift new file mode 100644 index 0000000..32ed0fe --- /dev/null +++ b/ClipFlow/UI/Views/Clean/CleanCenterView.swift @@ -0,0 +1,487 @@ +import AppKit +import SwiftUI + +/// Módulos do centro de limpeza, inspirados no CleanMyMac. +enum CleanModule: String, CaseIterable, Identifiable { + case smartScan + case junk + case speedup + case apps + case duplicates + case largeFiles + case diskMap + case protection + + var id: String { rawValue } + + var systemImage: String { + switch self { + case .smartScan: return "sparkles" + case .junk: return "trash.circle.fill" + case .speedup: return "bolt.fill" + case .apps: return "square.grid.2x2.fill" + case .duplicates: return "folder.fill.badge.questionmark" + case .largeFiles: return "externaldrive.fill" + case .diskMap: return "circle.hexagongrid.fill" + case .protection: return "shield.lefthalf.filled" + } + } + + var tint: Color { + switch self { + case .smartScan: return .purple + case .junk: return .green + case .speedup: return .orange + case .apps: return .blue + case .duplicates: return .teal + case .largeFiles: return .indigo + case .diskMap: return Color(red: 0.72, green: 0.4, blue: 1.0) + case .protection: return .mint + } + } + + /// Gradiente imersivo de fundo, no estilo CleanMyMac. + var backgroundGradient: LinearGradient { + let colors: [Color] + switch self { + case .smartScan: + colors = [Color(red: 0.16, green: 0.09, blue: 0.30), Color(red: 0.05, green: 0.03, blue: 0.12)] + case .junk: + colors = [Color(red: 0.05, green: 0.30, blue: 0.14), Color(red: 0.01, green: 0.10, blue: 0.05)] + case .speedup: + colors = [Color(red: 0.45, green: 0.22, blue: 0.06), Color(red: 0.16, green: 0.07, blue: 0.02)] + case .apps: + colors = [Color(red: 0.12, green: 0.16, blue: 0.50), Color(red: 0.03, green: 0.05, blue: 0.20)] + case .duplicates: + colors = [Color(red: 0.05, green: 0.30, blue: 0.30), Color(red: 0.01, green: 0.12, blue: 0.13)] + case .largeFiles: + colors = [Color(red: 0.16, green: 0.15, blue: 0.45), Color(red: 0.05, green: 0.04, blue: 0.17)] + case .diskMap: + colors = [Color(red: 0.26, green: 0.11, blue: 0.48), Color(red: 0.08, green: 0.02, blue: 0.19)] + case .protection: + colors = [Color(red: 0.03, green: 0.27, blue: 0.22), Color(red: 0.01, green: 0.10, blue: 0.09)] + } + return LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom) + } + + func title(_ t: (String, String) -> String) -> String { + switch self { + case .smartScan: return t("Análise Inteligente", "Smart Scan") + case .junk: return t("Limpeza", "Cleanup") + case .speedup: return t("Desempenho", "Performance") + case .apps: return t("Aplicativos", "Applications") + case .duplicates: return t("Meu Acúmulo", "My Clutter") + case .largeFiles: return t("Grandes e Antigos", "Large & Old") + case .diskMap: return t("Lupa de Espaço", "Space Lens") + case .protection: return t("Proteção", "Protection") + } + } +} + +struct CleanCenterView: View { + @ObservedObject var settings: AppSettings + + @StateObject private var junkService = JunkScanService() + @StateObject private var startupService = StartupItemsService() + @StateObject private var maintenanceService = MaintenanceService() + @StateObject private var appsService = AppInventoryService() + @StateObject private var duplicatesService = DuplicateFinderService() + @StateObject private var similarImagesService = SimilarImagesService() + @StateObject private var largeFilesService = LargeOldFilesService() + @StateObject private var diskMapService = DiskMapService() + @StateObject private var protectionService = ProtectionService() + + @State private var selection: CleanModule = .smartScan + + var body: some View { + HStack(spacing: 0) { + sidebar + detail + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(selection.backgroundGradient.ignoresSafeArea()) + .environment(\.colorScheme, .dark) + .frame(minWidth: 1080, minHeight: 700) + } + + // MARK: - Sidebar (apenas ícones, como no CleanMyMac) + + private var sidebar: some View { + VStack(spacing: 10) { + ForEach(CleanModule.allCases) { module in + sidebarIcon(module) + } + Spacer() + } + .padding(.top, 46) + .frame(width: 52) + .background(Color.black.opacity(0.22)) + } + + private func sidebarIcon(_ module: CleanModule) -> some View { + Button { + withAnimation(.easeInOut(duration: 0.22)) { + selection = module + } + } label: { + Image(systemName: module.systemImage) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(selection == module ? Color.white : Color.white.opacity(0.5)) + .frame(width: 34, height: 34) + .background( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(selection == module ? .white.opacity(0.22) : .clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(module.title(t)) + } + + // MARK: - Detail + + @ViewBuilder + private var detail: some View { + switch selection { + case .smartScan: + SmartScanView( + settings: settings, + junkService: junkService, + protectionService: protectionService, + startupService: startupService, + onOpenModule: { selection = $0 } + ) + case .junk: + JunkCleanView(settings: settings, service: junkService) + case .speedup: + SpeedupView(settings: settings, service: startupService, maintenance: maintenanceService) + case .apps: + AppsManagerView(settings: settings, service: appsService) + case .duplicates: + DuplicatesView(settings: settings, service: duplicatesService, similarService: similarImagesService) + case .largeFiles: + LargeFilesView(settings: settings, service: largeFilesService) + case .diskMap: + DiskMapView(settings: settings, service: diskMapService) + case .protection: + ProtectionView(settings: settings, service: protectionService) + } + } + + private func t(_ pt: String, _ en: String) -> String { settings.text(ptBR: pt, en: en) } +} + +// MARK: - Barra superior dos módulos + +/// Barra do topo: ação à esquerda ("Recomeçar"/"Voltar"), título centralizado. +struct CleanTopBar: View { + let leftIcon: String + let leftTitle: String + let title: String + let leftAction: () -> Void + @ViewBuilder var trailing: Trailing + + init( + leftIcon: String, + leftTitle: String, + title: String, + leftAction: @escaping () -> Void, + @ViewBuilder trailing: () -> Trailing = { EmptyView() } + ) { + self.leftIcon = leftIcon + self.leftTitle = leftTitle + self.title = title + self.leftAction = leftAction + self.trailing = trailing() + } + + var body: some View { + ZStack { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary.opacity(0.85)) + HStack { + Button(action: leftAction) { + Label(leftTitle, systemImage: leftIcon) + .font(.system(size: 12, weight: .medium)) + } + .buttonStyle(.plain) + .foregroundStyle(.primary.opacity(0.75)) + Spacer() + trailing + } + } + .padding(.horizontal, 16) + .frame(height: 40) + } +} + +// MARK: - Componentes da tela hero (fundo colorido) + +/// Headline central grande + botão-pílula, como "There are 80 GB of junk files…". +struct HeroHeadline: View { + let text: String + let pillTitle: String? + let pillAction: (() -> Void)? + + var body: some View { + VStack(spacing: 16) { + Text(text) + .font(.system(size: 25, weight: .bold)) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + if let pillTitle, let pillAction { + Button(pillTitle, action: pillAction) + .buttonStyle(CleanGlassButtonStyle()) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 26) + .padding(.bottom, 8) + } +} + +/// Card da tela hero com ações "Revisar"/"Limpar" no rodapé direito. +struct HeroCard: View { + var minHeight: CGFloat = 150 + var emphasized: Bool = false + let reviewTitle: String + var cleanTitle: String? = nil + let onReview: () -> Void + var onClean: (() -> Void)? = nil + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + content + Spacer(minLength: 6) + HStack(spacing: 8) { + Spacer() + Button(reviewTitle, action: onReview) + .buttonStyle(CleanGlassButtonStyle()) + if let cleanTitle, let onClean { + Button(cleanTitle, action: onClean) + .buttonStyle(CleanWhiteButtonStyle()) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, minHeight: minHeight, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.white.opacity(emphasized ? 0.16 : 0.10)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(.white.opacity(0.10), lineWidth: 1) + ) + ) + } +} + +/// Ícone redondo translúcido usado no canto dos cards hero. +struct HeroCardIcon: View { + let systemImage: String + let tint: Color + + var body: some View { + Image(systemName: systemImage) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 38, height: 38) + .background( + Circle().fill( + LinearGradient( + colors: [tint.opacity(0.9), tint.opacity(0.5)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + ) + } +} + +// MARK: - Superfície clara de "Manager" (como o Cleanup Manager) + +/// Contêiner claro dos gerenciadores: fundo branco, cantos arredondados, +/// esquema claro forçado — contraste com o fundo colorido do módulo. +struct ManagerSurface: View { + @ViewBuilder var content: Content + + var body: some View { + content + .environment(\.colorScheme, .light) + .background(Color(red: 0.98, green: 0.98, blue: 0.99)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(10) + .transition(.opacity) + } +} + +/// Barra inferior clara: resumo centralizado + CTA rosa à direita. +struct ManagerBottomBar: View { + let summary: String + let actionTitle: String + let actionDisabled: Bool + let action: () -> Void + + var body: some View { + ZStack { + Text(summary) + .font(.callout.weight(.medium)) + .foregroundStyle(.secondary) + HStack { + Spacer() + Button(actionTitle, action: action) + .buttonStyle(CleanCTAButtonStyle()) + .disabled(actionDisabled) + .opacity(actionDisabled ? 0.35 : 1) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.white) + .overlay(alignment: .top) { Divider() } + } +} + +/// Linha de categoria da sidebar do manager (destaque lavanda quando ativa). +struct ManagerSidebarRow: View { + let icon: String + let iconTint: Color + let title: String + let badge: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(Circle().fill(iconTint)) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + } + Spacer() + Text(badge) + .font(.caption2.monospacedDigit().weight(.bold)) + .foregroundStyle(isSelected ? .white : .secondary) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + Capsule().fill(isSelected ? Color(red: 0.56, green: 0.35, blue: 0.97) : Color.black.opacity(0.06)) + ) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(isSelected ? Color(red: 0.90, green: 0.86, blue: 0.99) : .clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +// MARK: - Estilos de botão + +/// CTA rosa, como o "Clean Up" do CleanMyMac. +struct CleanCTAButtonStyle: ButtonStyle { + var tint: Color = Color(red: 0.91, green: 0.15, blue: 0.60) + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 18) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(tint.opacity(configuration.isPressed ? 0.7 : 1)) + ) + .shadow(color: tint.opacity(0.35), radius: 8, y: 2) + } +} + +/// Botão translúcido de vidro (como "Review"). +struct CleanGlassButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, 13) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.white.opacity(configuration.isPressed ? 0.32 : 0.18)) + ) + } +} + +/// Botão branco sólido (como o "Clean" dos cards hero). +struct CleanWhiteButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(.black.opacity(0.85)) + .padding(.horizontal, 13) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.white.opacity(configuration.isPressed ? 0.75 : 0.95)) + ) + } +} + +/// Cartão de vidro genérico sobre o gradiente (usos diversos). +struct CleanCard: View { + var prominent: Bool = false + @ViewBuilder var content: Content + + var body: some View { + content + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.white.opacity(prominent ? 0.14 : 0.08)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 1) + ) + ) + } +} + +/// Barra inferior escura (usada fora dos managers claros, ex.: Lupa de Espaço). +struct CleanBottomBar: View { + let summary: String + let actionTitle: String + let actionDisabled: Bool + let action: () -> Void + + var body: some View { + ZStack { + Text(summary) + .font(.callout.weight(.medium)) + .foregroundStyle(.white.opacity(0.8)) + HStack { + Spacer() + Button(actionTitle, action: action) + .buttonStyle(CleanCTAButtonStyle()) + .disabled(actionDisabled) + .opacity(actionDisabled ? 0.35 : 1) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(.black.opacity(0.35)) + } +} From f42191b1127bda0de9aed9f06a61006936d7987a Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:51 -0300 Subject: [PATCH 13/23] feat(clean-ui): analise inteligente com limpeza segura em 1 clique --- ClipFlow/UI/Views/Clean/SmartScanView.swift | 185 ++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 ClipFlow/UI/Views/Clean/SmartScanView.swift diff --git a/ClipFlow/UI/Views/Clean/SmartScanView.swift b/ClipFlow/UI/Views/Clean/SmartScanView.swift new file mode 100644 index 0000000..6df91d4 --- /dev/null +++ b/ClipFlow/UI/Views/Clean/SmartScanView.swift @@ -0,0 +1,185 @@ +import SwiftUI + +/// Análise Inteligente: botão único que roda detritos + proteção + inicialização. +struct SmartScanView: View { + @ObservedObject var settings: AppSettings + @ObservedObject var junkService: JunkScanService + @ObservedObject var protectionService: ProtectionService + @ObservedObject var startupService: StartupItemsService + let onOpenModule: (CleanModule) -> Void + + @State private var hasScanned = false + @State private var confirmsCleanup = false + + private var isScanning: Bool { + junkService.isScanning || protectionService.isScanning || startupService.isLoading + } + + var body: some View { + VStack(spacing: 0) { + CleanTopBar( + leftIcon: "arrow.counterclockwise", + leftTitle: t("Recomeçar", "Start Over"), + title: t("Análise Inteligente", "Smart Scan"), + leftAction: { runScan() } + ) + + ScrollView { + VStack(spacing: 18) { + HeroHeadline( + text: headline, + pillTitle: nil, + pillAction: nil + ) + + scanButton + + if hasScanned && !isScanning { + resultsGrid + .transition(.opacity) + } + } + .padding(.horizontal, 22) + .padding(.bottom, 20) + } + } + .confirmationDialog( + t("Mover os detritos seguros para a Lixeira?", "Move safe junk to the Trash?"), + isPresented: $confirmsCleanup + ) { + Button(t("Limpar itens seguros", "Clean safe items"), role: .destructive) { + junkService.clean(ruleIDs: junkService.safeDefaultRuleIDs) + } + Button(t("Cancelar", "Cancel"), role: .cancel) {} + } + } + + private var headline: String { + if isScanning { + return t("Analisando o seu Mac…", "Scanning your Mac…") + } + if hasScanned { + return t("Análise concluída. Veja os resultados.", "Scan complete. Review your results.") + } + return t("Uma análise para cuidar de tudo:\ndetritos, proteção e inicialização.", + "One scan to care for everything:\njunk, protection and startup.") + } + + private var scanButton: some View { + Button { + runScan() + } label: { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color(red: 0.65, green: 0.35, blue: 1.0), Color(red: 0.35, green: 0.2, blue: 0.85)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 130, height: 130) + .shadow(color: .purple.opacity(0.45), radius: 20, y: 6) + if isScanning { + ProgressView().controlSize(.large).tint(.white) + } else { + VStack(spacing: 4) { + Image(systemName: "sparkles") + .font(.system(size: 32, weight: .bold)) + Text(hasScanned ? t("Reanalisar", "Rescan") : t("Analisar", "Scan")) + .font(.system(size: 14, weight: .semibold)) + } + .foregroundStyle(.white) + } + } + } + .buttonStyle(.plain) + .disabled(isScanning) + .padding(.vertical, 8) + } + + private var resultsGrid: some View { + VStack(spacing: 10) { + resultRow( + module: .junk, + title: t("Detritos encontrados", "Junk found"), + value: CleanFormat.bytes(junkService.totalBytes), + detail: t("\(junkService.rules.count) regras: caches, logs, dev, navegadores e apps", + "\(junkService.rules.count) rules: caches, logs, dev, browsers and apps") + ) + resultRow( + module: .protection, + title: t("Itens para revisar", "Items to review"), + value: "\(protectionService.findings.count)", + detail: protectionService.warningCount > 0 + ? t("\(protectionService.warningCount) com alerta", "\(protectionService.warningCount) flagged") + : t("Nenhum alerta crítico", "No critical flags") + ) + resultRow( + module: .speedup, + title: t("Itens de inicialização", "Startup items"), + value: "\(startupService.items.count)", + detail: t("Agentes e daemons carregados no login", + "Agents and daemons loaded at login") + ) + + if junkService.safeBytes > 0 { + Button { + confirmsCleanup = true + } label: { + Label( + t("Limpar \(CleanFormat.bytes(junkService.safeBytes)) (itens seguros)", + "Clean \(CleanFormat.bytes(junkService.safeBytes)) (safe items)"), + systemImage: "sparkles" + ) + .frame(maxWidth: .infinity) + } + .buttonStyle(CleanCTAButtonStyle(tint: .purple)) + .disabled(junkService.isCleaning) + } + + if let cleanup = junkService.lastCleanup { + Text(t("Última limpeza liberou ", "Last cleanup reclaimed ") + CleanFormat.bytes(cleanup.reclaimedBytes)) + .font(.caption.weight(.medium)) + .foregroundStyle(.green) + } + } + } + + private func resultRow(module: CleanModule, title: String, value: String, detail: String) -> some View { + Button { + onOpenModule(module) + } label: { + CleanCard { + HStack(spacing: 12) { + HeroCardIcon(systemImage: module.systemImage, tint: module.tint) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + Text(detail) + .font(.caption) + .foregroundStyle(.white.opacity(0.6)) + } + Spacer() + Text(value) + .font(.system(size: 16, weight: .bold).monospacedDigit()) + .foregroundStyle(.white) + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.white.opacity(0.4)) + } + } + } + .buttonStyle(.plain) + } + + private func runScan() { + withAnimation(.easeInOut(duration: 0.2)) { hasScanned = true } + junkService.scan() + protectionService.scan() + startupService.refresh() + } + + private func t(_ pt: String, _ en: String) -> String { settings.text(ptBR: pt, en: en) } +} From 2712aa77b12410cee50fa2308a815705c3d85d25 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:51 -0300 Subject: [PATCH 14/23] feat(clean-ui): hero de limpeza e gerenciador claro com selecao por item --- ClipFlow/UI/Views/Clean/JunkCleanView.swift | 440 ++++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 ClipFlow/UI/Views/Clean/JunkCleanView.swift diff --git a/ClipFlow/UI/Views/Clean/JunkCleanView.swift b/ClipFlow/UI/Views/Clean/JunkCleanView.swift new file mode 100644 index 0000000..4ca811d --- /dev/null +++ b/ClipFlow/UI/Views/Clean/JunkCleanView.swift @@ -0,0 +1,440 @@ +import SwiftUI + +/// Limpeza fiel ao CleanMyMac: hero verde com cards por seção e +/// Gerenciador de Limpeza claro (master-detail) com seleção por item. +struct JunkCleanView: View { + @ObservedObject var settings: AppSettings + @ObservedObject var service: JunkScanService + + private enum Screen { + case hero + case review + } + + @State private var screen: Screen = .hero + @State private var focusedRuleID: String? + /// Seleção por item, agrupada por regra. + @State private var selectedItems: [String: Set] = [:] + @State private var appliedDefaultSelection = false + @State private var confirmsCleanup = false + @State private var pendingClean: [String: Set] = [:] + + var body: some View { + VStack(spacing: 0) { + switch screen { + case .hero: hero + case .review: reviewManager + } + } + .onAppear { + if service.results.isEmpty { service.scan { applyDefaultSelection() } } + else if !appliedDefaultSelection { applyDefaultSelection() } + } + .confirmationDialog(confirmationMessage, isPresented: $confirmsCleanup) { + Button(t("Limpar", "Clean Up"), role: .destructive) { + service.clean(itemsByRule: pendingClean) { applyDefaultSelection() } + } + Button(t("Cancelar", "Cancel"), role: .cancel) {} + } + } + + // MARK: - Hero + + private var hero: some View { + VStack(spacing: 0) { + CleanTopBar( + leftIcon: "arrow.counterclockwise", + leftTitle: t("Recomeçar", "Start Over"), + title: t("Limpeza", "Cleanup"), + leftAction: { service.scan { applyDefaultSelection() } } + ) + + ScrollView { + VStack(spacing: 18) { + HeroHeadline( + text: heroHeadline, + pillTitle: service.isScanning ? nil : t("Revisar Todos os Detritos", "Review All Junk"), + pillAction: { openReview(rule: nil) } + ) + + if service.isScanning { + ProgressView().controlSize(.large).tint(.white).padding(.top, 30) + } else if service.totalBytes > 0 { + heroGrid + } + + if let cleanup = service.lastCleanup { + Label( + t("Última limpeza liberou ", "Last cleanup reclaimed ") + + CleanFormat.bytes(cleanup.reclaimedBytes), + systemImage: "checkmark.circle.fill" + ) + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + } + } + .padding(.horizontal, 22) + .padding(.bottom, 20) + } + } + } + + private var heroHeadline: String { + if service.isScanning { + return t("Analisando o seu Mac…", "Scanning your Mac…") + } + if service.totalBytes == 0 { + return t("Seu Mac está livre de detritos. Muito bem!", + "Your Mac is free of junk. Nice work!") + } + return t("Há \(CleanFormat.bytes(service.totalBytes)) de detritos no seu Mac.", + "There are \(CleanFormat.bytes(service.totalBytes)) of junk files on your Mac.") + } + + /// Card grande do Sistema à esquerda + grade 2x2 das demais seções. + private var heroGrid: some View { + HStack(alignment: .top, spacing: 14) { + systemCard + .frame(width: 300) + + LazyVGrid( + columns: [GridItem(.flexible(), spacing: 14), GridItem(.flexible(), spacing: 14)], + spacing: 14 + ) { + ForEach(secondarySections) { section in + sectionCard(section) + } + } + } + } + + private var secondarySections: [CleanupSection] { + CleanupSection.allCases.filter { $0 != .system && service.bytes(in: $0) > 0 } + } + + private var systemCard: some View { + HeroCard( + minHeight: 314, + emphasized: true, + reviewTitle: t("Revisar", "Review"), + cleanTitle: service.safeBytes(in: .system) > 0 ? t("Limpar", "Clean") : nil, + onReview: { openReview(section: .system) }, + onClean: { requestSectionClean(.system) } + ) { + VStack(alignment: .leading, spacing: 8) { + Text(t("\(CleanFormat.bytes(service.bytes(in: .system))) de Detritos do Sistema", + "\(CleanFormat.bytes(service.bytes(in: .system))) of System Junk Found")) + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(.white) + Text(t("Limpe os arquivos desnecessários gerados pelo sistema e pelos seus aplicativos.", + "Clean up all of the unneeded files generated by your system and applications.")) + .font(.caption) + .foregroundStyle(.white.opacity(0.65)) + + Spacer(minLength: 10) + + HStack(spacing: 14) { + Spacer() + ForEach(["clock.badge.checkmark", "person.crop.circle.badge.checkmark", "doc.badge.gearshape"], id: \.self) { icon in + Image(systemName: icon) + .font(.system(size: 25, weight: .medium)) + .foregroundStyle(.white.opacity(0.85)) + .frame(width: 52, height: 52) + .background(Circle().fill(.white.opacity(0.12))) + } + Spacer() + } + .padding(.bottom, 10) + } + } + } + + private func sectionCard(_ section: CleanupSection) -> some View { + HeroCard( + minHeight: 150, + reviewTitle: t("Revisar", "Review"), + cleanTitle: service.safeBytes(in: section) > 0 ? t("Limpar", "Clean") : nil, + onReview: { openReview(section: section) }, + onClean: { requestSectionClean(section) } + ) { + HStack(alignment: .top) { + Text(t("\(CleanFormat.bytes(service.bytes(in: section))) de \(section.title(t))", + "\(CleanFormat.bytes(service.bytes(in: section))) of \(section.title(t)) Found")) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) + Spacer() + HeroCardIcon(systemImage: section.systemImage, tint: sectionTint(section)) + } + } + } + + private func sectionTint(_ section: CleanupSection) -> Color { + switch section { + case .system: return .green + case .browsers: return .blue + case .developer: return .indigo + case .apps: return .teal + case .bigItems: return .orange + } + } + + // MARK: - Gerenciador de Limpeza (claro) + + private var reviewManager: some View { + ManagerSurface { + VStack(spacing: 0) { + CleanTopBar( + leftIcon: "chevron.left", + leftTitle: t("Voltar", "Back"), + title: t("Gerenciador de Limpeza", "Cleanup Manager"), + leftAction: { withAnimation(.easeInOut(duration: 0.2)) { screen = .hero } } + ) + + Divider() + + HStack(spacing: 0) { + managerSidebar + .frame(width: 270) + Divider() + managerDetail + .frame(maxWidth: .infinity) + } + + ManagerBottomBar( + summary: selectionSummary, + actionTitle: t("Limpar", "Clean Up"), + actionDisabled: totalSelectedCount == 0 || service.isCleaning || service.isScanning, + action: { + pendingClean = selectedItems + confirmsCleanup = true + } + ) + } + } + } + + private var managerSidebar: some View { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 3) { + Text(t("Detritos do Mac", "Mac Junk")) + .font(.system(size: 15, weight: .bold)) + Text(t("Arquivos redundantes que ocupam espaço e atrapalham o desempenho.", + "Redundant files that clog up storage and impede performance.")) + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.top, 12) + .padding(.bottom, 6) + + ForEach(CleanupSection.allCases) { section in + let rules = visibleRules(in: section) + if !rules.isEmpty { + Text(section.title(t).uppercased()) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.top, 10) + ForEach(rules) { rule in + ManagerSidebarRow( + icon: rule.systemImage, + iconTint: rule.safety == .safe ? .green : .orange, + title: settings.text(ptBR: rule.titlePT, en: rule.titleEN), + badge: CleanFormat.bytes(service.bytes(forRule: rule.id)), + isSelected: focusedRuleID == rule.id, + action: { focusedRuleID = rule.id } + ) + .padding(.horizontal, 6) + } + } + } + } + .padding(.bottom, 12) + } + } + + @ViewBuilder + private var managerDetail: some View { + if let ruleID = focusedRuleID, + let rule = service.rule(withID: ruleID), + let result = service.results[ruleID] { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(settings.text(ptBR: rule.titlePT, en: rule.titleEN)) + .font(.system(size: 16, weight: .bold)) + safetyBadge(rule.safety) + } + Text(settings.text(ptBR: rule.detailPT, en: rule.detailEN)) + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 4) { + Text(t("Selecionar:", "Select:")) + .font(.caption) + .foregroundStyle(.secondary) + Button(t("Tudo", "All")) { + selectedItems[ruleID] = Set(result.items.map(\.url)) + } + .buttonStyle(.plain) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color(red: 0.91, green: 0.15, blue: 0.60)) + Text("·").foregroundStyle(.secondary) + Button(t("Nada", "None")) { + selectedItems[ruleID] = [] + } + .buttonStyle(.plain) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color(red: 0.91, green: 0.15, blue: 0.60)) + } + .padding(.top, 4) + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + + ScrollView { + LazyVStack(spacing: 2) { + ForEach(result.items) { item in + managerItemRow(item, rule: rule) + } + } + .padding(.horizontal, 12) + .padding(.bottom, 12) + } + } + } else { + VStack(spacing: 8) { + Image(systemName: "tray") + .font(.system(size: 32)) + .foregroundStyle(.secondary) + Text(t("Selecione uma categoria à esquerda.", "Select a category on the left.")) + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func managerItemRow(_ item: CleanFileItem, rule: CleanupRule) -> some View { + HStack(spacing: 10) { + Toggle("", isOn: itemBinding(rule.id, item.url)) + .toggleStyle(.checkbox) + .labelsHidden() + Image(systemName: "folder.fill") + .font(.system(size: 13)) + .foregroundStyle(Color(red: 0.35, green: 0.65, blue: 0.95)) + Text(item.name) + .font(.system(size: 12)) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(CleanFormat.bytes(item.bytes)) + .font(.system(size: 11).monospacedDigit()) + .foregroundStyle(.secondary) + Button { + FileSweeper.revealInFinder(item.url) + } label: { + Image(systemName: "magnifyingglass").font(.system(size: 10)) + } + .buttonStyle(.borderless) + .help(t("Mostrar no Finder", "Reveal in Finder")) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(isItemSelected(rule.id, item.url) ? Color(red: 0.95, green: 0.93, blue: 0.99) : .clear) + ) + } + + private func safetyBadge(_ safety: CleanupSafety) -> some View { + Text(safety == .safe ? t("Seguro", "Safe") : t("Revisar", "Review")) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(Capsule().fill(safety == .safe ? Color.green.opacity(0.15) : Color.orange.opacity(0.15))) + .foregroundStyle(safety == .safe ? Color.green : Color.orange) + } + + // MARK: - Navegação e seleção + + private func openReview(rule: CleanupRule? = nil, section: CleanupSection? = nil) { + if let rule { + focusedRuleID = rule.id + } else if let section { + focusedRuleID = visibleRules(in: section).first?.id + } else { + focusedRuleID = service.rules.first { service.bytes(forRule: $0.id) > 0 }?.id + } + withAnimation(.easeInOut(duration: 0.2)) { screen = .review } + } + + private func requestSectionClean(_ section: CleanupSection) { + var pending: [String: Set] = [:] + for id in service.safeRuleIDs(in: section) { + pending[id] = Set((service.results[id]?.items ?? []).map(\.url)) + } + pendingClean = pending + confirmsCleanup = true + } + + private func visibleRules(in section: CleanupSection) -> [CleanupRule] { + service.rules.filter { $0.section == section && service.bytes(forRule: $0.id) > 0 } + } + + /// Pré-seleciona todos os itens das regras seguras. + private func applyDefaultSelection() { + var defaults: [String: Set] = [:] + for id in service.safeDefaultRuleIDs { + defaults[id] = Set((service.results[id]?.items ?? []).map(\.url)) + } + selectedItems = defaults + appliedDefaultSelection = true + } + + private func isItemSelected(_ ruleID: String, _ url: URL) -> Bool { + selectedItems[ruleID]?.contains(url) ?? false + } + + private func itemBinding(_ ruleID: String, _ url: URL) -> Binding { + Binding( + get: { selectedItems[ruleID]?.contains(url) ?? false }, + set: { isOn in + var set = selectedItems[ruleID] ?? [] + if isOn { set.insert(url) } else { set.remove(url) } + selectedItems[ruleID] = set + } + ) + } + + private var totalSelectedCount: Int { + selectedItems.values.reduce(0) { $0 + $1.count } + } + + private var totalSelectedBytes: UInt64 { + var total: UInt64 = 0 + for (ruleID, urls) in selectedItems { + guard let items = service.results[ruleID]?.items else { continue } + total += items.filter { urls.contains($0.url) }.reduce(0) { $0 + $1.bytes } + } + return total + } + + private var selectionSummary: String { + t("\(totalSelectedCount) Itens Selecionados", "\(totalSelectedCount) Items Selected") + + " | " + CleanFormat.bytes(totalSelectedBytes) + } + + private var confirmationMessage: String { + let hasPermanent = pendingClean.keys.contains { service.rule(withID: $0)?.deletesPermanently == true } + return hasPermanent + ? t("Itens da Lixeira serão apagados em definitivo. Os demais vão para a Lixeira. Continuar?", + "Trash items will be permanently deleted. Everything else moves to the Trash. Continue?") + : t("Os itens selecionados serão movidos para a Lixeira. Continuar?", + "Selected items will be moved to the Trash. Continue?") + } + + private func t(_ pt: String, _ en: String) -> String { settings.text(ptBR: pt, en: en) } +} From bcab89c647727555a7c999063f280dd7a2f3d2e7 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:51 -0300 Subject: [PATCH 15/23] feat(clean-ui): desempenho com tarefas, inicializacao e processos --- ClipFlow/UI/Views/Clean/SpeedupView.swift | 470 ++++++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 ClipFlow/UI/Views/Clean/SpeedupView.swift diff --git a/ClipFlow/UI/Views/Clean/SpeedupView.swift b/ClipFlow/UI/Views/Clean/SpeedupView.swift new file mode 100644 index 0000000..ac34209 --- /dev/null +++ b/ClipFlow/UI/Views/Clean/SpeedupView.swift @@ -0,0 +1,470 @@ +import SwiftUI + +/// Desempenho fiel ao CleanMyMac: hero laranja com recomendações e +/// Gerenciador de Desempenho claro (tarefas, inicialização, processos). +struct SpeedupView: View { + @ObservedObject var settings: AppSettings + @ObservedObject var service: StartupItemsService + @ObservedObject var maintenance: MaintenanceService + + private enum Screen { + case hero + case manager + } + + private enum ManagerCategory: String, CaseIterable, Identifiable { + case tasks + case startup + case processes + var id: String { rawValue } + } + + @State private var screen: Screen = .hero + @State private var category: ManagerCategory = .tasks + @State private var selectedTasks: Set = [] + @State private var appliedDefaultTasks = false + @State private var itemPendingRemoval: StartupItem? + + var body: some View { + VStack(spacing: 0) { + switch screen { + case .hero: hero + case .manager: manager + } + } + .onAppear { + if !appliedDefaultTasks { + selectedTasks = Set(maintenance.tasks.map(\.id)) + appliedDefaultTasks = true + } + if service.items.isEmpty { service.refresh() } + } + .confirmationDialog( + t("Mover o item de inicialização para a Lixeira? Ele deixa de carregar no próximo login.", + "Move this startup item to the Trash? It stops loading at next login."), + isPresented: Binding( + get: { itemPendingRemoval != nil }, + set: { if !$0 { itemPendingRemoval = nil } } + ) + ) { + Button(t("Remover", "Remove"), role: .destructive) { + if let item = itemPendingRemoval { service.remove(item) } + itemPendingRemoval = nil + } + Button(t("Cancelar", "Cancel"), role: .cancel) { itemPendingRemoval = nil } + } + } + + // MARK: - Hero + + private var hero: some View { + VStack(spacing: 0) { + CleanTopBar( + leftIcon: "arrow.counterclockwise", + leftTitle: t("Recomeçar", "Start Over"), + title: t("Desempenho", "Performance"), + leftAction: { service.refresh() } + ) + + ScrollView { + VStack(spacing: 18) { + HeroHeadline( + text: t("Aplique recomendações selecionadas\nou execute tarefas de desempenho.", + "Apply curated recommendations\nor run performance tasks manually."), + pillTitle: t("Ver Todas as Tarefas", "View All Tasks"), + pillAction: { openManager(.tasks) } + ) + + HStack(alignment: .top, spacing: 14) { + tasksCard + .frame(width: 300) + VStack(spacing: 14) { + startupHeroCard + processesHeroCard + } + } + + if let pt = maintenance.lastOutcomePT, let en = maintenance.lastOutcomeEN { + Label(settings.text(ptBR: pt, en: en), systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.white.opacity(0.7)) + } + } + .padding(.horizontal, 22) + .padding(.bottom, 20) + } + } + } + + private var tasksCard: some View { + HeroCard( + minHeight: 314, + emphasized: true, + reviewTitle: t("Revisar", "Review"), + cleanTitle: t("Executar", "Run Tasks"), + onReview: { openManager(.tasks) }, + onClean: { maintenance.run(taskIDs: selectedTasks) } + ) { + VStack(alignment: .leading, spacing: 8) { + Text(t("\(maintenance.tasks.count) Tarefas de Manutenção Recomendadas", + "\(maintenance.tasks.count) Maintenance Tasks Recommended")) + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(.white) + Text(t("Seu coquetel semanal de manutenção está pronto. Execute as tarefas para manter o Mac em forma.", + "Your weekly maintenance cocktail is ready to be served! Run these tasks to keep your Mac in shape.")) + .font(.caption) + .foregroundStyle(.white.opacity(0.65)) + + Spacer(minLength: 10) + + HStack(spacing: 14) { + Spacer() + ForEach(Array(maintenance.tasks.prefix(3))) { task in + Image(systemName: task.systemImage) + .font(.system(size: 25, weight: .medium)) + .foregroundStyle(.white.opacity(0.9)) + .frame(width: 52, height: 52) + .background(Circle().fill(.orange.opacity(0.35))) + } + Spacer() + } + .padding(.bottom, 10) + + if maintenance.isRunning { + HStack { + Spacer() + ProgressView().controlSize(.small).tint(.white) + Spacer() + } + } + } + } + } + + private var startupHeroCard: some View { + HeroCard( + minHeight: 150, + reviewTitle: t("Revisar", "Review"), + onReview: { openManager(.startup) } + ) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(t("Você tem \(service.items.count) itens de inicialização", + "You have \(service.items.count) startup items")) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.white) + Text(t("Revise os agentes que abrem automaticamente ao ligar o Mac.", + "Review the agents that open automatically when you start up your Mac.")) + .font(.caption) + .foregroundStyle(.white.opacity(0.6)) + } + Spacer() + HeroCardIcon(systemImage: "power", tint: .red) + } + } + } + + private var processesHeroCard: some View { + HeroCard( + minHeight: 150, + reviewTitle: t("Revisar", "Review"), + onReview: { openManager(.processes) } + ) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(t("\(service.processes.count) processos em atividade", + "\(service.processes.count) background items found")) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.white) + Text(t("Veja o que está consumindo CPU e memória agora.", + "See what's consuming CPU and memory right now.")) + .font(.caption) + .foregroundStyle(.white.opacity(0.6)) + } + Spacer() + HeroCardIcon(systemImage: "gauge.with.needle", tint: .orange) + } + } + } + + // MARK: - Gerenciador de Desempenho (claro) + + private var manager: some View { + ManagerSurface { + VStack(spacing: 0) { + CleanTopBar( + leftIcon: "chevron.left", + leftTitle: t("Voltar", "Back"), + title: t("Gerenciador de Desempenho", "Performance Manager"), + leftAction: { withAnimation(.easeInOut(duration: 0.2)) { screen = .hero } } + ) + + Divider() + + HStack(spacing: 0) { + managerSidebar + .frame(width: 250) + Divider() + managerDetail + .frame(maxWidth: .infinity) + } + + ManagerBottomBar( + summary: managerSummary, + actionTitle: t("Executar", "Run"), + actionDisabled: category != .tasks || selectedTasks.isEmpty || maintenance.isRunning, + action: { maintenance.run(taskIDs: selectedTasks) } + ) + } + } + } + + private var managerSidebar: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(ManagerCategory.allCases) { value in + ManagerSidebarRow( + icon: categoryIcon(value), + iconTint: .orange, + title: categoryTitle(value), + badge: categoryBadge(value), + isSelected: category == value, + action: { category = value } + ) + .padding(.horizontal, 6) + } + Spacer() + } + .padding(.top, 12) + } + + @ViewBuilder + private var managerDetail: some View { + switch category { + case .tasks: tasksDetail + case .startup: startupDetail + case .processes: processesDetail + } + } + + private var tasksDetail: some View { + VStack(alignment: .leading, spacing: 0) { + detailHeader( + title: t("Tarefas de Manutenção", "Maintenance Tasks"), + subtitle: t("O macOS se cuida bem, mas sempre dá para ir além. Selecione e execute as tarefas recomendadas.", + "macOS does a pretty good job of self-care, but there's always room for more.") + ) + ScrollView { + VStack(spacing: 4) { + ForEach(maintenance.tasks) { task in + HStack(spacing: 10) { + Toggle("", isOn: taskBinding(task.id)) + .toggleStyle(.checkbox) + .labelsHidden() + Image(systemName: task.systemImage) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 30, height: 30) + .background(RoundedRectangle(cornerRadius: 8).fill(.orange)) + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text(settings.text(ptBR: task.titlePT, en: task.titleEN)) + .font(.system(size: 12.5, weight: .semibold)) + if task.needsAdmin { + Text(t("senha", "password")) + .font(.system(size: 9, weight: .medium)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(Color.black.opacity(0.06))) + .foregroundStyle(.secondary) + } + } + Text(settings.text(ptBR: task.detailPT, en: task.detailEN)) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + } + + if maintenance.isRunning { + ProgressView(t("Executando…", "Running…")).controlSize(.small) + } + } + .padding(.horizontal, 10) + .padding(.bottom, 12) + } + } + } + + private var startupDetail: some View { + VStack(alignment: .leading, spacing: 0) { + detailHeader( + title: t("Itens de Inicialização", "Login Items"), + subtitle: t("Agentes e daemons carregados no login. Itens do usuário podem ser removidos.", + "Agents and daemons loaded at login. User items can be removed.") + ) + ScrollView { + VStack(spacing: 2) { + ForEach(service.items) { item in + HStack(spacing: 10) { + Image(systemName: domainIcon(item.domain)) + .font(.system(size: 12)) + .foregroundStyle(.orange) + .frame(width: 22) + VStack(alignment: .leading, spacing: 1) { + Text(item.label) + .font(.system(size: 12, weight: .medium)) + if let program = item.programPath { + Text(program) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + Spacer() + Text(domainLabel(item.domain)) + .font(.system(size: 10, weight: .medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(.orange.opacity(0.14))) + .foregroundStyle(.orange) + Button { + FileSweeper.revealInFinder(item.url) + } label: { + Image(systemName: "magnifyingglass").font(.system(size: 10)) + } + .buttonStyle(.borderless) + if item.isRemovable { + Button(role: .destructive) { + itemPendingRemoval = item + } label: { + Image(systemName: "trash").font(.system(size: 10)) + } + .buttonStyle(.borderless) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + } + } + .padding(.horizontal, 10) + .padding(.bottom, 12) + } + } + } + + private var processesDetail: some View { + VStack(alignment: .leading, spacing: 0) { + detailHeader( + title: t("Processos Pesados", "Heavy Processes"), + subtitle: t("Os maiores consumidores de CPU e memória. Para encerrar, use o Monitor de Atividade.", + "Top CPU and memory consumers. Use Activity Monitor to quit them.") + ) + ScrollView { + VStack(spacing: 2) { + ForEach(service.processes) { process in + HStack { + Text(process.name) + .font(.system(size: 12)) + .lineLimit(1) + Spacer() + Text(String(format: "CPU %.1f%%", process.cpuPercent)) + .font(.system(size: 11).monospacedDigit()) + .foregroundStyle(process.cpuPercent > 50 ? .orange : .secondary) + .frame(width: 86, alignment: .trailing) + Text(String(format: "RAM %.1f%%", process.memoryPercent)) + .font(.system(size: 11).monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 86, alignment: .trailing) + } + .padding(.horizontal, 12) + .padding(.vertical, 5) + } + } + .padding(.horizontal, 10) + .padding(.bottom, 12) + } + } + } + + private func detailHeader(title: String, subtitle: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.system(size: 16, weight: .bold)) + Text(subtitle).font(.caption).foregroundStyle(.secondary) + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + } + + // MARK: - Helpers + + private func openManager(_ value: ManagerCategory) { + category = value + withAnimation(.easeInOut(duration: 0.2)) { screen = .manager } + } + + private var managerSummary: String { + switch category { + case .tasks: + return t("\(selectedTasks.count) Tarefas Selecionadas", "\(selectedTasks.count) Tasks Selected") + case .startup: + return t("\(service.items.count) itens de inicialização", "\(service.items.count) login items") + case .processes: + return t("\(service.processes.count) processos", "\(service.processes.count) processes") + } + } + + private func categoryTitle(_ value: ManagerCategory) -> String { + switch value { + case .tasks: return t("Tarefas de Manutenção", "Maintenance Tasks") + case .startup: return t("Inicialização", "Login Items") + case .processes: return t("Processos", "Processes") + } + } + + private func categoryIcon(_ value: ManagerCategory) -> String { + switch value { + case .tasks: return "wrench.and.screwdriver.fill" + case .startup: return "power" + case .processes: return "cpu" + } + } + + private func categoryBadge(_ value: ManagerCategory) -> String { + switch value { + case .tasks: return "\(maintenance.tasks.count)" + case .startup: return "\(service.items.count)" + case .processes: return "\(service.processes.count)" + } + } + + private func taskBinding(_ id: String) -> Binding { + Binding( + get: { selectedTasks.contains(id) }, + set: { isOn in + if isOn { selectedTasks.insert(id) } else { selectedTasks.remove(id) } + } + ) + } + + private func domainIcon(_ domain: StartupItem.Domain) -> String { + switch domain { + case .userAgent: return "person" + case .globalAgent: return "person.2" + case .globalDaemon: return "gearshape.2" + } + } + + private func domainLabel(_ domain: StartupItem.Domain) -> String { + switch domain { + case .userAgent: return t("Usuário", "User") + case .globalAgent: return t("Global", "Global") + case .globalDaemon: return "Daemon" + } + } + + private func t(_ pt: String, _ en: String) -> String { settings.text(ptBR: pt, en: en) } +} From c747dd9c85feab3fa66b643e7a2da9caa7115044 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:51 -0300 Subject: [PATCH 16/23] feat(clean-ui): desinstalador de apps com revisao de sobras --- ClipFlow/UI/Views/Clean/AppsManagerView.swift | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 ClipFlow/UI/Views/Clean/AppsManagerView.swift diff --git a/ClipFlow/UI/Views/Clean/AppsManagerView.swift b/ClipFlow/UI/Views/Clean/AppsManagerView.swift new file mode 100644 index 0000000..4aa94c4 --- /dev/null +++ b/ClipFlow/UI/Views/Clean/AppsManagerView.swift @@ -0,0 +1,323 @@ +import AppKit +import SwiftUI + +/// Aplicativos fiel ao CleanMyMac: hero azul com Scan e gerenciador claro +/// com desinstalação + sobras. +struct AppsManagerView: View { + @ObservedObject var settings: AppSettings + @ObservedObject var service: AppInventoryService + + private enum Screen { + case hero + case manager + } + + @State private var screen: Screen = .hero + @State private var search = "" + @State private var selectedLeftovers: Set = [] + + var body: some View { + VStack(spacing: 0) { + switch screen { + case .hero: hero + case .manager: manager + } + } + .sheet(isPresented: Binding( + get: { service.leftovers != nil }, + set: { if !$0 { service.dismissLeftovers() } } + )) { + if let leftovers = service.leftovers { + leftoversSheet(leftovers) + } + } + } + + // MARK: - Hero + + private var hero: some View { + VStack(spacing: 0) { + CleanTopBar( + leftIcon: "arrow.counterclockwise", + leftTitle: t("Recomeçar", "Start Over"), + title: t("Aplicativos", "Applications"), + leftAction: {} + ) + + Spacer() + + HStack(spacing: 40) { + appHexIcon + + VStack(alignment: .leading, spacing: 14) { + Text(t("Aplicativos", "Applications")) + .font(.system(size: 32, weight: .bold)) + .foregroundStyle(.white) + Text(t("Assuma o controle dos seus aplicativos. Desinstale,\nveja tamanhos e remova sobras antigas.", + "Take control of your applications. Uninstall,\nsee sizes or remove old application leftovers.")) + .font(.callout) + .foregroundStyle(.white.opacity(0.7)) + + VStack(alignment: .leading, spacing: 10) { + featureRow(icon: "xmark.circle.fill", text: t("Desinstalador de Apps", "App Uninstaller")) + featureRow(icon: "internaldrive.fill", text: t("Tamanho real de cada app", "True size of every app")) + featureRow(icon: "doc.badge.gearshape.fill", text: t("Sobras de Arquivos", "File Leftovers")) + } + .padding(.top, 6) + } + } + .frame(maxWidth: .infinity) + + Spacer() + + scanButton + .padding(.bottom, 34) + } + .onAppear { + if !service.apps.isEmpty { screen = .manager } + } + } + + private var appHexIcon: some View { + ZStack { + RoundedRectangle(cornerRadius: 38, style: .continuous) + .fill( + LinearGradient( + colors: [Color(red: 0.25, green: 0.35, blue: 0.95), Color(red: 0.10, green: 0.14, blue: 0.55)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 170, height: 170) + .shadow(color: .blue.opacity(0.5), radius: 26, y: 8) + Image(systemName: "square.grid.2x2.fill") + .font(.system(size: 62, weight: .bold)) + .foregroundStyle(.white.opacity(0.92)) + } + } + + private func featureRow(icon: String, text: String) -> some View { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.cyan) + .frame(width: 26, height: 26) + .background(RoundedRectangle(cornerRadius: 7).fill(.white.opacity(0.12))) + Text(text) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white) + } + } + + private var scanButton: some View { + Button { + service.refresh() + withAnimation(.easeInOut(duration: 0.2)) { screen = .manager } + } label: { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color(red: 0.35, green: 0.75, blue: 1.0), Color(red: 0.15, green: 0.45, blue: 0.95)], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: 84, height: 84) + .shadow(color: .cyan.opacity(0.5), radius: 16, y: 4) + Text(t("Analisar", "Scan")) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + } + } + .buttonStyle(.plain) + } + + // MARK: - Manager (claro) + + private var manager: some View { + ManagerSurface { + VStack(spacing: 0) { + CleanTopBar( + leftIcon: "chevron.left", + leftTitle: t("Voltar", "Back"), + title: t("Gerenciador de Aplicativos", "Applications Manager"), + leftAction: { withAnimation(.easeInOut(duration: 0.2)) { screen = .hero } } + ) { + TextField(t("Buscar…", "Search…"), text: $search) + .textFieldStyle(.roundedBorder) + .frame(width: 180) + } + + Divider() + + if service.isLoading { + Spacer() + ProgressView(t("Calculando tamanhos…", "Calculating sizes…")) + Spacer() + } else { + ScrollView { + LazyVStack(spacing: 2) { + ForEach(filteredApps) { app in + appRow(app) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + } + + if let error = service.errorMessage { + Text(error).font(.caption).foregroundStyle(.orange).padding(.bottom, 4) + } + + ManagerBottomBar( + summary: "\(filteredApps.count) apps | " + CleanFormat.bytes(totalBytes), + actionTitle: t("Atualizar", "Refresh"), + actionDisabled: service.isLoading, + action: { service.refresh() } + ) + } + } + } + + private var filteredApps: [InstalledApp] { + guard !search.isEmpty else { return service.apps } + return service.apps.filter { $0.name.localizedCaseInsensitiveContains(search) } + } + + private var totalBytes: UInt64 { + filteredApps.reduce(0) { $0 + $1.bytes } + } + + private func appRow(_ app: InstalledApp) -> some View { + HStack(spacing: 10) { + Image(nsImage: app.icon) + .resizable() + .frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 1) { + Text(app.name).font(.system(size: 12.5, weight: .semibold)) + HStack(spacing: 6) { + if let version = app.version { Text("v\(version)") } + if let bundleID = app.bundleIdentifier { + Text(bundleID).lineLimit(1).truncationMode(.middle) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + Text(CleanFormat.bytes(app.bytes)) + .font(.system(size: 12, weight: .bold).monospacedDigit()) + .foregroundStyle(Color(red: 0.2, green: 0.45, blue: 0.95)) + Button { + FileSweeper.revealInFinder(app.url) + } label: { + Image(systemName: "magnifyingglass").font(.system(size: 10)) + } + .buttonStyle(.borderless) + Button(role: .destructive) { + selectedLeftovers = [] + service.findLeftovers(for: app) + } label: { + Text(t("Desinstalar", "Uninstall")).font(.system(size: 11, weight: .medium)) + } + .buttonStyle(.bordered) + .disabled(isProtected(app)) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 9).fill(Color.black.opacity(0.025))) + } + + /// Evita que o usuário remova o próprio ClipFlow sem querer. + private func isProtected(_ app: InstalledApp) -> Bool { + app.bundleIdentifier?.lowercased().contains("clipflow") == true + } + + // MARK: - Sheet de sobras + + private func leftoversSheet(_ leftovers: AppLeftovers) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 10) { + Image(nsImage: leftovers.app.icon) + .resizable() + .frame(width: 36, height: 36) + VStack(alignment: .leading, spacing: 2) { + Text(t("Desinstalar ", "Uninstall ") + leftovers.app.name) + .font(.headline) + Text(t("O app e as sobras marcadas vão para a Lixeira.", + "The app and checked leftovers move to the Trash.")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if leftovers.items.isEmpty { + Text(t("Nenhuma sobra encontrada.", "No leftovers found.")) + .font(.callout) + .foregroundStyle(.secondary) + } else { + ScrollView { + VStack(spacing: 6) { + ForEach(leftovers.items) { item in + HStack(spacing: 8) { + Toggle("", isOn: leftoverBinding(item.url)) + .toggleStyle(.checkbox) + .labelsHidden() + VStack(alignment: .leading, spacing: 1) { + Text(item.name).font(.caption.weight(.medium)) + Text(item.path) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + Text(CleanFormat.bytes(item.bytes)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + } + .frame(maxHeight: 240) + } + + HStack { + Button(t("Cancelar", "Cancel")) { + service.dismissLeftovers() + } + Spacer() + Button { + service.uninstall(app: leftovers.app, leftoverURLs: Array(selectedLeftovers)) + } label: { + if service.isUninstalling { + ProgressView().controlSize(.small) + } else { + Text(t("Mover para a Lixeira", "Move to Trash")) + } + } + .buttonStyle(CleanCTAButtonStyle()) + .disabled(service.isUninstalling) + } + } + .padding(20) + .frame(width: 480) + .environment(\.colorScheme, .light) + .onAppear { + selectedLeftovers = Set(leftovers.items.map(\.url)) + } + } + + private func leftoverBinding(_ url: URL) -> Binding { + Binding( + get: { selectedLeftovers.contains(url) }, + set: { isOn in + if isOn { selectedLeftovers.insert(url) } else { selectedLeftovers.remove(url) } + } + ) + } + + private func t(_ pt: String, _ en: String) -> String { settings.text(ptBR: pt, en: en) } +} From bc8738340a2fdcd7ba12509f5b921d46c46ade6b Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:51 -0300 Subject: [PATCH 17/23] feat(clean-ui): meu acumulo com duplicatas e imagens similares --- ClipFlow/UI/Views/Clean/DuplicatesView.swift | 461 +++++++++++++++++++ 1 file changed, 461 insertions(+) create mode 100644 ClipFlow/UI/Views/Clean/DuplicatesView.swift diff --git a/ClipFlow/UI/Views/Clean/DuplicatesView.swift b/ClipFlow/UI/Views/Clean/DuplicatesView.swift new file mode 100644 index 0000000..f57fd31 --- /dev/null +++ b/ClipFlow/UI/Views/Clean/DuplicatesView.swift @@ -0,0 +1,461 @@ +import AppKit +import SwiftUI +import ImageIO + +/// "Meu Acúmulo" fiel ao CleanMyMac: hero teal com recomendações e +/// gerenciador claro com duplicatas e imagens similares. +struct DuplicatesView: View { + @ObservedObject var settings: AppSettings + @ObservedObject var service: DuplicateFinderService + @ObservedObject var similarService: SimilarImagesService + + private enum Screen { + case hero + case manager + } + + private enum Category: String, CaseIterable, Identifiable { + case duplicates + case similars + var id: String { rawValue } + } + + @State private var screen: Screen = .hero + @State private var category: Category = .duplicates + @State private var selected: Set = [] + @State private var confirmsTrash = false + + private var isScanning: Bool { service.isScanning || similarService.isScanning } + + var body: some View { + VStack(spacing: 0) { + switch screen { + case .hero: hero + case .manager: manager + } + } + .onAppear { + if service.groups.isEmpty && service.scannedFileCount == 0 && !isScanning { + service.scan() + similarService.scan() + } + } + .confirmationDialog( + t("Mover \(selected.count) arquivo(s) para a Lixeira?", + "Move \(selected.count) file(s) to the Trash?"), + isPresented: $confirmsTrash + ) { + Button(t("Remover", "Remove"), role: .destructive) { + service.trash(urls: selected) + similarService.trash(urls: selected) + selected = [] + } + Button(t("Cancelar", "Cancel"), role: .cancel) {} + } + } + + // MARK: - Hero + + private var hero: some View { + VStack(spacing: 0) { + CleanTopBar( + leftIcon: "arrow.counterclockwise", + leftTitle: t("Recomeçar", "Start Over"), + title: t("Meu Acúmulo", "My Clutter"), + leftAction: { + selected = [] + service.scan() + similarService.scan() + } + ) + + ScrollView { + VStack(spacing: 18) { + HeroHeadline( + text: heroHeadline, + pillTitle: isScanning ? nil : t("Revisar Todos os Arquivos", "Review All Files"), + pillAction: { openManager(.duplicates) } + ) + + if isScanning { + ProgressView().controlSize(.large).tint(.white).padding(.top, 30) + } else { + HStack(alignment: .top, spacing: 14) { + duplicatesCard + .frame(width: 300) + similarsCard + } + } + } + .padding(.horizontal, 22) + .padding(.bottom, 20) + } + } + } + + private var heroHeadline: String { + if isScanning { + return t("Vasculhando seus arquivos…", "Sorting through your files…") + } + let count = service.groups.flatMap(\.items).count + similarService.groups.flatMap(\.items).count + if count == 0 { + return t("Nenhum acúmulo por aqui. Impecável!", "No clutter here. Spotless!") + } + return t("Você tem \(count) arquivos para revisar.\nUse as recomendações ou revise manualmente.", + "You have \(count) files to sort through.\nUse quick recommendations or review them by hand.") + } + + private var duplicatesCard: some View { + HeroCard( + minHeight: 250, + emphasized: true, + reviewTitle: t("Revisar", "Review"), + onReview: { openManager(.duplicates) } + ) { + VStack(alignment: .leading, spacing: 8) { + Text(t("\(service.groups.count) Grupos de Duplicatas", + "\(service.groups.count) Fresh Duplicates Found")) + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.white) + Text(t("Remova \(CleanFormat.bytes(service.totalWastedBytes)) de arquivos duplicados.", + "Remove \(CleanFormat.bytes(service.totalWastedBytes)) of duplicate files.")) + .font(.caption) + .foregroundStyle(.white.opacity(0.65)) + + Spacer(minLength: 8) + + HStack(spacing: 10) { + Spacer() + ForEach(Array(previewDuplicateItems.prefix(3)), id: \.url) { item in + ImageThumbnail(url: item.url) + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder(.white.opacity(0.3), lineWidth: 1) + ) + } + Spacer() + } + .padding(.bottom, 8) + } + } + } + + private var previewDuplicateItems: [CleanFileItem] { + service.groups.flatMap { $0.items.prefix(1) } + } + + private var similarsCard: some View { + HeroCard( + minHeight: 250, + reviewTitle: t("Revisar", "Review"), + onReview: { openManager(.similars) } + ) { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(t("\(similarService.groups.count) Grupos de Similares", + "\(similarService.groups.count) Similars Found")) + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.white) + Text(t("Você pode remover até \(CleanFormat.bytes(similarService.totalWastedBytes)) de imagens repetidas.", + "You may want to remove up to \(CleanFormat.bytes(similarService.totalWastedBytes)) of unneeded images.")) + .font(.caption) + .foregroundStyle(.white.opacity(0.65)) + } + Spacer() + HStack(spacing: -14) { + let preview = Array(similarService.groups.prefix(1).flatMap { $0.items.prefix(3) }) + ForEach(preview.indices, id: \.self) { index in + ImageThumbnail(url: preview[index].url) + .frame(width: 46, height: 46) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.white.opacity(0.4), lineWidth: 1)) + .rotationEffect(.degrees([-6.0, 3.0, 8.0][index % 3])) + } + } + } + } + } + } + + // MARK: - Manager (claro) + + private var manager: some View { + ManagerSurface { + VStack(spacing: 0) { + CleanTopBar( + leftIcon: "chevron.left", + leftTitle: t("Voltar", "Back"), + title: t("Gerenciador do Acúmulo", "My Clutter Manager"), + leftAction: { withAnimation(.easeInOut(duration: 0.2)) { screen = .hero } } + ) { + Button(t("Seleção inteligente", "Smart select")) { + autoSelect() + } + .buttonStyle(.bordered) + .font(.caption) + } + + Divider() + + HStack(spacing: 0) { + managerSidebar + .frame(width: 250) + Divider() + managerDetail + .frame(maxWidth: .infinity) + } + + ManagerBottomBar( + summary: selectionSummary, + actionTitle: t("Remover", "Remove"), + actionDisabled: selected.isEmpty, + action: { confirmsTrash = true } + ) + } + } + } + + private var managerSidebar: some View { + VStack(alignment: .leading, spacing: 4) { + ManagerSidebarRow( + icon: "doc.on.doc.fill", + iconTint: .teal, + title: t("Duplicatas", "Duplicates"), + badge: "\(service.groups.count)", + isSelected: category == .duplicates, + action: { category = .duplicates } + ) + .padding(.horizontal, 6) + ManagerSidebarRow( + icon: "photo.on.rectangle.angled", + iconTint: .teal, + title: t("Imagens similares", "Similar images"), + badge: "\(similarService.groups.count)", + isSelected: category == .similars, + action: { category = .similars } + ) + .padding(.horizontal, 6) + Spacer() + + Text(t("Varre Mesa, Downloads, Documentos e Imagens. A biblioteca do Photos não é tocada.", + "Scans Desktop, Downloads, Documents and Pictures. The Photos library is untouched.")) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(12) + } + .padding(.top, 12) + } + + @ViewBuilder + private var managerDetail: some View { + ScrollView { + VStack(spacing: 10) { + switch category { + case .duplicates: + if service.groups.isEmpty { + emptyState(t("Nenhuma duplicata entre \(service.scannedFileCount) arquivos.", + "No duplicates among \(service.scannedFileCount) files.")) + } + ForEach(service.groups) { group in + groupCard( + title: "\(group.items.count) " + t("cópias idênticas", "identical copies"), + waste: group.wastedBytes, + items: group.items, + keeperLabel: t("mais recente", "newest"), + showThumbs: false + ) + } + case .similars: + if similarService.groups.isEmpty { + emptyState(t("Nenhuma imagem similar entre \(similarService.scannedCount) analisadas.", + "No similar images among \(similarService.scannedCount) scanned.")) + } + ForEach(similarService.groups) { group in + groupCard( + title: "\(group.items.count) " + t("imagens parecidas", "similar images"), + waste: group.wastedBytes, + items: group.items, + keeperLabel: t("melhor qualidade", "best quality"), + showThumbs: true + ) + } + } + } + .padding(14) + } + } + + private func emptyState(_ message: String) -> some View { + HStack { + Image(systemName: "checkmark.seal.fill").foregroundStyle(.green) + Text(message).font(.callout).foregroundStyle(.secondary) + Spacer() + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(Color.black.opacity(0.03))) + } + + private func groupCard( + title: String, + waste: UInt64, + items: [CleanFileItem], + keeperLabel: String, + showThumbs: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(title).font(.system(size: 12.5, weight: .semibold)) + Spacer() + Text(t("Recuperável: ", "Reclaimable: ") + CleanFormat.bytes(waste)) + .font(.caption.monospacedDigit().weight(.medium)) + .foregroundStyle(.teal) + } + + if showThumbs { + HStack(spacing: 8) { + ForEach(items.prefix(6)) { item in + ImageThumbnail(url: item.url) + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder( + selected.contains(item.url) ? Color.teal : Color.black.opacity(0.1), + lineWidth: selected.contains(item.url) ? 2 : 1 + ) + ) + } + } + } + + Divider() + + ForEach(items.indices, id: \.self) { index in + let item = items[index] + HStack(spacing: 8) { + Toggle("", isOn: selectionBinding(item.url)) + .toggleStyle(.checkbox) + .labelsHidden() + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text(item.name).font(.system(size: 11.5, weight: .medium)) + if index == 0 { + Text(keeperLabel) + .font(.system(size: 9, weight: .semibold)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(.green.opacity(0.15))) + .foregroundStyle(.green) + } + } + Text(item.path) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + Text(CleanFormat.bytes(item.bytes)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + Button { + FileSweeper.revealInFinder(item.url) + } label: { + Image(systemName: "magnifyingglass").font(.system(size: 10)) + } + .buttonStyle(.borderless) + } + } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 12).fill(.white)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.black.opacity(0.07), lineWidth: 1)) + } + + // MARK: - Helpers + + private func openManager(_ value: Category) { + category = value + withAnimation(.easeInOut(duration: 0.2)) { screen = .manager } + } + + /// Marca tudo menos a primeira cópia (mais recente/melhor) de cada grupo. + private func autoSelect() { + var newSelection: Set = [] + for group in service.groups { + for item in group.items.dropFirst() { newSelection.insert(item.url) } + } + for group in similarService.groups { + for item in group.items.dropFirst() { newSelection.insert(item.url) } + } + selected = newSelection + } + + private var selectionSummary: String { + guard !selected.isEmpty else { + return t("Nenhum Item Selecionado | 0 KB", "No Items Selected | 0 KB") + } + let all = service.groups.flatMap(\.items) + similarService.groups.flatMap(\.items) + var seen = Set() + var bytes: UInt64 = 0 + for item in all where selected.contains(item.url) && !seen.contains(item.url) { + seen.insert(item.url) + bytes += item.bytes + } + return t("\(selected.count) Selecionado(s)", "\(selected.count) Selected") + + " | " + CleanFormat.bytes(bytes) + } + + private func selectionBinding(_ url: URL) -> Binding { + Binding( + get: { selected.contains(url) }, + set: { isOn in + if isOn { selected.insert(url) } else { selected.remove(url) } + } + ) + } + + private func t(_ pt: String, _ en: String) -> String { settings.text(ptBR: pt, en: en) } +} + +/// Thumbnail leve carregada fora da main thread via ImageIO. +struct ImageThumbnail: View { + let url: URL + @State private var image: NSImage? + + var body: some View { + Group { + if let image { + Image(nsImage: image) + .resizable() + .scaledToFill() + } else { + Rectangle() + .fill(.gray.opacity(0.15)) + .overlay( + Image(systemName: "photo") + .foregroundStyle(.gray.opacity(0.5)) + ) + } + } + .task(id: url) { + let target = url + let loaded = await Task.detached(priority: .utility) { () -> NSImage? in + guard let source = CGImageSourceCreateWithURL(target as CFURL, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: 128, + kCGImageSourceCreateThumbnailWithTransform: true + ] + guard let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + return NSImage(cgImage: cg, size: NSSize(width: cg.width, height: cg.height)) + }.value + image = loaded + } + } +} From ae976b7626a893b49380c40b3c6c1ef41110ff5f Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:51 -0300 Subject: [PATCH 18/23] feat(clean-ui): grandes e antigos com filtro de tamanho minimo --- ClipFlow/UI/Views/Clean/LargeFilesView.swift | 225 +++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 ClipFlow/UI/Views/Clean/LargeFilesView.swift diff --git a/ClipFlow/UI/Views/Clean/LargeFilesView.swift b/ClipFlow/UI/Views/Clean/LargeFilesView.swift new file mode 100644 index 0000000..59f3d9c --- /dev/null +++ b/ClipFlow/UI/Views/Clean/LargeFilesView.swift @@ -0,0 +1,225 @@ +import SwiftUI + +/// Grandes e Antigos: hero índigo com Scan e gerenciador claro com filtro. +struct LargeFilesView: View { + @ObservedObject var settings: AppSettings + @ObservedObject var service: LargeOldFilesService + + private enum Screen { + case hero + case manager + } + + @State private var screen: Screen = .hero + @State private var selected: Set = [] + @State private var confirmsTrash = false + + var body: some View { + VStack(spacing: 0) { + switch screen { + case .hero: hero + case .manager: manager + } + } + .confirmationDialog( + t("Mover \(selected.count) arquivo(s) para a Lixeira?", + "Move \(selected.count) file(s) to the Trash?"), + isPresented: $confirmsTrash + ) { + Button(t("Mover para a Lixeira", "Move to Trash"), role: .destructive) { + service.trash(urls: selected) + selected = [] + } + Button(t("Cancelar", "Cancel"), role: .cancel) {} + } + } + + // MARK: - Hero + + private var hero: some View { + VStack(spacing: 0) { + CleanTopBar( + leftIcon: "arrow.counterclockwise", + leftTitle: t("Recomeçar", "Start Over"), + title: t("Grandes e Antigos", "Large & Old"), + leftAction: {} + ) + + Spacer() + + HStack(spacing: 40) { + ZStack { + RoundedRectangle(cornerRadius: 38, style: .continuous) + .fill( + LinearGradient( + colors: [Color(red: 0.4, green: 0.38, blue: 0.95), Color(red: 0.18, green: 0.15, blue: 0.6)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 170, height: 170) + .shadow(color: .indigo.opacity(0.5), radius: 26, y: 8) + Image(systemName: "externaldrive.fill") + .font(.system(size: 62, weight: .bold)) + .foregroundStyle(.white.opacity(0.92)) + } + + VStack(alignment: .leading, spacing: 14) { + Text(t("Grandes e Antigos", "Large & Old")) + .font(.system(size: 32, weight: .bold)) + .foregroundStyle(.white) + Text(t("Encontre os maiores arquivos escondidos na sua\npasta pessoal e decida o que merece ficar.", + "Find the largest files hiding in your home folder\nand decide what deserves to stay.")) + .font(.callout) + .foregroundStyle(.white.opacity(0.7)) + + Picker("", selection: $service.threshold) { + ForEach(LargeOldFilesService.SizeThreshold.allCases) { threshold in + Text("≥ " + threshold.label).tag(threshold) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: 330) + } + } + .frame(maxWidth: .infinity) + + Spacer() + + Button { + selected = [] + service.scan() + withAnimation(.easeInOut(duration: 0.2)) { screen = .manager } + } label: { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color(red: 0.55, green: 0.5, blue: 1.0), Color(red: 0.3, green: 0.25, blue: 0.85)], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: 84, height: 84) + .shadow(color: .indigo.opacity(0.55), radius: 16, y: 4) + Text(t("Analisar", "Scan")) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + } + } + .buttonStyle(.plain) + .padding(.bottom, 34) + } + } + + // MARK: - Manager (claro) + + private var manager: some View { + ManagerSurface { + VStack(spacing: 0) { + CleanTopBar( + leftIcon: "chevron.left", + leftTitle: t("Voltar", "Back"), + title: t("Arquivos Grandes e Antigos", "Large & Old Files"), + leftAction: { withAnimation(.easeInOut(duration: 0.2)) { screen = .hero } } + ) { + Button { + selected = [] + service.scan() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .disabled(service.isScanning) + } + + Divider() + + if service.isScanning { + Spacer() + ProgressView(t("Varrendo a pasta pessoal…", "Sweeping home folder…")) + Spacer() + } else { + ScrollView { + LazyVStack(spacing: 2) { + ForEach(service.files) { file in + fileRow(file) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + } + + ManagerBottomBar( + summary: selectionSummary, + actionTitle: t("Remover", "Remove"), + actionDisabled: selected.isEmpty, + action: { confirmsTrash = true } + ) + } + } + } + + private func fileRow(_ file: CleanFileItem) -> some View { + HStack(spacing: 10) { + Toggle("", isOn: selectionBinding(file.url)) + .toggleStyle(.checkbox) + .labelsHidden() + Image(systemName: "doc.fill") + .font(.system(size: 13)) + .foregroundStyle(.indigo) + VStack(alignment: .leading, spacing: 1) { + Text(file.name).font(.system(size: 12, weight: .medium)) + HStack(spacing: 6) { + Text(file.path) + .lineLimit(1) + .truncationMode(.middle) + if let modified = file.modifiedAt { + Text("· " + modified.formatted(date: .abbreviated, time: .omitted)) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + Text(CleanFormat.bytes(file.bytes)) + .font(.system(size: 12, weight: .bold).monospacedDigit()) + .foregroundStyle(.indigo) + Button { + FileSweeper.revealInFinder(file.url) + } label: { + Image(systemName: "magnifyingglass").font(.system(size: 10)) + } + .buttonStyle(.borderless) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(selected.contains(file.url) ? Color(red: 0.93, green: 0.92, blue: 0.99) : Color.black.opacity(0.02)) + ) + } + + // MARK: - Helpers + + private var selectionSummary: String { + guard !selected.isEmpty else { + return "\(service.files.count) " + t("arquivos | ", "files | ") + CleanFormat.bytes(service.totalBytes) + } + let bytes = service.files.filter { selected.contains($0.url) }.reduce(UInt64(0)) { $0 + $1.bytes } + return t("\(selected.count) Selecionado(s)", "\(selected.count) Selected") + " | " + CleanFormat.bytes(bytes) + } + + private func selectionBinding(_ url: URL) -> Binding { + Binding( + get: { selected.contains(url) }, + set: { isOn in + if isOn { selected.insert(url) } else { selected.remove(url) } + } + ) + } + + private func t(_ pt: String, _ en: String) -> String { settings.text(ptBR: pt, en: en) } +} From 9ba2e29081215a083eaf3a66e5ceae941a235f3d Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:51 -0300 Subject: [PATCH 19/23] feat(clean-ui): lupa de espaco com bolhas proporcionais interativas --- ClipFlow/UI/Views/Clean/DiskMapView.swift | 352 ++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 ClipFlow/UI/Views/Clean/DiskMapView.swift diff --git a/ClipFlow/UI/Views/Clean/DiskMapView.swift b/ClipFlow/UI/Views/Clean/DiskMapView.swift new file mode 100644 index 0000000..6f11c75 --- /dev/null +++ b/ClipFlow/UI/Views/Clean/DiskMapView.swift @@ -0,0 +1,352 @@ +import SwiftUI + +/// Lupa de Espaço no estilo CleanMyMac: bolhas proporcionais ao tamanho, +/// painel lateral com lista, navegação por pastas e remoção com revisão. +struct DiskMapView: View { + @ObservedObject var settings: AppSettings + @ObservedObject var service: DiskMapService + + @State private var selected: Set = [] + @State private var confirmsRemoval = false + + var body: some View { + VStack(spacing: 0) { + breadcrumb + .padding(.horizontal, 18) + .padding(.vertical, 12) + + HStack(spacing: 0) { + sidebarPanel + .frame(width: 280) + bubbleCanvas + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + CleanBottomBar( + summary: selectionSummary, + actionTitle: t("Revisar e Remover", "Review and Remove"), + actionDisabled: selected.isEmpty, + action: { confirmsRemoval = true } + ) + } + .onAppear { + if service.root == nil && !service.isScanning { service.scan() } + } + .confirmationDialog( + t("Mover \(selected.count) item(ns) para a Lixeira?", + "Move \(selected.count) item(s) to the Trash?"), + isPresented: $confirmsRemoval + ) { + Button(t("Mover para a Lixeira", "Move to Trash"), role: .destructive) { + removeSelected() + } + Button(t("Cancelar", "Cancel"), role: .cancel) {} + } + } + + // MARK: - Breadcrumb + + private var breadcrumb: some View { + HStack(spacing: 8) { + Button { + navigate(to: service.currentURL.deletingLastPathComponent()) + } label: { + Image(systemName: "chevron.left") + } + .buttonStyle(CleanGlassButtonStyle()) + .disabled(service.isScanning || service.currentURL.path == "/") + + HStack(spacing: 4) { + Image(systemName: "folder.fill") + .font(.caption) + .foregroundStyle(.cyan) + Text(displayPath) + .font(.callout.weight(.medium)) + .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.head) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + .background(Capsule().fill(.white.opacity(0.1))) + + Button { + service.scan() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(CleanGlassButtonStyle()) + .disabled(service.isScanning) + } + } + + private var displayPath: String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let path = service.currentURL.path + return path.hasPrefix(home) ? "~" + path.dropFirst(home.count) : path + } + + // MARK: - Painel lateral + + private var sidebarPanel: some View { + VStack(alignment: .leading, spacing: 8) { + if let root = service.root { + VStack(alignment: .leading, spacing: 2) { + Text(root.name) + .font(.headline) + .foregroundStyle(.white) + .lineLimit(1) + Text(CleanFormat.bytes(root.bytes) + " · \(root.children.count) " + t("itens", "items")) + .font(.caption) + .foregroundStyle(.white.opacity(0.55)) + } + .padding(.horizontal, 14) + .padding(.top, 6) + + ScrollView { + VStack(spacing: 2) { + ForEach(root.children) { node in + sidebarRow(node) + } + } + .padding(.horizontal, 8) + .padding(.bottom, 10) + } + } else if service.isScanning { + Spacer() + HStack { + Spacer() + ProgressView(t("Medindo pastas…", "Measuring folders…")) + .tint(.white) + Spacer() + } + Spacer() + } else { + Spacer() + } + } + .frame(maxHeight: .infinity) + .background(.black.opacity(0.2)) + } + + private func sidebarRow(_ node: DiskNode) -> some View { + HStack(spacing: 8) { + Toggle("", isOn: selectionBinding(node.url)) + .toggleStyle(.checkbox) + .labelsHidden() + .disabled(FileSweeper.isProtected(node.url)) + Button { + open(node) + } label: { + HStack(spacing: 8) { + Image(systemName: node.isDirectory ? "folder.fill" : "doc.fill") + .font(.system(size: 12)) + .foregroundStyle(.cyan.opacity(0.9)) + Text(node.name) + .font(.caption.weight(.medium)) + .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(CleanFormat.bytes(node.bytes)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.white.opacity(0.6)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(selected.contains(node.url) ? .white.opacity(0.1) : .clear) + ) + } + + // MARK: - Bolhas + + private var bubbleCanvas: some View { + GeometryReader { proxy in + let nodes = Array((service.root?.children ?? []).prefix(11)) + let bubbles = BubbleLayout.compute( + nodes: nodes, + in: CGSize(width: proxy.size.width, height: proxy.size.height) + ) + ZStack { + if service.isScanning { + ProgressView().tint(.white) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2) + } else { + ForEach(bubbles, id: \.node.id) { bubble in + bubbleView(bubble) + .position(bubble.center) + } + } + } + } + .padding(12) + } + + private func bubbleView(_ bubble: BubbleLayout.Bubble) -> some View { + let isSelected = selected.contains(bubble.node.url) + return Button { + open(bubble.node) + } label: { + ZStack { + Circle() + .fill( + RadialGradient( + colors: [.white.opacity(0.22), .white.opacity(0.06)], + center: .topLeading, + startRadius: 0, + endRadius: bubble.radius * 2 + ) + ) + .overlay( + Circle().strokeBorder( + isSelected ? Color(red: 0.91, green: 0.15, blue: 0.6) : .white.opacity(0.25), + lineWidth: isSelected ? 2.5 : 1 + ) + ) + VStack(spacing: 3) { + Image(systemName: bubble.node.isDirectory ? "folder.fill" : "doc.fill") + .font(.system(size: min(bubble.radius * 0.42, 34))) + .foregroundStyle(.cyan) + if bubble.radius > 34 { + Text(bubble.node.name) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + .frame(maxWidth: bubble.radius * 1.6) + Text(CleanFormat.bytes(bubble.node.bytes)) + .font(.system(size: 10).monospacedDigit()) + .foregroundStyle(.white.opacity(0.7)) + } + } + } + .frame(width: bubble.radius * 2, height: bubble.radius * 2) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .help(bubble.node.url.path + " — " + CleanFormat.bytes(bubble.node.bytes)) + .contextMenu { + Button(t("Mostrar no Finder", "Reveal in Finder")) { + FileSweeper.revealInFinder(bubble.node.url) + } + if !FileSweeper.isProtected(bubble.node.url) { + Button(selected.contains(bubble.node.url) + ? t("Desmarcar", "Deselect") + : t("Selecionar para remoção", "Select for removal")) { + toggleSelection(bubble.node.url) + } + } + } + } + + // MARK: - Ações + + private func open(_ node: DiskNode) { + guard node.isDirectory, !service.isScanning else { return } + navigate(to: node.url) + } + + private func navigate(to url: URL) { + selected = [] + service.scan(url: url) + } + + private func removeSelected() { + _ = FileSweeper.trash(urls: Array(selected)) + selected = [] + service.scan() + } + + private func toggleSelection(_ url: URL) { + if selected.contains(url) { selected.remove(url) } else { selected.insert(url) } + } + + private func selectionBinding(_ url: URL) -> Binding { + Binding( + get: { selected.contains(url) }, + set: { isOn in + if isOn { selected.insert(url) } else { selected.remove(url) } + } + ) + } + + private var selectionSummary: String { + guard !selected.isEmpty else { + return t("Nenhum item selecionado | 0 KB", "No items selected | 0 KB") + } + let bytes = (service.root?.children ?? []) + .filter { selected.contains($0.url) } + .reduce(UInt64(0)) { $0 + $1.bytes } + return t("\(selected.count) selecionado(s)", "\(selected.count) selected") + + " | " + CleanFormat.bytes(bytes) + } + + private func t(_ pt: String, _ en: String) -> String { settings.text(ptBR: pt, en: en) } +} + +/// Empacotamento de círculos: maior no centro, demais em espiral ao redor. +enum BubbleLayout { + struct Bubble { + let node: DiskNode + let center: CGPoint + let radius: CGFloat + } + + static func compute(nodes: [DiskNode], in size: CGSize) -> [Bubble] { + guard !nodes.isEmpty, size.width > 60, size.height > 60 else { return [] } + let total = nodes.reduce(Double(0)) { $0 + Double($1.bytes) } + guard total > 0 else { return [] } + + // Área alvo ~42% do canvas; raio mínimo legível, máximo limitado. + let targetArea = Double(size.width * size.height) * 0.42 + let scale = sqrt(targetArea / (.pi * total)) + let maxRadius = Double(min(size.width, size.height)) * 0.30 + let minRadius = 16.0 + + var placed: [Bubble] = [] + let canvasCenter = CGPoint(x: size.width / 2, y: size.height / 2) + + for (index, node) in nodes.enumerated() { + let radius = CGFloat(min(max(sqrt(Double(node.bytes)) * scale, minRadius), maxRadius)) + if index == 0 { + placed.append(Bubble(node: node, center: canvasCenter, radius: radius)) + continue + } + + // Busca em espiral a partir do centro até achar posição livre. + var position: CGPoint? + let golden = 2.39996 + var step = 0 + while position == nil && step < 2000 { + let angle = Double(step) * golden * 0.12 + let distance = Double(step) * 0.55 + Double(placed[0].radius + radius) + let candidate = CGPoint( + x: canvasCenter.x + CGFloat(cos(angle) * distance), + y: canvasCenter.y + CGFloat(sin(angle) * distance * 0.82) + ) + let insideBounds = candidate.x - radius > 4 && candidate.x + radius < size.width - 4 + && candidate.y - radius > 4 && candidate.y + radius < size.height - 4 + let collides = placed.contains { other in + let dx = other.center.x - candidate.x + let dy = other.center.y - candidate.y + return sqrt(Double(dx * dx + dy * dy)) < Double(other.radius + radius) + 6 + } + if insideBounds && !collides { + position = candidate + } + step += 1 + } + + if let position { + placed.append(Bubble(node: node, center: position, radius: radius)) + } + } + return placed + } +} From 479c64e5b22d0c16e604ca45fb2536e2a770b511 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:51 -0300 Subject: [PATCH 20/23] feat(clean-ui): protecao com achados, alertas e quarentena --- ClipFlow/UI/Views/Clean/ProtectionView.swift | 155 +++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 ClipFlow/UI/Views/Clean/ProtectionView.swift diff --git a/ClipFlow/UI/Views/Clean/ProtectionView.swift b/ClipFlow/UI/Views/Clean/ProtectionView.swift new file mode 100644 index 0000000..d0db8bc --- /dev/null +++ b/ClipFlow/UI/Views/Clean/ProtectionView.swift @@ -0,0 +1,155 @@ +import SwiftUI + +/// Proteção: hero verde-menta com status e lista de achados em cards claros. +struct ProtectionView: View { + @ObservedObject var settings: AppSettings + @ObservedObject var service: ProtectionService + + @State private var findingPendingQuarantine: ProtectionFinding? + + var body: some View { + VStack(spacing: 0) { + CleanTopBar( + leftIcon: "arrow.counterclockwise", + leftTitle: t("Recomeçar", "Start Over"), + title: t("Proteção", "Protection"), + leftAction: { service.scan() } + ) + + ScrollView { + VStack(spacing: 18) { + HeroHeadline( + text: heroHeadline, + pillTitle: service.isScanning ? nil : t("Revisar Agora", "Review Now"), + pillAction: { service.scan() } + ) + + if service.isScanning { + ProgressView().controlSize(.large).tint(.white).padding(.top, 20) + } else if service.reviewedCount > 0 { + statusCard + VStack(spacing: 8) { + ForEach(service.findings) { finding in + findingRow(finding) + } + } + } + + Text(t("Isto não é um antivírus: é uma revisão heurística de LaunchAgents e LaunchDaemons — o padrão mais comum de adware no macOS.", + "This is not an antivirus: it's a heuristic review of LaunchAgents and LaunchDaemons — the most common macOS adware pattern.")) + .font(.caption2) + .foregroundStyle(.white.opacity(0.5)) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + .padding(.horizontal, 22) + .padding(.bottom, 20) + } + } + .onAppear { + if service.reviewedCount == 0 && !service.isScanning { service.scan() } + } + .confirmationDialog( + t("Mover o item para a Lixeira? Ele deixa de carregar no próximo login.", + "Move this item to the Trash? It stops loading at next login."), + isPresented: Binding( + get: { findingPendingQuarantine != nil }, + set: { if !$0 { findingPendingQuarantine = nil } } + ) + ) { + Button(t("Mover para a Lixeira", "Move to Trash"), role: .destructive) { + if let finding = findingPendingQuarantine { service.quarantine(finding) } + findingPendingQuarantine = nil + } + Button(t("Cancelar", "Cancel"), role: .cancel) { findingPendingQuarantine = nil } + } + } + + private var heroHeadline: String { + if service.isScanning { + return t("Revisando itens de inicialização…", "Reviewing startup items…") + } + if service.reviewedCount == 0 { + return t("Revise itens suspeitos de inicialização.", "Review suspicious startup items.") + } + if service.warningCount == 0 { + return t("Nenhum alerta em \(service.reviewedCount) itens revisados.", + "No flags across \(service.reviewedCount) reviewed items.") + } + return t("\(service.warningCount) alerta(s) em \(service.reviewedCount) itens revisados.", + "\(service.warningCount) flag(s) across \(service.reviewedCount) reviewed items.") + } + + private var statusCard: some View { + CleanCard(prominent: true) { + HStack(spacing: 12) { + Image(systemName: service.warningCount == 0 ? "checkmark.shield.fill" : "exclamationmark.shield.fill") + .font(.system(size: 26)) + .foregroundStyle(service.warningCount == 0 ? .green : .orange) + VStack(alignment: .leading, spacing: 2) { + Text(service.warningCount == 0 + ? t("Tudo em ordem", "All clear") + : t("Itens para a sua atenção", "Items for your attention")) + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.white) + Text(t("\(service.findings.count) itens listados para revisão manual", + "\(service.findings.count) items listed for manual review")) + .font(.caption) + .foregroundStyle(.white.opacity(0.6)) + } + Spacer() + } + } + } + + private func findingRow(_ finding: ProtectionFinding) -> some View { + CleanCard { + HStack(spacing: 12) { + Image(systemName: finding.severity == .warning + ? "exclamationmark.triangle.fill" + : "questionmark.circle.fill") + .foregroundStyle(finding.severity == .warning ? .orange : .cyan) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(finding.item.label) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(.white) + Text(settings.text(ptBR: finding.reasonPT, en: finding.reasonEN)) + .font(.caption) + .foregroundStyle(.white.opacity(0.65)) + if let program = finding.item.programPath { + Text(program) + .font(.caption2) + .foregroundStyle(.white.opacity(0.4)) + .lineLimit(1) + .truncationMode(.middle) + } + } + Spacer() + Button { + FileSweeper.revealInFinder(finding.item.url) + } label: { + Image(systemName: "magnifyingglass").font(.caption) + } + .buttonStyle(.borderless) + .help(t("Mostrar no Finder", "Reveal in Finder")) + + if finding.item.isRemovable { + Button(role: .destructive) { + findingPendingQuarantine = finding + } label: { + Image(systemName: "trash").font(.caption) + } + .buttonStyle(.borderless) + .help(t("Mover para a Lixeira", "Move to Trash")) + } else { + Text(t("Requer admin", "Needs admin")) + .font(.caption2) + .foregroundStyle(.white.opacity(0.4)) + } + } + } + } + + private func t(_ pt: String, _ en: String) -> String { settings.text(ptBR: pt, en: en) } +} From 5c5fc13c80f1bdc80c3f42f7cbf8efb23da7fdcd Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:51 -0300 Subject: [PATCH 21/23] feat(clean-ui): janela dedicada do CleanFlow --- .../Clean/CleanCenterWindowController.swift | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 ClipFlow/UI/Views/Clean/CleanCenterWindowController.swift diff --git a/ClipFlow/UI/Views/Clean/CleanCenterWindowController.swift b/ClipFlow/UI/Views/Clean/CleanCenterWindowController.swift new file mode 100644 index 0000000..a7289fb --- /dev/null +++ b/ClipFlow/UI/Views/Clean/CleanCenterWindowController.swift @@ -0,0 +1,41 @@ +import AppKit +import SwiftUI + +/// Janela dedicada do centro de limpeza (CleanFlow). +@MainActor +final class CleanCenterWindowController { + private var windowController: NSWindowController? + private let settings: AppSettings + + init(settings: AppSettings) { + self.settings = settings + } + + func show() { + if let window = windowController?.window { + window.orderFrontRegardless() + window.makeKey() + NSApp.activate(ignoringOtherApps: true) + return + } + + let hosting = NSHostingController(rootView: CleanCenterView(settings: settings)) + let window = NSWindow(contentViewController: hosting) + window.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] + window.title = settings.text(ptBR: "CleanFlow", en: "CleanFlow") + window.titleVisibility = .visible + window.titlebarAppearsTransparent = true + window.toolbarStyle = .unified + window.isReleasedWhenClosed = false + window.isMovableByWindowBackground = true + window.setContentSize(NSSize(width: 1180, height: 740)) + window.minSize = NSSize(width: 1080, height: 700) + window.center() + + let controller = NSWindowController(window: window) + windowController = controller + controller.showWindow(nil) + window.makeKey() + NSApp.activate(ignoringOtherApps: true) + } +} From 762f2b7a5cc8fbdbe8a9f11fd140f4da6d4259b4 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:51 -0300 Subject: [PATCH 22/23] feat(menu-bar): item Abrir CleanFlow integrado ao ciclo do app --- ClipFlow/App/AppDelegate.swift | 4 ++++ ClipFlow/Core/Managers/MenuBarController.swift | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/ClipFlow/App/AppDelegate.swift b/ClipFlow/App/AppDelegate.swift index 8a7c27e..9887fcf 100644 --- a/ClipFlow/App/AppDelegate.swift +++ b/ClipFlow/App/AppDelegate.swift @@ -44,6 +44,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var panelTargetApplication: NSRunningApplication? private var settingsWindowController: NSWindowController? + private lazy var cleanCenterController = CleanCenterWindowController(settings: settings) private var cancellables: Set = [] @@ -404,6 +405,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } menuBarController = MenuBarController( onOpenDashboard: openDashboard, + onOpenCleanCenter: { [weak self] in + self?.cleanCenterController.show() + }, onOpenPanel: { [weak self] in self?.captureFrontmostExternalApplication() self?.panelTargetApplication = self?.lastExternalApplication diff --git a/ClipFlow/Core/Managers/MenuBarController.swift b/ClipFlow/Core/Managers/MenuBarController.swift index adb2a55..7e20921 100644 --- a/ClipFlow/Core/Managers/MenuBarController.swift +++ b/ClipFlow/Core/Managers/MenuBarController.swift @@ -12,12 +12,14 @@ final class MenuBarController: NSObject { case toggleVoice = 1004 case checkUpdates = 1005 case systemMetrics = 1006 + case openCleanCenter = 1007 } private let statusItem: NSStatusItem private let menu = NSMenu() private let onOpenDashboard: () -> Void + private let onOpenCleanCenter: () -> Void private let onOpenPanel: () -> Void private let onOpenSettings: () -> Void private let onCheckForUpdates: () -> Void @@ -33,6 +35,7 @@ final class MenuBarController: NSObject { init( onOpenDashboard: @escaping () -> Void, + onOpenCleanCenter: @escaping () -> Void, onOpenPanel: @escaping () -> Void, onOpenSettings: @escaping () -> Void, onCheckForUpdates: @escaping () -> Void, @@ -46,6 +49,7 @@ final class MenuBarController: NSObject { ) { self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) self.onOpenDashboard = onOpenDashboard + self.onOpenCleanCenter = onOpenCleanCenter self.onOpenPanel = onOpenPanel self.onOpenSettings = onOpenSettings self.onCheckForUpdates = onCheckForUpdates @@ -103,6 +107,7 @@ final class MenuBarController: NSObject { } openDashboard.title = t("Abrir Central do Mac", "Open Mac Command Center") + menu.item(withTag: ItemTag.openCleanCenter.rawValue)?.title = t("Abrir CleanFlow", "Open CleanFlow") openPanel.title = t("Abrir Clipboard", "Open Clipboard") pause.title = t("Pausar Monitoramento", "Pause Monitoring") voice.title = t("Comandos de Voz", "Voice Commands") @@ -157,6 +162,15 @@ final class MenuBarController: NSObject { dashboardItem.tag = ItemTag.openDashboard.rawValue menu.addItem(dashboardItem) + let cleanCenterItem = NSMenuItem( + title: t("Abrir CleanFlow", "Open CleanFlow"), + action: #selector(openCleanCenter), + keyEquivalent: "" + ) + cleanCenterItem.target = self + cleanCenterItem.tag = ItemTag.openCleanCenter.rawValue + menu.addItem(cleanCenterItem) + let metricsItem = NSMenuItem(title: "CPU — · RAM — · GPU — · —", action: nil, keyEquivalent: "") metricsItem.tag = ItemTag.systemMetrics.rawValue metricsItem.isEnabled = false @@ -215,6 +229,10 @@ final class MenuBarController: NSObject { onOpenDashboard() } + @objc private func openCleanCenter() { + onOpenCleanCenter() + } + @objc private func openSettings() { onOpenSettings() } From c30087788077e015233ad9388e0496c1fde32209 Mon Sep 17 00:00:00 2001 From: Richard Farias Date: Wed, 22 Jul 2026 15:27:51 -0300 Subject: [PATCH 23/23] chore(xcodeproj): registra os 19 arquivos do modulo CleanFlow --- ClipFlow.xcodeproj/project.pbxproj | 84 ++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/ClipFlow.xcodeproj/project.pbxproj b/ClipFlow.xcodeproj/project.pbxproj index a194dec..bb83d70 100644 --- a/ClipFlow.xcodeproj/project.pbxproj +++ b/ClipFlow.xcodeproj/project.pbxproj @@ -74,6 +74,27 @@ F1000000000000000000000C /* MetricsPopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000000000000000000000C /* MetricsPopoverView.swift */; }; F1000000000000000000000D /* ActivityMonitorLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000000000000000000000D /* ActivityMonitorLauncher.swift */; }; F1000000000000000000000E /* FanMetricsSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000000000000000000000E /* FanMetricsSampler.swift */; }; + C10000000000000000000001 /* CleanSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000001 /* CleanSupport.swift */; }; + C10000000000000000000002 /* JunkScanService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000002 /* JunkScanService.swift */; }; + C10000000000000000000003 /* StartupItemsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000003 /* StartupItemsService.swift */; }; + C10000000000000000000004 /* AppInventoryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000004 /* AppInventoryService.swift */; }; + C10000000000000000000005 /* DuplicateFinderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000005 /* DuplicateFinderService.swift */; }; + C10000000000000000000006 /* LargeOldFilesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000006 /* LargeOldFilesService.swift */; }; + C10000000000000000000007 /* DiskMapService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000007 /* DiskMapService.swift */; }; + C10000000000000000000008 /* ProtectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000008 /* ProtectionService.swift */; }; + C10000000000000000000009 /* CleanCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000009 /* CleanCenterView.swift */; }; + C1000000000000000000000A /* SmartScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000000A /* SmartScanView.swift */; }; + C1000000000000000000000B /* JunkCleanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000000B /* JunkCleanView.swift */; }; + C1000000000000000000000C /* SpeedupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000000C /* SpeedupView.swift */; }; + C1000000000000000000000D /* AppsManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000000D /* AppsManagerView.swift */; }; + C1000000000000000000000E /* DuplicatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000000E /* DuplicatesView.swift */; }; + C1000000000000000000000F /* LargeFilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000000F /* LargeFilesView.swift */; }; + C10000000000000000000010 /* DiskMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000010 /* DiskMapView.swift */; }; + C10000000000000000000011 /* ProtectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000011 /* ProtectionView.swift */; }; + C10000000000000000000012 /* CleanCenterWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000012 /* CleanCenterWindowController.swift */; }; + C10000000000000000000013 /* CleanupRuleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000013 /* CleanupRuleCatalog.swift */; }; + C10000000000000000000014 /* MaintenanceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000014 /* MaintenanceService.swift */; }; + C10000000000000000000015 /* SimilarImagesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000015 /* SimilarImagesService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -166,6 +187,27 @@ F20000000000000000000007 /* CacheMaintenanceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheMaintenanceService.swift; sourceTree = ""; }; F20000000000000000000008 /* MenuBarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarSettingsView.swift; sourceTree = ""; }; F20000000000000000000009 /* MaintenanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaintenanceView.swift; sourceTree = ""; }; + C20000000000000000000001 /* CleanSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/CleanSupport.swift; sourceTree = ""; }; + C20000000000000000000002 /* JunkScanService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/JunkScanService.swift; sourceTree = ""; }; + C20000000000000000000003 /* StartupItemsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/StartupItemsService.swift; sourceTree = ""; }; + C20000000000000000000004 /* AppInventoryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/AppInventoryService.swift; sourceTree = ""; }; + C20000000000000000000005 /* DuplicateFinderService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/DuplicateFinderService.swift; sourceTree = ""; }; + C20000000000000000000006 /* LargeOldFilesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/LargeOldFilesService.swift; sourceTree = ""; }; + C20000000000000000000007 /* DiskMapService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/DiskMapService.swift; sourceTree = ""; }; + C20000000000000000000008 /* ProtectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/ProtectionService.swift; sourceTree = ""; }; + C20000000000000000000009 /* CleanCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/CleanCenterView.swift; sourceTree = ""; }; + C2000000000000000000000A /* SmartScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/SmartScanView.swift; sourceTree = ""; }; + C2000000000000000000000B /* JunkCleanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/JunkCleanView.swift; sourceTree = ""; }; + C2000000000000000000000C /* SpeedupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/SpeedupView.swift; sourceTree = ""; }; + C2000000000000000000000D /* AppsManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/AppsManagerView.swift; sourceTree = ""; }; + C2000000000000000000000E /* DuplicatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/DuplicatesView.swift; sourceTree = ""; }; + C2000000000000000000000F /* LargeFilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/LargeFilesView.swift; sourceTree = ""; }; + C20000000000000000000010 /* DiskMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/DiskMapView.swift; sourceTree = ""; }; + C20000000000000000000011 /* ProtectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/ProtectionView.swift; sourceTree = ""; }; + C20000000000000000000012 /* CleanCenterWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/CleanCenterWindowController.swift; sourceTree = ""; }; + C20000000000000000000013 /* CleanupRuleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/CleanupRuleCatalog.swift; sourceTree = ""; }; + C20000000000000000000014 /* MaintenanceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/MaintenanceService.swift; sourceTree = ""; }; + C20000000000000000000015 /* SimilarImagesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clean/SimilarImagesService.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -294,6 +336,17 @@ F2000000000000000000000E /* FanMetricsSampler.swift */, F20000000000000000000004 /* SystemMetricsService.swift */, F20000000000000000000007 /* CacheMaintenanceService.swift */, + C20000000000000000000001 /* CleanSupport.swift */, + C20000000000000000000015 /* SimilarImagesService.swift */, + C20000000000000000000014 /* MaintenanceService.swift */, + C20000000000000000000013 /* CleanupRuleCatalog.swift */, + C20000000000000000000002 /* JunkScanService.swift */, + C20000000000000000000003 /* StartupItemsService.swift */, + C20000000000000000000004 /* AppInventoryService.swift */, + C20000000000000000000005 /* DuplicateFinderService.swift */, + C20000000000000000000006 /* LargeOldFilesService.swift */, + C20000000000000000000007 /* DiskMapService.swift */, + C20000000000000000000008 /* ProtectionService.swift */, ); path = Services; sourceTree = ""; @@ -332,6 +385,16 @@ F20000000000000000000009 /* MaintenanceView.swift */, F2000000000000000000000C /* MetricsPopoverView.swift */, AAAA00000000000000000006 /* VoiceHUDController.swift */, + C20000000000000000000009 /* CleanCenterView.swift */, + C2000000000000000000000A /* SmartScanView.swift */, + C2000000000000000000000B /* JunkCleanView.swift */, + C2000000000000000000000C /* SpeedupView.swift */, + C2000000000000000000000D /* AppsManagerView.swift */, + C2000000000000000000000E /* DuplicatesView.swift */, + C2000000000000000000000F /* LargeFilesView.swift */, + C20000000000000000000010 /* DiskMapView.swift */, + C20000000000000000000011 /* ProtectionView.swift */, + C20000000000000000000012 /* CleanCenterWindowController.swift */, ); path = Views; sourceTree = ""; @@ -446,6 +509,27 @@ buildActionMask = 2147483647; files = ( A676EEDD45AFBD34937484B4 /* AppDelegate.swift in Sources */, + C10000000000000000000001 /* CleanSupport.swift in Sources */, + C10000000000000000000015 /* SimilarImagesService.swift in Sources */, + C10000000000000000000014 /* MaintenanceService.swift in Sources */, + C10000000000000000000013 /* CleanupRuleCatalog.swift in Sources */, + C10000000000000000000002 /* JunkScanService.swift in Sources */, + C10000000000000000000003 /* StartupItemsService.swift in Sources */, + C10000000000000000000004 /* AppInventoryService.swift in Sources */, + C10000000000000000000005 /* DuplicateFinderService.swift in Sources */, + C10000000000000000000006 /* LargeOldFilesService.swift in Sources */, + C10000000000000000000007 /* DiskMapService.swift in Sources */, + C10000000000000000000008 /* ProtectionService.swift in Sources */, + C10000000000000000000009 /* CleanCenterView.swift in Sources */, + C1000000000000000000000A /* SmartScanView.swift in Sources */, + C1000000000000000000000B /* JunkCleanView.swift in Sources */, + C1000000000000000000000C /* SpeedupView.swift in Sources */, + C1000000000000000000000D /* AppsManagerView.swift in Sources */, + C1000000000000000000000E /* DuplicatesView.swift in Sources */, + C1000000000000000000000F /* LargeFilesView.swift in Sources */, + C10000000000000000000010 /* DiskMapView.swift in Sources */, + C10000000000000000000011 /* ProtectionView.swift in Sources */, + C10000000000000000000012 /* CleanCenterWindowController.swift in Sources */, C40894AEEAEF63A41EB3E8A3 /* AppSettings.swift in Sources */, 752C1FD4FCCA412B0D21BFDD /* BrandLogoView.swift in Sources */, BBBB00000000000000000016 /* VoiceFieldView.swift in Sources */,