fix(startup): stop listing programs that are no longer installed - #260
Conversation
Startup entries could persist after the program was uninstalled, with no way for the user to clear them (#245). Disabling an item deletes its Run value and records it in disabled-startups.json, which is the only remaining trace of it. If the program was later uninstalled, that record kept re-creating the entry on every scan — surviving registry cleaning and even a Kudu reinstall, since the file lives in userData. Entries whose program is verifiably gone are now pruned from the file while the list is built. Registry, Task Scheduler and Startup-folder entries that point at a missing executable are flagged as stale so the leftovers Windows itself still honours are visible and can be removed. Detection is deliberately conservative: unresolvable commands, unknown environment variables, UNC paths, unmounted volumes and anything under the Windows directory are never treated as missing. Also: - deleting a scheduled task that is already gone now succeeds instead of failing and leaving the item stuck in the list - the Startup page re-reads its items on mount, so changes made outside the app are picked up instead of showing the previous visit's list
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5375b4534d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const backed = items.some((i) => i.name === entry.name && i.source === entry.source) | ||
| return backed || !isTargetMissing(entry.command) | ||
| }) | ||
| if (kept.length !== entries.length) writeDisabledEntries(kept) |
There was a problem hiding this comment.
Remove the StartupApproved marker when pruning
When a Kudu-disabled program is uninstalled, this deletes its only disabled-startups.json record but leaves the 03 value written under StartupApproved. If the executable is later restored—through a reinstall or an updater temporarily replacing it—mergeStartupApproved marks the new Run entry disabled, while toggleStartupItem refuses to enable it because the stored record was deleted. Pruning must also clear the corresponding StartupApproved value, or retain the record until that marker is removed successfully.
Useful? React with 👍 / 👎.
Dropping our disabled-startups.json record left Windows' own "03" marker behind. Reinstalling the program would then bring back a Run value that Windows keeps suppressed, while toggling it on in Kudu failed because the stored command was gone. Pruning now removes the marker as well, and keeps the record when that cleanup fails (HKLM without elevation) so the item can still be re-enabled. Also drops the unused Lock import flagged on the Startup page.
|
Both review comments addressed in 6c923fa. Codex P1 — stale Pruning now clears the marker first ( Unused
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6c923faf92
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } catch { | ||
| return true // nothing recorded there — safe to forget |
There was a problem hiding this comment.
Keep records when marker lookup is inconclusive
When reg query fails because of a timeout, access denial, or another transient registry error rather than a missing value, this catch treats the marker as absent; the pruning loop then removes the only saved command without ever deleting the potentially existing StartupApproved marker. The newly added cleanup therefore still permits the orphaned-marker state from the earlier review: a later reinstall can remain disabled with no record available to re-enable it. Only a confirmed value-not-found result should return true; other query failures must retain the record.
Useful? React with 👍 / 👎.
| const backed = items.some((i) => i.name === entry.name && i.source === entry.source) | ||
| if (backed || !isTargetMissing(entry.command)) { | ||
| kept.push(entry) | ||
| continue | ||
| } | ||
| // Forget the entry only once Windows' own disable marker is gone too | ||
| if (!(await clearStartupApprovedMarker(entry.name, entry.source))) { |
There was a problem hiding this comment.
Avoid pruning during transient executable replacement
If the Startup page is opened while an updater has temporarily removed an executable before replacing it, isTargetMissing is true and a Kudu-disabled entry has no backing Run value by design, so this path prunes its only saved command and clears its marker. When the updater restores the executable without recreating the Run value, the user's startup entry is permanently lost and Kudu can no longer re-enable it. A single missing-file snapshot is insufficient evidence for destructive pruning; retain or merely flag the record until the absence is confirmed or the user removes it.
Useful? React with 👍 / 👎.
| publisher: extractPublisher(entry.command), | ||
| impact: estimateImpact(entry.name, entry.command) | ||
| impact: estimateImpact(entry.name, entry.command), | ||
| stale: false |
There was a problem hiding this comment.
Mark retained missing disabled entries as stale
When clearStartupApprovedMarker fails, such as for an unelevated HKLM entry, the loop deliberately retains an entry whose target was already proven missing; reconstruction nevertheless hardcodes stale: false. Those uninstalled programs therefore remain indistinguishable from valid disabled programs and never receive the new “Not installed” badge, defeating the intended recovery UI precisely where automatic pruning cannot proceed. Preserve the missing-target result and set stale accordingly.
Useful? React with 👍 / 👎.
Pruning acted on a single observation, which is not enough evidence to throw away the only copy of a disabled item's command: an updater that briefly removes its executable would lose the entry for good. A missing program is now stamped and flagged as stale on first sight — the user gets the "Not installed" badge and can clear it in one click — and the record is only dropped once the program has stayed missing for 24 hours. It comes back to normal by itself if the program reappears. Marker cleanup no longer treats a failed StartupApproved lookup as proof that no marker exists. mergeStartupApproved already reads those keys, so it now reports which values it saw and whether the read succeeded; an unreadable key keeps the record and retries on the next scan. Entries retained because cleanup could not finish are flagged stale too, instead of being indistinguishable from working disabled programs.
|
All three P2s addressed in 7b41259 — the common thread is that pruning was acting on a single observation, which isn't enough evidence to discard the only copy of a disabled item's command. Avoid pruning during transient executable replacement — agreed, this was the root problem. Pruning is now two-stage: a missing program is stamped with Keep records when marker lookup is inconclusive — right, Mark retained missing disabled entries as stale — fixed,
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7b41259b28
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| function approvedKeyFor(source: StartupItem['source']): string { | ||
| return source === 'registry-hkcu' | ||
| ? 'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\StartupApproved\\Run' | ||
| : 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\StartupApproved\\Run' | ||
| } |
There was a problem hiding this comment.
Clear the StartupApproved Run32 marker
For entries read from HKLM\SOFTWARE\WOW6432Node\...\Run, the shared registry-hklm source makes this helper select StartupApproved\Run, although Windows stores the approval state for these 32-bit startup entries under StartupApproved\Run32. After the grace period, pruning can therefore remove the saved command while leaving the real disabled marker behind, so a reinstalled application remains disabled with no record available for Kudu to re-enable it. Preserve the registry location or otherwise map WOW6432Node entries to Run32 during marker lookup and deletion.
Useful? React with 👍 / 👎.
| try { | ||
| await execNativeUtf8('reg', ['delete', approvedKeyFor(source), '/v', name, '/f'], { timeout: 5000 }) | ||
| return true |
There was a problem hiding this comment.
Preserve OS or policy disable markers
When a stale entry's StartupApproved value has byte 06, which mergeStartupApproved identifies as disabled by the OS or policy, the marker map retains only its name and this call deletes it exactly like Kudu's own 03 marker. Opening the Startup page after the grace period can therefore remove an externally imposed disable state, allowing a later recreation of the Run value to start the program. Retain the marker status and delete only the 03 marker Kudu owns.
Useful? React with 👍 / 👎.
| kept.push({ ...entry, missingSince }) | ||
| } | ||
|
|
||
| if (changed) writeDisabledEntries(kept) |
There was a problem hiding this comment.
Keep listing when stale-state persistence fails
If disabled-startups.json is readable but cannot be written—for example because userData is temporarily locked, read-only, or out of space—the first missing entry makes this write throw through listStartupItems(). The entire Startup page then fails to load instead of merely retaining the stale record, and it keeps failing on every refresh while the write error persists. Catch persistence errors here and return the retained entries rather than turning bookkeeping failure into a listing failure.
Useful? React with 👍 / 👎.
Fixes #245.
The bug
Startup entries could survive the program being uninstalled, with no way for the user to clear them — the reporter uninstalled the apps, cleaned the registry by hand and reinstalled Kudu, and the entries came back every time.
Two separate causes:
1. Our own bookkeeping resurrects them. Disabling an item deletes its
Runvalue and records{name, command, location, source}indisabled-startups.json, which then becomes the only trace of the item.listStartupItemsre-creates a list entry from every record it can't match against a live item — so once the program is uninstalled, nothing on the system references it any more but the record keeps rebuilding it forever. The file lives inuserData, so it outlives registry cleaning and an uninstall/reinstall of Kudu.2. Real leftovers look identical to phantoms.
Runvalues and logon-triggered tasks whose target executable is gone are shown exactly like working entries, with no hint that the program behind them no longer exists.The fix
disabled-startups.jsonas the list is built, so the entry disappears on the next scan with no user action.staleand shown with a "Not installed" badge, so genuine leftovers are visible and can be removed.Get-ScheduledTaskguard) instead of failing and leaving the item wedged in the list with its bookkeeping intact.Startup-folder shortcuts are resolved via
shell.readShortcutLinkso a.lnkpointing at an uninstalled program is caught too.Detection is deliberately conservative
isTargetMissingonly reportstruewhen the target is known to be gone. It returns "unknown, assume present" for:rundll32.exe foo.dll,Entry, relative paths)%VAR%references%SystemRoot%, avoiding WOW64 redirection false positives on OS entriesTesting
npm test— 2194 pass, including 21 new cases covering pruning, retention (still installed / unresolvable / unmounted volume / live registry value), the stale flag across all four sources,%VAR%expansion, and the idempotent task delete.New
enstrings only (staleBadge,staleTooltip); other locales fall back to English untilnpm run translateruns.