Skip to content

feat: in-app auto-update via electron-updater - #46

Merged
AhmedFr merged 18 commits into
mainfrom
feat/auto-update
Jul 29, 2026
Merged

AhmedFr merged 18 commits into
mainfrom
feat/auto-update

Conversation

@AhmedFr

@AhmedFr AhmedFr commented Jul 22, 2026 •

Copy link
Copy Markdown
Owner

What

TidyDisk now updates in place: silent background checks (launch + every 6 h, packaged builds only), then a fully user-driven flow — one click to download, another to restart and install.

  • Panel (tray dropdown): compact dismissible banner "v1.2.0 available · 4.2 MB · Jul 20"; clicking deep-links to Settings → Updates. Dismissal is per-version.
  • Settings → Updates tab: current version, manual check, release notes from the GitHub release body, Download → progress → Restart and install.
  • UpdaterService wraps electron-updater (autoDownload=false, install-on-quit after a download) behind an injectable interface; state machine unit-tested.
  • Translocation (app not in /Applications) detected up front with a friendly fix-it message.
  • Analytics: update_available (deduped per version), update_download_clicked, update_installed.
  • Release process unchanged: publishing the GitHub release draft remains the go-live switch. Spec: docs/superpowers/specs/2026-07-22-auto-update-design.md

Manual test checklist (needs a signed build — CI can't do this)

  • Build current version + a fake higher version (pnpm release twice, bumping package.json version in between)
  • Publish the higher version as a GitHub prerelease-draft → publish; install the lower one in /Applications
  • Banner appears in panel within ~10 s of launch with version/size/date
  • Banner ✕ hides it; stays hidden after app restart; Settings → Updates still shows the update
  • Banner click opens launcher on Settings → Updates
  • Download shows live percent; Restart and install relaunches on the new version
  • update_installed fires on the relaunch (PostHog)
  • Run from ~/Downloads (translocated): friendly "move to Applications" message, no crash

Follow-ups (non-blocking, from final review)

  • Generic "Settings" nav from the panel can snap an already-open Settings window back to the Scanning tab (pre-existing interaction surfaced by the live deep-link effect)
  • Panel rows (incl. the new banner) are mouse-only; panel-wide keyboard access is a known gap
  • IPC-layer tests cover 2 of 4 updater channels; updater start/stop cadence untested
  • noteVersionChange read failures are silent (write side logs)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added in-app update support with automatic background checks.
    • Added an Updates section in Settings to check, download, and install releases.
    • Added a menu-bar update banner with download progress, release details, dismissal, and direct navigation to Updates.
    • Added clearer messaging for network and app-location issues.
  • Documentation
    • Added implementation and design documentation for the update experience.
  • Tests
    • Added coverage for update states, version tracking, banner behavior, navigation, and error handling.

AhmedFr and others added 16 commits July 22, 2026 17:10
…all UX)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s tab

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… checks, live deep link

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clean-my-node-modules Ready Ready Preview, Comment Jul 29, 2026 12:20pm

@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AhmedFr, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 58 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f9b2a4c6-a941-48bb-97b6-8b10a9888ca2

📥 Commits

Reviewing files that changed from the base of the PR and between 5433d47 and ac0e495.

📒 Files selected for processing (8)
  • STATUS.html
  • package.json
  • src/main/updater/updater-logic.test.ts
  • src/main/updater/updater-logic.ts
  • src/main/updater/updater-service.test.ts
  • src/main/updater/updater-service.ts
  • src/renderer/src/launcher/LauncherApp/LauncherApp.tsx
  • src/renderer/src/launcher/views/SettingsView.tsx
📝 Walkthrough

Walkthrough

Adds an Electron auto-update system with packaged background checks, user-controlled download/install actions, IPC state propagation, update settings UI, a dismissible panel banner, analytics, persistence, navigation deep links, tests, and implementation documentation.

Changes

In-app auto-update

Layer / File(s) Summary
Updater contracts and settings
src/shared/*, src/main/settings/*, package.json
Adds updater state types, IPC channels, dismissal settings, validation, navigation targets, and the electron-updater runtime dependency.
Updater logic and state machine
src/main/updater/*
Implements metadata normalization, error classification, translocation handling, lifecycle transitions, guarded actions, timers, analytics event deduplication, and tests.
Main-process integration
src/main/index.ts, src/main/ipc/*, src/preload/*, src/main/analytics/*
Wires updater startup/shutdown, IPC handlers, preload methods, state broadcasts, analytics, and installed-version detection.
Update settings and banner
src/renderer/src/components/*, src/renderer/src/hooks/*, src/renderer/src/panel/*, src/renderer/src/lib/format/*
Adds update controls, release metadata rendering, download/install states, errors, banner dismissal, and panel mounting.
Deep links and status documentation
src/renderer/src/launcher/*, STATUS.html, docs/superpowers/*
Routes settings-updates to the Updates tab and documents the implementation, verification, and session status.

Estimated code review effort: 4 (Complex) | ~60 minutes

Poem

I’m a rabbit with updates tucked under my ear,
Checking for versions that quietly appear.
Click once to download, twice to restart,
A banner hops neatly to Settings’ heart.
Tests guard the trail through each little state—
Fresh code grows carrots while we wait.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding in-app auto-update support via electron-updater.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/auto-update

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/superpowers/plans/2026-07-22-auto-update.md`:
- Around line 1178-1182: Synchronize the mounted Settings view with changes to
settingsLandingTab: update the SettingsView tab state after applyNav changes the
landing tab, or remount SettingsView when that value changes. Ensure clicking a
live deep-link banner switches the visible tab to Updates, while retaining
initialTab as the initial fallback for the existing scanning view.
- Around line 559-572: Update UpdaterController.check() to serialize update
checks by returning when a check is already in progress, and also return while
status.phase is 'available' so a known update remains available until
downloaded. Preserve the existing translocation handling and the guards for
'downloading' and 'downloaded', and ensure the in-progress state is cleared when
the check resolves or fails.
- Around line 575-581: Update the download() method to make repeated clicks
idempotent while an update download is pending: transition status.phase to
downloading immediately, or add an explicit in-flight guard before emitting
update_download_clicked and calling updater.downloadUpdate(). Preserve the
existing available-state check and error-event handling, and ensure the guard is
cleared if the download fails before progress begins.

In `@docs/superpowers/specs/2026-07-22-auto-update-design.md`:
- Line 93: Update the wording in the manual test checklist description to say
“requires a signed app” instead of “needs signed app,” preserving the
surrounding meaning and formatting.

In `@src/main/ipc/register-ipc.test.ts`:
- Around line 257-263: Extend the test “exposes updater state and forwards
updater actions” to invoke IPC.updaterCheck and IPC.updaterInstall, then assert
ctx.updater.check and ctx.updater.quitAndInstall are each called once, alongside
the existing getState and download coverage.

In `@src/main/updater/note-version-change.ts`:
- Around line 21-29: Update the error path in the previous !== current
persistence block of the note-version-change function so a mkdirSync or
writeFileSync failure returns null immediately after logging. Add a regression
test verifying that persistence failure does not return the previous version or
report an update, while preserving the existing return behavior when persistence
succeeds.

In `@src/main/updater/updater-service.ts`:
- Around line 98-103: Update download() in src/main/updater/updater-service.ts
(lines 98-103) to transition status to downloading with percent 0 before
emitting update_download_clicked or calling updater.downloadUpdate(). In
src/main/updater/updater-service.test.ts (lines 84-94), call download() twice
before progress and assert only one updater call and one analytics event.

In `@src/renderer/src/components/UpdateBanner/UpdateBanner.tsx`:
- Around line 23-72: Replace the clickable banner div with a primary
type="button" element so it can receive focus and activate the settings-updates
deep link via Enter/Space. Preserve the existing styling and click behavior, and
keep the dismiss button as a sibling rather than nesting it inside the primary
button; update the surrounding structure in the UpdateBanner component
accordingly.

In `@src/renderer/src/hooks/useUpdater.ts`:
- Around line 15-20: Update the useUpdater effect so the initial getUpdaterState
result is applied only if no newer onUpdaterState event has been received while
it was pending. Track whether the subscription callback has delivered an update
before applying the snapshot, while preserving the existing alive cleanup guard
and unsubscribe behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 64a1cc54-5b11-4366-a49b-c98352937e77

📥 Commits

Reviewing files that changed from the base of the PR and between 3f1514c and 5433d47.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (41)
  • STATUS.html
  • docs/superpowers/plans/2026-07-22-auto-update.md
  • docs/superpowers/specs/2026-07-22-auto-update-design.md
  • package.json
  • src/main/analytics/analytics.ts
  • src/main/app-context.types.ts
  • src/main/index.ts
  • src/main/ipc/register-ipc.test.ts
  • src/main/ipc/register-ipc.ts
  • src/main/settings/validate-setting.test.ts
  • src/main/settings/validate-setting.ts
  • src/main/updater/index.ts
  • src/main/updater/note-version-change.test.ts
  • src/main/updater/note-version-change.ts
  • src/main/updater/updater-logic.test.ts
  • src/main/updater/updater-logic.ts
  • src/main/updater/updater-service.test.ts
  • src/main/updater/updater-service.ts
  • src/preload/api.types.ts
  • src/preload/index.ts
  • src/renderer/src/components/UpdateBanner/UpdateBanner.constants.test.ts
  • src/renderer/src/components/UpdateBanner/UpdateBanner.constants.ts
  • src/renderer/src/components/UpdateBanner/UpdateBanner.tsx
  • src/renderer/src/components/UpdateBanner/UpdateBanner.types.ts
  • src/renderer/src/components/UpdateBanner/index.ts
  • src/renderer/src/components/UpdateSettings/UpdateSettings.tsx
  • src/renderer/src/components/UpdateSettings/UpdateSettings.types.ts
  • src/renderer/src/components/UpdateSettings/index.ts
  • src/renderer/src/hooks/useUpdater.ts
  • src/renderer/src/launcher/LauncherApp/LauncherApp.tsx
  • src/renderer/src/launcher/LauncherApp/launcherNav.test.ts
  • src/renderer/src/launcher/LauncherApp/launcherNav.ts
  • src/renderer/src/launcher/views/SettingsView.tsx
  • src/renderer/src/lib/format/format.test.ts
  • src/renderer/src/lib/format/format.ts
  • src/renderer/src/lib/format/index.ts
  • src/renderer/src/panel/PanelApp/PanelApp.tsx
  • src/shared/ipc.constants.ts
  • src/shared/launcher-nav.types.ts
  • src/shared/settings.types.ts
  • src/shared/updater.types.ts

Comment on lines +559 to +572
check(): void {
if (isTranslocated(this.opts.execPath)) {
this.setStatus({
phase: 'error',
message: 'TidyDisk is running from a translocated path.',
kind: 'translocation',
})
return
}
// Never interrupt an in-flight or completed download with a fresh check.
if (this.status.phase === 'downloading' || this.status.phase === 'downloaded') return
void this.updater.checkForUpdates().catch(() => {
// failures surface through the 'error' event
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Serialize checks and preserve an available update.

check() can run while another check is already in progress, or while status.phase === 'available'. A later update-not-available or error then replaces the known update and removes the download action. Guard concurrent checks and retain the available update until the user downloads it.

Proposed guard
-    if (this.status.phase === 'downloading' || this.status.phase === 'downloaded') return
+    if (
+      this.status.phase === 'checking' ||
+      this.status.phase === 'available' ||
+      this.status.phase === 'downloading' ||
+      this.status.phase === 'downloaded'
+    ) return
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
check(): void {
if (isTranslocated(this.opts.execPath)) {
this.setStatus({
phase: 'error',
message: 'TidyDisk is running from a translocated path.',
kind: 'translocation',
})
return
}
// Never interrupt an in-flight or completed download with a fresh check.
if (this.status.phase === 'downloading' || this.status.phase === 'downloaded') return
void this.updater.checkForUpdates().catch(() => {
// failures surface through the 'error' event
})
check(): void {
if (isTranslocated(this.opts.execPath)) {
this.setStatus({
phase: 'error',
message: 'TidyDisk is running from a translocated path.',
kind: 'translocation',
})
return
}
// Never interrupt an in-flight or completed download with a fresh check.
if (
this.status.phase === 'checking' ||
this.status.phase === 'available' ||
this.status.phase === 'downloading' ||
this.status.phase === 'downloaded'
) return
void this.updater.checkForUpdates().catch(() => {
// failures surface through the 'error' event
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-07-22-auto-update.md` around lines 559 - 572,
Update UpdaterController.check() to serialize update checks by returning when a
check is already in progress, and also return while status.phase is 'available'
so a known update remains available until downloaded. Preserve the existing
translocation handling and the guards for 'downloading' and 'downloaded', and
ensure the in-progress state is cleared when the check resolves or fails.

Comment on lines +575 to +581
download(): void {
if (this.status.phase !== 'available') return
this.opts.onEvent('update_download_clicked', { version: this.status.info.version })
void this.updater.downloadUpdate().catch(() => {
// failures surface through the 'error' event
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Make downloads idempotent while pending.

The state remains available until the first progress event, so repeated clicks can invoke downloadUpdate() multiple times and emit duplicate analytics events. Transition to downloading immediately or maintain an explicit in-flight guard.

Proposed fix
     if (this.status.phase !== 'available') return
+    const info = this.status.info
     this.opts.onEvent('update_download_clicked', { version: this.status.info.version })
+    this.setStatus({ phase: 'downloading', info, percent: 0 })
     void this.updater.downloadUpdate().catch(() => {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
download(): void {
if (this.status.phase !== 'available') return
this.opts.onEvent('update_download_clicked', { version: this.status.info.version })
void this.updater.downloadUpdate().catch(() => {
// failures surface through the 'error' event
})
}
download(): void {
if (this.status.phase !== 'available') return
const info = this.status.info
this.opts.onEvent('update_download_clicked', { version: this.status.info.version })
this.setStatus({ phase: 'downloading', info, percent: 0 })
void this.updater.downloadUpdate().catch(() => {
// failures surface through the 'error' event
})
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-07-22-auto-update.md` around lines 575 - 581,
Update the download() method to make repeated clicks idempotent while an update
download is pending: transition status.phase to downloading immediately, or add
an explicit in-flight guard before emitting update_download_clicked and calling
updater.downloadUpdate(). Preserve the existing available-state check and
error-event handling, and ensure the guard is cleared if the download fails
before progress begins.

Comment on lines +1178 to +1182
In the component: destructure `initialTab` and change the tab state line to

```ts
const [tab, setTab] = useState<SettingsTab>(initialTab ?? 'scanning')
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Update the mounted Settings view for live deep links.

initialTab is only used as the useState initializer. When the launcher is already showing Settings, applyNav() changes settingsLandingTab but SettingsView remains mounted, so clicking the banner does not switch to Updates. Remount on landing-tab changes or synchronize the tab with an effect.

Minimal remount fix
-              <SettingsView initialTab={settingsLandingTab}
+              <SettingsView key={settingsLandingTab} initialTab={settingsLandingTab}

Also applies to: 1486-1507

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-07-22-auto-update.md` around lines 1178 - 1182,
Synchronize the mounted Settings view with changes to settingsLandingTab: update
the SettingsView tab state after applyNav changes the landing tab, or remount
SettingsView when that value changes. Ensure clicking a live deep-link banner
switches the visible tab to Updates, while retaining initialTab as the initial
fallback for the existing scanning view.

- PostHog: `update_available`, `update_download_clicked`, `update_installed` (fired on
next launch via persisted last-seen-version comparison), all with version props.
- Tests: vitest unit tests for state-machine transitions and banner-dismissal logic.
Real download/install can't run in CI (needs signed app) → manual test checklist in

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use “requires a signed app.”

Line 93’s “needs signed app” wording is ungrammatical.

🧰 Tools
🪛 LanguageTool

[style] ~93-~93: The double modal “needs signed” is nonstandard (only accepted in certain dialects). Consider “to be signed”.
Context: ...download/install can't run in CI (needs signed app) → manual test checklist in the P...

(NEEDS_FIXED)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/specs/2026-07-22-auto-update-design.md` at line 93, Update
the wording in the manual test checklist description to say “requires a signed
app” instead of “needs signed app,” preserving the surrounding meaning and
formatting.

Source: Linters/SAST tools

Comment on lines +257 to +263

it('exposes updater state and forwards updater actions', async () => {
const { ctx } = makeCtx(false)
expect(await invoke(IPC.updaterGetState)).toMatchObject({ status: { phase: 'idle' } })
await invoke(IPC.updaterDownload)
expect(ctx.updater.download).toHaveBeenCalledOnce()
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover all updater IPC routes.

This test exercises getState and download, but not updaterCheck or updaterInstall. Add assertions for ctx.updater.check and ctx.updater.quitAndInstall so missing or miswired handlers cannot pass unnoticed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/ipc/register-ipc.test.ts` around lines 257 - 263, Extend the test
“exposes updater state and forwards updater actions” to invoke IPC.updaterCheck
and IPC.updaterInstall, then assert ctx.updater.check and
ctx.updater.quitAndInstall are each called once, alongside the existing getState
and download coverage.

Comment on lines +21 to +29
if (previous !== current) {
try {
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, JSON.stringify({ version: current }))
} catch (err) {
console.error('Failed to persist last-run version', err)
}
}
return previous && valid(previous) && valid(current) && lt(previous, current) ? previous : null

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Do not report an update when persistence fails.

If writeFileSync fails, this function still returns the previous lower version. The next launch reads the same old version and emits update_installed again, producing duplicate analytics indefinitely. Return null from the write-failure path (and add a regression test).

Proposed fix
     } catch (err) {
       console.error('Failed to persist last-run version', err)
+      return null
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (previous !== current) {
try {
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, JSON.stringify({ version: current }))
} catch (err) {
console.error('Failed to persist last-run version', err)
}
}
return previous && valid(previous) && valid(current) && lt(previous, current) ? previous : null
if (previous !== current) {
try {
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, JSON.stringify({ version: current }))
} catch (err) {
console.error('Failed to persist last-run version', err)
return null
}
}
return previous && valid(previous) && valid(current) && lt(previous, current) ? previous : null
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/updater/note-version-change.ts` around lines 21 - 29, Update the
error path in the previous !== current persistence block of the
note-version-change function so a mkdirSync or writeFileSync failure returns
null immediately after logging. Add a regression test verifying that persistence
failure does not return the previous version or report an update, while
preserving the existing return behavior when persistence succeeds.

Comment thread src/main/updater/updater-service.ts
Comment on lines +23 to +72
return (
<div
onClick={() => void window.clean.openLauncher('settings-updates')}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
margin: '6px 10px 2px',
padding: '7px 10px',
borderRadius: 8,
background: 'var(--surface-2)',
border: '1px solid var(--hairline)',
cursor: 'pointer',
}}
>
<span style={{ width: 6, height: 6, borderRadius: 3, background: accent, flex: '0 0 auto' }} />
<span
style={{
fontSize: 11.5,
color: 'var(--text)',
flex: 1,
minWidth: 0,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{label}
</span>
{model.dismissible && (
<button
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation()
onDismiss(info.version)
}}
style={{
background: 'none',
border: 'none',
color: 'var(--text-dim)',
cursor: 'pointer',
fontSize: 12,
padding: '0 2px',
flex: '0 0 auto',
}}
>
✕
</button>
)}
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the update deep link keyboard-accessible.

The clickable <div> cannot receive focus or be activated with Enter/Space. Use a primary type="button" for the banner content and retain the dismiss button as its sibling; this also avoids nesting a button inside an element exposed as another button.

Proposed structure
-    <div onClick={() => void window.clean.openLauncher('settings-updates')} style={{ ... }}>
+    <div style={{ ... }}>
+      <button
+        type="button"
+        onClick={() => void window.clean.openLauncher('settings-updates')}
+        className="update-banner__open"
+      >
         <span ... />
         <span ...>{label}</span>
+      </button>
       {model.dismissible && (
-        <button
+        <button type="button"
🧰 Tools
🪛 React Doctor (0.7.6)

[warning] 24-24: Keyboard users can't trigger this click handler because there's no keyboard one, so add onKeyUp, onKeyDown, or onKeyPress.

Pair onClick with a key handler so keyboard users can trigger it.

(click-events-have-key-events)


[warning] 24-24: Screen reader users can't tell this click handler is interactive because it has no role, so add a role or use a button or link.

Give clickable static elements a role, or use a button or link.

(no-static-element-interactions)


[warning] 53-53: Your users can submit the form by accident because a <button> with no type defaults to submit.

Set an explicit button type so plain buttons do not submit forms by accident: type="button", "submit", or "reset".

(button-has-type)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/src/components/UpdateBanner/UpdateBanner.tsx` around lines 23 -
72, Replace the clickable banner div with a primary type="button" element so it
can receive focus and activate the settings-updates deep link via Enter/Space.
Preserve the existing styling and click behavior, and keep the dismiss button as
a sibling rather than nesting it inside the primary button; update the
surrounding structure in the UpdateBanner component accordingly.

Source: Linters/SAST tools

Comment on lines +15 to +20
useEffect(() => {
let alive = true
void window.clean.getUpdaterState().then((s) => {
if (alive) setState(s)
})
const unsubscribe = window.clean.onUpdaterState(setState)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent a stale initial snapshot from overwriting a newer update event.

A state event can arrive before getUpdaterState() resolves; its older response then replaces the newer state. This can regress the UI (for example, from downloaded back to available).

Proposed fix
   useEffect(() => {
     let alive = true
-    void window.clean.getUpdaterState().then((s) => {
-      if (alive) setState(s)
+    let receivedPush = false
+    const unsubscribe = window.clean.onUpdaterState((next) => {
+      receivedPush = true
+      if (alive) setState(next)
     })
-    const unsubscribe = window.clean.onUpdaterState(setState)
+    void window.clean.getUpdaterState().then((snapshot) => {
+      if (alive && !receivedPush) setState(snapshot)
+    })
     return () => {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
let alive = true
void window.clean.getUpdaterState().then((s) => {
if (alive) setState(s)
})
const unsubscribe = window.clean.onUpdaterState(setState)
useEffect(() => {
let alive = true
let receivedPush = false
const unsubscribe = window.clean.onUpdaterState((next) => {
receivedPush = true
if (alive) setState(next)
})
void window.clean.getUpdaterState().then((snapshot) => {
if (alive && !receivedPush) setState(snapshot)
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/src/hooks/useUpdater.ts` around lines 15 - 20, Update the
useUpdater effect so the initial getUpdaterState result is applied only if no
newer onUpdaterState event has been received while it was pending. Track whether
the subscription callback has delivered an update before applying the snapshot,
while preserving the existing alive cleanup guard and unsubscribe behavior.

AhmedFr and others added 2 commits July 29, 2026 14:19
- download() transitions to downloading 0% synchronously: UI reacts before
  the first progress event and a double-click can't start a second download
- drop download-progress events whose rounded percent is unchanged (IPC spam)
- repeat settings-updates deep links land on the Updates tab via a nav seq
- 4xx no longer classified as network so a broken feed surfaces its message
- decode &amp; last in stripHtml (no double-decoding)
- explicit repository field so app-update.yml generation is deterministic

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AhmedFr
AhmedFr merged commit 31e7487 into main Jul 29, 2026
5 checks passed
@AhmedFr
AhmedFr deleted the feat/auto-update branch July 29, 2026 12:25
AhmedFr added a commit that referenced this pull request Jul 29, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — ac0e495e Deployed Jul 29, 2026 by vercel[bot]
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.

1 participant