From 9dee0bbd3c3d94372e565fc5c883ecfa133716f7 Mon Sep 17 00:00:00 2001
From: Wxw-Gu
Date: Tue, 25 Aug 2026 21:07:25 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=9B=B4=E6=96=B0?=
=?UTF-8?q?=E9=A1=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 11 +-
build/app-update.yml | 5 +
docs/user-guide/getting-started.md | 2 +-
electron-builder.win.yml | 6 +-
electron-builder.yml | 9 +-
package.json | 1 +
src/main/index.ts | 2 +
src/main/services/app-update-service.ts | 293 +++++++++++++++---
src/main/services/skill-resource-service.ts | 2 +-
src/preload/index.d.ts | 10 +-
src/preload/index.ts | 1 +
src/renderer/src/App.tsx | 9 +-
.../src/components/DatabaseConnectionPage.tsx | 3 +-
.../src/components/FirstUseWelcome.tsx | 3 +-
.../api-center/model/skillDistribution.ts | 2 +-
.../features/app-update/AppUpdatePrompt.tsx | 72 +++++
.../features/app-update/useAppUpdateState.ts | 28 ++
.../src/features/settings/pages/AboutPage.tsx | 154 +++++----
.../src/styles/settings-preferences.scss | 15 -
src/shared/app-update.ts | 24 +-
tests/component/app-update.test.tsx | 159 ++++++++++
tests/component/first-use-welcome.test.tsx | 2 +-
tests/component/settings-basic-pages.test.tsx | 11 +-
tests/e2e/app.spec.ts | 56 ++++
tests/e2e/support/electron-main.cjs | 82 ++++-
tests/e2e/support/electron.ts | 4 +
tests/e2e/visual.spec.ts | 66 ++++
tests/unit/app-update-service.test.ts | 182 +++++++++++
28 files changed, 1079 insertions(+), 135 deletions(-)
create mode 100644 build/app-update.yml
create mode 100644 src/renderer/src/features/app-update/AppUpdatePrompt.tsx
create mode 100644 src/renderer/src/features/app-update/useAppUpdateState.ts
create mode 100644 tests/component/app-update.test.tsx
create mode 100644 tests/unit/app-update-service.test.ts
diff --git a/README.md b/README.md
index 32cbb48..8d83214 100644
--- a/README.md
+++ b/README.md
@@ -12,13 +12,13 @@
-
-
-
+
+
+
- 下载 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()
+ })
+})