feat(debloat): allow-listed Appx removal with per-package results - #45
Merged
Conversation
Debloat is deliberately not a Tweak: an uninstalled app cannot be put back by this app, only reinstalled from the Store. So it gets its own boundary rather than a reversible one it could not honour. The allow-list is a translation, not a filter. A DebloatEntry carries an id of our own making (weather, xbox, oneDrive) and keeps the real Appx family names inside DebloatCatalog.cs, so the IPC payload only names ids and the removal command only names catalog constants. No string a payload can carry reaches a command, which closes the near-match class by construction; it also keeps ids usable as i18n path segments, which a dotted package name is not. SafetyCheckpoint is extracted from TweakEngine rather than copied, so the restore point cannot exist on only one of the paths that need it — Debloat needs it most, being the one action with no in-app undo. Its log keys move to log.checkpoint.*, and the preference now lives on the shared object. Per-entry outcomes are read back off the machine: Remove-AppxPackage can decline a package Windows considers in use and still exit 0, the same lesson Checkpoint-Computer taught, applied before it could bite. The screen is not a desired-state model — there is no un-remove to express. Nothing starts ticked, Optional sits in its own warned section, and the confirmation names every app and how each would come back. 321 xUnit, 316 Vitest, 127 Cypress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The read-back was right; what it read through was not. ReadInstalledPackageNames returned an empty set when the query failed. On the load path that is the safe direction — the UI then offers nothing to remove. On the read-back after a removal the same answer means the opposite, and every selected app would have been reported as removed on the strength of a query that never ran. It was not expressible in the harness either: FakeCommandRunner returned Success: true for every scripted answer, so "the machine said nothing" and "the query did not run" were one string. FailingCapturesByArgs tells them apart, and the read is tri-state now. Three things must be true before an app may be called removed: a removal was really issued for it, the verification read really answered, and it is not in what that answer contained. The first clause also closes a quieter hole around OneDrive, whose absence marker reads the same whether this app removed it or it was never installed at all. From the Standards axis: Debloat's action bar is a byte-identical copy of Optimize's and had inherited its fix but not its measured guard. Both bars are measured now — dropping mt-auto leaves 428px of padding below the bar, which the guard catches. The raw rgba that copy carried across is a shadow-action-bar token, in both pages. 324 xUnit, 316 Vitest, 131 Cypress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both branches invented a SafetyCheckpoint independently, which is the strongest evidence the extraction was right — and #46's is the better one. It also owns the maintenance registry backup, keeps the log.tweaks.* keys, and exposes CreateSafetyBackupEnabled as a forwarding property that ActionRegistry.SaveSettings already writes to. Taken wholesale; the log.checkpoint.* rename this branch carried is dropped, in code, locales and tests. Debloat routing moved from ActionHost into ActionRegistry, where routing now lives: ActionHost keeps concurrency, dispatch and actionFinished, per ADR 0009. loadDebloat, runDebloat, the mutation entry and PushDebloat are re-expressed there unchanged. DebloatManager shares the window's checkpoint with Optimizer, so the "create a safety backup" preference reaches it through the object SaveSettings already writes to rather than a second instance. Frontend: sendAction is typed against SystemActionPayloads now, so both Debloat actions are declared there — runDebloat carries catalog ids, never Appx family names. App.tsx keeps the per-run debloatResults state alongside the useHostMutation/useLogBuffer split. 346 xUnit, 327 Vitest, 131 Cypress; validation ran green before this commit, which skipped the hook only because commitlint rejects "merge:". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chrystiamjr
marked this pull request as ready for review
August 21, 2026 06:44
chrystiamjr
pushed a commit
that referenced
this pull request
Aug 21, 2026
## [0.4.0](v0.3.0...v0.4.0) (2026-08-21) ### Features * **debloat:** allow-listed Appx removal with per-package results ([#45](#45)) ([b0bb69f](b0bb69f))
Owner
Author
|
🎉 This PR is included in version 0.4.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes E4 — Debloat (
E4-01…E4-04). Curated, allow-listed removal of preinstalled apps, behind a restore point, with per-package results.Draft: E4 has not been exercised on hardware. See Before this merges below.
The allow-list is a translation, not a filter
The obvious shape is "validate the package name the frontend sent". This does not do that.
A
DebloatEntrycarries an id of the app's own making —weather,xbox,oneDrive— and keeps the real Appx family names (Microsoft.BingWeather, …) insideDebloatCatalog.cs. The IPC payload only ever names ids; the removal command only ever names catalog constants. There is therefore no string a payload can carry that reaches a command — the worst a malformed one can do is fail to match an id.That closes the near-match class by construction rather than by pattern. Submitting the real package name is rejected exactly like a wildcard is, and a Cypress spec asserts the same thing from the other side: no package family name appears anywhere in what the frontend puts on the wire.
It also solved a problem the Tweak catalog does not have — a dotted package name cannot be an i18n path segment, because rosetta splits on dots.
Not a Tweak, and never presented as one
An uninstalled app cannot be put back by this app, only reinstalled from the Store by the user. So Debloat is deliberately not a desired-state screen the way Optimize is — there is no un-remove to express. A tick means "uninstall this". Nothing starts ticked, Optional sits in its own warned section, and the confirmation names every app plus how each one would have to come back.
SafetyCheckpointis extracted fromTweakEnginerather than copied. Debloat needs the restore point more than a Tweak batch does, since it is the only way back at all — and a safeguard that exists on one of the paths that need it is a failure this project has already had once. Its log keys moved tolog.checkpoint.*, and the shared preference now lives on the shared object.Outcomes are read off the machine
Remove-AppxPackagecan decline a package Windows considers in use and still leave a zero behind it — the same lessonCheckpoint-Computertaught, applied before it could bite. The batch re-reads the installed set afterwards and reports each entry by what it finds.The second commit fixes a defect the Spec review found in that guard: the read returned an empty set when the query failed, which is the safe direction on the load path but the exact opposite on the read-back, where it would have reported every selected app as removed on the strength of a query that never ran. It was not even expressible in the harness — the fake returned success for every scripted answer — so
FailingCapturesByArgsnow tells "the machine said nothing" from "the query did not run", and the read is tri-state.Three things must now be true before an app may be called removed: a removal was really issued for it, the verification read really answered, and the app is not in what it answered. The first clause also closes a quieter hole around OneDrive, whose absence marker reads identically whether this app removed it or it was never installed there at all.
Validation
npm run validateanddotnet buildclean.Four guards were proven by injecting the regression each targets, per
.agents/rules/falsifiable-test-guards.md— the mutation lock, the near-match id rejection, both new i18n copy guards, and the measured action bar (droppingmt-autoleaves 428px and 586px of padding below it, and the guard catches both).Deliberate deviations
debloatLoadedpayload carries no label/description keys, though E4-02's Scope lists them. Doing so would undo the invariant that makes the copy guard possible: no C# literal ties a row to its title, which is exactly whyi18n.test.tsreads the ids out of the catalog source.RunDebloathand-rolls its refresh instead of callingMutateAndRefresh. So doesApplyTweaks, for the same reason — both refresh conditionally on whether the batch really ran.ARCHITECTURE.md's "refresh only after success" was amended to describe what both actually do.Microsoft.GamingAppandMicrosoft.Xbox.TCUIare one decision to the user.Before this merges
Get-AppxPackage's real output shape, and whetherRemove-AppxPackagereally does exit 0 on a package it declined — the read-back exists for exactly that, and hardware is where it gets proven.semantic-releasewill derive the version from these commit types, build, tag, and attach the installer to a public Release.🤖 Generated with Claude Code