diff --git a/README.md b/README.md index 32cbb48..8d83214 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@

- GitHub stars - GitHub downloads - Latest release + GitHub stars + GitHub downloads + Latest release

- 下载 TraceMemo + 下载 TraceMemo · 第一次使用 · @@ -309,7 +309,7 @@ flowchart LR ## 快速开始 -1. 从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载安装包。 +1. 从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载安装包。 2. 启动 TraceMemo,按照“第一次使用”页面选择微信数据目录。 3. 第一次使用请先点击“开始连接”,按页面提示准备连接组件并获取数据库密钥;只有已经有密钥的高级用户才需要“手动连接”。 4. 连接成功后打开“档案”,确认联系人和聊天消息已经出现。 @@ -447,7 +447,6 @@ TraceMemo 在支持微信 4.x 时,参考并使用了 **[WeFlow](https://github 也因此,这个项目随时可能继续折腾,也可能因为其他事情暂时搁置。如果你有想要的功能,可以提Issue;如果觉得现有实现不符合你的需求,也欢迎直接 Fork 后自己改。 -

TraceMemo(迹忆)
diff --git a/build/app-update.yml b/build/app-update.yml new file mode 100644 index 0000000..1a33e8e --- /dev/null +++ b/build/app-update.yml @@ -0,0 +1,5 @@ +provider: github +owner: Wxw-Gu +repo: TraceMemo +releaseType: release +updaterCacheDirName: tracememo-updater diff --git a/docs/user-guide/getting-started.md b/docs/user-guide/getting-started.md index ff93b34..1b81f87 100644 --- a/docs/user-guide/getting-started.md +++ b/docs/user-guide/getting-started.md @@ -22,7 +22,7 @@ ## 2. 安装并启动 -安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载。 +安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载。 ### Windows diff --git a/electron-builder.win.yml b/electron-builder.win.yml index d1d0156..a7d3131 100644 --- a/electron-builder.win.yml +++ b/electron-builder.win.yml @@ -20,6 +20,10 @@ asarUnpack: - node_modules/sherpa-onnx-node/** - node_modules/sherpa-onnx-*/** extraResources: + # Keep the updater provider in every packaged Windows app. electron-builder also + # regenerates this file during publish, using the same release configuration. + - from: build/app-update.yml + to: app-update.yml # Keep only resources required by the Windows x64 runtime. - from: resources to: resources @@ -58,5 +62,5 @@ electronLanguages: publish: provider: github owner: Wxw-Gu - repo: WechatExplorer + repo: TraceMemo releaseType: release diff --git a/electron-builder.yml b/electron-builder.yml index feb124e..728991f 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -20,6 +20,10 @@ asarUnpack: - node_modules/sherpa-onnx-node/** - node_modules/sherpa-onnx-*/** extraResources: + # Keep the updater provider in every packaged macOS app. electron-builder also + # regenerates this file during publish, using the same release configuration. + - from: build/app-update.yml + to: app-update.yml # Includes the optional WeChat connector binary for the target platform. - from: resources to: resources @@ -39,6 +43,9 @@ nsis: createDesktopShortcut: always mac: icon: icon.icns + target: + - dmg + - zip entitlementsInherit: build/entitlements.mac.plist extendInfo: CFBundleDisplayName: TraceMemo @@ -63,5 +70,5 @@ npmRebuild: false publish: provider: github owner: Wxw-Gu - repo: WechatExplorer + repo: TraceMemo releaseType: release diff --git a/package.json b/package.json index 72dcf11..db94c5d 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "start": "electron-vite preview", "predev": "node -e \"require('electron')\"", "dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev", + "dev:update": "cross-env TRACEMEMO_UPDATE_SIMULATION=true pnpm dev", "test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...", "test:unit": "vitest run --config vitest.unit.config.ts", "test:component": "vitest run --config vitest.component.config.ts", diff --git a/src/main/index.ts b/src/main/index.ts index d1161a8..433b860 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -685,6 +685,8 @@ app.whenReady().then(async () => { ipcMain.handle('app-update:check', () => appUpdateService.check()) ipcMain.handle('app-update:download', () => appUpdateService.download()) ipcMain.handle('app-update:install', () => appUpdateService.install()) + ipcMain.handle('app-update:openDownloadPage', () => appUpdateService.openDownloadPage()) + appUpdateService.scheduleStartupCheck() ipcMain.handle('cache:getSummary', () => getCacheSummary()) ipcMain.handle('cache:openKnowledgeDirectory', () => openKnowledgeDirectory()) ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => { diff --git a/src/main/services/app-update-service.ts b/src/main/services/app-update-service.ts index d7dfdc0..388bb84 100644 --- a/src/main/services/app-update-service.ts +++ b/src/main/services/app-update-service.ts @@ -1,25 +1,108 @@ -import { app, BrowserWindow } from 'electron' +import { app, BrowserWindow, shell } from 'electron' import { autoUpdater, type ProgressInfo } from 'electron-updater' -import type { AppUpdateCheckResult, AppUpdateState } from '../../shared/app-update' +import type { + AppUpdateCheckResult, + AppUpdateCheckSource, + AppUpdateDelivery, + AppUpdateInstallResult, + AppUpdateOpenDownloadPageResult, + AppUpdateState +} from '../../shared/app-update' +import { APP_UPDATE_RELEASES_URL } from '../../shared/app-update' import { isPackagedRuntime } from '../runtime-mode' +const SIMULATION_VERSION = '2.0.0' +const SIMULATION_TOTAL_BYTES = 60 * 1024 * 1024 +const SIMULATION_PROGRESS = [0, 5, 12, 21, 33, 46, 58, 69, 78, 86, 93, 97, 100] +const DEFAULT_SIMULATION_DURATION_MS = 8_000 +const STARTUP_CHECK_DELAY_MS = 1_500 +// Enable only after macOS packages use Developer ID Application signing and notarization. +const MAC_AUTO_UPDATE_ENABLED = false + +interface AppUpdateServiceOptions { + currentVersion?: () => string + packagedRuntime?: () => boolean + simulationEnabled?: boolean + simulationDurationMs?: number + platform?: NodeJS.Platform + macAutoUpdateEnabled?: boolean + broadcast?: (state: AppUpdateState) => void +} + export class AppUpdateService { - private state: AppUpdateState = { - status: 'idle', - currentVersion: app.getVersion() - } + private readonly currentVersion: () => string + private readonly packagedRuntime: () => boolean + private readonly simulationEnabled: boolean + private readonly simulationDurationMs: number + private readonly delivery: AppUpdateDelivery + private readonly broadcast?: (state: AppUpdateState) => void + private state: AppUpdateState + private listeners = new Set<(state: AppUpdateState) => void>() + private activeCheckSource: AppUpdateCheckSource = 'manual' + private startupCheckScheduled = false + private checkInFlight?: Promise + private downloadInFlight?: Promise + + constructor(options: AppUpdateServiceOptions = {}) { + this.currentVersion = options.currentVersion || (() => app.getVersion()) + this.packagedRuntime = options.packagedRuntime || isPackagedRuntime + this.simulationEnabled = + options.simulationEnabled ?? + (!app.isPackaged && process.env['TRACEMEMO_UPDATE_SIMULATION'] === 'true') + const configuredDuration = Number( + options.simulationDurationMs ?? process.env['TRACEMEMO_UPDATE_SIMULATION_DURATION_MS'] + ) + this.simulationDurationMs = Number.isFinite(configuredDuration) + ? Math.max(1_000, configuredDuration) + : DEFAULT_SIMULATION_DURATION_MS + const platform = options.platform || process.platform + const macAutoUpdateEnabled = options.macAutoUpdateEnabled ?? MAC_AUTO_UPDATE_ENABLED + this.delivery = + !this.simulationEnabled && platform === 'darwin' && !macAutoUpdateEnabled + ? 'release-page' + : 'automatic' + this.broadcast = options.broadcast + this.state = { + status: 'idle', + currentVersion: this.currentVersion(), + delivery: this.delivery, + isSimulation: this.simulationEnabled + } - constructor() { autoUpdater.autoDownload = false - autoUpdater.autoInstallOnAppQuit = true - autoUpdater.on('checking-for-update', () => this.setState({ status: 'checking' })) + autoUpdater.autoInstallOnAppQuit = this.delivery === 'automatic' + autoUpdater.on('checking-for-update', () => + this.setState({ + status: 'checking', + source: this.activeCheckSource, + message: '正在检查更新', + version: undefined, + percent: undefined, + transferred: undefined, + total: undefined, + bytesPerSecond: undefined, + error: undefined + }) + ) autoUpdater.on('update-available', (info) => - this.setState({ status: 'available', version: info.version, message: '发现新版本' }) + this.setState({ + status: 'available', + source: this.activeCheckSource, + version: info.version, + message: '发现新版本', + error: undefined + }) ) autoUpdater.on('update-not-available', () => - this.setState({ status: 'not-available', message: '当前已是最新版本' }) + this.setState({ + status: 'up-to-date', + source: this.activeCheckSource, + message: '已是最新版本', + error: undefined + }) ) - autoUpdater.on('download-progress', (progress: ProgressInfo) => + autoUpdater.on('download-progress', (progress: ProgressInfo) => { + if (this.delivery !== 'automatic') return this.setState({ status: 'downloading', percent: progress.percent, @@ -27,94 +110,222 @@ export class AppUpdateService { total: progress.total, bytesPerSecond: progress.bytesPerSecond }) - ) - autoUpdater.on('update-downloaded', (info) => + }) + autoUpdater.on('update-downloaded', (info) => { + if (this.delivery !== 'automatic') return this.setState({ status: 'downloaded', - version: info.version, + version: info.version || this.state.version, percent: 100, + transferred: this.state.total, message: '更新已下载' }) - ) + }) autoUpdater.on('error', (error) => - this.setState({ status: 'error', message: error.message || '更新失败' }) + this.setState({ + status: 'error', + message: '检查更新失败', + error: error.message || '更新失败' + }) ) } getState(): AppUpdateState { - return { ...this.state, currentVersion: app.getVersion() } + return { ...this.state, currentVersion: this.currentVersion() } } - async check(): Promise { - if (!isPackagedRuntime()) { + scheduleStartupCheck(delayMs = STARTUP_CHECK_DELAY_MS): void { + if (this.startupCheckScheduled) return + if (!this.simulationEnabled && !this.packagedRuntime()) return + this.startupCheckScheduled = true + setTimeout(() => void this.check('startup'), Math.max(0, delayMs)) + } + + check(source: AppUpdateCheckSource = 'manual'): Promise { + if (this.checkInFlight) return this.checkInFlight + this.checkInFlight = this.runCheck(source).finally(() => { + this.checkInFlight = undefined + }) + return this.checkInFlight + } + + private async runCheck(source: AppUpdateCheckSource): Promise { + this.activeCheckSource = source + if (this.simulationEnabled) { + this.setState({ + status: 'checking', + source, + isSimulation: true, + message: '正在检查模拟更新', + version: undefined, + percent: undefined, + transferred: undefined, + total: undefined, + bytesPerSecond: undefined, + error: undefined + }) + await this.delay(350) + const state = this.setState({ + status: 'available', + source, + version: SIMULATION_VERSION, + message: '发现模拟更新', + error: undefined + }) + return { success: true, state } + } + + if (!this.packagedRuntime()) { const state = this.setState({ status: 'unsupported', - message: '开发模式不执行安装包更新,请在正式安装包中检查更新' + source, + message: '开发模式不执行安装包更新,请在正式安装包中检查更新', + error: undefined }) return { success: false, state } } try { - const result = await autoUpdater.checkForUpdates() - if (result?.updateInfo.version) { - this.setState({ - status: 'available', - version: result.updateInfo.version, - message: '发现新版本' - }) - } + await autoUpdater.checkForUpdates() return { success: true, state: this.getState() } } catch (error) { const state = this.setState({ status: 'error', - message: error instanceof Error ? error.message : String(error) + message: '检查更新失败', + error: error instanceof Error ? error.message : String(error) }) return { success: false, state } } } - async download(): Promise { - if (!isPackagedRuntime()) { - const state = this.setState({ status: 'unsupported', message: '开发模式不能下载更新' }) + download(): Promise { + if (this.downloadInFlight) return this.downloadInFlight + this.downloadInFlight = this.runDownload().finally(() => { + this.downloadInFlight = undefined + }) + return this.downloadInFlight + } + + private async runDownload(): Promise { + if (this.simulationEnabled) return this.simulateDownload() + + if (this.delivery === 'release-page') { + const state = this.setState({ + status: 'available', + message: '有新版本可用,前往 GitHub 下载最新版本。' + }) + return { success: false, state } + } + + if (!this.packagedRuntime()) { + const state = this.setState({ + status: 'unsupported', + message: '开发模式不能下载更新', + error: undefined + }) return { success: false, state } } try { - this.setState({ status: 'downloading', percent: 0 }) + this.setState({ status: 'downloading', percent: 0, error: undefined }) await autoUpdater.downloadUpdate() return { success: true, state: this.getState() } } catch (error) { const state = this.setState({ status: 'error', - message: error instanceof Error ? error.message : String(error) + message: '下载更新失败', + error: error instanceof Error ? error.message : String(error) }) return { success: false, state } } } - install(): { success: boolean; error?: string } { + install(): AppUpdateInstallResult { + if (this.delivery !== 'automatic') { + return { success: false, error: '当前 macOS 版本不支持自动安装,请前往 GitHub 下载更新。' } + } if (this.state.status !== 'downloaded') { return { success: false, error: '更新包尚未下载完成' } } + if (this.simulationEnabled) { + const message = '开发模拟模式:更新安装动作已模拟,未实际退出应用。' + this.setState({ status: 'downloaded', message }) + return { success: true, simulated: true, message } + } + this.setState({ status: 'installing', message: '正在重启并安装更新' }) autoUpdater.quitAndInstall() return { success: true } } + async openDownloadPage(): Promise { + try { + await shell.openExternal(APP_UPDATE_RELEASES_URL) + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + handleState(callback: (state: AppUpdateState) => void): () => void { this.listeners.add(callback) callback(this.getState()) return () => this.listeners.delete(callback) } - private listeners = new Set<(state: AppUpdateState) => void>() - private setState(patch: Partial): AppUpdateState { - this.state = { ...this.state, ...patch, currentVersion: app.getVersion() } + this.state = { ...this.state, ...patch, currentVersion: this.currentVersion() } const state = this.getState() - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) window.webContents.send('app-update:state', state) + if (this.broadcast) { + this.broadcast(state) + } else { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send('app-update:state', state) + } } for (const listener of this.listeners) listener(state) return state } + + private async simulateDownload(): Promise { + const stepDuration = this.simulationDurationMs / (SIMULATION_PROGRESS.length - 1) + let previousTransferred = 0 + this.setState({ + status: 'downloading', + version: SIMULATION_VERSION, + percent: 0, + transferred: 0, + total: SIMULATION_TOTAL_BYTES, + bytesPerSecond: 0, + message: '正在下载模拟更新' + }) + + for (const percent of SIMULATION_PROGRESS.slice(1)) { + await this.delay(stepDuration) + const transferred = Math.round((SIMULATION_TOTAL_BYTES * percent) / 100) + const bytesPerSecond = Math.round(((transferred - previousTransferred) * 1000) / stepDuration) + previousTransferred = transferred + this.setState({ + status: 'downloading', + percent, + transferred, + total: SIMULATION_TOTAL_BYTES, + bytesPerSecond + }) + } + + const state = this.setState({ + status: 'downloaded', + version: SIMULATION_VERSION, + percent: 100, + transferred: SIMULATION_TOTAL_BYTES, + total: SIMULATION_TOTAL_BYTES, + bytesPerSecond: undefined, + message: '模拟更新已下载' + }) + return { success: true, state } + } + + private delay(durationMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, durationMs)) + } } export const appUpdateService = new AppUpdateService() diff --git a/src/main/services/skill-resource-service.ts b/src/main/services/skill-resource-service.ts index 3712f31..d182fc7 100644 --- a/src/main/services/skill-resource-service.ts +++ b/src/main/services/skill-resource-service.ts @@ -7,7 +7,7 @@ const SKILL_RELATIVE_PATHS = [ join('skill', 'tracememo-reader', 'SKILL.md'), join('skill', 'wechatexplorer-reader', 'SKILL.md') ] -const GITHUB_URL = 'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/tracememo-reader' +const GITHUB_URL = 'https://github.com/Wxw-Gu/TraceMemo/tree/main/docs/skill/tracememo-reader' const SKILL_VERSION = 'v1.2' type SkillResourceSource = 'development' | 'bundled' diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 9707820..403b410 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -66,7 +66,12 @@ import type { PersonalWechatRuntimeStatus } from '../shared/personal-wechat-runtime' import type { AppLogEntry } from '../shared/app-log' -import type { AppUpdateCheckResult, AppUpdateState } from '../shared/app-update' +import type { + AppUpdateCheckResult, + AppUpdateInstallResult, + AppUpdateOpenDownloadPageResult, + AppUpdateState +} from '../shared/app-update' import type { CacheSummary } from '../shared/cache' import type { ExportRequest, ExportJobProgress, ExportResult } from '../shared/export' import type { @@ -173,7 +178,8 @@ declare global { getAppUpdateState: () => Promise checkAppUpdate: () => Promise downloadAppUpdate: () => Promise - installAppUpdate: () => Promise<{ success: boolean; error?: string }> + installAppUpdate: () => Promise + openAppUpdateDownloadPage: () => Promise onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void getCacheSummary: () => Promise clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all') => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 0b76214..1294b7f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -85,6 +85,7 @@ const api = { checkAppUpdate: () => ipcRenderer.invoke('app-update:check'), downloadAppUpdate: () => ipcRenderer.invoke('app-update:download'), installAppUpdate: () => ipcRenderer.invoke('app-update:install'), + openAppUpdateDownloadPage: () => ipcRenderer.invoke('app-update:openDownloadPage'), onAppUpdateState: (callback: (state: AppUpdateState) => void) => { const listener = (_event: Electron.IpcRendererEvent, state: AppUpdateState): void => callback(state) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2f568d5..bda0527 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -39,6 +39,7 @@ import type { SelectableReportTemplateId } from '../../shared/report-templates' import { switchGeneratedReportTemplate } from './utils/report-template-switch' import { runtimePlatform } from './utils/runtime-environment' import { useToast } from './components/ui' +import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt' const SIDEBAR_MIN_WIDTH = 260 const SIDEBAR_MAX_WIDTH = 380 @@ -72,7 +73,7 @@ interface SelfInfo { accountRoot: string } -const MAC_KEY_FAQ_URL = 'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/mac-disable-sip.md' +const MAC_KEY_FAQ_URL = 'https://github.com/Wxw-Gu/TraceMemo/blob/main/docs/mac-disable-sip.md' const FIRST_USE_WELCOME_SEEN_KEY = 'wxe_first_use_welcome_seen' const MESSAGE_MONITOR_DEBOUNCE_MS = 8000 const INITIAL_MESSAGE_COUNT = 20 @@ -1468,6 +1469,11 @@ function App(): React.ReactElement { setActivePage('settings') } + const openUpdateSettings = (): void => { + setSettingsCategory('about') + setActivePage('settings') + } + const openModelSettings = (): void => { setSettingsCategory('ai-model') setActivePage('settings') @@ -2113,6 +2119,7 @@ function App(): React.ReactElement { appearanceTheme={appearanceSettings.theme} compactMode={appearanceSettings.compactMode} > + {showFirstUseWelcome && ( void } -const GUIDE_URL = - 'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md' +const GUIDE_URL = 'https://github.com/Wxw-Gu/TraceMemo/blob/main/docs/user-guide/getting-started.md' export function FirstUseWelcome({ onDismiss, diff --git a/src/renderer/src/features/api-center/model/skillDistribution.ts b/src/renderer/src/features/api-center/model/skillDistribution.ts index 5e72d3b..81b70c1 100644 --- a/src/renderer/src/features/api-center/model/skillDistribution.ts +++ b/src/renderer/src/features/api-center/model/skillDistribution.ts @@ -12,6 +12,6 @@ export const AGENT_INSTALL_TARGETS: { value: AgentInstallTarget; label: string } ] export const githubLatestUrl = - 'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/tracememo-reader' + 'https://github.com/Wxw-Gu/TraceMemo/tree/main/docs/skill/tracememo-reader' export const githubVersionedInstallUrl: string | null = null diff --git a/src/renderer/src/features/app-update/AppUpdatePrompt.tsx b/src/renderer/src/features/app-update/AppUpdatePrompt.tsx new file mode 100644 index 0000000..13fcb51 --- /dev/null +++ b/src/renderer/src/features/app-update/AppUpdatePrompt.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button +} from '../../components/ui' +import { useAppUpdateState } from './useAppUpdateState' + +export function AppUpdatePrompt({ + onDownloadStart, + onNotice +}: { + onDownloadStart: () => void + onNotice: (message: string) => void +}): React.ReactElement { + const update = useAppUpdateState() + const [dismissedVersion, setDismissedVersion] = useState() + const shouldPrompt = + update.status === 'available' && + update.source === 'startup' && + Boolean(update.version) && + update.version !== dismissedVersion + + const dismiss = (): void => setDismissedVersion(update.version) + + const continueUpdate = async (): Promise => { + dismiss() + if (update.delivery === 'release-page') { + const result = await window.api.openAppUpdateDownloadPage() + if (!result.success) onNotice(result.error || '无法打开下载页面') + return + } + onDownloadStart() + const result = await window.api.downloadAppUpdate() + if (result && !result.success) onNotice(result.state.message || '更新下载失败') + } + + return ( + + + + 发现新版本 v{update.version} + + {update.isSimulation + ? '开发模拟更新已准备好。' + : update.delivery === 'release-page' + ? '有新版本可用,前往 GitHub 下载最新版本。' + : '是否立即下载?'} + + + + + + + + + + + + + ) +} diff --git a/src/renderer/src/features/app-update/useAppUpdateState.ts b/src/renderer/src/features/app-update/useAppUpdateState.ts new file mode 100644 index 0000000..94c3aef --- /dev/null +++ b/src/renderer/src/features/app-update/useAppUpdateState.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react' +import type { AppUpdateState } from '../../../../shared/app-update' + +const INITIAL_UPDATE_STATE: AppUpdateState = { + status: 'idle', + currentVersion: '读取中...', + delivery: 'automatic' +} + +export function useAppUpdateState(): AppUpdateState { + const [state, setState] = useState(INITIAL_UPDATE_STATE) + + useEffect(() => { + let active = true + void window.api.getAppUpdateState().then((nextState) => { + if (active) setState(nextState) + }) + const unsubscribe = window.api.onAppUpdateState((nextState) => { + if (active) setState(nextState) + }) + return () => { + active = false + unsubscribe() + } + }, []) + + return state +} diff --git a/src/renderer/src/features/settings/pages/AboutPage.tsx b/src/renderer/src/features/settings/pages/AboutPage.tsx index 9d7dcba..0dc3a1d 100644 --- a/src/renderer/src/features/settings/pages/AboutPage.tsx +++ b/src/renderer/src/features/settings/pages/AboutPage.tsx @@ -1,14 +1,15 @@ -import { useEffect, useMemo, useState } from 'react' -import type { AppUpdateState } from '../../../../../shared/app-update' -import { Button } from '../../../components/ui' +import { useMemo, useState } from 'react' +import { Button, Progress } from '../../../components/ui' +import { useAppUpdateState } from '../../app-update/useAppUpdateState' -const REPOSITORY_URL = 'https://github.com/Wxw-Gu/WechatExplorer' +const REPOSITORY_URL = 'https://github.com/Wxw-Gu/TraceMemo' const RELEASES_URL = `${REPOSITORY_URL}/releases` -function formatBytes(value?: number): string { - if (!value) return '' - if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB/s` - return `${(value / 1024 / 1024).toFixed(1)} MB/s` +function formatDataSize(value?: number): string { + if (!value) return '0 B' + if (value < 1024) return `${value} B` + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB` + return `${(value / 1024 / 1024).toFixed(1)} MB` } export function AboutPage({ @@ -16,47 +17,49 @@ export function AboutPage({ }: { onNotice: (message: string) => void }): React.ReactElement { - const [update, setUpdate] = useState({ - status: 'idle', - currentVersion: '读取中...' - }) + const update = useAppUpdateState() const [busy, setBusy] = useState(false) - useEffect(() => { - let active = true - void window.api.getAppUpdateState().then((state) => active && setUpdate(state)) - const unsubscribe = window.api.onAppUpdateState((state) => { - if (active) setUpdate(state) - }) - return () => { - active = false - unsubscribe() - } - }, []) - const action = useMemo(() => { - if (update.status === 'downloaded') return '重启并安装' - if (update.status === 'available') return '下载更新' - if (update.status === 'checking' || update.status === 'downloading') return '处理中...' + if (update.status === 'available') { + return update.delivery === 'release-page' ? '前往下载' : '下载更新' + } + if (update.status === 'checking') return '正在检查...' + if (update.status === 'downloading') return '正在下载...' + if (update.status === 'installing') return '正在安装...' + if (update.status === 'error') return '重试' return '检查更新' - }, [update.status]) + }, [update.delivery, update.status]) const runUpdate = async (): Promise => { setBusy(true) try { - if (update.status === 'downloaded') { - const result = await window.api.installAppUpdate() - if (!result.success) onNotice(result.error || '更新安装失败') - } else if (update.status === 'available') { - await window.api.downloadAppUpdate() - } else { - await window.api.checkAppUpdate() + const result = + update.status === 'available' && update.delivery === 'release-page' + ? await window.api.openAppUpdateDownloadPage() + : update.status === 'available' + ? await window.api.downloadAppUpdate() + : await window.api.checkAppUpdate() + if (result && !result.success) { + const message = 'state' in result ? result.state.message : result.error + onNotice(message || '更新操作失败') } } finally { setBusy(false) } } + const installUpdate = async (): Promise => { + const result = await window.api.installAppUpdate() + if (!result.success) { + onNotice(result.error || '更新安装失败') + return + } + if (result.message) onNotice(result.message) + } + + const percent = Math.max(0, Math.min(100, Math.round(update.percent || 0))) + return (

@@ -82,29 +85,72 @@ export function AboutPage({
- {update.status === 'available' || update.status === 'downloaded' - ? `发现 v${update.version}` - : update.message || '检查 GitHub Releases 获取最新版本'} - - {update.status === 'downloading' - ? `正在下载 ${Math.round(update.percent || 0)}% · ${formatBytes(update.bytesPerSecond)}` - : '会根据当前系统和 CPU 自动选择对应安装包,安装前会等待你的确认。'} - - {update.status === 'downloading' && ( -
- + ? `正在下载 v${update.version}` + : update.status === 'downloaded' + ? `v${update.version} 已准备完成` + : update.status === 'available' + ? `发现新版本 v${update.version}` + : update.message || '检查 GitHub Releases 获取最新版本'} + + {update.status === 'downloading' ? ( +
+ {percent}% + +
+ + {formatDataSize(update.transferred)} / {formatDataSize(update.total)} + + {formatDataSize(update.bytesPerSecond)}/s +
+ ) : ( + + {update.status === 'downloaded' ? ( + `更新将在重启 TraceMemo 后生效。${update.isSimulation ? ' 当前为开发模拟模式。' : ''}` + ) : update.status === 'available' && update.delivery === 'release-page' ? ( + + 当前版本:v{update.currentVersion} + 最新版本:v{update.version} + + ) : ( + '会根据当前系统和 CPU 自动选择对应安装包,安装前会等待你的确认。' + )} + + )} +
+
+ {update.status === 'downloaded' ? ( + <> + + + + ) : ( + )}
-

支持

diff --git a/src/renderer/src/styles/settings-preferences.scss b/src/renderer/src/styles/settings-preferences.scss index d519622..d593fe5 100644 --- a/src/renderer/src/styles/settings-preferences.scss +++ b/src/renderer/src/styles/settings-preferences.scss @@ -629,21 +629,6 @@ } } -.update-progress { - height: 6px; - overflow: hidden; - border-radius: 999px; - background: var(--wxex-border); - - i { - display: block; - height: 100%; - border-radius: inherit; - background: var(--wxex-brand); - transition: width 0.2s ease; - } -} - .about-links-card { display: grid; gap: 12px; diff --git a/src/shared/app-update.ts b/src/shared/app-update.ts index 5fb482a..feedbbc 100644 --- a/src/shared/app-update.ts +++ b/src/shared/app-update.ts @@ -1,25 +1,47 @@ +export const APP_UPDATE_RELEASES_URL = 'https://github.com/Wxw-Gu/TraceMemo/releases/latest' + export type AppUpdateStatus = | 'idle' | 'checking' | 'available' - | 'not-available' + | 'up-to-date' | 'downloading' | 'downloaded' + | 'installing' | 'error' | 'unsupported' +export type AppUpdateCheckSource = 'startup' | 'manual' +export type AppUpdateDelivery = 'automatic' | 'release-page' + export interface AppUpdateState { status: AppUpdateStatus currentVersion: string + delivery: AppUpdateDelivery version?: string percent?: number transferred?: number total?: number bytesPerSecond?: number message?: string + error?: string + source?: AppUpdateCheckSource + isSimulation?: boolean } export interface AppUpdateCheckResult { success: boolean state: AppUpdateState } + +export interface AppUpdateInstallResult { + success: boolean + simulated?: boolean + message?: string + error?: string +} + +export interface AppUpdateOpenDownloadPageResult { + success: boolean + error?: string +} diff --git a/tests/component/app-update.test.tsx b/tests/component/app-update.test.tsx new file mode 100644 index 0000000..ea63220 --- /dev/null +++ b/tests/component/app-update.test.tsx @@ -0,0 +1,159 @@ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppUpdateState } from '../../src/shared/app-update' +import { AppUpdatePrompt } from '../../src/renderer/src/features/app-update/AppUpdatePrompt' +import { AboutPage } from '../../src/renderer/src/features/settings/pages/AboutPage' + +let updateListener: ((state: AppUpdateState) => void) | undefined +const api = { + checkAppUpdate: vi.fn(), + downloadAppUpdate: vi.fn(), + getAppUpdateState: vi.fn(), + installAppUpdate: vi.fn(), + openAppUpdateDownloadPage: vi.fn(), + onAppUpdateState: vi.fn((listener: (state: AppUpdateState) => void) => { + updateListener = listener + return vi.fn() + }), + revealAppLog: vi.fn() +} + +describe('app update UI', () => { + beforeEach(() => { + vi.clearAllMocks() + updateListener = undefined + Object.defineProperty(window, 'api', { configurable: true, value: api }) + }) + + it('prompts once for a startup update and starts the download after navigation', async () => { + const user = userEvent.setup() + const state: AppUpdateState = { + status: 'available', + currentVersion: '1.9.0', + delivery: 'automatic', + version: '2.0.0', + source: 'startup', + isSimulation: true + } + api.getAppUpdateState.mockResolvedValue(state) + api.downloadAppUpdate.mockResolvedValue({ success: true, state }) + const onDownloadStart = vi.fn() + + render() + + await screen.findByRole('alertdialog', { name: '发现新版本 v2.0.0' }) + await user.click(screen.getByRole('button', { name: '立即下载' })) + + expect(onDownloadStart).toHaveBeenCalledOnce() + expect(api.downloadAppUpdate).toHaveBeenCalledOnce() + await waitFor(() => + expect( + screen.queryByRole('alertdialog', { name: '发现新版本 v2.0.0' }) + ).not.toBeInTheDocument() + ) + }) + + it('renders live transfer details and keeps simulated install inside the app', async () => { + const user = userEvent.setup() + api.getAppUpdateState.mockResolvedValue({ + status: 'downloading', + currentVersion: '1.9.0', + delivery: 'automatic', + version: '2.0.0', + percent: 76, + transferred: 45.6 * 1024 * 1024, + total: 60 * 1024 * 1024, + bytesPerSecond: 3.8 * 1024 * 1024, + isSimulation: true + }) + api.installAppUpdate.mockResolvedValue({ + success: true, + simulated: true, + message: '开发模拟模式:更新安装动作已模拟,未实际退出应用。' + }) + const onNotice = vi.fn() + render() + + await screen.findByText('正在下载 v2.0.0') + expect(screen.getByText('76%')).toBeVisible() + expect(screen.getByText('45.6 MB / 60.0 MB')).toBeVisible() + expect(screen.getByText('3.8 MB/s')).toBeVisible() + + act(() => { + updateListener?.({ + status: 'downloaded', + currentVersion: '1.9.0', + delivery: 'automatic', + version: '2.0.0', + percent: 100, + transferred: 60 * 1024 * 1024, + total: 60 * 1024 * 1024, + isSimulation: true + }) + }) + await user.click(screen.getByRole('button', { name: '立即重启更新' })) + + expect(api.installAppUpdate).toHaveBeenCalledOnce() + expect(onNotice).toHaveBeenCalledWith('开发模拟模式:更新安装动作已模拟,未实际退出应用。') + }) + + it('opens the latest release page instead of downloading in unsigned macOS mode', async () => { + const user = userEvent.setup() + const state: AppUpdateState = { + status: 'available', + currentVersion: '2.2.2', + delivery: 'release-page', + version: '2.2.3', + source: 'startup' + } + api.getAppUpdateState.mockResolvedValue(state) + api.openAppUpdateDownloadPage.mockResolvedValue({ success: true }) + const onDownloadStart = vi.fn() + + render() + await screen.findByRole('alertdialog', { name: '发现新版本 v2.2.3' }) + await user.click(screen.getByRole('button', { name: '前往下载' })) + + expect(api.openAppUpdateDownloadPage).toHaveBeenCalledOnce() + expect(api.downloadAppUpdate).not.toHaveBeenCalled() + expect(onDownloadStart).not.toHaveBeenCalled() + }) + + it('shows a release-page action in About without progress UI', async () => { + const user = userEvent.setup() + api.getAppUpdateState.mockResolvedValue({ + status: 'available', + currentVersion: '2.2.2', + delivery: 'release-page', + version: '2.2.3' + }) + api.openAppUpdateDownloadPage.mockResolvedValue({ success: true }) + render() + + await screen.findByText('发现新版本 v2.2.3') + expect(screen.getByText('当前版本:v2.2.2')).toBeVisible() + expect(screen.getByText('最新版本:v2.2.3')).toBeVisible() + expect(screen.getByRole('button', { name: '前往下载' })).toBeVisible() + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: '前往下载' })) + expect(api.openAppUpdateDownloadPage).toHaveBeenCalledOnce() + expect(api.downloadAppUpdate).not.toHaveBeenCalled() + }) + + it.each([ + ['up-to-date', '已是最新版本', '检查更新'], + ['error', '检查更新失败', '重试'] + ] as const)('renders the %s manual-check result', async (status, message, action) => { + api.getAppUpdateState.mockResolvedValue({ + status, + currentVersion: '2.2.2', + delivery: 'release-page', + message + }) + render() + + await screen.findByText(message) + expect(screen.getByRole('button', { name: action })).toBeVisible() + }) +}) diff --git a/tests/component/first-use-welcome.test.tsx b/tests/component/first-use-welcome.test.tsx index e1b8d9d..a2a4bb1 100644 --- a/tests/component/first-use-welcome.test.tsx +++ b/tests/component/first-use-welcome.test.tsx @@ -64,7 +64,7 @@ describe('FirstUseWelcome', () => { expect(onOpenAISettings).toHaveBeenCalledOnce() expect(screen.getByRole('link', { name: '查看完整使用教程' })).toHaveAttribute( 'href', - 'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md' + 'https://github.com/Wxw-Gu/TraceMemo/blob/main/docs/user-guide/getting-started.md' ) }) }) diff --git a/tests/component/settings-basic-pages.test.tsx b/tests/component/settings-basic-pages.test.tsx index 103924d..d53edb9 100644 --- a/tests/component/settings-basic-pages.test.tsx +++ b/tests/component/settings-basic-pages.test.tsx @@ -20,9 +20,16 @@ describe('basic settings pages', () => { beforeEach(() => { vi.clearAllMocks() Object.defineProperty(window, 'api', { configurable: true, value: api }) - api.getAppUpdateState.mockResolvedValue({ status: 'idle', currentVersion: '2.2.2' }) + api.getAppUpdateState.mockResolvedValue({ + status: 'idle', + currentVersion: '2.2.2', + delivery: 'automatic' + }) api.onAppUpdateState.mockReturnValue(vi.fn()) - api.checkAppUpdate.mockResolvedValue(undefined) + api.checkAppUpdate.mockResolvedValue({ + success: true, + state: { status: 'up-to-date', currentVersion: '2.2.2' } + }) api.revealAppLog.mockResolvedValue(undefined) api.getSettings.mockResolvedValue({ settings: { debugEnabled: false } }) api.setSettings.mockResolvedValue({ settings: { debugEnabled: true } }) diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index 91c7e2d..0a665b4 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -340,6 +340,62 @@ test('SETTINGS-02 basic settings controls fit a narrow viewport and keep their s } }) +test('UPDATE-01 simulated startup update navigates to live progress and never exits on install', async () => { + const fixture = await launchTestApp({ updateSimulation: true }) + const pageErrors: Error[] = [] + fixture.page.on('pageerror', (error) => pageErrors.push(error)) + try { + const dialog = fixture.page.getByRole('alertdialog', { name: '发现新版本 v2.0.0' }) + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: '立即下载' }).click() + + await expect(fixture.page.getByRole('heading', { name: '关于' })).toBeVisible() + await expect(fixture.page.getByRole('button', { name: '关于' })).toHaveClass(/active/) + await expect(fixture.page.getByText('正在下载 v2.0.0')).toBeVisible() + await expect + .poll(async () => { + const value = await fixture.page + .getByRole('progressbar', { name: /更新下载进度/ }) + .getAttribute('aria-valuenow') + return Number(value) + }) + .toBeGreaterThan(0) + await expect(fixture.page.getByText(/MB \/ 60\.0 MB/)).toBeVisible() + await expect(fixture.page.getByText(/MB\/s/)).toBeVisible() + + await expect(fixture.page.getByText('v2.0.0 已准备完成')).toBeVisible({ timeout: 5_000 }) + await fixture.page.getByRole('button', { name: '立即重启更新' }).click() + await expect( + fixture.page.getByText('开发模拟模式:更新安装动作已模拟,未实际退出应用。') + ).toBeVisible() + await expect(fixture.page.getByRole('heading', { name: '关于' })).toBeVisible() + expect(pageErrors).toEqual([]) + } finally { + await fixture.close() + } +}) + +test('UPDATE-02 unsigned macOS update opens the latest release without downloading', async () => { + const fixture = await launchTestApp({ unsignedMacUpdate: true }) + const pageErrors: Error[] = [] + fixture.page.on('pageerror', (error) => pageErrors.push(error)) + try { + const dialog = fixture.page.getByRole('alertdialog', { name: '发现新版本 v2.2.3' }) + await expect(dialog).toBeVisible() + await expect(dialog.getByText('有新版本可用,前往 GitHub 下载最新版本。')).toBeVisible() + await dialog.getByRole('button', { name: '前往下载' }).click() + + const openedUrl = await fixture.page.evaluate(() => + window.electron.ipcRenderer.invoke('app-update:getOpenedDownloadUrl') + ) + expect(openedUrl).toBe('https://github.com/Wxw-Gu/TraceMemo/releases/latest') + await expect(fixture.page.getByRole('progressbar')).toHaveCount(0) + expect(pageErrors).toEqual([]) + } finally { + await fixture.close() + } +}) + test('SETTINGS-03 database key actions keep destructive confirmation keyboard-safe', async () => { const fixture = await launchTestApp() const pageErrors: Error[] = [] diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs index 1d62545..cdab8a9 100644 --- a/tests/e2e/support/electron-main.cjs +++ b/tests/e2e/support/electron-main.cjs @@ -15,6 +15,8 @@ const VALID_KEY = 'a'.repeat(64) const imageData = `data:image/png;base64,${fs.readFileSync(path.join(root, 'resources/icon.png')).toString('base64')}` const voiceData = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=' const configuredNow = Number(process.env.WXE_E2E_NOW_MS) +const updateSimulation = process.env.WXE_E2E_UPDATE_SIMULATION === '1' +const unsignedMacUpdate = process.env.WXE_E2E_UNSIGNED_MAC_UPDATE === '1' const fixtureNowMs = Number.isFinite(configuredNow) && configuredNow > 0 ? configuredNow : Date.now() @@ -801,7 +803,83 @@ handle('accounts:discover', (inputPath) => ] } ) -handle('app-update:getState', () => ({ status: 'idle', currentVersion: '2.2.0' })) +let appUpdateState = updateSimulation + ? { + status: 'available', + currentVersion: '1.9.0', + delivery: 'automatic', + version: '2.0.0', + source: 'startup', + isSimulation: true + } + : unsignedMacUpdate + ? { + status: 'available', + currentVersion: '2.2.2', + delivery: 'release-page', + version: '2.2.3', + source: 'startup' + } + : { status: 'idle', currentVersion: '2.2.0', delivery: 'automatic' } +let openedUpdateDownloadUrl = '' +handle('app-update:getState', () => appUpdateState) +handle('app-update:openDownloadPage', () => { + openedUpdateDownloadUrl = 'https://github.com/Wxw-Gu/TraceMemo/releases/latest' + return { success: true } +}) +handle('app-update:getOpenedDownloadUrl', () => openedUpdateDownloadUrl) +handle('app-update:download', async () => { + if (!updateSimulation) return { success: true, state: appUpdateState } + const total = 60 * 1024 * 1024 + const steps = [0, 5, 12, 21, 33, 46, 58, 69, 78, 86, 93, 97, 100] + let previousTransferred = 0 + appUpdateState = { + ...appUpdateState, + status: 'downloading', + percent: 0, + transferred: 0, + total, + bytesPerSecond: 0 + } + for (const percent of steps.slice(1)) { + await new Promise((resolve) => setTimeout(resolve, 150)) + const transferred = Math.round((total * percent) / 100) + const bytesPerSecond = Math.round((transferred - previousTransferred) / 0.15) + previousTransferred = transferred + appUpdateState = { + ...appUpdateState, + status: 'downloading', + percent, + transferred, + total, + bytesPerSecond + } + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send('app-update:state', appUpdateState) + } + } + appUpdateState = { + ...appUpdateState, + status: 'downloaded', + percent: 100, + transferred: total, + total, + bytesPerSecond: undefined + } + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send('app-update:state', appUpdateState) + } + return { success: true, state: appUpdateState } +}) +handle('app-update:install', async () => + updateSimulation + ? { + success: true, + simulated: true, + message: '开发模拟模式:更新安装动作已模拟,未实际退出应用。' + } + : { success: true } +) for (const channel of [ 'export:start', @@ -817,8 +895,6 @@ for (const channel of [ 'image:selectDecoder', 'image:openDecoderDownload', 'app-update:check', - 'app-update:download', - 'app-update:install', 'agent-hub:clearLogs', 'agent-hub:startLogin', 'agent-hub:cancelLogin', diff --git a/tests/e2e/support/electron.ts b/tests/e2e/support/electron.ts index e8f51ab..f8657b4 100644 --- a/tests/e2e/support/electron.ts +++ b/tests/e2e/support/electron.ts @@ -21,6 +21,8 @@ export async function launchTestApp( largeContacts?: number corruptCache?: boolean aiFailure?: string + updateSimulation?: boolean + unsignedMacUpdate?: boolean now?: number appearanceTheme?: 'light' | 'dark' stableUserData?: string @@ -49,6 +51,8 @@ export async function launchTestApp( WXE_E2E_LARGE_CONTACTS: String(options.largeContacts || 0), WXE_E2E_CORRUPT_CACHE: options.corruptCache ? '1' : '0', WXE_E2E_AI_FAILURE: options.aiFailure || '', + WXE_E2E_UPDATE_SIMULATION: options.updateSimulation ? '1' : '0', + WXE_E2E_UNSIGNED_MAC_UPDATE: options.unsignedMacUpdate ? '1' : '0', WXE_E2E_NOW_MS: options.now ? String(options.now) : '', WXE_E2E_APPEARANCE_THEME: options.appearanceTheme || 'light' } diff --git a/tests/e2e/visual.spec.ts b/tests/e2e/visual.spec.ts index 43af264..6d034f0 100644 --- a/tests/e2e/visual.spec.ts +++ b/tests/e2e/visual.spec.ts @@ -497,6 +497,72 @@ test('SETTINGS-02 basic settings controls visual @visual', async () => { } }) +test('UPDATE-01 startup prompt and downloaded state visual @visual', async () => { + const fixture = await launchTestApp({ now: visualNow, updateSimulation: true }) + const pageErrors: Error[] = [] + fixture.page.on('pageerror', (error) => pageErrors.push(error)) + try { + await fixture.setWindowContentSize(visualViewport) + const dialog = fixture.page.getByRole('alertdialog', { name: '发现新版本 v2.0.0' }) + await expect(dialog).toBeVisible() + await clearScreenshotFocus(fixture.page) + await expect(fixture.page).toHaveScreenshot('update-available-dialog.png', { + animations: 'disabled', + caret: 'hide' + }) + + await dialog.getByRole('button', { name: '立即下载' }).click() + await expect(fixture.page.getByText('v2.0.0 已准备完成')).toBeVisible({ timeout: 5_000 }) + expect( + await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) + ).toBe(true) + expect(pageErrors).toEqual([]) + await clearScreenshotFocus(fixture.page) + await expect(fixture.page).toHaveScreenshot('settings-update-downloaded.png', { + animations: 'disabled', + caret: 'hide' + }) + } finally { + await fixture.close() + } +}) + +test('UPDATE-02 unsigned macOS release prompt visual @visual', async () => { + const fixture = await launchTestApp({ now: visualNow, unsignedMacUpdate: true }) + const pageErrors: Error[] = [] + fixture.page.on('pageerror', (error) => pageErrors.push(error)) + try { + await fixture.setWindowContentSize(visualViewport) + const dialog = fixture.page.getByRole('alertdialog', { name: '发现新版本 v2.2.3' }) + await expect(dialog).toBeVisible() + await clearScreenshotFocus(fixture.page) + await expect(fixture.page).toHaveScreenshot('update-release-page-dialog.png', { + animations: 'disabled', + caret: 'hide' + }) + + await dialog.getByRole('button', { name: '取消' }).click() + await fixture.page + .getByRole('navigation', { name: '一级导航' }) + .getByRole('button', { name: '设置' }) + .click() + await fixture.page.getByRole('button', { name: '关于' }).click() + await expect(fixture.page.getByRole('button', { name: '前往下载' })).toBeVisible() + await expect(fixture.page.getByRole('progressbar')).toHaveCount(0) + expect( + await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) + ).toBe(true) + expect(pageErrors).toEqual([]) + await clearScreenshotFocus(fixture.page) + await expect(fixture.page).toHaveScreenshot('settings-update-release-page.png', { + animations: 'disabled', + caret: 'hide' + }) + } finally { + await fixture.close() + } +}) + test('SETTINGS-03 database key controls visual @visual', async () => { const fixture = await launchTestApp({ now: visualNow }) const pageErrors: Error[] = [] diff --git a/tests/unit/app-update-service.test.ts b/tests/unit/app-update-service.test.ts new file mode 100644 index 0000000..fafaa1e --- /dev/null +++ b/tests/unit/app-update-service.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { APP_UPDATE_RELEASES_URL } from '../../src/shared/app-update' + +const mocks = vi.hoisted(() => { + const listeners = new Map void>>() + return { + listeners, + updater: { + autoDownload: true, + autoInstallOnAppQuit: false, + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + quitAndInstall: vi.fn(), + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + const eventListeners = listeners.get(event) || new Set() + eventListeners.add(listener) + listeners.set(event, eventListeners) + }) + }, + shell: { + openExternal: vi.fn() + }, + emit(event: string, ...args: unknown[]): void { + for (const listener of listeners.get(event) || []) listener(...args) + } + } +}) + +vi.mock('electron', () => ({ + app: { + getVersion: () => '1.9.0', + isPackaged: false + }, + BrowserWindow: { + getAllWindows: () => [] + }, + shell: mocks.shell +})) + +vi.mock('electron-updater', () => ({ autoUpdater: mocks.updater })) +vi.mock('../../src/main/runtime-mode', () => ({ isPackagedRuntime: () => false })) + +import { AppUpdateService } from '../../src/main/services/app-update-service' + +describe('AppUpdateService', () => { + beforeEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + mocks.listeners.clear() + }) + + it('keeps update-not-available as the final state when checkForUpdates returns metadata', async () => { + const service = new AppUpdateService({ packagedRuntime: () => true, platform: 'win32' }) + mocks.updater.checkForUpdates.mockImplementationOnce(async () => { + mocks.emit('checking-for-update') + mocks.emit('update-not-available', { version: '1.9.0' }) + return { updateInfo: { version: '1.9.0' } } + }) + + const result = await service.check('manual') + + expect(result.success).toBe(true) + expect(result.state).toMatchObject({ + status: 'up-to-date', + currentVersion: '1.9.0', + delivery: 'automatic', + source: 'manual' + }) + }) + + it('simulates a realistic 2.0.0 download without invoking the real updater', async () => { + vi.useFakeTimers() + const states: ReturnType[] = [] + const service = new AppUpdateService({ + simulationEnabled: true, + simulationDurationMs: 1_000, + currentVersion: () => '1.9.0' + }) + service.handleState((state) => states.push(state)) + + const checkPromise = service.check('startup') + await vi.advanceTimersByTimeAsync(350) + const checkResult = await checkPromise + expect(checkResult.state).toMatchObject({ + status: 'available', + version: '2.0.0', + source: 'startup', + isSimulation: true + }) + + const downloadPromise = service.download() + await vi.advanceTimersByTimeAsync(1_100) + const downloadResult = await downloadPromise + + expect(downloadResult.state).toMatchObject({ + status: 'downloaded', + version: '2.0.0', + percent: 100, + transferred: 60 * 1024 * 1024, + total: 60 * 1024 * 1024 + }) + expect( + states.filter((state) => state.status === 'downloading').map((state) => state.percent) + ).toEqual([0, 5, 12, 21, 33, 46, 58, 69, 78, 86, 93, 97, 100]) + expect(states.some((state) => (state.bytesPerSecond || 0) > 0)).toBe(true) + expect(mocks.updater.downloadUpdate).not.toHaveBeenCalled() + + expect(service.install()).toEqual({ + success: true, + simulated: true, + message: '开发模拟模式:更新安装动作已模拟,未实际退出应用。' + }) + expect(mocks.updater.quitAndInstall).not.toHaveBeenCalled() + }) + + it('uses release-page delivery on unsigned macOS without downloading or installing', async () => { + const service = new AppUpdateService({ + platform: 'darwin', + packagedRuntime: () => true, + currentVersion: () => '2.2.2', + macAutoUpdateEnabled: false + }) + mocks.updater.checkForUpdates.mockImplementationOnce(async () => { + mocks.emit('checking-for-update') + mocks.emit('update-available', { version: '2.2.3' }) + return { updateInfo: { version: '2.2.3' } } + }) + + const result = await service.check('manual') + expect(result.state).toMatchObject({ + status: 'available', + delivery: 'release-page', + version: '2.2.3' + }) + + const download = await service.download() + expect(download.success).toBe(false) + expect(download.state.status).toBe('available') + expect(mocks.updater.downloadUpdate).not.toHaveBeenCalled() + expect(service.install()).toMatchObject({ success: false }) + expect(mocks.updater.quitAndInstall).not.toHaveBeenCalled() + + mocks.shell.openExternal.mockResolvedValue(undefined) + await expect(service.openDownloadPage()).resolves.toEqual({ success: true }) + expect(mocks.shell.openExternal).toHaveBeenCalledWith(APP_UPDATE_RELEASES_URL) + }) + + it('keeps automatic download and quitAndInstall on Windows', async () => { + const service = new AppUpdateService({ + platform: 'win32', + packagedRuntime: () => true, + currentVersion: () => '2.2.2' + }) + mocks.updater.checkForUpdates.mockImplementationOnce(async () => { + mocks.emit('checking-for-update') + mocks.emit('update-available', { version: '2.2.3' }) + return { updateInfo: { version: '2.2.3' } } + }) + mocks.updater.downloadUpdate.mockImplementationOnce(async () => { + mocks.emit('download-progress', { + percent: 50, + transferred: 30, + total: 60, + bytesPerSecond: 10 + }) + mocks.emit('update-downloaded', { version: '2.2.3' }) + }) + + await service.check('manual') + const download = await service.download() + expect(download.state).toMatchObject({ + status: 'downloaded', + delivery: 'automatic', + version: '2.2.3', + percent: 100 + }) + expect(mocks.updater.downloadUpdate).toHaveBeenCalledOnce() + + expect(service.install()).toEqual({ success: true }) + expect(mocks.updater.quitAndInstall).toHaveBeenCalledOnce() + }) +})