diff --git a/README.md b/README.md index be620a0..ed00164 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ - **订阅与花费**:显示服务商返回的套餐到期 / 续费日;为每个账号填上月费,首页汇总你每月在 AI 订阅上花了多少。 - **及时提醒**:用量超过你设的阈值、窗口重置额度恢复、套餐快到期、登录失效时,发本地通知提醒你。 - **小组件**(iOS):主屏和锁屏小组件,不打开应用也能看余量。 +- **新版本提示**:有新版时在首页标题旁提示,点一下前往下载;关闭后同一版本不再提示。 - **好用的细节**:中文 / English(可跟随系统)、浅色 / 深色、7 种主题色、流畅的动画与触觉反馈,并尊重系统的「减弱动态效果」。 ## 下载与安装 @@ -83,6 +84,7 @@ - **凭据只存在本机**:API Key 和登录令牌保存在系统钥匙串(iOS Keychain / Android Keystore)中,不会上传。 - **用量历史只存在本机**:保存在应用的本地数据库里,可在「设置 → 数据」一键全部删除。 - **无广告、无统计**:不收集任何使用数据。 +- **版本检查**:应用每隔几小时读取一次本仓库在 GitHub 上的最新 Release,用来提示新版本;请求不携带任何账号或用量信息。 - 浏览器登录使用本机 `127.0.0.1` 临时回调,只在登录过程中短暂开启,授权完成后立即关闭。 ## 常见问题 diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index 7176f38..9e17ee3 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -16,6 +16,8 @@ import { AccountCard } from '@/ui/account-card'; import { AccountCardSkeleton } from '@/ui/account-card-skeleton'; import { Button } from '@/ui/controls'; import { RefreshButton } from '@/ui/refresh-button'; +import { UpdatePill } from '@/ui/update-pill'; +import { useUpdateNotice } from '@/data/update-check'; import { enterFade, enterItem, exitFade, layoutShift } from '@/ui/motion'; export default function OverviewScreen() { @@ -26,6 +28,7 @@ export default function OverviewScreen() { const services = useServices(); const views = useAccountViews(); const refresh = useRefreshAll(); + const update = useUpdateNotice(); // Refresh once when the app opens; the rate limiter makes this cheap if data is recent. const ready = !!services.data; @@ -44,6 +47,11 @@ export default function OverviewScreen() { + ) : undefined + } action={ - - {title} - + + + {title} + + {accessory} + {action} ); @@ -26,5 +38,6 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', gap: Spacing.three, }, + lead: { flexDirection: 'row', alignItems: 'center', gap: Spacing.two, flexShrink: 1 }, title: { flexShrink: 1 }, }); diff --git a/src/data/__tests__/update-check.test.ts b/src/data/__tests__/update-check.test.ts new file mode 100644 index 0000000..42e2cce --- /dev/null +++ b/src/data/__tests__/update-check.test.ts @@ -0,0 +1,38 @@ +import { compareVersions, fetchLatestRelease, pickNotice, RELEASES_API } from '@/data/update-check'; +import { fakeFetch } from '@/test-utils/fetch'; + +describe('update check', () => { + it('compares dotted versions numerically, with or without a leading v', () => { + expect(compareVersions('1.0.10', '1.0.9')).toBe(1); + expect(compareVersions('v1.2.0', '1.10.0')).toBe(-1); + expect(compareVersions('1.0', '1.0.0')).toBe(0); + expect(compareVersions('nightly', '1.0.0')).toBe(0); + }); + + it('reads the latest published release and skips drafts and pre-releases', async () => { + const ok = fakeFetch({ + [RELEASES_API]: { + json: { tag_name: 'v1.0.3', html_url: 'https://github.com/x/releases/tag/v1.0.3' }, + }, + }); + expect(await fetchLatestRelease(ok.fetch)).toEqual({ + version: '1.0.3', + url: 'https://github.com/x/releases/tag/v1.0.3', + }); + const pre = fakeFetch({ + [RELEASES_API]: { json: { tag_name: 'v2.0.0', html_url: 'u', prerelease: true } }, + }); + expect(await fetchLatestRelease(pre.fetch)).toBeUndefined(); + expect(await fetchLatestRelease(fakeFetch({}).fetch)).toBeUndefined(); + }); + + it('offers only a newer, not yet dismissed version', () => { + const latest = { version: '1.0.3', url: 'u' }; + expect(pickNotice(latest, '1.0.2', null)).toBe(latest); + expect(pickNotice(latest, '1.0.3', null)).toBeUndefined(); + // dismissing 1.0.3 silences it, but 1.0.4 is offered again + expect(pickNotice(latest, '1.0.2', '1.0.3')).toBeUndefined(); + expect(pickNotice({ version: '1.0.4', url: 'u' }, '1.0.2', '1.0.3')).toBeTruthy(); + expect(pickNotice(undefined, '1.0.2', null)).toBeUndefined(); + }); +}); diff --git a/src/data/update-check.ts b/src/data/update-check.ts new file mode 100644 index 0000000..e1adac2 --- /dev/null +++ b/src/data/update-check.ts @@ -0,0 +1,100 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import Constants from 'expo-constants'; + +import { useServices } from '@/data/hooks'; + +/** + * New-version notice: the latest GitHub release of this app against the installed version. + * Checked at most every few hours; the user can dismiss a version, and that version is never + * offered again (a newer one is). + */ +export const RELEASES_API = 'https://api.github.com/repos/nextroad-dev/aiusage/releases/latest'; +const DISMISSED_KEY = 'update-dismissed-version'; +const CHECK_EVERY_MS = 6 * 3_600_000; + +export interface LatestRelease { + /** without the leading "v", e.g. "1.0.3" */ + version: string; + url: string; +} + +/** Numeric dotted compare: 1 when a > b, -1 when a < b, 0 when equal or not comparable. */ +export function compareVersions(a: string, b: string): number { + const parse = (v: string) => v.trim().replace(/^v/i, '').split(/[.+-]/).slice(0, 3).map(Number); + const x = parse(a); + const y = parse(b); + if ([...x, ...y].some((n) => !Number.isFinite(n))) return 0; + for (let i = 0; i < 3; i++) { + const d = (x[i] ?? 0) - (y[i] ?? 0); + if (d !== 0) return d > 0 ? 1 : -1; + } + return 0; +} + +/** Latest published (non-draft, non-prerelease) release, or undefined when it cannot be read. */ +export async function fetchLatestRelease( + doFetch: typeof fetch, +): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 8000); + try { + const res = await doFetch(RELEASES_API, { + headers: { Accept: 'application/vnd.github+json' }, + signal: ctrl.signal, + }); + if (!res.ok) return undefined; + const json = (await res.json()) as { + tag_name?: unknown; + html_url?: unknown; + draft?: unknown; + prerelease?: unknown; + }; + if (json.draft === true || json.prerelease === true) return undefined; + if (typeof json.tag_name !== 'string' || typeof json.html_url !== 'string') return undefined; + return { version: json.tag_name.replace(/^v/i, ''), url: json.html_url }; + } catch { + return undefined; + } finally { + clearTimeout(timer); + } +} + +/** The release to offer, if it is newer than this build and has not been dismissed. */ +export function pickNotice( + latest: LatestRelease | undefined, + installed: string | undefined, + dismissed: string | null, +): LatestRelease | undefined { + if (!latest || !installed) return undefined; + if (compareVersions(latest.version, installed) <= 0) return undefined; + if (dismissed && compareVersions(latest.version, dismissed) <= 0) return undefined; + return latest; +} + +export function useUpdateNotice(): { notice?: LatestRelease; dismiss: () => void } { + const services = useServices().data; + const qc = useQueryClient(); + const latest = useQuery({ + queryKey: ['latestRelease'], + queryFn: () => fetchLatestRelease((...a) => fetch(...a)), + staleTime: CHECK_EVERY_MS, + retry: false, + }); + const dismissed = useQuery({ + queryKey: ['updateDismissed'], + queryFn: () => services!.repos.settings.get(DISMISSED_KEY), + enabled: !!services, + }); + // decide only once the dismissed version is known, so a dismissed notice never flashes + const notice = dismissed.isSuccess + ? pickNotice(latest.data, Constants.expoConfig?.version, dismissed.data ?? null) + : undefined; + return { + notice, + dismiss: () => { + if (!notice || !services) return; + qc.setQueryData(['updateDismissed'], notice.version); + void services.repos.settings.set(DISMISSED_KEY, notice.version); + }, + }; +} diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 448fb99..ffba123 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -36,6 +36,9 @@ export const zh: Record = { '账号、凭据和用量历史只保存在这台设备上,不会发送到我们的任何服务器。', // API relays 'API relay': '中转站', + 'New v{version}': '新版本 v{version}', + 'New version {version} available': '有新版本 {version}', + 'Dismiss this version': '不再提示此版本', 'Tip the author': '打赏作者', Afdian: '爱发电', Close: '关闭', diff --git a/src/ui/update-pill.tsx b/src/ui/update-pill.tsx new file mode 100644 index 0000000..497966b --- /dev/null +++ b/src/ui/update-pill.tsx @@ -0,0 +1,84 @@ +import { SymbolView } from 'expo-symbols'; +import { Linking, StyleSheet, View } from 'react-native'; +import Animated from 'react-native-reanimated'; + +import { ThemedText } from '@/components/themed-text'; +import { Radius, Spacing } from '@/constants/theme'; +import type { LatestRelease } from '@/data/update-check'; +import { useTheme } from '@/hooks/use-theme'; +import { useT } from '@/i18n'; +import { haptics } from '@/ui/haptics'; +import { enterFade, exitFade, PressableScale } from '@/ui/motion'; + +/** + * "New version" pill beside a page title: tapping it opens the release page, the ✕ dismisses this + * version for good. + */ +export function UpdatePill({ + release, + onDismiss, +}: { + release: LatestRelease; + onDismiss: () => void; +}) { + const theme = useTheme(); + const t = useT(); + return ( + + { + haptics.tap(); + void Linking.openURL(release.url); + }} + style={styles.label} + > + + {t('New v{version}', { version: release.version })} + + + + { + haptics.select(); + onDismiss(); + }} + style={styles.close} + > + + + + ); +} + +const styles = StyleSheet.create({ + pill: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: Radius.pill, + paddingLeft: Spacing.two + 2, + paddingRight: Spacing.one, + minHeight: 26, + flexShrink: 1, + }, + label: { paddingVertical: 3, flexShrink: 1 }, + divider: { width: StyleSheet.hairlineWidth * 2, height: 12, opacity: 0.4, marginHorizontal: 6 }, + close: { width: 20, height: 20, alignItems: 'center', justifyContent: 'center' }, +});