From 2fd70679feca6aaf53c4ea2af3396231f24324d4 Mon Sep 17 00:00:00 2001 From: yexisu <1761512688@qq.com> Date: Sat, 3 Oct 2026 14:07:42 +0800 Subject: [PATCH] fix(updater): persist update banner dismissal across restarts (#1317) The update banner's dismiss button only wrote component-local state, so every restart brought the same notice back for a version the user had already declined. Record the dismissed version through a new `updatesDismiss` IPC channel into host settings (`updateDismissedVersion`), surface it as a `dismissed` flag on UpdateState, and clear it when a newer version is discovered so future releases keep notifying. Validated: update-preference tests 6/6, auto-update tests 8/8, desktop typecheck clean, cargo fmt --check clean. host-core test run requires a Rust toolchain and is left to CI. Fixes #1317 --- apps/desktop/electron/main/index.ts | 5 ++ apps/desktop/electron/main/ipc/app-ipc.ts | 4 ++ apps/desktop/electron/main/updater.ts | 48 ++++++++++++++++++++ apps/desktop/src/components/UpdateBanner.tsx | 7 ++- apps/desktop/src/lib/api.ts | 1 + apps/desktop/test/update-preference.test.mjs | 7 +++ crates/host-core/src/rpc/mod.rs | 26 +++++++++++ packages/shared/src/protocol.ts | 2 + packages/shared/src/types/platform.ts | 5 ++ 9 files changed, 103 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index 9c774d4071..c07281e6d4 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -254,6 +254,11 @@ const updater = new AppUpdaterController({ if (!host?.isAvailable()) throw new Error("host unavailable"); await host.call("settings.set", { lastNotifiedUpdateVersion: version }); }, + persistDismissedVersion: async (version) => { + const host = getHost(); + if (!host?.isAvailable()) throw new Error("host unavailable"); + await host.call("settings.set", { updateDismissedVersion: version }); + }, }); /** diff --git a/apps/desktop/electron/main/ipc/app-ipc.ts b/apps/desktop/electron/main/ipc/app-ipc.ts index 287c05fea2..1ba1e4c37f 100644 --- a/apps/desktop/electron/main/ipc/app-ipc.ts +++ b/apps/desktop/electron/main/ipc/app-ipc.ts @@ -235,4 +235,8 @@ export function registerAppIpc({ await updater.openReleases(); return { ok: true }; }); + handle(IPC.invoke.updatesDismiss, async () => { + await updater.dismiss(); + return { ok: true }; + }); } diff --git a/apps/desktop/electron/main/updater.ts b/apps/desktop/electron/main/updater.ts index c59bd4c05b..9c1cdbe8c8 100644 --- a/apps/desktop/electron/main/updater.ts +++ b/apps/desktop/electron/main/updater.ts @@ -84,6 +84,7 @@ export const MANUAL_CHECK_TIMEOUT_MS = 15_000; export type UpdaterSettings = { updatePreference?: unknown; lastNotifiedUpdateVersion?: unknown; + updateDismissedVersion?: unknown; }; export type UpdaterOptions = { @@ -92,6 +93,8 @@ export type UpdaterOptions = { currentVersion: string; readUpdateSettings?: () => Promise; persistLastNotifiedVersion?: (version: string) => Promise; + /** Persists the version whose update notice the user dismissed (#1317). */ + persistDismissedVersion?: (version: string | null) => Promise; /** * Active product UI locale for shipped-locale release notes. * Called when attaching notes to update state; defaults to English. @@ -120,6 +123,9 @@ export class AppUpdaterController { private readonly automaticSupported: boolean; private readonly readUpdateSettings?: () => Promise; private readonly persistLastNotifiedVersion?: (version: string) => Promise; + private readonly persistDismissedVersion?: ( + version: string | null, + ) => Promise; private readonly manualReminderTracker = new ManualUpdateReminderTracker(); private preference: UpdatePreference; private preferenceRevision = 0; @@ -127,6 +133,8 @@ export class AppUpdaterController { private autoCheckStarted = false; private disposed = false; private state: UpdateState; + /** Version whose notice the user dismissed; cleared when a new one appears. */ + private dismissedVersion?: string; private manualRequested = false; private initialTimer: NodeJS.Timeout | null = null; private intervalTimer: NodeJS.Timeout | null = null; @@ -198,6 +206,7 @@ export class AppUpdaterController { ); this.readUpdateSettings = options.readUpdateSettings; this.persistLastNotifiedVersion = options.persistLastNotifiedVersion; + this.persistDismissedVersion = options.persistDismissedVersion; const mode = resolveUpdateModePolicy( platform, isPackaged, @@ -275,6 +284,17 @@ export class AppUpdaterController { ) { this.manualReminderTracker.hydrate(lastNotifiedVersion); } + const dismissedVersion = settings.updateDismissedVersion; + if ( + typeof dismissedVersion === "string" && + dismissedVersion.length > 0 && + dismissedVersion.length <= 128 + ) { + this.dismissedVersion = dismissedVersion; + if (this.state.availableVersion === dismissedVersion) { + this.setState({ dismissed: true }); + } + } const preference = resolveStoredUpdatePreference( settings.updatePreference, this.defaultPreference, @@ -364,6 +384,26 @@ export class AppUpdaterController { this.applyPreference(preference, true); } + /** + * Records the user's decision to stop nudging about `availableVersion` + * until a newer version is detected (#1317). The banner hides itself from + * the pushed `dismissed` flag, so the decision survives restarts. + */ + async dismiss(): Promise { + const version = this.state.availableVersion; + if (!version) return; + this.dismissedVersion = version; + this.setState({ dismissed: true }); + if (!this.persistDismissedVersion) return; + try { + await this.persistDismissedVersion(version); + } catch (error) { + this.logger.app("updater", "warn", "update dismissal persistence failed", { + data: { detail: String(error), version }, + }); + } + } + private attachListeners() { if (this.listenersAttached) return; this.listenersAttached = true; @@ -403,11 +443,19 @@ export class AppUpdaterController { }); this.autoUpdater.on("update-available", (info: UpdateInfo) => { const automatic = this.state.mode === "in-app"; + // A newly discovered version supersedes any earlier dismissal. + if (this.dismissedVersion && this.dismissedVersion !== info.version) { + this.dismissedVersion = undefined; + if (this.persistDismissedVersion) { + void this.persistDismissedVersion(null).catch(() => undefined); + } + } this.setState({ status: automatic ? "downloading" : "available", availableVersion: info.version, releaseNotes: this.notesFor(info.version), progressPercent: automatic ? 0 : undefined, + dismissed: this.dismissedVersion === info.version, manualReminder: automatic ? false : this.manualReminderFor(info.version), diff --git a/apps/desktop/src/components/UpdateBanner.tsx b/apps/desktop/src/components/UpdateBanner.tsx index e83c6a084d..d05a1f17fe 100644 --- a/apps/desktop/src/components/UpdateBanner.tsx +++ b/apps/desktop/src/components/UpdateBanner.tsx @@ -43,7 +43,7 @@ export function UpdateBanner() { if (!update?.availableVersion) return null; const stateKey = `${update.availableVersion}:${update.status}`; - if (stateKey === dismissedState) return null; + if (stateKey === dismissedState || update.dismissed === true) return null; const visible = update.status === "downloaded" || @@ -124,7 +124,10 @@ export function UpdateBanner() { tooltip={t("updates.dismiss")} ariaLabel={t("updates.dismiss")} className="update-notice-dismiss" - onClick={() => setDismissedState(stateKey)} + onClick={() => { + setDismissedState(stateKey); + void api.updatesDismiss().catch(() => undefined); + }} > diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 904c0f34a5..c28f35e53e 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -544,6 +544,7 @@ export const api = { updatesDownload: () => invoke(IPC.invoke.updatesDownload), updatesInstall: () => invoke(IPC.invoke.updatesInstall), updatesOpenReleases: () => invoke(IPC.invoke.updatesOpenReleases), + updatesDismiss: () => invoke(IPC.invoke.updatesDismiss), openFeedback: () => invoke(IPC.invoke.appOpenFeedback), listNotifications: (input?: { unreadOnly?: boolean; limit?: number }) => invoke(IPC.invoke.notificationList, input ?? {}), diff --git a/apps/desktop/test/update-preference.test.mjs b/apps/desktop/test/update-preference.test.mjs index 2137f19e72..02dbe552d4 100644 --- a/apps/desktop/test/update-preference.test.mjs +++ b/apps/desktop/test/update-preference.test.mjs @@ -87,3 +87,10 @@ test("Settings → Info saves only valid automatic/manual preferences", async () assert.match(updateSettingsSource, /persistUpdatePreference\(value, saveSettings\)/); assert.match(updateBannerSource, /manualReminder === true/); }); + +test("dismissed update banner hides via persisted state, not component memory", () => { + // The banner must consult the durable `dismissed` flag from UpdateState and + // record the dismissal through the updatesDismiss IPC channel (#1317). + assert.match(updateBannerSource, /update\.dismissed === true/); + assert.match(updateBannerSource, /api\.updatesDismiss\(\)/); +}); diff --git a/crates/host-core/src/rpc/mod.rs b/crates/host-core/src/rpc/mod.rs index 63026025f2..ab1a75bb4a 100644 --- a/crates/host-core/src/rpc/mod.rs +++ b/crates/host-core/src/rpc/mod.rs @@ -1013,6 +1013,27 @@ fn validate_settings_value(value: &Value) -> Result<(), JsonRpcError> { )); } } + if let Some(version) = object.get("updateDismissedVersion") { + match version { + serde_json::Value::Null => {} + serde_json::Value::String(version) => { + if version.trim().is_empty() || version.len() > 128 { + return Err(rpc_err( + 1002, + "updateDismissedVersion must contain 1 to 128 characters", + "INVALID_PARAMS", + )); + } + } + _ => { + return Err(rpc_err( + 1002, + "updateDismissedVersion must be a string or null", + "INVALID_PARAMS", + )); + } + } + } if let Some(infinite_retry) = object.get("infiniteProviderRetry") { if !infinite_retry.is_boolean() { return Err(rpc_err( @@ -9463,6 +9484,8 @@ mod update_settings_tests { json!({"updatePreference": "automatic"}), json!({"updatePreference": "manual"}), json!({"lastNotifiedUpdateVersion": "0.15.9"}), + json!({"updateDismissedVersion": "0.15.9"}), + json!({"updateDismissedVersion": null}), ] { assert!(validate_settings_value(&value).is_ok(), "{value}"); } @@ -9472,6 +9495,9 @@ mod update_settings_tests { json!({"lastNotifiedUpdateVersion": " "}), json!({"lastNotifiedUpdateVersion": 12}), json!({"lastNotifiedUpdateVersion": "x".repeat(129)}), + json!({"updateDismissedVersion": " "}), + json!({"updateDismissedVersion": 12}), + json!({"updateDismissedVersion": "x".repeat(129)}), ] { assert!(validate_settings_value(&value).is_err(), "{value}"); } diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 50dc9bdc6a..7f954d3f87 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -73,6 +73,8 @@ export const IPC = { updatesDownload: "pi-desktop/updates/download", updatesInstall: "pi-desktop/updates/install", updatesOpenReleases: "pi-desktop/updates/openReleases", + /** Persist the user's decision to stop nudging about one version (#1317). */ + updatesDismiss: "pi-desktop/updates/dismiss", notificationList: "pi-desktop/notification/list", notificationMarkRead: "pi-desktop/notification/markRead", notificationMarkAllRead: "pi-desktop/notification/markAllRead", diff --git a/packages/shared/src/types/platform.ts b/packages/shared/src/types/platform.ts index 6db64cf0c7..e3d50caac4 100644 --- a/packages/shared/src/types/platform.ts +++ b/packages/shared/src/types/platform.ts @@ -78,6 +78,11 @@ export type UpdateState = { error?: string; /** True when the transition came from a user-initiated check. */ manual?: boolean; + /** + * True when the user dismissed the notice for `availableVersion`. Stays + * dismissed across restarts until a newer version is detected (#1317). + */ + dismissed?: boolean; releasesUrl: string; };