From 40620f3d25522267fcd0d9d3a7ff9873c798879a Mon Sep 17 00:00:00 2001 From: vadik likholetov Date: Thu, 8 Oct 2026 22:30:06 +0300 Subject: [PATCH] Block Music launches from AirPods --- README.md | 2 +- Sources/removemacai/Engine.swift | 67 +++++++++++++++------ Sources/removemacai/MusicLaunch.swift | 38 ++++++++++++ Sources/removemacai/SelfTest.swift | 83 +++++++++++++++++++++++++++ Sources/removemacai/Tweaks.swift | 11 ++++ 5 files changed, 183 insertions(+), 18 deletions(-) create mode 100644 Sources/removemacai/MusicLaunch.swift diff --git a/README.md b/README.md index f322da5..2f6fd88 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/Sources/removemacai/Engine.swift b/Sources/removemacai/Engine.swift index 92ca4d9..56222ea 100644 --- a/Sources/removemacai/Engine.swift +++ b/Sources/removemacai/Engine.swift @@ -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 } @@ -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: @@ -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 { - let out = Shell.run("/bin/launchctl", ["print-disabled", "gui/\(uid)"]).output + let out = launchctl(["print-disabled", "gui/\(uid)"]).output var labels = Set() for line in out.split(separator: "\n") { let parts = line.components(separatedBy: "=>") @@ -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] = [] @@ -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]] = [] @@ -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() diff --git a/Sources/removemacai/MusicLaunch.swift b/Sources/removemacai/MusicLaunch.swift new file mode 100644 index 0000000..1b35a65 --- /dev/null +++ b/Sources/removemacai/MusicLaunch.swift @@ -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) -> 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() + } +} diff --git a/Sources/removemacai/SelfTest.swift b/Sources/removemacai/SelfTest.swift index 9159c81..7fbe454 100644 --- a/Sources/removemacai/SelfTest.swift +++ b/Sources/removemacai/SelfTest.swift @@ -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 = ["com.example.unrelated"] + var running = Set() + var failEnable = Set() + 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"]) diff --git a/Sources/removemacai/Tweaks.swift b/Sources/removemacai/Tweaks.swift index c6e73ab..62afce4 100644 --- a/Sources/removemacai/Tweaks.swift +++ b/Sources/removemacai/Tweaks.swift @@ -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. @@ -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" } @@ -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" } @@ -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.",