-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat(updater): persistent Skip This Version and 24h snooze for update notifications #1980
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -27,6 +27,7 @@ import { IPC_CHANNELS } from '../shared/constants'; | |||||||||||||||
| import type { AppUpdateInfo } from '../shared/types'; | ||||||||||||||||
| import { compareVersions } from './updater/version-manager'; | ||||||||||||||||
| import { isMacOS } from './platform'; | ||||||||||||||||
| import { readSettingsFile, writeSettingsFile } from './settings-utils'; | ||||||||||||||||
|
|
||||||||||||||||
| // GitHub repo info for API calls | ||||||||||||||||
| const GITHUB_OWNER = 'AndyMik90'; | ||||||||||||||||
|
|
@@ -155,6 +156,59 @@ function formatReleaseNotes(releaseNotes: UpdateInfo['releaseNotes']): string | | |||||||||||||||
| return undefined; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| /** | ||||||||||||||||
| * Read update suppression preferences from settings. | ||||||||||||||||
| */ | ||||||||||||||||
| function getUpdatePreferences(): { skippedVersion: string | null; remindAfter: number | null } { | ||||||||||||||||
| const settings = readSettingsFile(); | ||||||||||||||||
| return { | ||||||||||||||||
| skippedVersion: (settings?.skippedUpdateVersion as string) || null, | ||||||||||||||||
| remindAfter: (settings?.updateRemindAfter as number) || null, | ||||||||||||||||
| }; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| /** | ||||||||||||||||
| * Check if notifications for a version should be suppressed. | ||||||||||||||||
| * Returns true if the user has skipped this exact version, or snoozed and the snooze hasn't expired. | ||||||||||||||||
| */ | ||||||||||||||||
| export function shouldSuppressUpdate(version: string): boolean { | ||||||||||||||||
| const prefs = getUpdatePreferences(); | ||||||||||||||||
| if (prefs.skippedVersion === version) return true; | ||||||||||||||||
| if (prefs.remindAfter && Date.now() < prefs.remindAfter) return true; | ||||||||||||||||
| return false; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| /** | ||||||||||||||||
| * Persist a request to skip a specific version permanently. | ||||||||||||||||
| * That version will never trigger notifications again unless a newer version arrives. | ||||||||||||||||
| */ | ||||||||||||||||
| export function skipUpdateVersion(version: string): void { | ||||||||||||||||
| const settings = readSettingsFile() || {}; | ||||||||||||||||
| settings.skippedUpdateVersion = version; | ||||||||||||||||
| delete settings.updateRemindAfter; | ||||||||||||||||
| writeSettingsFile(settings); | ||||||||||||||||
| console.warn('[app-updater] Skipped version:', version); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| /** | ||||||||||||||||
| * Snooze update notifications for 24 hours. | ||||||||||||||||
| */ | ||||||||||||||||
| export function snoozeUpdate(): void { | ||||||||||||||||
| const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; | ||||||||||||||||
| const settings = readSettingsFile() || {}; | ||||||||||||||||
| settings.updateRemindAfter = Date.now() + TWENTY_FOUR_HOURS; | ||||||||||||||||
| writeSettingsFile(settings); | ||||||||||||||||
| console.warn('[app-updater] Snoozed updates until:', new Date(settings.updateRemindAfter as number).toISOString()); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| /** | ||||||||||||||||
| * 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); | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+204
to
+210
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 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
♻️ 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
Suggested change
🤖 Prompt for AI Agents
Comment on lines
+208
to
+210
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Exported
|
||||||||||||||||
|
|
||||||||||||||||
| /** | ||||||||||||||||
| * Set the update channel for electron-updater. | ||||||||||||||||
| * - 'latest': Only receive stable releases (default) | ||||||||||||||||
|
|
@@ -233,6 +287,16 @@ export function initializeAppUpdater(window: BrowserWindow, betaUpdates = false) | |||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // Check if user has skipped or snoozed this version | ||||||||||||||||
| if (shouldSuppressUpdate(info.version)) { | ||||||||||||||||
| console.warn('[app-updater] Update suppressed (skipped or snoozed):', info.version); | ||||||||||||||||
| // Still download silently so it's ready when the suppression expires | ||||||||||||||||
| autoUpdater.downloadUpdate().catch((error) => { | ||||||||||||||||
| console.error('[app-updater] Failed to download update:', error.message); | ||||||||||||||||
| }); | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| if (mainWindow) { | ||||||||||||||||
| mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_AVAILABLE, { | ||||||||||||||||
| version: info.version, | ||||||||||||||||
|
|
@@ -265,9 +329,14 @@ export function initializeAppUpdater(window: BrowserWindow, betaUpdates = false) | |||||||||||||||
| releaseNotes: formatReleaseNotes(info.releaseNotes), | ||||||||||||||||
| releaseDate: info.releaseDate | ||||||||||||||||
| }; | ||||||||||||||||
| if (mainWindow) { | ||||||||||||||||
| // Reuse downloadedUpdateInfo instead of calling formatReleaseNotes again | ||||||||||||||||
| mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_DOWNLOADED, downloadedUpdateInfo); | ||||||||||||||||
|
|
||||||||||||||||
| // Only notify renderer if update is not suppressed | ||||||||||||||||
| if (!shouldSuppressUpdate(info.version)) { | ||||||||||||||||
| if (mainWindow) { | ||||||||||||||||
| mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_DOWNLOADED, downloadedUpdateInfo); | ||||||||||||||||
| } | ||||||||||||||||
| } else { | ||||||||||||||||
| console.warn('[app-updater] Downloaded update suppressed (skipped or snoozed):', info.version); | ||||||||||||||||
| } | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -14,7 +14,10 @@ import { | |||||||||||||||||||||||||||||||||||||||||||||||||||
| downloadStableVersion, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| quitAndInstall, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| getCurrentVersion, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| getDownloadedUpdateInfo | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| getDownloadedUpdateInfo, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| skipUpdateVersion, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| snoozeUpdate, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| shouldSuppressUpdate | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from '../app-updater'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -151,5 +154,58 @@ export function registerAppUpdateHandlers(): void { | |||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * APP_UPDATE_SKIP_VERSION: Permanently skip a specific version | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * That version will never show notifications again until a newer version arrives | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ipcMain.handle( | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| IPC_CHANNELS.APP_UPDATE_SKIP_VERSION, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| async (_event, version: string): Promise<IPCResult> => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Validate version format (semver-like, max 50 chars) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!version || typeof version !== 'string' || version.length > 50 || !/^[\d\w.-]+$/.test(version)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { success: false, error: 'Invalid version format' }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| skipUpdateVersion(version); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { success: true }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error('[app-update-handlers] Skip version failed:', error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { success: false, error: error instanceof Error ? error.message : 'Failed to skip version' }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * APP_UPDATE_SNOOZE: Snooze update notifications for 24 hours | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ipcMain.handle( | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| IPC_CHANNELS.APP_UPDATE_SNOOZE, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| async (): Promise<IPCResult> => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| snoozeUpdate(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { success: true }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error('[app-update-handlers] Snooze update failed:', error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { success: false, error: error instanceof Error ? error.message : 'Failed to snooze update' }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * APP_UPDATE_IS_SUPPRESSED: Check if update notifications are suppressed for a version | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Used by the renderer to avoid showing stale banners after a skip/snooze | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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' }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+198
to
+208
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Consider adding version validation for consistency. The
♻️ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.warn('[IPC] App update handlers registered successfully'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -165,6 +165,26 @@ export function AppUpdateNotification() { | |
| setIsOpen(false); | ||
| }; | ||
|
|
||
| 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); | ||
| }; | ||
|
Comment on lines
+168
to
+186
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
|
||
| if (!updateInfo) { | ||
| return null; | ||
| } | ||
|
|
@@ -298,7 +318,12 @@ export function AppUpdateNotification() { | |
| </div> | ||
|
|
||
| <DialogFooter className="flex flex-col sm:flex-row gap-3"> | ||
| <Button variant="outline" onClick={handleDismiss} disabled={isDownloading}> | ||
| {!isDownloaded && !isDownloading && ( | ||
| <Button variant="ghost" onClick={handleSkipVersion} className="text-muted-foreground"> | ||
| {t("dialogs:appUpdate.skipThisVersion", "Skip This Version")} | ||
| </Button> | ||
| )} | ||
| <Button variant="outline" onClick={isDownloaded ? handleDismiss : handleRemindLater} disabled={isDownloading}> | ||
| {isDownloaded | ||
| ? t("dialogs:appUpdate.installLater", "Install Later") | ||
| : t("dialogs:appUpdate.remindMeLater", "Remind Me Later")} | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -36,6 +36,14 @@ export function UpdateBanner({ className }: UpdateBannerProps) { | |||||||||||||||||||||||||||||||||||||||||||
| const result = await window.electronAPI.checkAppUpdate(); | ||||||||||||||||||||||||||||||||||||||||||||
| if (result.success && result.data) { | ||||||||||||||||||||||||||||||||||||||||||||
| const newVersion = result.data.version; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Check if this version is suppressed (skipped or snoozed) | ||||||||||||||||||||||||||||||||||||||||||||
| const suppressed = await window.electronAPI.isAppUpdateSuppressed(newVersion); | ||||||||||||||||||||||||||||||||||||||||||||
| if (suppressed.success && suppressed.data) { | ||||||||||||||||||||||||||||||||||||||||||||
| setUpdateInfo(null); | ||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Banner snooze becomes permanent within a single sessionMedium Severity After the 24-hour snooze expires, the banner never reappears within the same app session. When Additional Locations (2) |
||||||||||||||||||||||||||||||||||||||||||||
| // New update available - show banner (unless same version already dismissed) | ||||||||||||||||||||||||||||||||||||||||||||
| if (currentVersionRef.current !== newVersion) { | ||||||||||||||||||||||||||||||||||||||||||||
| setIsDismissed(false); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -62,6 +70,11 @@ export function UpdateBanner({ className }: UpdateBannerProps) { | |||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||
| const result = await window.electronAPI.getDownloadedAppUpdate(); | ||||||||||||||||||||||||||||||||||||||||||||
| if (result.success && result.data) { | ||||||||||||||||||||||||||||||||||||||||||||
| // Check if this downloaded version is suppressed (skipped or snoozed) | ||||||||||||||||||||||||||||||||||||||||||||
| const suppressed = await window.electronAPI.isAppUpdateSuppressed(result.data.version); | ||||||||||||||||||||||||||||||||||||||||||||
| if (suppressed.success && suppressed.data) { | ||||||||||||||||||||||||||||||||||||||||||||
| return; // Don't show banner for suppressed downloaded updates | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| currentVersionRef.current = result.data.version; | ||||||||||||||||||||||||||||||||||||||||||||
| setUpdateInfo({ | ||||||||||||||||||||||||||||||||||||||||||||
| version: result.data.version, | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -173,8 +186,15 @@ export function UpdateBanner({ className }: UpdateBannerProps) { | |||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Handle dismiss | ||||||||||||||||||||||||||||||||||||||||||||
| const handleDismiss = () => { | ||||||||||||||||||||||||||||||||||||||||||||
| // Handle dismiss - snoozes for 24 hours so the banner doesn't reappear on next poll | ||||||||||||||||||||||||||||||||||||||||||||
| const handleDismiss = async () => { | ||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||
| if (updateInfo) { | ||||||||||||||||||||||||||||||||||||||||||||
| await window.electronAPI.snoozeAppUpdate(); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||
| // Snooze is best-effort; dismiss the banner regardless | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| setIsDismissed(true); | ||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+190
to
199
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The type assertions
as stringandas numberare not type-safe. If the settings file gets corrupted or manually edited with incorrect types forskippedUpdateVersionorupdateRemindAfter, this could lead to runtime errors. It's safer to usetypeofchecks to validate the types before using them.