Skip to content
Merged
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
18 changes: 14 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ string-payload compatibility to maintain.

`logReceived` · `statusUpdated` · `progressUpdated` · `backupsLoaded` · `tasksLoaded` ·
`restorePointsLoaded` · `settingsLoaded` · `updateAvailable` · `actionFinished` · `tweaksLoaded` ·
`snapshotCaptured` · `historyLoaded`
`snapshotCaptured` · `historyLoaded` · `debloatLoaded` · `debloatCompleted`

---

Expand Down Expand Up @@ -114,6 +114,8 @@ WebView2 and that tests capture directly.
| `Optimizer` | Runs the named **Optimization Plans** — `Quick`, `Full`, `Revert` — plus `ClearUpdateCache`, `CleanPrefetch`, `ReportDiskHealth`. These are **maintenance**: cleanup and repair, owning no Tweaks. Success is derived from **exit codes**, not stderr presence. Honors the safety-backup preference. |
| `TweakCatalog` + `ITweak` | The **Tweak** catalog: one registry, BCD, service, or power-plan optimization each, able to `Detect`, `Capture`, `Apply`, and `Revert` itself, and declaring whether it `RequiresReboot`. **Presets** are Tweak-id sets over it, keyed by the CLI task names, and may reference `Safe` Tweaks only; **Recommended** additionally excludes anything needing a restart (ADR [0003](docs/adr/0003-tweak-as-reversible-unit.md), [0005](docs/adr/0005-advanced-risk-tier.md)). |
| `TweakEngine` | Orchestrates a batch: **Safety Checkpoint**, per-Tweak capture, then apply **and revert together under that one checkpoint** (reverts first), and the before/after measurement (ADR [0004](docs/adr/0004-safety-checkpoint.md)). Reports which settings actually moved, read back off the live system. Returns facts; `ActionHost` publishes them. `ApplyPreset` is the headless CLI's entry point. |
| `DebloatCatalog` + `DebloatManager` | Curated, **allow-listed** removal of preinstalled apps — explicitly *not* Tweaks, because an uninstalled app can only be reinstalled from the Store, by the user. The catalog names each entry by an id of the app's own making and keeps the real Appx family names to itself, so **no string the frontend sends can reach a removal command**. A batch runs behind the same Safety Checkpoint, and each entry's outcome is **read back off the machine** rather than taken from an exit code. |
| `SafetyCheckpoint` | The restore point a batch of system changes runs behind, and the single decision on whether the batch may proceed without one. Shared by `TweakEngine` and `DebloatManager` so the safeguard cannot exist on only one of the paths that need it. |
| `SnapshotManager` + `ISnapshotStore` | Captures an **Optimization Snapshot** from built-in facilities only (CIM, `Get-Service`, the Diagnostics-Performance log) and appends it to the append-only JSONL **Optimization History** (ADR [0006](docs/adr/0006-built-in-only-boundary.md), [0007](docs/adr/0007-jsonl-snapshot-store.md)). |
| `RegistryBackupManager` | Exports/imports TCP/IP registry `.reg` backups and lists them as Management Records; also exports/imports arbitrary keys as a Tweak's capture archive. |
| `SystemRestoreManager` | Lists, creates, and rolls back Windows System Restore points (rollback reboots). |
Expand Down Expand Up @@ -167,7 +169,7 @@ React state is split into three focused owners instead of one mega-component:
| Hook | Owns |
| :-- | :-- |
| `useExecutionLifecycle` | The execution lock, mirroring the host: `runMutation` acquires it and only the matching `actionFinished` Event releases it; `runRead` never locks; progress is visual-only. |
| `useOsBackedLists` | The **Management Records** (backups, tasks, restore points) and the Tweak catalog. Refreshes on relevant navigation and after successful mutations; a failed read **keeps the last valid list** rather than blanking it. |
| `useOsBackedLists` | The **Management Records** (backups, tasks, restore points), the Tweak catalog, and the Debloat list. Refreshes on relevant navigation and after successful mutations; a failed read **keeps the last valid list** rather than blanking it. |
| `usePreferences` | Settings. Updates are optimistic, but the host re-emits the persisted settings after acceptance or rejection, which wins. |

### UI conventions
Expand All @@ -178,6 +180,10 @@ React state is split into three focused owners instead of one mega-component:
host reports and the action bar submits the *difference*, so one batch can both apply and revert
under a single Safety Checkpoint. A host re-emit always wins over the drawn intent, and anything
that undoes an applied Tweak passes a `ConfirmDialog` naming exactly what will be undone.
- **Removal is not a desired state.** The Debloat screen is the deliberate exception to the rule
above: a tick there means "uninstall this", nothing starts ticked (Optional least of all), and a
`ConfirmDialog` names every selected app and how it would have to come back. Nothing on that
screen calls a removal reversible, because this app cannot reverse one.
- **Tailwind design tokens only** — no inline hex/colors.
- **i18n** via Rosetta with nested keys mirrored in `pt_BR.json` / `en_US.json`. Host log/status
messages travel as **i18n keys + args** and are translated in React, so the live console follows
Expand Down Expand Up @@ -214,5 +220,9 @@ React state is split into three focused owners instead of one mega-component:
the UI.
- **Locale-neutral boundary data.** Management Records cross IPC as ISO timestamps, byte counts, and
structured schedule fields — never localized OS display strings.
- **Refresh only after success.** OS-backed lists refresh on navigation and successful mutations, and
retain their last valid state when a read fails.
- **Refresh after a mutation that ran, not only after one that succeeded.** OS-backed lists refresh
on navigation and after mutations, and retain their last valid state when a read fails. A batch
refused *before* it touched anything — an unknown id, a Safety Checkpoint that could not be built —
refreshes nothing, so a refused batch never looks like one that happened to change nothing. A
batch that ran and partly failed does refresh: a stale row claiming an app is still installed, or
a Tweak still unapplied, is worse than a partial truth.
185 changes: 185 additions & 0 deletions desktop.Tests/Features/Debloat/DebloatCatalogTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Xunit;

namespace VeloSysPro.Tests;

/// <summary>
/// The allow-list is the removal security boundary, so these tests compare the whole approved set
/// rather than sampling it, and probe the near-misses an attacker or a typo would produce.
/// </summary>
public class DebloatCatalogTests
{
/// <summary>
/// The confirmed set from `.local/grills/2026-07-25/research-catalog.md` section F, written out
/// here so the catalog is compared against the research rather than against itself.
/// </summary>
private static readonly string[] ApprovedSafePackages =
{
"MicrosoftCorporationII.QuickAssist",
"Microsoft.WindowsFeedbackHub",
"Microsoft.Copilot",
"Microsoft.BingWeather",
"MicrosoftCorporationII.MicrosoftFamily",
"Microsoft.MicrosoftOfficeHub",
"Microsoft.BingSearch",
"Clipchamp.Clipchamp",
"Microsoft.BingNews",
"Microsoft.OutlookForWindows",
"Microsoft.WindowsAlarms",
"Microsoft.MicrosoftSolitaireCollection",
};

private static readonly string[] ApprovedOptionalPackages =
{
"Microsoft.WindowsCamera",
"Microsoft.WindowsSoundRecorder",
"Microsoft.ScreenSketch",
"Microsoft.PowerAutomateDesktop",
"Microsoft.Xbox.TCUI",
"Microsoft.GamingApp",
};

private static IEnumerable<string> PackagesOf(DebloatGroup group) =>
DebloatCatalog
.CreateDefault()
.Entries.Where(entry => entry.Group == group)
.SelectMany(entry => entry.Packages);

[Fact]
public void SafeGroupIsExactlyTheConfirmedResearchSet()
{
Assert.Equal(
ApprovedSafePackages.OrderBy(name => name, StringComparer.Ordinal),
PackagesOf(DebloatGroup.Safe).OrderBy(name => name, StringComparer.Ordinal)
);
}

[Fact]
public void OptionalGroupIsExactlyTheConfirmedResearchSet()
{
Assert.Equal(
ApprovedOptionalPackages.OrderBy(name => name, StringComparer.Ordinal),
PackagesOf(DebloatGroup.Optional).OrderBy(name => name, StringComparer.Ordinal)
);
}

[Fact]
public void EveryApprovedPackageAppearsExactlyOnce()
{
List<string> all = DebloatCatalog
.CreateDefault()
.Entries.SelectMany(entry => entry.Packages)
.ToList();

Assert.Equal(all.Count, all.Distinct(StringComparer.Ordinal).Count());
}

[Fact]
public void OneDriveIsOptionalAndRemovedThroughItsOwnUninstaller()
{
DebloatEntry? oneDrive = DebloatCatalog.CreateDefault().Find("oneDrive");

Assert.NotNull(oneDrive);
Assert.Equal(DebloatGroup.Optional, oneDrive!.Group);
Assert.Equal(DebloatRemoval.OneDriveUninstaller, oneDrive.Removal);
// It is not an Appx package, so nothing may reach Remove-AppxPackage on its behalf.
Assert.Empty(oneDrive.Packages);
}

[Fact]
public void XboxTargetsOnlyTheTwoDeclaredPackages()
{
DebloatEntry? xbox = DebloatCatalog.CreateDefault().Find("xbox");

Assert.NotNull(xbox);
Assert.Equal(
new[] { "Microsoft.GamingApp", "Microsoft.Xbox.TCUI" },
xbox!.Packages.OrderBy(name => name, StringComparer.Ordinal)
);
}

[Theory]
// An arbitrary package that was never approved.
[InlineData("Microsoft.WindowsStore")]
// Prefix- and suffix-similar near-misses of an approved entry.
[InlineData("weatherApp")]
[InlineData("weathe")]
[InlineData("Weather")]
[InlineData("Microsoft.BingWeather")]
// Wildcards, which is how a broad substring removal would be smuggled in.
[InlineData("*")]
[InlineData("weather*")]
[InlineData("*.Bing*")]
// Structurally malformed ids.
[InlineData("")]
[InlineData(" ")]
[InlineData("weather;calc")]
public void RejectsAnythingThatIsNotAnExactCatalogId(string id)
{
Assert.Null(DebloatCatalog.CreateDefault().Find(id));
}

[Fact]
public void FindMatchesTheCatalogIdExactly()
{
Assert.NotNull(DebloatCatalog.CreateDefault().Find("weather"));
}

[Fact]
public void EveryEntryCarriesAUniqueId()
{
List<string> ids = DebloatCatalog.CreateDefault().Entries.Select(entry => entry.Id).ToList();

Assert.Equal(ids.Count, ids.Distinct(StringComparer.Ordinal).Count());
}

[Fact]
public void RefusesToConstructWithAWildcardPackageName()
{
// The catalog is written by hand, so this guard protects the one mistake that would turn a
// curated list into a broad removal: a wildcard slipping into an entry.
Assert.Throws<ArgumentException>(() =>
new DebloatCatalog(
new[]
{
new DebloatEntry("news", DebloatGroup.Safe, new[] { "Microsoft.Bing*" }, DebloatRemoval.Appx),
}
)
);
}

[Fact]
public void RefusesToConstructAnAppxEntryWithNoPackages()
{
Assert.Throws<ArgumentException>(() =>
new DebloatCatalog(
new[] { new DebloatEntry("news", DebloatGroup.Safe, Array.Empty<string>(), DebloatRemoval.Appx) }
)
);
}

[Fact]
public void RefusesToConstructWithDuplicateIds()
{
Assert.Throws<ArgumentException>(() =>
new DebloatCatalog(
new[]
{
new DebloatEntry("news", DebloatGroup.Safe, new[] { "Microsoft.BingNews" }, DebloatRemoval.Appx),
new DebloatEntry("news", DebloatGroup.Optional, new[] { "Microsoft.BingWeather" }, DebloatRemoval.Appx),
}
)
);
}

[Fact]
public void EveryIdIsUsableAsAnI18nPathSegment()
{
// The frontend builds `debloat.package.<id>.title` and rosetta splits that on dots, so a
// dotted id would look up a nesting level the locale files do not have.
foreach (DebloatEntry entry in DebloatCatalog.CreateDefault().Entries)
Assert.Matches("^[a-z][A-Za-z0-9]*$", entry.Id);
}
}
Loading