diff --git a/README.md b/README.md index e2728e3..de033e0 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,13 @@ Core installation, version comparison, and offline installation are available fr mirror proxies or add custom HTTPS mirror prefixes; application and core updates prefer the selected channel and fall back automatically. +Application and core updates are each checked once in the background at startup, then automatically +on every fifth visit to **Version Management** (visits 5, 10, 15, and so on). Their respective +**Check for Updates** buttons remain available for manual checks. Re-renders do not count as visits, +and restarting the application resets the counter. Changing download sources does not trigger a +check, and reopening the page preserves the saved source. Checks do not automatically download or +install updates. + ### OAuth account authorization ![OAuth account authorization](docs/screenshots/en/2.png) diff --git a/README.zh-CN.md b/README.zh-CN.md index 91da69e..5561293 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -47,6 +47,8 @@ APIMart 是一个低成本的 AI 图像与视频生成 API 平台——GPT-Image 内核安装、版本对比和离线安装功能位于 **版本管理** 页面;可在此切换 GitHub 官方源、GitCode 国内源或 GitHub 镜像代理,也可以添加自定义 HTTPS 镜像前缀。软件与内核更新会优先使用所选渠道,并在失败时自动回退。 +每次启动软件时,软件和内核会各自在后台自动检查一次更新;之后每进入 **版本管理** 页面 5 次(第 5、10、15 次,以此类推)自动检查一次,也可随时通过各自的 **检查更新** 按钮手动检查。页面内部刷新不重复计数,重启软件后次数归零。修改下载源不会自动检查更新,重新进入页面也会保留已保存的下载源。检查不会自动下载或安装更新。 + ### OAuth 账号授权 ![OAuth 账号授权](docs/screenshots/zh-CN/2.png) diff --git a/src/pages/VersionManagementPage.tsx b/src/pages/VersionManagementPage.tsx index c0cc827..94de1a6 100644 --- a/src/pages/VersionManagementPage.tsx +++ b/src/pages/VersionManagementPage.tsx @@ -16,6 +16,7 @@ import { useCoreRuntime } from '../coreRuntime'; import { useCoreUpdate } from '../coreUpdate'; import { useI18n } from '../i18n'; import { useAppUpdate } from '../appUpdate'; +import { createVersionManagementVisitTracker } from '../services/versionManagementVisits'; export type CoreInstallResult = { version: string; @@ -64,6 +65,7 @@ function downloadSourceLabel(source: VersionDownloadSource, t: ReturnType(null); const completedInstallKeyRef = useRef(''); const manualInstallInProgressRef = useRef(false); + const pageVisitRef = useRef({}); const showToast = (message: string, tone: MessageType = 'info') => { if (toastTimerRef.current !== null) { @@ -206,9 +209,6 @@ export function VersionManagementPage() { showToast(t('kernel.versions.sourceSwitched', { source: downloadSourceLabel(settings.source, t), }), 'info'); - setVersionSourceSaving(false); - await checkAppUpdate(); - await checkLatest(true); } catch (error) { await loadVersionSourceSettings(); setVersionSourceError(t('kernel.versions.gitcodeSaveFailed', { error: String(error) })); @@ -230,9 +230,6 @@ export function VersionManagementPage() { setCustomMirrorDialogOpen(false); resetLatest(); showToast(t('kernel.versions.customMirrorAdded'), 'success'); - setVersionSourceSaving(false); - await checkAppUpdate(); - await checkLatest(true); } catch (error) { const message = t('kernel.versions.customMirrorAddFailed', { error: String(error) }); setVersionSourceError(message); @@ -252,10 +249,8 @@ export function VersionManagementPage() { }); setVersionSource(settings); showToast(t('kernel.versions.customMirrorRemoved'), 'success'); - setVersionSourceSaving(false); if (wasSelected) { - await checkAppUpdate(); - await checkLatest(true); + resetLatest(); } } catch (error) { const message = t('kernel.versions.customMirrorRemoveFailed', { error: String(error) }); @@ -351,6 +346,16 @@ export function VersionManagementPage() { } }; + useEffect(() => { + if (!recordVersionManagementVisit(pageVisitRef.current)) return; + if (!checkingAppUpdate && !appUpdateTask.running) { + void checkAppUpdate(); + } + if (!checkingLatest) { + void checkLatest(); + } + }, [appUpdateTask.running, checkAppUpdate, checkLatest, checkingAppUpdate, checkingLatest]); + useEffect(() => { let disposed = false; let unlisten: (() => void) | null = null; @@ -392,33 +397,7 @@ export function VersionManagementPage() { }); loadInstallTask(); - - const initializeDefaultVersionSource = async () => { - setVersionSourceSaving(true); - setVersionSourceError(''); - try { - const settings = await invoke('set_download_source', { - source: DEFAULT_VERSION_DOWNLOAD_SOURCE, - }); - if (disposed) return; - setVersionSource(settings); - resetLatest(); - await Promise.all([ - checkAppUpdate(), - checkLatest(true), - ]); - } catch (error) { - if (!disposed) { - await loadVersionSourceSettings(); - setVersionSourceError(String(error)); - } - } finally { - if (!disposed) { - setVersionSourceSaving(false); - } - } - }; - void initializeDefaultVersionSource(); + void loadVersionSourceSettings(); void getVersion() .then((version) => { diff --git a/src/services/versionManagementVisits.ts b/src/services/versionManagementVisits.ts new file mode 100644 index 0000000..3f6e115 --- /dev/null +++ b/src/services/versionManagementVisits.ts @@ -0,0 +1,13 @@ +export function createVersionManagementVisitTracker() { + const countedVisits = new WeakSet(); + let visitsSinceLastCheck = 0; + + return (visit: object) => { + if (countedVisits.has(visit)) return false; + countedVisits.add(visit); + visitsSinceLastCheck += 1; + if (visitsSinceLastCheck < 5) return false; + visitsSinceLastCheck = 0; + return true; + }; +} diff --git a/tests/versionManagement.test.ts b/tests/versionManagement.test.ts index 4ad02ea..5f67460 100644 --- a/tests/versionManagement.test.ts +++ b/tests/versionManagement.test.ts @@ -1,13 +1,103 @@ import { describe, expect, it } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import ts from 'typescript'; import { DEFAULT_VERSION_DOWNLOAD_SOURCE, displayAppVersion, } from '../src/pages/VersionManagementPage'; import { coreUpdateAvailable } from '../src/coreUpdate'; import { appUpdateIndicatorState } from '../src/appUpdateModel'; +import { createVersionManagementVisitTracker } from '../src/services/versionManagementVisits'; + +describe('VersionManagement visit counting', () => { + it('checks on the fifth, tenth, and fifteenth visits, but not in between', () => { + const recordVisit = createVersionManagementVisitTracker(); + const checkedVisits: number[] = []; + for (let visitNumber = 1; visitNumber <= 15; visitNumber += 1) { + if (recordVisit({})) checkedVisits.push(visitNumber); + } + expect(checkedVisits).toEqual([5, 10, 15]); + }); + + it('counts each mounted page once despite repeated effects or re-renders', () => { + const recordVisit = createVersionManagementVisitTracker(); + for (let visitNumber = 1; visitNumber <= 10; visitNumber += 1) { + const visit = {}; + expect(recordVisit(visit)).toBe(visitNumber % 5 === 0); + expect(recordVisit(visit)).toBe(false); + expect(recordVisit(visit)).toBe(false); + } + }); + + it('starts counting from zero for a new application session', () => { + const recordVisit = createVersionManagementVisitTracker(); + for (let visitNumber = 1; visitNumber <= 4; visitNumber += 1) { + expect(recordVisit({})).toBe(false); + } + const recordNewSessionVisit = createVersionManagementVisitTracker(); + expect(recordNewSessionVisit({})).toBe(false); + expect(recordVisit({})).toBe(true); + }); +}); + +describe('VersionManagement update triggers', () => { + const pageSource = ts.createSourceFile( + 'VersionManagementPage.tsx', + readFileSync(new URL('../src/pages/VersionManagementPage.tsx', import.meta.url), 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + + const findCalls = (name: string) => { + const calls: ts.CallExpression[] = []; + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node) && node.expression.getText(pageSource) === name) { + calls.push(node); + } + ts.forEachChild(node, visit); + }; + visit(pageSource); + return calls; + }; + + for (const name of ['checkAppUpdate', 'checkLatest']) { + it(`calls ${name} only from the visit effect and its manual check button`, () => { + const calls = findCalls(name); + expect(calls).toHaveLength(2); + const triggers = calls.map((call) => { + let ancestor: ts.Node | undefined = call.parent; + while (ancestor) { + if (ts.isJsxAttribute(ancestor)) return ancestor.name.getText(pageSource); + if (ts.isCallExpression(ancestor) + && ancestor.expression.getText(pageSource) === 'useEffect') return 'useEffect'; + ancestor = ancestor.parent; + } + return undefined; + }); + expect(triggers.sort()).toEqual(['onClick', 'useEffect']); + }); + } + + it('only changes the saved download source from the source selector handler', () => { + const sourceWrites = findCalls('invoke').filter((call) => { + const command = call.arguments[0]; + return command && ts.isStringLiteral(command) && command.text === 'set_download_source'; + }); + expect(sourceWrites).toHaveLength(1); + let ancestor: ts.Node | undefined = sourceWrites[0]; + while (ancestor && !(ts.isVariableDeclaration(ancestor) + && ancestor.initializer && ts.isArrowFunction(ancestor.initializer))) { + ancestor = ancestor.parent; + } + expect(ancestor && ts.isVariableDeclaration(ancestor) + ? ancestor.name.getText(pageSource) + : undefined).toBe('updateVersionSource'); + }); +}); describe('VersionManagement helper functions', () => { - it('defaults every version management session to GitHub', () => { + it('uses GitHub as the fallback before saved download source settings load', () => { expect(DEFAULT_VERSION_DOWNLOAD_SOURCE).toBe('github'); });