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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 10 additions & 3 deletions Sources/removemacai/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")."
}
Expand Down
17 changes: 12 additions & 5 deletions Sources/removemacai/App/Pages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) }
}
Expand All @@ -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)
}
Expand All @@ -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."))
}
}
}
Expand Down
11 changes: 9 additions & 2 deletions Sources/removemacai/Storage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
12 changes: 10 additions & 2 deletions Sources/removemacai/TweakCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down