diff --git a/README.md b/README.md index 442c2fb..765c74a 100644 --- a/README.md +++ b/README.md @@ -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 ` | Write the configuration profile for an MDM | +| `removemacai export --for iphone\|ipad` | Write an iPhone or iPad profile | | `removemacai revert` | Undo everything RemoveMacAI changed | | `removemacai app` | Open the app | @@ -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. diff --git a/Sources/removemacai/Mobile.swift b/Sources/removemacai/Mobile.swift new file mode 100644 index 0000000..238494f --- /dev/null +++ b/Sources/removemacai/Mobile.swift @@ -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, 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", + ] + } +} diff --git a/Sources/removemacai/SelfTest.swift b/Sources/removemacai/SelfTest.swift index a160db5..aca0d16 100644 --- a/Sources/removemacai/SelfTest.swift +++ b/Sources/removemacai/SelfTest.swift @@ -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) } diff --git a/Sources/removemacai/TweakCommands.swift b/Sources/removemacai/TweakCommands.swift index 2f7a8ca..642a439 100644 --- a/Sources/removemacai/TweakCommands.swift +++ b/Sources/removemacai/TweakCommands.swift @@ -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, 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, 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() for id in ids { guard let t = Tweaks.tweak(id) else { Term.fail("there is no tweak called \"\(id)\"") } @@ -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, 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.")) + } } diff --git a/Sources/removemacai/main.swift b/Sources/removemacai/main.swift index 6c44bd3..44e572a 100644 --- a/Sources/removemacai/main.swift +++ b/Sources/removemacai/main.swift @@ -26,6 +26,8 @@ let usage = """ removemacai clean [] show space to get back, move some to the Trash removemacai export [--preset p] [] [--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 @@ -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 { @@ -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":