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:
1 parent
08c915b18b
commit
bd959af6ed
5 files changed
+128
-37
No files matched your search
@@ -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
|
||||
|
||||

|
||||
|
||||
@@ -47,6 +47,8 @@ APIMart 是一个低成本的 AI 图像与视频生成 API 平台——GPT-Image
|
||||
|
||||
内核安装、版本对比和离线安装功能位于 **版本管理** 页面;可在此切换 GitHub 官方源、GitCode 国内源或 GitHub 镜像代理,也可以添加自定义 HTTPS 镜像前缀。软件与内核更新会优先使用所选渠道,并在失败时自动回退。
|
||||
|
||||
每次启动软件时,软件和内核会各自在后台自动检查一次更新;之后每进入 **版本管理** 页面 5 次(第 5、10、15 次,以此类推)自动检查一次,也可随时通过各自的 **检查更新** 按钮手动检查。页面内部刷新不重复计数,重启软件后次数归零。修改下载源不会自动检查更新,重新进入页面也会保留已保存的下载源。检查不会自动下载或安装更新。
|
||||
|
||||
### OAuth 账号授权
|
||||
|
||||

|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user