diff --git a/README.md b/README.md index e23019f..1f417d5 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ It holds the Apple Intelligence restrictions, the download block for removed mod - **Locked settings** (Apple Intelligence and the tweaks marked with a lock) go in one configuration profile that you approve in System Settings, because macOS requires that. Removing the profile undoes all of them. - **Other settings** are written the way `defaults write` writes them. RemoveMacAI first records each setting's previous value in `~/Library/Application Support/RemoveMacAI/journal.json`, and undo puts back exactly that value. - **Models** are removed through Apple's asset service, and the profile points their downloads at a closed local port so macOS doesn't fetch them again. -- **Storage** cleanup moves files to the Trash, so nothing is gone until you empty it. +- **Storage** cleanup moves files to the Trash, so they aren't gone until you empty it. Unavailable simulators and Time Machine local snapshots are deleted right away by `simctl` and `tmutil`. System Integrity Protection stays on, nothing under `/System` is modified, and RemoveMacAI makes no network requests and collects no data. diff --git a/Sources/removemacai/App/AppModel.swift b/Sources/removemacai/App/AppModel.swift index 7265409..d2b7b02 100644 --- a/Sources/removemacai/App/AppModel.swift +++ b/Sources/removemacai/App/AppModel.swift @@ -309,13 +309,20 @@ final class AppModel { func clean() { let chosen = storage.filter { storageChosen.contains($0.id) } - let total = chosenBytes cleaning = true Task.detached(priority: .userInitiated) { let result = Storage.clean(chosen) let items = Storage.scan() - let left = items.filter { chosen.map(\.id).contains($0.id) }.reduce(Int64(0)) { $0 + $1.bytes } - var message = "Moved \(Term.size(max(0, total - left))) to the Trash. Empty the Trash to free the space." + let trashedIDs = chosen.filter { !$0.permanent }.map(\.id) + let trashedTotal = chosen.filter { !$0.permanent }.reduce(Int64(0)) { $0 + $1.bytes } + let left = items.filter { trashedIDs.contains($0.id) }.reduce(Int64(0)) { $0 + $1.bytes } + var parts: [String] = [] + if !trashedIDs.isEmpty { parts.append("Moved \(Term.size(max(0, trashedTotal - left))) to the Trash. Empty the Trash to free the space.") } + let permanent = chosen.filter(\.permanent) + if !permanent.isEmpty && result.problems.isEmpty { + parts.append("Deleted \(permanent.map(\.title).joined(separator: " and ")).") + } + var message = parts.joined(separator: " ") if result.protected > 0 { message += " \(result.protected) item\(result.protected == 1 ? "" : "s") macOS protects stayed where \(result.protected == 1 ? "it was" : "they were")." } diff --git a/Sources/removemacai/App/Pages.swift b/Sources/removemacai/App/Pages.swift index aadf4d3..b9ae5bc 100644 --- a/Sources/removemacai/App/Pages.swift +++ b/Sources/removemacai/App/Pages.swift @@ -328,6 +328,11 @@ struct StorageView: View { @Environment(AppModel.self) private var model @State private var confirming = false + /// Selected items that are deleted right away instead of going to the Trash. + var permanentChosen: [String] { + model.storage.filter { model.storageChosen.contains($0.id) && $0.permanent }.map(\.title) + } + var body: some View { Form { if !model.scanned { @@ -372,7 +377,7 @@ struct StorageView: View { } } header: { HStack { - Text("Files go to the Trash, so nothing is gone until you empty it.").textCase(nil) + Text("Files go to the Trash, so they aren't gone until you empty it. Simulators and snapshots are deleted right away.").textCase(nil) Spacer() if model.scanning { ProgressView().controlSize(.small) } else { Button("Scan Again") { model.scan() }.buttonStyle(.link) } } @@ -386,7 +391,7 @@ struct StorageView: View { } Spacer() if model.cleaning { ProgressView().controlSize(.small) } - Button("Move to Trash…") { confirming = true } + Button(permanentChosen.isEmpty ? "Move to Trash…" : "Remove…") { confirming = true } .buttonStyle(.borderedProminent) .disabled(model.storageChosen.isEmpty || model.cleaning) } @@ -396,11 +401,13 @@ struct StorageView: View { .formStyle(.grouped) .navigationTitle("Storage") .confirmationDialog( - "Move the selected items to the Trash?", isPresented: $confirming + permanentChosen.isEmpty ? "Move the selected items to the Trash?" : "Remove the selected items?", + isPresented: $confirming ) { - Button("Move to Trash") { model.clean() } + Button(permanentChosen.isEmpty ? "Move to Trash" : "Remove") { model.clean() } } message: { - Text("About \(Term.size(model.chosenBytes)). Items only an administrator can move ask for your password.") + Text("About \(Term.size(model.chosenBytes)). Items only an administrator can move ask for your password." + + (permanentChosen.isEmpty ? "" : " \(permanentChosen.joined(separator: " and ")) are deleted right away, not moved to the Trash.")) } } } diff --git a/Sources/removemacai/Storage.swift b/Sources/removemacai/Storage.swift index 59aa3fe..777f872 100644 --- a/Sources/removemacai/Storage.swift +++ b/Sources/removemacai/Storage.swift @@ -21,6 +21,9 @@ struct StorageItem: Identifiable { var paths: [String] = [] var bytes: Int64 = 0 var count: Int = 0 + + /// Simulators and snapshots are deleted by their own tools, not moved to the Trash. + var permanent: Bool { kind != .files } } enum Storage { @@ -77,7 +80,8 @@ enum Storage { var simulators = StorageItem( id: "simulators", title: "Unavailable simulators", - detail: "Simulators for runtimes that are no longer installed, so they can't run.", kind: .simulators) + detail: "Simulators for runtimes that are no longer installed, so they can't run.", + caveat: "Deleted right away, not moved to the Trash.", kind: .simulators) let unavailable = unavailableSimulators() simulators.paths = unavailable simulators.count = unavailable.count @@ -102,7 +106,8 @@ enum Storage { var snapshots = StorageItem( id: "snapshots", title: "Time Machine local snapshots", detail: "Hourly copies Time Machine keeps on this disk between backups. macOS counts them as System Data.", - caveat: "Your backups on the backup disk aren't touched.", kind: .snapshots) + caveat: "Deleted right away, not moved to the Trash. Your backups on the backup disk aren't touched.", + kind: .snapshots) snapshots.paths = localSnapshotDates() snapshots.count = snapshots.paths.count items.append(snapshots) @@ -172,6 +177,7 @@ enum Storage { var problems: [String] = [] /// Items macOS protects, which nobody can move without turning protections off. var protected = 0 + var protectedBytes: Int64 = 0 } static func clean(_ items: [StorageItem]) -> CleanResult { @@ -192,6 +198,7 @@ enum Storage { let owner = (try? FileManager.default.attributesOfItem(atPath: path)[.ownerAccountID] as? NSNumber)?.uint32Value guard let owner, owner != getuid() else { result.protected += 1 + result.protectedBytes += size(path) continue } let name = (path as NSString).lastPathComponent diff --git a/Sources/removemacai/TweakCommands.swift b/Sources/removemacai/TweakCommands.swift index 0c11e7d..bbad2ef 100644 --- a/Sources/removemacai/TweakCommands.swift +++ b/Sources/removemacai/TweakCommands.swift @@ -196,20 +196,28 @@ enum TweakCommands { Term.fail("\"\(id)\" is not in the list above") } let total = chosen.reduce(Int64(0)) { $0 + $1.bytes } + let permanent = chosen.filter(\.permanent) + if !permanent.isEmpty { + print(Term.yellow("!") + " " + permanent.map(\.title).joined(separator: " and ") + " are deleted right away, not moved to the Trash.") + } if dryRun { print(Term.bold("Dry run, nothing moved.") + " It would free about \(Term.size(total)).") return true } if !yes { guard isatty(STDIN_FILENO) == 1 else { Term.fail("run it in a terminal, or add --yes") } - guard Term.ask("Move \(chosen.map(\.title).joined(separator: ", ")) to the Trash?") else { + let verb = permanent.isEmpty ? "Move" : "Remove" + let suffix = permanent.isEmpty ? " to the Trash" : "" + guard Term.ask("\(verb) \(chosen.map(\.title).joined(separator: ", "))\(suffix)?") else { print("Nothing moved.") return true } } let result = Storage.clean(chosen) for p in result.problems { print(Term.yellow("!") + " " + p) } - print(Term.green("✓") + " Moved to the Trash. Empty the Trash to free about \(Term.size(total)).") + let trashed = chosen.filter { !$0.permanent }.reduce(Int64(0)) { $0 + $1.bytes } - result.protectedBytes + if trashed > 0 { print(Term.green("✓") + " Moved to the Trash. Empty the Trash to free about \(Term.size(trashed)).") } + if !permanent.isEmpty && result.problems.isEmpty { print(Term.green("✓") + " Deleted " + permanent.map(\.title).joined(separator: " and ") + ".") } if result.protected > 0 { print(Term.dim(" \(result.protected) item(s) macOS protects stayed where they were.")) } return result.problems.isEmpty }