diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7c9cb64e..4bcf5b75 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2456,6 +2456,8 @@ function App() { close: () => void; isOpen: () => boolean; } | null>(null); + // 手动「检查更新」发现新版本时,由 useAppUpdateManager 触发打开更新日志弹窗 + const openReleaseNotesOnManualCheckRef = useRef<(() => void) | null>(null); const { aboutDisplayVersion, aboutInfo, @@ -2489,6 +2491,7 @@ function App() { runtimeBuildType, t, updateCenterBridgeRef, + onManualCheckHasUpdateRef: openReleaseNotesOnManualCheckRef, }); const [aboutLastCheckedAt, setAboutLastCheckedAt] = useState(''); const [releaseNotesModalOpen, setReleaseNotesModalOpen] = useState(false); @@ -3637,6 +3640,14 @@ function App() { updateCenterBridgeRef.current = null; }; }, [handleOpenSettingsCenterPane]); + useEffect(() => { + openReleaseNotesOnManualCheckRef.current = () => { + setReleaseNotesModalOpen(true); + }; + return () => { + openReleaseNotesOnManualCheckRef.current = null; + }; + }, []); useEffect(() => { if (!isSettingsAboutPaneOpen) { return; @@ -5409,7 +5420,7 @@ function App() { key="check" icon={} loading={isCheckingForUpdates} - onClick={() => checkForUpdates(false)} + onClick={() => checkForUpdates(false, true)} > {t('app.about.action.check_updates')} , diff --git a/frontend/src/hooks/useAppUpdateManager.test.tsx b/frontend/src/hooks/useAppUpdateManager.test.tsx index 200d463b..8223f901 100644 --- a/frontend/src/hooks/useAppUpdateManager.test.tsx +++ b/frontend/src/hooks/useAppUpdateManager.test.tsx @@ -669,6 +669,46 @@ describe('useAppUpdateManager', () => { expect(hook?.lastUpdateInfo?.channel).toBe('dev'); }); + it('does not invoke the manual-check bridge when a channel change re-check finds an update', async () => { + const openReleaseNotes = vi.fn(); + const openReleaseNotesRef = { current: openReleaseNotes }; + + backendApp.SetUpdateChannel.mockResolvedValue({ success: true, data: { channel: 'dev' } }); + backendApp.CheckForUpdates.mockResolvedValue({ + success: true, + data: { + hasUpdate: true, + channel: 'dev', + currentVersion: '0.8.1', + latestVersion: 'dev-a1b2c3d', + }, + }); + + const Harness = () => { + hook = useAppUpdateManager({ + runtimeBuildType: 'release', + t, + onManualCheckHasUpdateRef: openReleaseNotesRef, + }); + return null; + }; + + act(() => { + renderer = create(); + }); + + await act(async () => { + await hook?.changeUpdateChannel('dev'); + }); + + expect(backendApp.SetUpdateChannel).toHaveBeenCalledWith('dev'); + expect(backendApp.CheckForUpdates).toHaveBeenCalledTimes(1); + // 通道切换后的自动复查即便发现更新,也不应打开更新日志弹窗(#818 触发边界修正) + expect(openReleaseNotes).not.toHaveBeenCalled(); + expect(hook?.lastUpdateInfo?.hasUpdate).toBe(true); + expect(hook?.lastUpdateInfo?.latestVersion).toBe('dev-a1b2c3d'); + }); + it('keeps release metadata from the backend update response', async () => { backendApp.CheckForUpdates.mockResolvedValue({ success: true, @@ -770,6 +810,114 @@ describe('useAppUpdateManager', () => { expect(hook?.lastUpdateInfo?.latestVersion).toBe('0.8.2'); }); + it('invokes the manual-check bridge when a manual check finds an update', async () => { + const openReleaseNotes = vi.fn(); + const openReleaseNotesRef = { current: openReleaseNotes }; + + backendApp.CheckForUpdates.mockResolvedValue({ + success: true, + data: { + hasUpdate: true, + channel: 'latest', + currentVersion: '0.8.1', + latestVersion: '0.8.2', + releaseNotesUrl: 'https://github.com/Syngnat/GoNavi/releases/tag/v0.8.2', + }, + }); + + const Harness = () => { + hook = useAppUpdateManager({ + runtimeBuildType: 'release', + t, + onManualCheckHasUpdateRef: openReleaseNotesRef, + }); + return null; + }; + + act(() => { + renderer = create(); + }); + + await act(async () => { + await hook?.checkForUpdates(false, true); + }); + + expect(openReleaseNotes).toHaveBeenCalledTimes(1); + expect(hook?.lastUpdateInfo?.hasUpdate).toBe(true); + expect(hook?.lastUpdateInfo?.latestVersion).toBe('0.8.2'); + }); + + it('does not invoke the manual-check bridge when a manual check finds no update', async () => { + const openReleaseNotes = vi.fn(); + const openReleaseNotesRef = { current: openReleaseNotes }; + + backendApp.CheckForUpdates.mockResolvedValue({ + success: true, + data: { + hasUpdate: false, + channel: 'latest', + currentVersion: '0.8.1', + latestVersion: '0.8.1', + }, + }); + + const Harness = () => { + hook = useAppUpdateManager({ + runtimeBuildType: 'release', + t, + onManualCheckHasUpdateRef: openReleaseNotesRef, + }); + return null; + }; + + act(() => { + renderer = create(); + }); + + await act(async () => { + await hook?.checkForUpdates(false, true); + }); + + expect(openReleaseNotes).not.toHaveBeenCalled(); + expect(hook?.lastUpdateInfo?.hasUpdate).toBe(false); + expect(messageApi.success).toHaveBeenCalled(); + }); + + it('does not invoke the manual-check bridge on silent update discovery', async () => { + const openReleaseNotes = vi.fn(); + const openReleaseNotesRef = { current: openReleaseNotes }; + + backendApp.CheckForUpdatesSilently.mockResolvedValue({ + success: true, + data: { + hasUpdate: true, + channel: 'latest', + currentVersion: '0.8.1', + latestVersion: '0.8.2', + }, + }); + + const Harness = () => { + hook = useAppUpdateManager({ + runtimeBuildType: 'release', + t, + onManualCheckHasUpdateRef: openReleaseNotesRef, + }); + return null; + }; + + act(() => { + renderer = create(); + }); + + await act(async () => { + await hook?.checkForUpdates(true); + }); + + expect(openReleaseNotes).not.toHaveBeenCalled(); + expect(hook?.lastUpdateInfo?.hasUpdate).toBe(true); + }); + it('opens the downloaded update directory when a package is already downloaded', async () => { backendApp.CheckForUpdates.mockResolvedValue({ success: true, diff --git a/frontend/src/hooks/useAppUpdateManager.ts b/frontend/src/hooks/useAppUpdateManager.ts index dff9be4e..df01c867 100644 --- a/frontend/src/hooks/useAppUpdateManager.ts +++ b/frontend/src/hooks/useAppUpdateManager.ts @@ -63,6 +63,8 @@ type UseAppUpdateManagerOptions = { runtimeBuildType: string; t: Translator; updateCenterBridgeRef?: MutableRefObject; + /** 手动「检查更新」发现新版本时,触发打开更新日志弹窗的桥接回调 */ + onManualCheckHasUpdateRef?: MutableRefObject<(() => void) | null>; }; type AboutInfo = { @@ -183,6 +185,7 @@ export const useAppUpdateManager = ({ runtimeBuildType, t, updateCenterBridgeRef, + onManualCheckHasUpdateRef, }: UseAppUpdateManagerOptions) => { const autoCheckForUpdates = useStore((state) => state.autoCheckForUpdates); const autoCheckForUpdatesIntervalMinutes = useStore( @@ -470,7 +473,7 @@ export const useAppUpdateManager = ({ void message.success(res?.message || t('app.about.message.install_directory_opened_manual_replace')); }, [t]); - const checkForUpdates = useCallback(async (silent: boolean) => { + const checkForUpdates = useCallback(async (silent: boolean, openReleaseNotes = false) => { if (updateCheckInFlightRef.current) return; updateCheckInFlightRef.current = true; setIsCheckingForUpdates(true); @@ -565,6 +568,11 @@ export const useAppUpdateManager = ({ if (!silent) { void message.info(t('app.about.message.new_version_found', { version: info.latestVersion })); setAboutUpdateStatus(statusText); + // 仅当显式请求打开更新日志时(如用户点击「检查更新」按钮),才触发弹窗; + // 通道切换后的自动复查等场景不传 openReleaseNotes,避免越界打开弹窗(#818) + if (openReleaseNotes) { + onManualCheckHasUpdateRef?.current?.(); + } } if (silent && aboutOpen) { setAboutUpdateStatus(statusText); @@ -598,7 +606,7 @@ export const useAppUpdateManager = ({ } else { setLastUpdateInfo(info); } - }, [formatAboutUpdateStatus, isUpdateCenterOpen, openUpdateCenter, t]); + }, [formatAboutUpdateStatus, isUpdateCenterOpen, onManualCheckHasUpdateRef, openUpdateCenter, t]); const loadAboutInfo = useCallback(async () => { setAboutLoading(true);