diff --git a/Sources/removemacai/App/AppModel.swift b/Sources/removemacai/App/AppModel.swift index 7265409..82acb3e 100644 --- a/Sources/removemacai/App/AppModel.swift +++ b/Sources/removemacai/App/AppModel.swift @@ -49,6 +49,8 @@ final class AppModel { // What the person wants. var wanted: Set = [] + /// Tweaks the person switched, so a partly applied one is only undone on request. + var touched: Set = [] var aiOff = false var aiKept: Set = [] @@ -88,6 +90,7 @@ final class AppModel { journal = Engine.loadJournal() if resetChoices { wanted = Set(states.filter { $0.value == .applied }.map(\.key)) + touched = [] aiOff = currentAI != nil aiKept = snapshot.profile.ai ? snapshot.profile.kept : [] } @@ -127,9 +130,24 @@ final class AppModel { func isOn(_ tweak: Tweak) -> Bool { wanted.contains(tweak.id) } func toggle(_ tweak: Tweak, _ on: Bool) { + guard wanted.contains(tweak.id) != on else { return } + touched.insert(tweak.id) if on { wanted.insert(tweak.id) } else { wanted.remove(tweak.id) } } + /// Drops any choice made for the tweak, so it stays as it is on the Mac. + func leave(_ tweak: Tweak) { + wanted.remove(tweak.id) + touched.remove(tweak.id) + } + + /// Whether leaving the tweak unwanted undoes it. + func undoes(_ tweak: Tweak) -> Bool { + let s = state(tweak) + guard !wanted.contains(tweak.id), s != .notApplied, s != .managed, s != .unsupported else { return false } + return s != .partial || touched.contains(tweak.id) + } + func choose(_ preset: Preset) { wanted.formUnion(Tweaks.preset(preset).map(\.id)) aiOff = true @@ -143,9 +161,8 @@ final class AppModel { Tweaks.all.compactMap { t in let s = state(t) guard s != .managed && s != .unsupported else { return nil } - let want = wanted.contains(t.id) - if want && s != .applied { return (t, true) } - if !want && s != .notApplied { return (t, false) } + if wanted.contains(t.id) && s != .applied { return (t, true) } + if undoes(t) { return (t, false) } return nil } } @@ -157,7 +174,7 @@ final class AppModel { func discard() { reload(resetChoices: true) } - func plan() -> Plan { Plan.make(wanted: wanted, ai: targetAI, snapshot: snapshot) } + func plan() -> Plan { Plan.make(wanted: wanted, ai: targetAI, undo: touched, snapshot: snapshot) } // MARK: applying diff --git a/Sources/removemacai/App/Pages.swift b/Sources/removemacai/App/Pages.swift index aadf4d3..eff3c32 100644 --- a/Sources/removemacai/App/Pages.swift +++ b/Sources/removemacai/App/Pages.swift @@ -218,7 +218,14 @@ struct TweakGroupView: View { Menu { Button("Select All") { for t in tweaks where t.supported { model.toggle(t, true) } } Button("Select Recommended") { - for t in tweaks { model.toggle(t, t.presets.contains(.recommended) || model.state(t) == .applied) } + for t in tweaks { + let recommended = t.presets.contains(.recommended) + if model.state(t) == .partial && !recommended { + model.leave(t) + } else { + model.toggle(t, recommended || model.state(t) == .applied) + } + } } Button("Deselect All") { for t in tweaks { model.toggle(t, false) } } } label: { @@ -244,7 +251,7 @@ struct TweakRow: View { Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.tertiary) .help("Locked by the RemoveMacAI profile") } - if let note = pendingNote(state: state, wanted: wanted) { + if let note = pendingNote(state: state, wanted: wanted, undoes: model.undoes(tweak)) { Text(note).font(.caption.weight(.medium)).foregroundStyle(.tint) } } @@ -257,7 +264,7 @@ struct TweakRow: View { } else if state == .unsupported { Text("Needs macOS \(tweak.since.0).\(tweak.since.1) or newer.").font(.caption).foregroundStyle(.secondary) } else if state == .partial { - Text("Partly applied.").font(.caption).foregroundStyle(.secondary) + Text("Partly applied. It stays as it is unless you switch it.").font(.caption).foregroundStyle(.secondary) } } .padding(.vertical, 2) @@ -266,9 +273,9 @@ struct TweakRow: View { .disabled(state == .managed || state == .unsupported) } - func pendingNote(state: TweakState, wanted: Bool) -> String? { + func pendingNote(state: TweakState, wanted: Bool, undoes: Bool) -> String? { if wanted && state != .applied { return "Applies on review" } - if !wanted && state != .notApplied && state != .managed && state != .unsupported { return "Undoes on review" } + if undoes { return "Undoes on review" } return nil } } diff --git a/Sources/removemacai/Engine.swift b/Sources/removemacai/Engine.swift index 68ffc81..ece6012 100644 --- a/Sources/removemacai/Engine.swift +++ b/Sources/removemacai/Engine.swift @@ -66,18 +66,28 @@ struct Plan { /// From the tweaks that should end up applied and what Apple Intelligence /// should look like (nil leaves it alone, a set turns it off except those). - static func make(wanted: Set, ai: Set?, snapshot s: Snapshot = Snapshot()) -> Plan { + /// A partly applied tweak is often the person's own setting, so it is only + /// undone when named in `undo`. + static func make( + wanted: Set, ai: Set?, undo: Set = [], snapshot s: Snapshot = Snapshot(), + state read: ((Tweak) -> TweakState)? = nil + ) -> Plan { + let stateOf = read ?? s.state var plan = Plan() + func keepsPartial(_ tweak: Tweak) -> Bool { stateOf(tweak) == .partial && !undo.contains(tweak.id) } for tweak in Tweaks.all { - let state = s.state(tweak) + let state = stateOf(tweak) guard state != .managed && state != .unsupported else { continue } if wanted.contains(tweak.id) && state != .applied { plan.apply.append(tweak) - } else if !wanted.contains(tweak.id) && state != .notApplied { + } else if !wanted.contains(tweak.id) && state != .notApplied && !keepsPartial(tweak) { plan.revert.append(tweak) } } - let profileTweaks = Set(Tweaks.all.filter { $0.inProfile && wanted.contains($0.id) && s.state($0) != .managed && $0.supported }.map(\.id)) + let profileTweaks = Set(Tweaks.all.filter { + $0.inProfile && stateOf($0) != .managed && $0.supported + && (wanted.contains($0.id) || (keepsPartial($0) && s.profile.tweaks.contains($0.id))) + }.map(\.id)) let target = Profile.Contents(ai: ai, tweaks: profileTweaks) let current = s.profile.contents ?? Profile.Contents(ai: nil, tweaks: []) if target != current { plan.profile = target } diff --git a/Sources/removemacai/SelfTest.swift b/Sources/removemacai/SelfTest.swift index e260482..ad576c4 100644 --- a/Sources/removemacai/SelfTest.swift +++ b/Sources/removemacai/SelfTest.swift @@ -217,6 +217,40 @@ func selfTest() -> Bool { program: "/Library/PrivilegedHelperTools/dev.orbstack.OrbStack.privhelper", system: true) check(script.owner == "sync.sh" && helper.owner == "OrbStack", "background items are named after what they run") + + // 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"]) + let states: [String: TweakState] = ["smart-punctuation": .partial, "autocorrect": .applied, "analytics": .partial] + let fake: (Tweak) -> TweakState = { states[$0.id] ?? .notApplied } + let applyOne = Plan.make(wanted: ["autocorrect", "file-extensions"], ai: nil, snapshot: s, state: fake) + check(applyOne.apply.map(\.id) == ["file-extensions"] && applyOne.revert.isEmpty && applyOne.profile == nil, + "applying a tweak leaves partly applied ones and the profile alone") + let undoOne = Plan.make(wanted: ["autocorrect"], ai: nil, undo: ["smart-punctuation"], snapshot: s, state: fake) + check(undoOne.revert.map(\.id) == ["smart-punctuation"] && undoOne.profile == nil, + "a partly applied tweak is undone when named") + let undoLocked = Plan.make(wanted: ["autocorrect"], ai: nil, undo: ["analytics"], snapshot: s, state: fake) + check(undoLocked.profile?.tweaks == [] && undoLocked.revert.map(\.id) == ["analytics"], + "a partly applied profile tweak leaves the profile when named") + + MainActor.assumeIsolated { + let model = AppModel() + let partialTweak = Tweaks.tweak("smart-punctuation")! + model.states = Dictionary(uniqueKeysWithValues: Tweaks.all.map { ($0.id, TweakState.notApplied) }) + model.states[partialTweak.id] = .partial + model.wanted = [] + model.touched = [] + model.toggle(partialTweak, false) + check(model.pendingTweaks.isEmpty, "switching off a partly applied tweak that is already off changes nothing") + model.toggle(partialTweak, true) + model.leave(partialTweak) + check(model.pendingTweaks.isEmpty, "leaving a partly applied tweak drops an earlier selection") + model.toggle(partialTweak, true) + model.toggle(partialTweak, false) + check(model.pendingTweaks.map(\.tweak.id) == [partialTweak.id] && model.pendingTweaks.first?.apply == false, + "switching a partly applied tweak on and off undoes it") + } + print(failed == 0 ? Term.green("all checks passed") : Term.red("\(failed) failed")) return failed == 0 } diff --git a/Sources/removemacai/TweakCommands.swift b/Sources/removemacai/TweakCommands.swift index 0c11e7d..99d16b7 100644 --- a/Sources/removemacai/TweakCommands.swift +++ b/Sources/removemacai/TweakCommands.swift @@ -65,7 +65,7 @@ enum TweakCommands { wanted.remove(id) } let ai: Set? = s.profile.on && s.profile.ai ? s.profile.kept : nil - let plan = Plan.make(wanted: wanted, ai: ai, snapshot: s) + let plan = Plan.make(wanted: wanted, ai: ai, undo: Set(ids), snapshot: s) return run(plan, dryRun: dryRun, yes: yes, verb: "Undo") }