feat(updater): persistent Skip This Version and 24h snooze for update notifications#1980
feat(updater): persistent Skip This Version and 24h snooze for update notifications#1980
Conversation
…date notifications Users were getting nagged with update popups every few minutes with no way to permanently dismiss them. Now the dialog offers "Skip This Version" (persists to settings.json, never shows again for that version) and "Remind Me Later" (24h snooze). The sidebar banner X-dismiss also snoozes for 24h instead of resetting on restart. Updates still download silently when suppressed so they're ready when the user wants them. Closes #1944 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add skippedUpdateVersion/updateRemindAfter to AppSettings type - Add version format validation on skip IPC handler - Add suppression check on UpdateBanner mount (downloaded updates) - Wrap async dismiss/skip/snooze handlers in try/catch - Export shouldSuppressUpdate directly, remove thin wrapper Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughImplemented persistent update suppression via three new operations: users can permanently skip a version, snooze for 24 hours, or check suppression status. Settings now store a skipped version string and snooze expiration timestamp. Update notifications are withheld when suppression is active, with IPC handlers and frontend components wired to enable these controls. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Suggested labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly enhances the user experience around application updates by providing persistent control over update notifications. Users can now choose to permanently skip a specific version or snooze all notifications for 24 hours, preventing repetitive and intrusive prompts. This change addresses a common user pain point by making the update system less disruptive, while still ensuring that new versions are downloaded and available for installation when convenient for the user. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/desktop/src/renderer/components/AppUpdateNotification.tsx (1)
85-98:⚠️ Potential issue | 🟡 MinorConsider checking suppression in
onAppUpdateAvailablelistener.The
onAppUpdateAvailableevent listener (lines 87-95) directly shows the dialog without checking if the version is suppressed. If the main process emits an event for a version the user previously skipped (e.g., after an app restart where the main process re-detects the update), the dialog will appear despite the user's preference.The suppression check in
UpdateBanner.tsxhandles the polling case, but this push-based notification path could bypass it.🛠️ Suggested fix
useEffect(() => { - const cleanup = window.electronAPI.onAppUpdateAvailable((info) => { + const cleanup = window.electronAPI.onAppUpdateAvailable(async (info) => { + // Check if this version is suppressed before showing the dialog + try { + const suppressed = await window.electronAPI.isAppUpdateSuppressed(info.version); + if (suppressed.success && suppressed.data) { + return; // Don't show dialog for suppressed versions + } + } catch { + // Continue showing dialog if suppression check fails + } + setUpdateInfo(info); setIsOpen(true); // ... rest of state resets }); return cleanup; }, []);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/renderer/components/AppUpdateNotification.tsx` around lines 85 - 98, The onAppUpdateAvailable listener in AppUpdateNotification.tsx should not unconditionally open the dialog; update the callback registered via window.electronAPI.onAppUpdateAvailable to first check the stored suppression state for the incoming info.version (use the same suppression key/mechanism used by UpdateBanner.tsx, e.g., the suppressed version in localStorage or the settings accessor). If the version is suppressed, return early and do not call setUpdateInfo/setIsOpen/setIsDownloading/setIsDownloaded/setDownloadProgress/setDownloadError/setShowReadOnlyWarning; otherwise proceed to set state as currently implemented. Ensure you reference the existing listener registration (onAppUpdateAvailable) and the suppression check used by UpdateBanner.tsx so both paths are consistent.apps/desktop/src/renderer/components/UpdateBanner.tsx (1)
105-121:⚠️ Potential issue | 🟡 MinorPush event listener bypasses suppression check.
The
onAppUpdateAvailablelistener updates the banner state directly without checking if the version is suppressed. This means a skipped/snoozed version pushed from the main process (e.g., after detecting an update on startup) will still show the banner, even though the polling path (lines 39-45) correctly filters it out.🛠️ Suggested fix
useEffect(() => { - const cleanup = window.electronAPI.onAppUpdateAvailable((info) => { + const cleanup = window.electronAPI.onAppUpdateAvailable(async (info) => { + // Check if this version is suppressed before showing banner + try { + const suppressed = await window.electronAPI.isAppUpdateSuppressed(info.version); + if (suppressed.success && suppressed.data) { + return; // Don't show banner for suppressed versions + } + } catch { + // Continue showing banner if suppression check fails + } + // New update notification - reset dismiss state if new version if (currentVersionRef.current !== info.version) { setIsDismissed(false); currentVersionRef.current = info.version; } setUpdateInfo(info); // ... rest of state resets }); return cleanup; }, []);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/desktop/src/renderer/components/UpdateBanner.tsx` around lines 105 - 121, The onAppUpdateAvailable listener currently sets banner state unconditionally; modify the callback in the useEffect that registers window.electronAPI.onAppUpdateAvailable to first check whether info.version matches the suppressed/skipped version (e.g., compare info.version against the existing suppressedVersion state or suppressedVersionRef.current) and return early if it is suppressed, otherwise proceed to update setUpdateInfo, setIsDownloading, setIsDownloaded, setDownloadProgress, setDownloadError and reset setIsDismissed when the version differs (currentVersionRef.current !== info.version) as before; ensure you reference the same suppression identifier used by the polling path so the push path respects the same suppression logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/desktop/src/main/app-updater.ts`:
- Around line 204-210: Remove the unused wrapper function isUpdateSuppressed by
deleting its exported declaration that simply calls shouldSuppressUpdate; ensure
you also remove any export references to isUpdateSuppressed (so only
shouldSuppressUpdate remains public) and run a quick grep to confirm there are
no remaining callers or IPC handlers referencing isUpdateSuppressed before
committing.
In `@apps/desktop/src/main/ipc-handlers/app-update-handlers.ts`:
- Around line 198-208: The APP_UPDATE_IS_SUPPRESSED IPC handler currently
accepts the version string without validation; update the handler
(IPC_CHANNELS.APP_UPDATE_IS_SUPPRESSED) to validate the incoming version format
the same way APP_UPDATE_SKIP_VERSION does before calling shouldSuppressUpdate,
and return a failure IPCResult with a clear error message when format validation
fails; reuse the same validation logic/function used by APP_UPDATE_SKIP_VERSION
to ensure consistency and fail fast on malformed input.
---
Outside diff comments:
In `@apps/desktop/src/renderer/components/AppUpdateNotification.tsx`:
- Around line 85-98: The onAppUpdateAvailable listener in
AppUpdateNotification.tsx should not unconditionally open the dialog; update the
callback registered via window.electronAPI.onAppUpdateAvailable to first check
the stored suppression state for the incoming info.version (use the same
suppression key/mechanism used by UpdateBanner.tsx, e.g., the suppressed version
in localStorage or the settings accessor). If the version is suppressed, return
early and do not call
setUpdateInfo/setIsOpen/setIsDownloading/setIsDownloaded/setDownloadProgress/setDownloadError/setShowReadOnlyWarning;
otherwise proceed to set state as currently implemented. Ensure you reference
the existing listener registration (onAppUpdateAvailable) and the suppression
check used by UpdateBanner.tsx so both paths are consistent.
In `@apps/desktop/src/renderer/components/UpdateBanner.tsx`:
- Around line 105-121: The onAppUpdateAvailable listener currently sets banner
state unconditionally; modify the callback in the useEffect that registers
window.electronAPI.onAppUpdateAvailable to first check whether info.version
matches the suppressed/skipped version (e.g., compare info.version against the
existing suppressedVersion state or suppressedVersionRef.current) and return
early if it is suppressed, otherwise proceed to update setUpdateInfo,
setIsDownloading, setIsDownloaded, setDownloadProgress, setDownloadError and
reset setIsDismissed when the version differs (currentVersionRef.current !==
info.version) as before; ensure you reference the same suppression identifier
used by the polling path so the push path respects the same suppression logic.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 68182f10-720b-46f6-acf4-6bc435f8fb16
📒 Files selected for processing (11)
apps/desktop/src/main/app-updater.tsapps/desktop/src/main/ipc-handlers/app-update-handlers.tsapps/desktop/src/preload/api/app-update-api.tsapps/desktop/src/renderer/components/AppUpdateNotification.tsxapps/desktop/src/renderer/components/UpdateBanner.tsxapps/desktop/src/renderer/lib/mocks/settings-mock.tsapps/desktop/src/shared/constants/ipc.tsapps/desktop/src/shared/i18n/locales/en/dialogs.jsonapps/desktop/src/shared/i18n/locales/fr/dialogs.jsonapps/desktop/src/shared/types/ipc.tsapps/desktop/src/shared/types/settings.ts
| /** | ||
| * Check if update notifications are currently suppressed for a given version. | ||
| * Used by the renderer via IPC to avoid showing stale banners. | ||
| */ | ||
| export function isUpdateSuppressed(version: string): boolean { | ||
| return shouldSuppressUpdate(version); | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if isUpdateSuppressed is used anywhere in the codebase
rg -n 'isUpdateSuppressed' --type=tsRepository: AndyMik90/Aperant
Length of output: 164
Remove the unused isUpdateSuppressed wrapper function.
isUpdateSuppressed is not called anywhere in the codebase—ripgrep found only its definition. It simply wraps shouldSuppressUpdate without adding value. Remove it to reduce API surface.
♻️ Remove unused function
-/**
- * Check if update notifications are currently suppressed for a given version.
- * Used by the renderer via IPC to avoid showing stale banners.
- */
-export function isUpdateSuppressed(version: string): boolean {
- return shouldSuppressUpdate(version);
-}📝 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.
| /** | |
| * Check if update notifications are currently suppressed for a given version. | |
| * Used by the renderer via IPC to avoid showing stale banners. | |
| */ | |
| export function isUpdateSuppressed(version: string): boolean { | |
| return shouldSuppressUpdate(version); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/desktop/src/main/app-updater.ts` around lines 204 - 210, Remove the
unused wrapper function isUpdateSuppressed by deleting its exported declaration
that simply calls shouldSuppressUpdate; ensure you also remove any export
references to isUpdateSuppressed (so only shouldSuppressUpdate remains public)
and run a quick grep to confirm there are no remaining callers or IPC handlers
referencing isUpdateSuppressed before committing.
| ipcMain.handle( | ||
| IPC_CHANNELS.APP_UPDATE_IS_SUPPRESSED, | ||
| async (_event, version: string): Promise<IPCResult<boolean>> => { | ||
| try { | ||
| return { success: true, data: shouldSuppressUpdate(version) }; | ||
| } catch (error) { | ||
| console.error('[app-update-handlers] Check suppression failed:', error); | ||
| return { success: false, error: error instanceof Error ? error.message : 'Failed to check suppression' }; | ||
| } | ||
| } | ||
| ); |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Consider adding version validation for consistency.
The APP_UPDATE_IS_SUPPRESSED handler accepts a version string without validation, while APP_UPDATE_SKIP_VERSION (lines 166-168) validates the format. Although shouldSuppressUpdate only performs string comparison (no security risk), adding consistent validation would:
- Ensure uniform input handling across related handlers
- Fail fast on malformed input rather than silently returning
false
♻️ Optional: Add validation for consistency
ipcMain.handle(
IPC_CHANNELS.APP_UPDATE_IS_SUPPRESSED,
async (_event, version: string): Promise<IPCResult<boolean>> => {
try {
+ if (!version || typeof version !== 'string' || version.length > 50 || !/^[\d\w.-]+$/.test(version)) {
+ return { success: false, error: 'Invalid version format' };
+ }
return { success: true, data: shouldSuppressUpdate(version) };
} catch (error) {📝 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.
| ipcMain.handle( | |
| IPC_CHANNELS.APP_UPDATE_IS_SUPPRESSED, | |
| async (_event, version: string): Promise<IPCResult<boolean>> => { | |
| try { | |
| return { success: true, data: shouldSuppressUpdate(version) }; | |
| } catch (error) { | |
| console.error('[app-update-handlers] Check suppression failed:', error); | |
| return { success: false, error: error instanceof Error ? error.message : 'Failed to check suppression' }; | |
| } | |
| } | |
| ); | |
| ipcMain.handle( | |
| IPC_CHANNELS.APP_UPDATE_IS_SUPPRESSED, | |
| async (_event, version: string): Promise<IPCResult<boolean>> => { | |
| try { | |
| if (!version || typeof version !== 'string' || version.length > 50 || !/^[\d\w.-]+$/.test(version)) { | |
| return { success: false, error: 'Invalid version format' }; | |
| } | |
| return { success: true, data: shouldSuppressUpdate(version) }; | |
| } catch (error) { | |
| console.error('[app-update-handlers] Check suppression failed:', error); | |
| return { success: false, error: error instanceof Error ? error.message : 'Failed to check suppression' }; | |
| } | |
| } | |
| ); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/desktop/src/main/ipc-handlers/app-update-handlers.ts` around lines 198 -
208, The APP_UPDATE_IS_SUPPRESSED IPC handler currently accepts the version
string without validation; update the handler
(IPC_CHANNELS.APP_UPDATE_IS_SUPPRESSED) to validate the incoming version format
the same way APP_UPDATE_SKIP_VERSION does before calling shouldSuppressUpdate,
and return a failure IPCResult with a clear error message when format validation
fails; reuse the same validation logic/function used by APP_UPDATE_SKIP_VERSION
to ensure consistency and fail fast on malformed input.
There was a problem hiding this comment.
Code Review
This pull request introduces persistent "skip" and "snooze" functionality for update notifications, which is a great improvement for user experience. The implementation is solid, touching on the main process, IPC, preload scripts, and renderer components. My review focuses on improving robustness and debuggability. I've suggested safer type handling when reading settings, removing a piece of redundant code, and adding error logging to currently empty catch blocks to aid in future debugging.
| skippedVersion: (settings?.skippedUpdateVersion as string) || null, | ||
| remindAfter: (settings?.updateRemindAfter as number) || null, |
There was a problem hiding this comment.
The type assertions as string and as number are not type-safe. If the settings file gets corrupted or manually edited with incorrect types for skippedUpdateVersion or updateRemindAfter, this could lead to runtime errors. It's safer to use typeof checks to validate the types before using them.
| skippedVersion: (settings?.skippedUpdateVersion as string) || null, | |
| remindAfter: (settings?.updateRemindAfter as number) || null, | |
| skippedVersion: typeof settings?.skippedUpdateVersion === 'string' ? settings.skippedUpdateVersion : null, | |
| remindAfter: typeof settings?.updateRemindAfter === 'number' ? settings.updateRemindAfter : null, |
| export function isUpdateSuppressed(version: string): boolean { | ||
| return shouldSuppressUpdate(version); | ||
| } |
There was a problem hiding this comment.
| const handleSkipVersion = async () => { | ||
| try { | ||
| if (updateInfo) { | ||
| await window.electronAPI.skipAppUpdate(updateInfo.version); | ||
| } | ||
| } catch { | ||
| // Skip is best-effort; close the dialog regardless | ||
| } | ||
| setIsOpen(false); | ||
| }; | ||
|
|
||
| const handleRemindLater = async () => { | ||
| try { | ||
| await window.electronAPI.snoozeAppUpdate(); | ||
| } catch { | ||
| // Snooze is best-effort; close the dialog regardless | ||
| } | ||
| setIsOpen(false); | ||
| }; |
There was a problem hiding this comment.
The catch blocks in handleSkipVersion and handleRemindLater are empty. While the comment explains that the action is "best-effort", swallowing errors completely can make debugging difficult if there's a persistent problem with skipping or snoozing updates. It would be better to log the error to the console.
const handleSkipVersion = async () => {
try {
if (updateInfo) {
await window.electronAPI.skipAppUpdate(updateInfo.version);
}
} catch (error) {
// Skip is best-effort; close the dialog regardless
console.error('[AppUpdateNotification] Failed to skip version:', error);
}
setIsOpen(false);
};
const handleRemindLater = async () => {
try {
await window.electronAPI.snoozeAppUpdate();
} catch (error) {
// Snooze is best-effort; close the dialog regardless
console.error('[AppUpdateNotification] Failed to snooze update:', error);
}
setIsOpen(false);
};
| const handleDismiss = async () => { | ||
| try { | ||
| if (updateInfo) { | ||
| await window.electronAPI.snoozeAppUpdate(); | ||
| } | ||
| } catch { | ||
| // Snooze is best-effort; dismiss the banner regardless | ||
| } | ||
| setIsDismissed(true); | ||
| }; |
There was a problem hiding this comment.
The catch block in handleDismiss is empty. While the comment explains the rationale, swallowing the error makes it difficult to diagnose issues if snoozing updates consistently fails. Please log the error to the console for better debuggability.
| const handleDismiss = async () => { | |
| try { | |
| if (updateInfo) { | |
| await window.electronAPI.snoozeAppUpdate(); | |
| } | |
| } catch { | |
| // Snooze is best-effort; dismiss the banner regardless | |
| } | |
| setIsDismissed(true); | |
| }; | |
| const handleDismiss = async () => { | |
| try { | |
| if (updateInfo) { | |
| await window.electronAPI.snoozeAppUpdate(); | |
| } | |
| } catch (error) { | |
| // Snooze is best-effort; dismiss the banner regardless | |
| console.error('[UpdateBanner] Failed to snooze update:', error); | |
| } | |
| setIsDismissed(true); | |
| }; |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
| setUpdateInfo(null); | ||
| return; | ||
| } | ||
|
|
There was a problem hiding this comment.
Banner snooze becomes permanent within a single session
Medium Severity
After the 24-hour snooze expires, the banner never reappears within the same app session. When handleDismiss fires, it sets isDismissed = true while currentVersionRef.current retains the version string. After snooze expiry, checkForUpdate finds the update is no longer suppressed, but the currentVersionRef.current !== newVersion check is false (same version), so isDismissed is never reset to false. The render guard !updateInfo || isDismissed then permanently hides the banner. The same issue affects the onAppUpdateAvailable push listener. The snooze effectively becomes permanent until restart or a new version, contradicting the intended 24-hour behavior.
Additional Locations (2)
| */ | ||
| export function isUpdateSuppressed(version: string): boolean { | ||
| return shouldSuppressUpdate(version); | ||
| } |
There was a problem hiding this comment.
Exported isUpdateSuppressed function is never imported
Low Severity
isUpdateSuppressed is exported but never imported anywhere in the codebase. The IPC handler for APP_UPDATE_IS_SUPPRESSED imports shouldSuppressUpdate directly instead. This function is a trivial wrapper that adds no value — it's dead code.


Summary
Adds persistent "Skip This Version" and 24-hour "Remind Me Later" snooze to the update notification system so users aren't nagged repeatedly when they can't or don't want to update.
Fixes #1944
Problem
Users were getting update popups every few minutes with no way to permanently dismiss them. The dismiss was in-memory only (reset on restart), and "Remind Me Later" just closed the dialog with no persistent effect.
Solution
settings.json. That specific version never shows notifications again. A newer version bypasses the skip automatically.Changes
app-updater.ts— Suppression logic (shouldSuppressUpdate), settings persistence (skipUpdateVersion,snoozeUpdate), gatedupdate-availableandupdate-downloadedIPC eventsapp-update-handlers.ts— 3 new IPC handlers with version format validationapp-update-api.ts— Preload API additions (skipAppUpdate,snoozeAppUpdate,isAppUpdateSuppressed)AppUpdateNotification.tsx— "Skip This Version" ghost button + "Remind Me Later" calls snoozeUpdateBanner.tsx— Suppression check on mount and poll, X-dismiss calls snoozesettings.ts— AddedskippedUpdateVersionandupdateRemindAftertoAppSettingstypeipc.ts— 3 new IPC channel constants + ElectronAPI type declarationssettings-mock.ts— Browser mock stubsen/dialogs.json,fr/dialogs.json— i18n key for "Skip This Version"Testing
Cross-Platform Compatibility
All new code uses Electron's
app.getPath('userData')viasettings-utils.tsfor settings persistence — works identically on macOS, Windows, and Linux. No platform-specific code introduced.Summary by CodeRabbit