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 @@ -66,7 +66,7 @@ The app and the command line are the same program and share the same undo histor
|---|---|
| Apple Intelligence | Siri, Writing Tools, Genmoji, Image Playground, ChatGPT, summaries in Mail, Messages, Safari, Notes and notifications, inline predictions, Spatial Photos, Photos Clean Up, Xcode predictive completion. The models are deleted and blocked from downloading again. Keep any feature you use. |
| Privacy | Mac Analytics, personalized ads, Improve Siri and Dictation, Improve Search, Spotlight internet results, Look Up suggestions, Safari search suggestions, Game Center |
| Annoyances | Clicking the wallpaper hiding every window, desktop widgets, the play key opening Music, iPhone Mirroring |
| Annoyances | Clicking the wallpaper hiding every window, desktop widgets, the play key opening Music, all Music launches (including AirPods; opt-in), iPhone Mirroring |
| Apple apps | Apple Music in the Music app, the Book Store, unused Apple apps in the Dock |
| Finder | File extensions, hidden files, the path and status bars, folders first, searching the current folder, the extension warning, `.DS_Store` files on network drives, emptying the Trash after 30 days, saving to your Mac instead of iCloud, the Library folder |
| Dock and windows | Recent apps, the auto-hide delay, bouncing icons, minimizing into the app icon, window animations, gaps between tiled windows, screenshot shadows |
Expand Down
67 changes: 50 additions & 17 deletions Sources/removemacai/Engine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ struct Snapshot {
func state(_ tweak: Tweak) -> TweakState {
guard tweak.supported else { return .unsupported }
if tweak.changes.contains(where: { managedElsewhere($0, tweak) }) { return .managed }
if tweak.changes == [.musicLaunches] { return MusicLaunch.state(disabled: disabledServices) }
let done = tweak.changes.filter(applied).count
return done == tweak.changes.count ? .applied : done == 0 ? .notApplied : .partial
}
Expand All @@ -32,6 +33,8 @@ struct Snapshot {
return value.matches(Prefs.read(domain, key, currentHost: host))
case .service(let label):
return disabledServices.contains(label)
case .musicLaunches:
return MusicLaunch.state(disabled: disabledServices) == .applied
case .dockRemove(let ids):
return dockApps.isDisjoint(with: ids)
case .showLibrary:
Expand Down Expand Up @@ -168,10 +171,12 @@ enum Engine {
// MARK: reading the system

static let uid = getuid()
/// The self-test substitutes launchd; it never changes the real services.
static var launchctl: ([String]) -> Shell.Result = { Shell.run("/bin/launchctl", $0) }

/// Services the user's session has switched off.
static func disabledServices() -> Set<String> {
let out = Shell.run("/bin/launchctl", ["print-disabled", "gui/\(uid)"]).output
let out = launchctl(["print-disabled", "gui/\(uid)"]).output
var labels = Set<String>()
for line in out.split(separator: "\n") {
let parts = line.components(separatedBy: "=>")
Expand Down Expand Up @@ -208,6 +213,40 @@ enum Engine {

// MARK: changing it

static func disableService(_ label: String, tweak: String, journal: inout Journal) -> [String] {
let key = Change.service(label).key
if journal.entries[key] == nil {
journal.entries[key] = .init(
tweak: tweak, date: Date(), previous: .service(disabled: disabledServices().contains(label)))
}
let target = "gui/\(uid)/\(label)"
let disabled = launchctl(["disable", target])
guard disabled.ok else { return ["could not disable \(label): \(disabled.output.trimmed)"] }
if launchctl(["print", target]).ok {
let stopped = launchctl(["bootout", target])
if !stopped.ok { return ["disabled \(label), but could not stop it: \(stopped.output.trimmed)"] }
}
return []
}

static func restoreService(_ label: String, journal: inout Journal) -> [String] {
let key = Change.service(label).key
if case .service(let wasDisabled)? = journal.entries[key]?.previous, wasDisabled {
journal.entries[key] = nil
return []
}
let target = "gui/\(uid)/\(label)"
let enabled = launchctl(["enable", target])
guard enabled.ok else { return ["could not enable \(label): \(enabled.output.trimmed)"] }
let plist = "/System/Library/LaunchAgents/\(label).plist"
if FileManager.default.fileExists(atPath: plist), !launchctl(["print", target]).ok {
let started = launchctl(["bootstrap", "gui/\(uid)", plist])
guard started.ok else { return ["could not start \(label): \(started.output.trimmed)"] }
}
journal.entries[key] = nil
return []
}

/// Applies the settings a tweak changes outside the profile. Returns problems.
static func apply(_ tweak: Tweak, journal: inout Journal) -> [String] {
var problems: [String] = []
Expand All @@ -226,13 +265,10 @@ enum Engine {
problems.append("\(domain) \(k) did not change")
}
case .service(let label):
if journal.entries[key] == nil {
journal.entries[key] = .init(
tweak: tweak.id, date: Date(), previous: .service(disabled: disabledServices().contains(label)))
}
let disabled = Shell.run("/bin/launchctl", ["disable", "gui/\(uid)/\(label)"])
if !disabled.ok { problems.append("could not disable \(label): \(disabled.output.trimmed)") }
Shell.run("/bin/launchctl", ["bootout", "gui/\(uid)/\(label)"])
problems += disableService(label, tweak: tweak.id, journal: &journal)
case .musicLaunches:
do { problems += disableService(try MusicLaunch.label(), tweak: tweak.id, journal: &journal) }
catch { problems.append("could not identify Music's application launch entry: \(error)") }
case .dockRemove(let ids):
let tiles = dockTiles()
var kept: [[String: Any]] = []
Expand Down Expand Up @@ -277,15 +313,12 @@ enum Engine {
if case .pref(let value)? = entry?.previous { previous = value }
Prefs.write(domain, k, previous, currentHost: host)
case .service(let label):
if case .service(let wasDisabled)? = entry?.previous, wasDisabled { break }
let enabled = Shell.run("/bin/launchctl", ["enable", "gui/\(uid)/\(label)"])
if !enabled.ok { problems.append("could not enable \(label): \(enabled.output.trimmed)") }
let plist = "/System/Library/LaunchAgents/\(label).plist"
if FileManager.default.fileExists(atPath: plist),
!Shell.run("/bin/launchctl", ["print", "gui/\(uid)/\(label)"]).ok
{
Shell.run("/bin/launchctl", ["bootstrap", "gui/\(uid)", plist])
}
problems += restoreService(label, journal: &journal)
continue // A failed restore keeps its journal entry for retry.
case .musicLaunches:
var labels = MusicLaunch.recordedLabels(tweak.id, journal: journal)
if labels.isEmpty, let current = try? MusicLaunch.label() { labels = [current] }
for label in labels { problems += restoreService(label, journal: &journal) }
case .dockRemove:
guard case .dock(let removed)? = entry?.previous else { break }
var tiles = dockTiles()
Expand Down
38 changes: 38 additions & 0 deletions Sources/removemacai/MusicLaunch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation

enum MusicLaunch {
static let prefix = "application.com.apple.Music."
/// The self-test substitutes a temporary bundle.
static var app = URL(fileURLWithPath: "/System/Applications/Music.app")

/// LaunchServices uses the bundle and executable inode numbers in this
/// application-job label (verified against launchctl on macOS 27.0.1).
/// Resolve it without opening Music; OS updates can change either inode.
static func label() throws -> String {
func inode(_ url: URL) throws -> String {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
guard let number = attributes[.systemFileNumber] as? NSNumber else {
throw Failure("could not read the file number of \(url.path)")
}
return number.stringValue
}
return try prefix + inode(app) + "." + inode(app.appendingPathComponent("Contents/MacOS/Music"))
}

static func state(disabled: Set<String>) -> TweakState {
if let current = try? label(), disabled.contains(current) {
// If bootout failed, reapply must still be able to close the running app.
return Engine.launchctl(["print", "gui/\(Engine.uid)/\(current)"]).ok ? .partial : .applied
}
// Keep stale entries visible so an update can be followed by apply or undo.
return disabled.contains(where: { $0.hasPrefix(prefix) }) ? .partial : .notApplied
}

static func recordedLabels(_ tweak: String, journal: Engine.Journal) -> [String] {
journal.entries.compactMap { key, entry in
guard entry.tweak == tweak, key.hasPrefix("service:" + prefix),
case .service = entry.previous else { return nil }
return String(key.dropFirst("service:".count))
}.sorted()
}
}
83 changes: 83 additions & 0 deletions Sources/removemacai/SelfTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,89 @@ func selfTest() -> Bool {
Engine.folder = realFolder
Engine.journalBlocked = nil

do {
let realApp = MusicLaunch.app
let realLaunchctl = Engine.launchctl
Engine.folder = scratch
MusicLaunch.app = scratch.appendingPathComponent("Music.app")
defer {
Engine.folder = realFolder
MusicLaunch.app = realApp
Engine.launchctl = realLaunchctl
try? FileManager.default.removeItem(at: scratch)
}
var disabled: Set<String> = ["com.example.unrelated"]
var running = Set<String>()
var failEnable = Set<String>()
var failStop = false
Engine.launchctl = { args in
let label = args.last?.split(separator: "/").last.map(String.init) ?? ""
switch args.first {
case "print-disabled":
return .init(status: 0, output: disabled.sorted().map { "\"\($0)\" => disabled" }.joined(separator: "\n"))
case "print": return .init(status: running.contains(label) ? 0 : 113, output: "")
case "disable": disabled.insert(label)
case "enable":
if failEnable.contains(label) { return .init(status: 5, output: "enable failed") }
disabled.remove(label)
case "bootout":
if failStop { return .init(status: 5, output: "stop failed") }
running.remove(label)
default: return .init(status: 1, output: "unexpected launchctl operation")
}
return .init(status: 0, output: "")
}
let tweak = Tweaks.tweak("music-launches")!
var journal = Engine.Journal()
check(tweak.presets.isEmpty && !tweak.inProfile,
"blocking manual Music launches is opt-in and needs no profile")
check(!Engine.apply(tweak, journal: &journal).isEmpty && journal.entries.isEmpty
&& disabled == ["com.example.unrelated"],
"missing Music files fail without disabling an unrelated job")
do {
let executable = MusicLaunch.app.appendingPathComponent("Contents/MacOS/Music")
try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data("first".utf8).write(to: executable)
let first = try MusicLaunch.label()
running.insert(first)
check(Engine.apply(tweak, journal: &journal).isEmpty && disabled.contains(first) && running.isEmpty
&& Snapshot().state(tweak) == .applied,
"applying the Music tweak disables its launch entry and closes the running app")
try FileManager.default.moveItem(at: executable, to: scratch.appendingPathComponent("old-Music"))
try Data("replacement".utf8).write(to: executable)
let second = try MusicLaunch.label()
check(first != second && Snapshot().state(tweak) == .partial,
"replacing Music changes its launch entry and leaves the tweak visibly partial")
check(Engine.apply(tweak, journal: &journal).isEmpty && disabled.contains(second)
&& Set(MusicLaunch.recordedLabels(tweak.id, journal: journal)) == [first, second],
"reapply blocks the replacement and retains the old entry for undo")
Engine.save(journal)
failEnable.insert(first)
check(!Engine.revertAll().isEmpty && disabled.contains(first) && !disabled.contains(second)
&& MusicLaunch.recordedLabels(tweak.id, journal: Engine.loadJournal()) == [first],
"failed undo keeps only the failed entry for retry while restoring the others")
failEnable.removeAll()
check(Engine.revertAll().isEmpty && disabled == ["com.example.unrelated"]
&& Engine.loadJournal().entries.isEmpty && Snapshot().state(tweak) == .notApplied,
"retry restores all Music launch entries and leaves unrelated jobs alone")
journal = Engine.Journal()
disabled.insert(second)
check(Engine.apply(tweak, journal: &journal).isEmpty && Engine.apply(tweak, journal: &journal).isEmpty
&& Engine.revert(tweak, journal: &journal).isEmpty && disabled.contains(second) && journal.entries.isEmpty,
"undo preserves a Music entry that was disabled before applying, even after reapply")
disabled.remove(second)
running.insert(second)
failStop = true
check(!Engine.apply(tweak, journal: &journal).isEmpty && disabled.contains(second) && running.contains(second)
&& Snapshot().state(tweak) == .partial,
"failure to stop a running Music app is reported and remains repairable")
failStop = false
check(Engine.apply(tweak, journal: &journal).isEmpty && running.isEmpty
&& Engine.revert(tweak, journal: &journal).isEmpty && disabled == ["com.example.unrelated"],
"a failed stop can be retried without overwriting the original enabled state")
} catch { check(false, "Music launch lifecycle: \(error)") }
}

// Partly applied tweaks are the person's own settings until they ask.
var s = Snapshot()
s.profile = Profile.Installed(on: true, ai: false, kept: [], tweaks: ["analytics"])
Expand Down
11 changes: 11 additions & 0 deletions Sources/removemacai/Tweaks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ enum Change: Equatable {
case pref(String, String, PlistValue, currentHost: Bool = false)
/// A launchd agent in the user's session, disabled and unloaded.
case service(String)
/// Music's application job, whose label changes when macOS replaces its files.
case musicLaunches
/// Dock icons removed by bundle identifier.
case dockRemove([String])
/// ~/Library shown in Finder.
Expand All @@ -90,6 +92,7 @@ enum Change: Equatable {
case .forced(let d, let k, _): return "forced:\(d):\(k)"
case .pref(let d, let k, _, let host): return "pref:\(d):\(k)" + (host ? ":currentHost" : "")
case .service(let l): return "service:\(l)"
case .musicLaunches: return "application:com.apple.Music"
case .dockRemove(let ids): return "dock:" + ids.joined(separator: ",")
case .showLibrary: return "library"
}
Expand All @@ -112,6 +115,9 @@ enum Change: Equatable {
if case .string(let s) = v { value = "\"\(s)\"" } else { value = v.description }
return "defaults\(host ? " -currentHost" : "") write \(d) \"\(k)\" \(type) \(value)"
case .service(let l): return "launchctl disable gui/$UID/\(l) && launchctl bootout gui/$UID/\(l)"
case .musicLaunches:
guard let label = try? MusicLaunch.label() else { return "could not identify Music's application launch entry" }
return Change.service(label).command
case .dockRemove(let ids): return "remove from the Dock: " + ids.joined(separator: ", ")
case .showLibrary: return "chflags nohidden ~/Library"
}
Expand Down Expand Up @@ -201,6 +207,11 @@ enum Tweaks {
detail: "Stops the play key opening Music when nothing else is playing.",
caveat: "The play key no longer starts Music itself.",
changes: [.service("com.apple.rcd")], presets: [.recommended]),
Tweak(
id: "music-launches", group: .annoyances, title: "Block Music launches, including from AirPods",
detail: "Disables Music's application launch entry, so an AirPod press no longer opens it. No background helper or configuration profile is installed.",
caveat: "Closes Music and blocks manual launches too. Reapply after a macOS update replaces Music's files.",
changes: [.musicLaunches]),
Tweak(
id: "iphone-mirroring", group: .annoyances, title: "Turn off iPhone Mirroring",
detail: "Turns iPhone Mirroring off, so the app and its prompts go away.",
Expand Down