Skip to content

feat: Disable Application Experience scheduled tasks - #59

Merged
carterscode merged 3 commits into
mainfrom
feat/disable-app-experience-tasks
Jun 27, 2026
Merged

feat: Disable Application Experience scheduled tasks#59
carterscode merged 3 commits into
mainfrom
feat/disable-app-experience-tasks

Conversation

@carterscode

Copy link
Copy Markdown
Owner

What & why

Adds a catalog-driven feature that keeps the five Windows Application Experience scheduled tasks (Microsoft Compatibility Appraiser + siblings) disabled, re-asserting on drift. These tasks drive CompatTelRunner.exe, whose periodic compatibility/telemetry scans spike CPU and disk (the "Microsoft Compatibility Telemetry" process) — an unpredictable hitch source during gaming. Disabling is safe (Windows Update keeps working) and reversible, but feature updates re-enable them, so monitoring + auto-apply is the differentiated value. Additive to the existing DiagTrack telemetry-service disable, since the heavy scan is launched by the task.

Mirrors the existing Windows-services feature: catalog → monitor → elevated controller.

Key decisions

  • New ScheduledTaskCatalog / ScheduledTaskMonitor / ScheduledTaskController trio (not an overloaded service catalog) — task path vs. service name, schtasks vs. sc.
  • schtasks mechanism, not registry: disabling the task is the only supported lever that stops the scans.
  • State read via schtasks /Query /XML (locale-independent <Settings><Enabled>) without elevation; disable/enable via elevated schtasks /Change, System32-absolute paths + shell-metacharacter injection guard (mirrors ElevatedRegistry).
  • All five tasks ship in the Recommended and Extreme presets; Reset un-manages them (does not re-enable — the telemetry tasks are harmless left disabled).
  • Manual Apply disables an unmonitored task ("do it now" ignores IsMonitored); fully-unmanaged tasks (default + unmonitored) skip the background query so there's no idle schtasks spawn.

What changed

ScheduledTaskDefinition + catalog, controller, AppConfig.ScheduledTasks + pref, monitor, App.xaml.cs registration, RecommendedPreset staging, a "Scheduled tasks" group on the Windows services tab, SettingDocs entries (+ SETTINGS-REFERENCE.md regenerated).

Testing

  • dotnet build clean (warnings-as-errors), dotnet test 594 passed.
  • New coverage: catalog integrity, controller command-builder + injection rejection + XML parse (incl. trigger-vs-settings), monitor drift (absent = no drift, manual-apply-disable, fully-unmanaged skip), config round-trip + back-compat, preset staging, docs coverage, and a regression test that AppConfigCloner.CopyInto carries ScheduledTasks.

A code review (correctness + cross-file) ran on the diff; all findings were fixed in this branch — most notably a CopyInto omission that would have made the feature silently no-op on Apply, and locale-dependent state parsing that could cause auto-apply churn on non-English Windows.

Post-Deploy Monitoring & Validation

No additional operational monitoring required — this is a local user-mode tray app with no service/runtime backend. Manual validation: open Settings → Windows services → Scheduled tasks, tick Disable + Monitor on a task, Apply (one UAC), confirm the row shows "Disabled" and schtasks /Query /TN "\Microsoft\Windows\Application Experience\Microsoft Compatibility Appraiser" /XML shows <Enabled>false</Enabled>.

🤖 Generated with Claude Code

Add a catalog-driven feature to keep the five Windows Application Experience
scheduled tasks (Microsoft Compatibility Appraiser + siblings) disabled, with
drift re-assert. Mirrors the existing Windows-services feature:

- ScheduledTaskDefinition/ScheduledTaskCatalog (all five, RecommendedTarget=Disabled)
- ScheduledTaskController: non-elevated schtasks /Query read; elevated batched
  /Change /Disable|/Enable with System32-absolute paths + injection guard
- AppConfig.ScheduledTasks + ScheduledTaskPref
- ScheduledTaskMonitor (absent task = not drift)
- App.xaml.cs registration; RecommendedPreset stages all five (Recommended +
  Extreme), Reset un-manages
- SettingDocs entries + SETTINGS-REFERENCE.md regenerated
- 37 new tests (catalog, controller builder/parse/injection, monitor, config, preset, docs)
ScheduledTaskRow view-model (single 'Disable this task' checkbox + Monitor +
Auto-apply + Learn more) and a 'Scheduled tasks' section rendering the five
Application Experience tasks below the services list. Loads live state via
ScheduledTaskController.QueryState; mirrors the service-row save/cleanup flow.
- CRITICAL: AppConfigCloner.CopyInto now copies ScheduledTasks -- without it the
  feature silently no-opped on Apply (draft prefs dropped on commit). Regression
  test added.
- ScheduledTaskController.QueryState: read state via schtasks /Query /XML and parse
  the locale-independent <Settings><Enabled> element instead of the localized
  /FO LIST 'Status:' field (was misclassifying state on non-English Windows ->
  auto-apply churn). stderr no longer redirected (un-drained pipe could deadlock).
- ScheduledTaskMonitor: skip only fully-unmanaged tasks (Default + unmonitored) so
  a manual Apply disables an unmonitored task ('do it now' ignores IsMonitored),
  while avoiding background schtasks spawns for never-engaged tasks.
- SettingDocs: add task: branches (mechanism/verify/apply/reverse) so ApplyResults
  and Learn More show real schtasks commands instead of '(unknown)'.
- Injection guard: block ( ) too.
- UI: query the five task states in parallel on Settings open.
@carterscode
carterscode force-pushed the feat/disable-app-experience-tasks branch from 999d53c to 1e26775 Compare June 27, 2026 17:55
Comment on lines +112 to +121
foreach (var def in ScheduledTaskCatalog.All)
{
if (def.RecommendedTarget is not { } target) continue;
if (!draft.ScheduledTasks.TryGetValue(def.TaskPath, out var pref) || pref is null)
{
pref = new ScheduledTaskPref();
draft.ScheduledTasks[def.TaskPath] = pref;
}
Count(SetScheduledTask(pref, $"Task: {def.DisplayName}", target, changes));
}
Comment on lines +179 to +188
foreach (var def in ScheduledTaskCatalog.All)
{
if (def.RecommendedTarget is not { } target) continue;
if (!draft.ScheduledTasks.TryGetValue(def.TaskPath, out var pref) || pref is null)
{
pref = new ScheduledTaskPref();
draft.ScheduledTasks[def.TaskPath] = pref;
}
Count(SetScheduledTask(pref, $"Task: {def.DisplayName}", target, changes, tag: "Extreme"));
}
var system32 = Environment.SystemDirectory;
var psi = new ProcessStartInfo
{
FileName = Path.Combine(system32, "schtasks.exe"),
if (p is null) return ScheduledTaskState.NotPresent;
var stdout = p.StandardOutput.ReadToEnd();
p.WaitForExit(10_000);
if (!p.HasExited) { try { p.Kill(entireProcessTree: true); } catch { } return ScheduledTaskState.NotPresent; }
if (p is null) return ScheduledTaskState.NotPresent;
var stdout = p.StandardOutput.ReadToEnd();
p.WaitForExit(10_000);
if (!p.HasExited) { try { p.Kill(entireProcessTree: true); } catch { } return ScheduledTaskState.NotPresent; }
var system32 = Environment.SystemDirectory;
var psi = new ProcessStartInfo
{
FileName = Path.Combine(system32, "cmd.exe"),
using var p = Process.Start(psi);
if (p is null) return false;
p.WaitForExit(15_000);
if (!p.HasExited) { try { p.Kill(entireProcessTree: true); } catch { } return false; }
using var p = Process.Start(psi);
if (p is null) return false;
p.WaitForExit(15_000);
if (!p.HasExited) { try { p.Kill(entireProcessTree: true); } catch { } return false; }

foreach (var def in ScheduledTaskCatalog.All)
{
Assert.True(cfg.ScheduledTasks.ContainsKey(def.TaskPath), $"missing {def.TaskPath}");
Comment on lines +89 to +98
foreach (var def in ScheduledTaskCatalog.All)
{
// Must resolve via the exact id the ScheduledTaskMonitor emits.
var id = $"task:{def.TaskPath.ToLowerInvariant()}";
var d = SettingDocsCatalog.Get(id);
Assert.NotNull(d);
Assert.False(string.IsNullOrWhiteSpace(d!.What), $"empty What for {id}");
Assert.False(string.IsNullOrWhiteSpace(d.Risks), $"empty Risks for {id}");
Assert.False(string.IsNullOrWhiteSpace(d.ReversibleVia), $"empty ReversibleVia for {id}");
}
@carterscode
carterscode merged commit d7741df into main Jun 27, 2026
5 checks passed
@carterscode
carterscode deleted the feat/disable-app-experience-tasks branch June 27, 2026 17:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants