feat: 增加更新页

This commit is contained in:
Wxw-Gu
2026-08-25 21:07:25 +08:00
parent eec6286a58
commit 9dee0bbd3c
28 changed files with 1079 additions and 135 deletions
+5 -6
View File
@@ -12,13 +12,13 @@
</p>
<p align="center">
<img src="https://img.shields.io/github/stars/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="GitHub stars" />
<img src="https://img.shields.io/github/downloads/Wxw-Gu/WechatExplorer/total?style=for-the-badge" alt="GitHub downloads" />
<img src="https://img.shields.io/github/v/release/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="Latest release" />
<img src="https://img.shields.io/github/stars/Wxw-Gu/TraceMemo?style=for-the-badge" alt="GitHub stars" />
<img src="https://img.shields.io/github/downloads/Wxw-Gu/TraceMemo/total?style=for-the-badge" alt="GitHub downloads" />
<img src="https://img.shields.io/github/v/release/Wxw-Gu/TraceMemo?style=for-the-badge" alt="Latest release" />
</p>
<p align="center">
<a href="https://github.com/Wxw-Gu/WechatExplorer/releases"><b>下载 TraceMemo</b></a>
<a href="https://github.com/Wxw-Gu/TraceMemo/releases"><b>下载 TraceMemo</b></a>
·
<a href="./docs/user-guide/getting-started.md"><b>第一次使用</b></a>
·
@@ -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 后自己改。
<p align="center">
<b>TraceMemo(迹忆)</b>
<br />
+5
View File
@@ -0,0 +1,5 @@
provider: github
owner: Wxw-Gu
repo: TraceMemo
releaseType: release
updaterCacheDirName: tracememo-updater
+1 -1
View File
@@ -22,7 +22,7 @@
## 2. 安装并启动
安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载。
安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载。
### Windows
+5 -1
View File
@@ -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
+8 -1
View File
@@ -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
+1
View File
@@ -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",
+2
View File
@@ -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) => {
+252 -41
View File
@@ -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<AppUpdateCheckResult>
private downloadInFlight?: Promise<AppUpdateCheckResult>
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<AppUpdateCheckResult> {
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<AppUpdateCheckResult> {
if (this.checkInFlight) return this.checkInFlight
this.checkInFlight = this.runCheck(source).finally(() => {
this.checkInFlight = undefined
})
return this.checkInFlight
}
private async runCheck(source: AppUpdateCheckSource): Promise<AppUpdateCheckResult> {
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<AppUpdateCheckResult> {
if (!isPackagedRuntime()) {
const state = this.setState({ status: 'unsupported', message: '开发模式不能下载更新' })
download(): Promise<AppUpdateCheckResult> {
if (this.downloadInFlight) return this.downloadInFlight
this.downloadInFlight = this.runDownload().finally(() => {
this.downloadInFlight = undefined
})
return this.downloadInFlight
}
private async runDownload(): Promise<AppUpdateCheckResult> {
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<AppUpdateOpenDownloadPageResult> {
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>): 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<AppUpdateCheckResult> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, durationMs))
}
}
export const appUpdateService = new AppUpdateService()
+1 -1
View File
@@ -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'
+8 -2
View File
@@ -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<AppUpdateState>
checkAppUpdate: () => Promise<AppUpdateCheckResult>
downloadAppUpdate: () => Promise<AppUpdateCheckResult>
installAppUpdate: () => Promise<{ success: boolean; error?: string }>
installAppUpdate: () => Promise<AppUpdateInstallResult>
openAppUpdateDownloadPage: () => Promise<AppUpdateOpenDownloadPageResult>
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
getCacheSummary: () => Promise<CacheSummary>
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all') => Promise<CacheSummary>
+1
View File
@@ -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)
+8 -1
View File
@@ -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}
>
<AppUpdatePrompt onDownloadStart={openUpdateSettings} onNotice={setReportNotice} />
{showFirstUseWelcome && (
<FirstUseWelcome
onDismiss={dismissFirstUseWelcome}
@@ -3,8 +3,7 @@ import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../../sh
import { WINDOWS_VC_RUNTIME_DOWNLOAD_URL } from '../../../shared/windows-runtime'
import { Button, IconButton, Input, Tabs, TabsList, TabsTrigger } from './ui'
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 type DatabaseConnectionMode = 'automatic' | 'manual'
export type DatabaseConnectionStatusKind = 'normal' | 'success' | 'error'
@@ -8,8 +8,7 @@ interface FirstUseWelcomeProps {
onOpenAISettings: () => 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,
@@ -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
@@ -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<string>()
const shouldPrompt =
update.status === 'available' &&
update.source === 'startup' &&
Boolean(update.version) &&
update.version !== dismissedVersion
const dismiss = (): void => setDismissedVersion(update.version)
const continueUpdate = async (): Promise<void> => {
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 (
<AlertDialog open={shouldPrompt}>
<AlertDialogContent aria-label="发现新版本">
<AlertDialogHeader>
<AlertDialogTitle>发现新版本 v{update.version}</AlertDialogTitle>
<AlertDialogDescription>
{update.isSimulation
? '开发模拟更新已准备好。'
: update.delivery === 'release-page'
? '有新版本可用,前往 GitHub 下载最新版本。'
: '是否立即下载?'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant="outline" onClick={dismiss}>
取消
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={() => void continueUpdate()}>
{update.delivery === 'release-page' ? '前往下载' : '立即下载'}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -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<AppUpdateState>(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
}
@@ -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<AppUpdateState>({
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<void> => {
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<void> => {
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 (
<div className="settings-page">
<header className="settings-page-header">
@@ -82,29 +85,72 @@ export function AboutPage({
<section className={`settings-card update-card status-${update.status}`}>
<div className="update-card-copy">
<strong>
{update.status === 'available' || update.status === 'downloaded'
? `发现 v${update.version}`
: update.message || '检查 GitHub Releases 获取最新版本'}
</strong>
<span>
{update.status === 'downloading'
? `正在下载 ${Math.round(update.percent || 0)}% · ${formatBytes(update.bytesPerSecond)}`
: '会根据当前系统和 CPU 自动选择对应安装包,安装前会等待你的确认。'}
</span>
{update.status === 'downloading' && (
<div className="update-progress">
<i style={{ width: `${update.percent || 0}%` }} />
? `正在下载 v${update.version}`
: update.status === 'downloaded'
? `v${update.version} 已准备完成`
: update.status === 'available'
? `发现新版本 v${update.version}`
: update.message || '检查 GitHub Releases 获取最新版本'}
</strong>
{update.status === 'downloading' ? (
<div className="grid gap-2 pt-1">
<span className="text-sm font-semibold text-foreground">{percent}%</span>
<Progress value={percent} aria-label={`更新下载进度 ${percent}%`} />
<div className="flex justify-between gap-4 text-xs">
<span>
{formatDataSize(update.transferred)} / {formatDataSize(update.total)}
</span>
<span>{formatDataSize(update.bytesPerSecond)}/s</span>
</div>
</div>
) : (
<span>
{update.status === 'downloaded' ? (
`更新将在重启 TraceMemo 后生效。${update.isSimulation ? ' 当前为开发模拟模式。' : ''}`
) : update.status === 'available' && update.delivery === 'release-page' ? (
<span className="grid gap-1">
<span>当前版本:v{update.currentVersion}</span>
<span>最新版本:v{update.version}</span>
</span>
) : (
'会根据当前系统和 CPU 自动选择对应安装包,安装前会等待你的确认。'
)}
</span>
)}
</div>
<div className="flex flex-wrap justify-end gap-2">
{update.status === 'downloaded' ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => onNotice('更新已保留,稍后可在此安装')}
>
稍后
</Button>
<Button size="sm" onClick={() => void installUpdate()}>
立即重启更新
</Button>
</>
) : (
<Button
size="sm"
disabled={
busy ||
update.status === 'checking' ||
update.status === 'downloading' ||
update.status === 'installing'
}
aria-busy={
busy || update.status === 'checking' || update.status === 'downloading'
}
onClick={() => void runUpdate()}
>
{action}
</Button>
)}
</div>
<Button
size="sm"
disabled={busy || update.status === 'checking' || update.status === 'downloading'}
aria-busy={busy || update.status === 'checking' || update.status === 'downloading'}
onClick={() => void runUpdate()}
>
{action}
</Button>
</section>
<h2 className="settings-section-heading">支持</h2>
@@ -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;
+23 -1
View File
@@ -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
}
+159
View File
@@ -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(<AppUpdatePrompt onDownloadStart={onDownloadStart} onNotice={vi.fn()} />)
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(<AboutPage onNotice={onNotice} />)
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(<AppUpdatePrompt onDownloadStart={onDownloadStart} onNotice={vi.fn()} />)
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(<AboutPage onNotice={vi.fn()} />)
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(<AboutPage onNotice={vi.fn()} />)
await screen.findByText(message)
expect(screen.getByRole('button', { name: action })).toBeVisible()
})
})
+1 -1
View File
@@ -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'
)
})
})
@@ -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 } })
+56
View File
@@ -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[] = []
+79 -3
View File
@@ -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',
+4
View File
@@ -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'
}
+66
View File
@@ -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[] = []
+182
View File
@@ -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<string, Set<(...args: unknown[]) => 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<AppUpdateService['getState']>[] = []
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()
})
})