fix(versions): check for updates every five page visits

- Increment the counter on each page visit, check for updates at five, and reset it to zero
- Prevent duplicate counting on re-renders and reset the counter on app restart
- Preserve startup and manual checks while avoiding duplicate requests
- Preserve the saved download source and stop checking automatically when sources change
- Add regression tests for visit counting and check triggers, and update English and Chinese documentation
This commit is contained in:
lzt404 committed 2026-09-06 19:02:49 +08:00
1 parent 08c915b18b
commit bd959af6ed
5 files changed
+128 -37

No files matched your search

+7
View File
@@ -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)
+2
View File
@@ -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)
+15 -36
View File
@@ -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<typeof
export type MessageType = 'info' | 'success' | 'error';
const APP_RELEASE_URL = 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest';
export const DEFAULT_VERSION_DOWNLOAD_SOURCE = 'github';
const recordVersionManagementVisit = createVersionManagementVisitTracker();
export function displayAppVersion(version: string) {
const resolvedVersion = version.trim();
@@ -119,6 +121,7 @@ export function VersionManagementPage() {
const toastTimerRef = useRef<number | null>(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<VersionSourceSettings>('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) => {
+13
View File
@@ -0,0 +1,13 @@
export function createVersionManagementVisitTracker() {
const countedVisits = new WeakSet<object>();
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;
};
}
+91 -1
View File
@@ -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');
});