Skip to content
Open
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ Two presets get you started. **Recommended** turns Apple Intelligence, analytics
| `removemacai background` | List other apps' background items; `off` or `on` with their labels |
| `removemacai clean` | Show the space you can get back; name items to move them to the Trash |
| `removemacai export <file>` | Write the configuration profile for an MDM |
| `removemacai export <file> --for iphone\|ipad` | Write an iPhone or iPad profile |
| `removemacai revert` | Undo everything RemoveMacAI changed |
| `removemacai app` | Open the app |

Expand All @@ -123,6 +124,17 @@ removemacai export Privacy.mobileconfig --no-ai analytics personalized-ads spotl

It holds the Apple Intelligence restrictions, the download block for removed models and the locked tweaks. Per-user tweaks such as Finder and Dock settings aren't in the profile; `removemacai apply` sets those on each Mac. `removemacai tweaks --json` reports the state of every tweak for compliance scripts.

## iPhone and iPad

`export --for iphone` or `export --for ipad` writes a profile for that device:

```sh
removemacai export Phone.mobileconfig --for iphone
removemacai export Pad.mobileconfig --for ipad
```

An unsupervised phone only honors the Siri and ChatGPT switches.

## How it works

- **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.
Expand Down
114 changes: 114 additions & 0 deletions Sources/removemacai/Mobile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import Foundation

/// A configuration profile that turns Apple Intelligence off on an iPhone or
/// iPad. It uses the same restriction keys as the Mac profile where Apple
/// documents them for iOS and iPadOS. Forced preferences and model deletion
/// stay on the Mac: iOS and iPadOS do not let a profile delete the models.
enum Mobile {
enum Device: String {
case iPhone
case iPad

/// `ios` is the iPhone profile. `ipados` is the iPad profile.
init?(argument: String) {
switch argument.lowercased() {
case "iphone", "ios": self = .iPhone
case "ipad", "ipados": self = .iPad
default: return nil
}
}

var profileIdentifier: String { "io.github.omlahore.removemacai.\(rawValue.lowercased())" }
var systemName: String { self == .iPhone ? "iOS" : "iPadOS" }
}

/// One Apple Intelligence feature a profile can switch off.
/// `supervised` is Apple's rule: the device has to be supervised or the
/// switch is ignored. Siri and the ChatGPT extension do not need that.
struct Feature {
let id: String
let title: String
let restrictions: [String]
let supervised: Bool
}

static let features: [Feature] = [
Feature(
id: "siri", title: "Siri and Siri AI",
restrictions: ["allowAssistant", "allowSiriAI"], supervised: false),
Feature(
id: "chatgpt", title: "ChatGPT and other AI extensions",
restrictions: [
"allowExternalIntelligenceIntegrations", "allowExternalIntelligenceIntegrationsSignIn",
], supervised: false),
Feature(
id: "writing-tools", title: "Writing Tools",
restrictions: ["allowWritingTools"], supervised: true),
Feature(
id: "genmoji", title: "Genmoji",
restrictions: ["allowGenmoji"], supervised: true),
Feature(
id: "image-playground", title: "Image Playground",
restrictions: ["allowImagePlayground"], supervised: true),
Feature(
id: "image-wand", title: "Image Wand",
restrictions: ["allowImageWand"], supervised: true),
Feature(
id: "mail", title: "Mail summaries and smart replies",
restrictions: ["allowMailSummary", "allowMailSmartReplies"], supervised: true),
Feature(
id: "safari-summaries", title: "Safari summaries",
restrictions: ["allowSafariSummary"], supervised: true),
Feature(
id: "notes-summaries", title: "Notes transcription summaries",
restrictions: ["allowNotesTranscriptionSummary"], supervised: true),
Feature(
id: "visual-intelligence", title: "Visual Intelligence summaries",
restrictions: ["allowVisualIntelligenceSummary"], supervised: true),
]

static func feature(_ id: String) -> Feature? { features.first { $0.id == id } }

/// iPhone and iPad use the same restriction keys. Apple's schema lists them under iOS.
static func features(for _: Device) -> [Feature] { features }

/// The profile for `device`, with every feature off except `kept`.
static func data(keeping kept: Set<String>, device: Device) throws -> Data {
var restrictions = payload(device: device)
for feature in features(for: device) where !kept.contains(feature.id) {
for key in feature.restrictions { restrictions[key] = false }
}
let supervised = features(for: device).filter(\.supervised).map(\.title).joined(separator: ", ")
let mailNote = kept.contains("mail")
? ""
: " Mail summaries turn off the ones you start yourself. Mail can still write them on its own."
let description = """
Turns off Apple Intelligence on this \(device.rawValue). Remove the profile and the switches come back. \
\(device.systemName) does not let a profile delete the model files. \
Siri applies without supervision. Siri AI and ChatGPT extensions apply without supervision, and Shared iPad ignores them. \(supervised) apply on a supervised device. Shared iPad ignores those too.\(mailNote)
"""
let profile: [String: Any] = [
"PayloadType": "Configuration",
"PayloadVersion": 1,
"PayloadIdentifier": device.profileIdentifier,
"PayloadUUID": Profile.uuid(device.profileIdentifier),
"PayloadDisplayName": "RemoveMacAI",
"PayloadDescription": description,
"PayloadOrganization": "RemoveMacAI",
"PayloadRemovalDisallowed": false,
"PayloadContent": [restrictions],
]
return try PropertyListSerialization.data(fromPropertyList: profile, format: .xml, options: 0)
}

private static func payload(device: Device) -> [String: Any] {
let id = device.profileIdentifier + ".restrictions"
return [
"PayloadType": "com.apple.applicationaccess",
"PayloadVersion": 1,
"PayloadIdentifier": id,
"PayloadUUID": Profile.uuid(id),
"PayloadDisplayName": "Apple Intelligence",
]
}
}
40 changes: 40 additions & 0 deletions Sources/removemacai/SelfTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,46 @@ func selfTest() -> Bool {
} catch {
check(false, "the profile builds: \(error)")
}

do {
check(Mobile.Device(argument: "ios") == .iPhone && Mobile.Device(argument: "ipados") == .iPad
&& Mobile.Device(argument: "phone") == nil,
"iphone and ipad profile targets accept the OS names")
check(Set(Mobile.features.map(\.id)).count == Mobile.features.count, "iPhone and iPad feature names are unique")
let phone = try PropertyListSerialization.propertyList(from: Mobile.data(keeping: [], device: .iPhone), format: nil) as! [String: Any]
let pad = try PropertyListSerialization.propertyList(from: Mobile.data(keeping: ["writing-tools"], device: .iPad), format: nil) as! [String: Any]
check(phone["PayloadScope"] == nil && pad["PayloadScope"] == nil
&& phone["PayloadRemovalDisallowed"] as? Bool == false,
"an iPhone or iPad profile is removable and is not a Mac system profile")
let phonePayloads = phone["PayloadContent"] as! [[String: Any]]
let padPayloads = pad["PayloadContent"] as! [[String: Any]]
let phoneRestrictions = phonePayloads[0]
let padRestrictions = padPayloads[0]
check(phonePayloads.count == 1 && phonePayloads[0]["PayloadType"] as? String == "com.apple.applicationaccess"
&& padPayloads.count == 1,
"an iPhone profile carries restrictions only, with no Mac preference payloads")
check(phoneRestrictions["allowWritingTools"] as? Bool == false
&& phoneRestrictions["allowAssistant"] as? Bool == false
&& phoneRestrictions["allowSiriAI"] as? Bool == false
&& phoneRestrictions["allowImageWand"] as? Bool == false
&& phoneRestrictions["allowVisualIntelligenceSummary"] as? Bool == false
&& phoneRestrictions["allowDiagnosticSubmission"] == nil,
"an iPhone profile turns Writing Tools, Siri, Image Wand, and Visual Intelligence summaries off")
check(padRestrictions["allowWritingTools"] == nil
&& padRestrictions["allowGenmoji"] as? Bool == false
&& padRestrictions["allowImageWand"] as? Bool == false
&& padRestrictions["allowExternalIntelligenceIntegrations"] as? Bool == false
&& padRestrictions["allowVisualIntelligenceSummary"] as? Bool == false,
"a kept iPad feature is left alone and iPad turns Visual Intelligence summaries off")
let phoneDescription = phone["PayloadDescription"] as? String ?? ""
check(phoneDescription.contains("Mail can still write them on its own")
&& phoneDescription.contains("Visual Intelligence summaries")
&& phoneDescription.contains("Shared iPad ignores them")
&& !phoneDescription.contains("any device"),
"the profile names its Mail and Visual Intelligence limits and does not promise Siri AI on Shared iPad")
} catch {
check(false, "the iPhone and iPad profiles build: \(error)")
}
// The debloater catalog.
check(Set(Tweaks.all.map(\.id)).count == Tweaks.all.count, "tweak names are unique")
let changeKeys = Tweaks.all.flatMap { $0.changes.map(\.key) }
Expand Down
40 changes: 39 additions & 1 deletion Sources/removemacai/TweakCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,12 @@ enum TweakCommands {
// MARK: export

/// Writes the profile for a preset or named tweaks, for deploying with an MDM.
static func export(path: String, ids: [String], preset: Preset?, keepAI: Set<String>, noAI: Bool) {
/// `device` writes an iPhone or iPad profile that only turns Apple Intelligence off.
static func export(path: String, ids: [String], preset: Preset?, keepAI: Set<String>, noAI: Bool, device: Mobile.Device? = nil) {
if let device {
exportMobile(path: path, device: device, ids: ids, preset: preset, keepAI: keepAI, noAI: noAI)
return
}
var tweaks = Set<String>()
for id in ids {
guard let t = Tweaks.tweak(id) else { Term.fail("there is no tweak called \"\(id)\"") }
Expand All @@ -256,4 +261,37 @@ enum TweakCommands {
print(Term.dim(" Tweaks: \(tweaks.isEmpty ? "none" : tweaks.sorted().joined(separator: ", "))"))
print(Term.dim(" Per-user tweaks (Finder, Dock, typing) aren't in profiles; run removemacai on each Mac for those."))
}

/// An iPhone or iPad profile. Tweaks and model deletion are Mac-only.
private static func exportMobile(path: String, device: Mobile.Device, ids: [String], preset: Preset?, keepAI: Set<String>, noAI: Bool) {
if !ids.isEmpty || preset != nil {
Term.fail("an \(device.rawValue) profile only turns Apple Intelligence off. Leave out tweak names and --preset")
}
if noAI { Term.fail("--no-ai leaves Apple Intelligence alone, so an \(device.rawValue) profile would be empty") }
let names = Mobile.features(for: device).map(\.id).joined(separator: ", ")
for id in keepAI.sorted() {
guard Mobile.feature(id) != nil else {
Term.fail("there is no \(device.rawValue) feature called \"\(id)\". Names: \(names)")
}
}
let off = Mobile.features(for: device).filter { !keepAI.contains($0.id) }
guard !off.isEmpty else { Term.fail("nothing would be turned off. Leave a feature out of --keep") }
do {
try Mobile.data(keeping: keepAI, device: device).write(to: URL(fileURLWithPath: path))
} catch { Term.fail("could not write \(path): \(error)") }
print(Term.green("✓") + " Wrote \(path) for \(device.rawValue) (\(device.systemName))")
print(Term.dim(" Off: \(off.map(\.title).joined(separator: ", "))"))
if !keepAI.isEmpty { print(Term.dim(" Kept on: \(keepAI.sorted().joined(separator: ", "))")) }
let supervised = off.filter(\.supervised).map(\.title)
if !supervised.isEmpty {
print(Term.dim(" A supervised \(device.rawValue) honors: \(supervised.joined(separator: ", ")). An unsupervised one ignores those."))
}
print(Term.dim(" Siri applies without supervision. Siri AI and ChatGPT extensions do too, except Shared iPad ignores them."))
if off.contains(where: { $0.id == "mail" }) {
print(Term.dim(" Mail summaries turn off the ones you start yourself. Mail can still write them on its own."))
}
print(Term.dim(" \(device.systemName) keeps the model files. Removing the profile turns the switches back on."))
print(Term.dim(" Shared iPad ignores the supervised switches."))
print(Term.dim(" Install it from Settings > General > VPN & Device Management."))
}
}
13 changes: 12 additions & 1 deletion Sources/removemacai/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ let usage = """
removemacai clean [<names>] show space to get back, move some to the Trash
removemacai export <file.mobileconfig> [--preset p] [<names>] [--keep a,b] [--no-ai]
write the profile for an MDM
--for iphone|ipad write an iPhone (ios) or iPad (ipados) profile instead.
It turns Apple Intelligence off. Models stay on the device.

removemacai revert undo everything RemoveMacAI changed
removemacai app open the RemoveMacAI app
Expand Down Expand Up @@ -80,6 +82,14 @@ func unknownCommandMessage(_ name: String) -> String {
if let hint = suggestCommand(for: name) { message += ". Did you mean \"\(hint)\"?" }
return message
}
func mobileDeviceOption() -> Mobile.Device? {
guard let name = option("--for") else { return nil }
guard let device = Mobile.Device(argument: name) else {
Term.fail("unknown device \"\(name)\". Use iphone, ios, ipad, or ipados")
}
return device
}

func presetOption() -> Preset? {
guard let name = option("--preset") else { return nil }
guard let preset = Preset(rawValue: name) else {
Expand Down Expand Up @@ -159,9 +169,10 @@ case "export":
let preset = presetOption()
let keep = Set((option("--keep") ?? "").split(separator: ",").map { String($0).trimmed }.filter { !$0.isEmpty })
let noAI = flag("--no-ai")
let device = mobileDeviceOption()
if let bad = unknownOption(in: args) { Term.fail("unknown option \(bad)") }
guard let path = args.first else { Term.fail("name the file to write, for example: removemacai export RemoveMacAI.mobileconfig") }
TweakCommands.export(path: path, ids: Array(args.dropFirst()), preset: preset, keepAI: keep, noAI: noAI)
TweakCommands.export(path: path, ids: Array(args.dropFirst()), preset: preset, keepAI: keep, noAI: noAI, device: device)
case "selftest":
exit(selfTest() ? 0 : 1)
case "--version", "-v", "version":
Expand Down