Skip to content

fix(startup): stop listing programs that are no longer installed - #260

Merged
dbfx merged 4 commits into
mainfrom
fix/startup-stale-entries
Jul 27, 2026
Merged

fix(startup): stop listing programs that are no longer installed#260
dbfx merged 4 commits into
mainfrom
fix/startup-stale-entries

Conversation

@dbfx

@dbfx dbfx commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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 Run value and records {name, command, location, source} in disabled-startups.json, which then becomes the only trace of the item. listStartupItems re-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 in userData, so it outlives registry cleaning and an uninstall/reinstall of Kudu.

2. Real leftovers look identical to phantoms. Run values 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 records whose program is verifiably gone are pruned from disabled-startups.json as the list is built, so the entry disappears on the next scan with no user action.
  • Registry, Task Scheduler and Startup-folder entries pointing at a missing executable are flagged stale and shown with a "Not installed" badge, so genuine leftovers are visible and can be removed.
  • Deleting a scheduled task that is already gone now succeeds (Get-ScheduledTask guard) instead of failing and leaving the item wedged in the list with its bookkeeping intact.
  • The Startup page re-reads its items on mount — the store previously kept the previous visit's list, so changes made outside the app (installs, uninstalls, Task Manager) weren't picked up until the manual refresh button.

Startup-folder shortcuts are resolved via shell.readShortcutLink so a .lnk pointing at an uninstalled program is caught too.

Detection is deliberately conservative

isTargetMissing only reports true when the target is known to be gone. It returns "unknown, assume present" for:

  • commands whose target can't be resolved (rundll32.exe foo.dll,Entry, relative paths)
  • unresolved %VAR% references
  • UNC paths — checking those can block for seconds when the host is offline
  • paths whose volume isn't mounted, so an unplugged drive isn't mistaken for an uninstall
  • anything under %SystemRoot%, avoiding WOW64 redirection false positives on OS entries

Testing

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 en strings only (staleBadge, staleTooltip); other locales fall back to English until npm run translate runs.

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
Comment thread src/renderer/src/pages/StartupPage.tsx Fixed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/main/ipc/startup-manager.ipc.ts Outdated
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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.
@dbfx

dbfx commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Both review comments addressed in 6c923fa.

Codex P1 — stale StartupApproved marker: correct, and the failure mode was worse than just a leftover value. Pruning the record while leaving the 03 byte meant a reinstall would re-create the Run value that Windows keeps suppressed, and toggleStartupItem would refuse to re-enable it because the stored command had been deleted — no way back from inside Kudu.

Pruning now clears the marker first (reg query to see whether one exists, then reg delete), and takes the second option you suggested when that fails: the record is retained if the marker survives, e.g. an HKLM entry without elevation, so the item stays re-enableable. Two tests cover both paths.

Unused Lock import: removed (it predated this PR, but the line was touched here anyway).

npm test — 2196 pass.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/main/ipc/startup-manager.ipc.ts Outdated
Comment on lines +381 to +382
} catch {
return true // nothing recorded there — safe to forget

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread src/main/ipc/startup-manager.ipc.ts Outdated
Comment on lines +464 to +470
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))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread src/main/ipc/startup-manager.ipc.ts Outdated
publisher: extractPublisher(entry.command),
impact: estimateImpact(entry.name, entry.command)
impact: estimateImpact(entry.name, entry.command),
stale: false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
@dbfx

dbfx commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

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 missingSince and flagged stale on first sight, and the record is only dropped once it has stayed missing for 24h. The stamp is cleared if the program reappears, so an updater window is a no-op. The user isn't left waiting on that timer — the entry gets the "Not installed" badge immediately and Remove purges it (record + marker) in one click.

Keep records when marker lookup is inconclusive — right, catch → true conflated "no such value" with "couldn't ask". Rather than parse localised reg.exe errors, mergeStartupApproved (which already queries all three StartupApproved keys, before pruning) now reports the value names it saw plus whether the read succeeded. Cleanup returns true only for a key that was read and doesn't contain the name; an unreadable key keeps the record and retries next scan. Net effect is also one fewer registry call per pruned entry.

Mark retained missing disabled entries as stale — fixed, stale now comes from missingSince rather than being hardcoded, so entries retained because cleanup couldn't finish (unelevated HKLM) still get the badge. That's what makes the manual escape hatch work in exactly the case automatic pruning can't proceed.

npm test — 2200 pass.

@dbfx
dbfx merged commit 9c7567c into main Jul 27, 2026
10 checks passed
@dbfx
dbfx deleted the fix/startup-stale-entries branch July 27, 2026 02:42

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +390 to +394
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'
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +416 to +418
try {
await execNativeUtf8('reg', ['delete', approvedKeyFor(source), '/v', name, '/f'], { timeout: 5000 })
return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Startup section shows non existing apps or sevices

1 participant