mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: 增加更新页
This commit is contained in:
@@ -12,13 +12,13 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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/stars/Wxw-Gu/TraceMemo?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/downloads/Wxw-Gu/TraceMemo/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/v/release/Wxw-Gu/TraceMemo?style=for-the-badge" alt="Latest release" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
<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,按照“第一次使用”页面选择微信数据目录。
|
2. 启动 TraceMemo,按照“第一次使用”页面选择微信数据目录。
|
||||||
3. 第一次使用请先点击“开始连接”,按页面提示准备连接组件并获取数据库密钥;只有已经有密钥的高级用户才需要“手动连接”。
|
3. 第一次使用请先点击“开始连接”,按页面提示准备连接组件并获取数据库密钥;只有已经有密钥的高级用户才需要“手动连接”。
|
||||||
4. 连接成功后打开“档案”,确认联系人和聊天消息已经出现。
|
4. 连接成功后打开“档案”,确认联系人和聊天消息已经出现。
|
||||||
@@ -447,7 +447,6 @@ TraceMemo 在支持微信 4.x 时,参考并使用了 **[WeFlow](https://github
|
|||||||
|
|
||||||
也因此,这个项目随时可能继续折腾,也可能因为其他事情暂时搁置。如果你有想要的功能,可以提Issue;如果觉得现有实现不符合你的需求,也欢迎直接 Fork 后自己改。
|
也因此,这个项目随时可能继续折腾,也可能因为其他事情暂时搁置。如果你有想要的功能,可以提Issue;如果觉得现有实现不符合你的需求,也欢迎直接 Fork 后自己改。
|
||||||
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<b>TraceMemo(迹忆)</b>
|
<b>TraceMemo(迹忆)</b>
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
provider: github
|
||||||
|
owner: Wxw-Gu
|
||||||
|
repo: TraceMemo
|
||||||
|
releaseType: release
|
||||||
|
updaterCacheDirName: tracememo-updater
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
## 2. 安装并启动
|
## 2. 安装并启动
|
||||||
|
|
||||||
安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载。
|
安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载。
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ asarUnpack:
|
|||||||
- node_modules/sherpa-onnx-node/**
|
- node_modules/sherpa-onnx-node/**
|
||||||
- node_modules/sherpa-onnx-*/**
|
- node_modules/sherpa-onnx-*/**
|
||||||
extraResources:
|
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.
|
# Keep only resources required by the Windows x64 runtime.
|
||||||
- from: resources
|
- from: resources
|
||||||
to: resources
|
to: resources
|
||||||
@@ -58,5 +62,5 @@ electronLanguages:
|
|||||||
publish:
|
publish:
|
||||||
provider: github
|
provider: github
|
||||||
owner: Wxw-Gu
|
owner: Wxw-Gu
|
||||||
repo: WechatExplorer
|
repo: TraceMemo
|
||||||
releaseType: release
|
releaseType: release
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ asarUnpack:
|
|||||||
- node_modules/sherpa-onnx-node/**
|
- node_modules/sherpa-onnx-node/**
|
||||||
- node_modules/sherpa-onnx-*/**
|
- node_modules/sherpa-onnx-*/**
|
||||||
extraResources:
|
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.
|
# Includes the optional WeChat connector binary for the target platform.
|
||||||
- from: resources
|
- from: resources
|
||||||
to: resources
|
to: resources
|
||||||
@@ -39,6 +43,9 @@ nsis:
|
|||||||
createDesktopShortcut: always
|
createDesktopShortcut: always
|
||||||
mac:
|
mac:
|
||||||
icon: icon.icns
|
icon: icon.icns
|
||||||
|
target:
|
||||||
|
- dmg
|
||||||
|
- zip
|
||||||
entitlementsInherit: build/entitlements.mac.plist
|
entitlementsInherit: build/entitlements.mac.plist
|
||||||
extendInfo:
|
extendInfo:
|
||||||
CFBundleDisplayName: TraceMemo
|
CFBundleDisplayName: TraceMemo
|
||||||
@@ -63,5 +70,5 @@ npmRebuild: false
|
|||||||
publish:
|
publish:
|
||||||
provider: github
|
provider: github
|
||||||
owner: Wxw-Gu
|
owner: Wxw-Gu
|
||||||
repo: WechatExplorer
|
repo: TraceMemo
|
||||||
releaseType: release
|
releaseType: release
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"predev": "node -e \"require('electron')\"",
|
"predev": "node -e \"require('electron')\"",
|
||||||
"dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev",
|
"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:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...",
|
||||||
"test:unit": "vitest run --config vitest.unit.config.ts",
|
"test:unit": "vitest run --config vitest.unit.config.ts",
|
||||||
"test:component": "vitest run --config vitest.component.config.ts",
|
"test:component": "vitest run --config vitest.component.config.ts",
|
||||||
|
|||||||
@@ -685,6 +685,8 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('app-update:check', () => appUpdateService.check())
|
ipcMain.handle('app-update:check', () => appUpdateService.check())
|
||||||
ipcMain.handle('app-update:download', () => appUpdateService.download())
|
ipcMain.handle('app-update:download', () => appUpdateService.download())
|
||||||
ipcMain.handle('app-update:install', () => appUpdateService.install())
|
ipcMain.handle('app-update:install', () => appUpdateService.install())
|
||||||
|
ipcMain.handle('app-update:openDownloadPage', () => appUpdateService.openDownloadPage())
|
||||||
|
appUpdateService.scheduleStartupCheck()
|
||||||
ipcMain.handle('cache:getSummary', () => getCacheSummary())
|
ipcMain.handle('cache:getSummary', () => getCacheSummary())
|
||||||
ipcMain.handle('cache:openKnowledgeDirectory', () => openKnowledgeDirectory())
|
ipcMain.handle('cache:openKnowledgeDirectory', () => openKnowledgeDirectory())
|
||||||
ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => {
|
ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => {
|
||||||
|
|||||||
@@ -1,25 +1,108 @@
|
|||||||
import { app, BrowserWindow } from 'electron'
|
import { app, BrowserWindow, shell } from 'electron'
|
||||||
import { autoUpdater, type ProgressInfo } from 'electron-updater'
|
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'
|
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 {
|
export class AppUpdateService {
|
||||||
private state: AppUpdateState = {
|
private readonly currentVersion: () => string
|
||||||
status: 'idle',
|
private readonly packagedRuntime: () => boolean
|
||||||
currentVersion: app.getVersion()
|
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.autoDownload = false
|
||||||
autoUpdater.autoInstallOnAppQuit = true
|
autoUpdater.autoInstallOnAppQuit = this.delivery === 'automatic'
|
||||||
autoUpdater.on('checking-for-update', () => this.setState({ status: 'checking' }))
|
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) =>
|
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', () =>
|
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({
|
this.setState({
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
percent: progress.percent,
|
percent: progress.percent,
|
||||||
@@ -27,94 +110,222 @@ export class AppUpdateService {
|
|||||||
total: progress.total,
|
total: progress.total,
|
||||||
bytesPerSecond: progress.bytesPerSecond
|
bytesPerSecond: progress.bytesPerSecond
|
||||||
})
|
})
|
||||||
)
|
})
|
||||||
autoUpdater.on('update-downloaded', (info) =>
|
autoUpdater.on('update-downloaded', (info) => {
|
||||||
|
if (this.delivery !== 'automatic') return
|
||||||
this.setState({
|
this.setState({
|
||||||
status: 'downloaded',
|
status: 'downloaded',
|
||||||
version: info.version,
|
version: info.version || this.state.version,
|
||||||
percent: 100,
|
percent: 100,
|
||||||
|
transferred: this.state.total,
|
||||||
message: '更新已下载'
|
message: '更新已下载'
|
||||||
})
|
})
|
||||||
)
|
})
|
||||||
autoUpdater.on('error', (error) =>
|
autoUpdater.on('error', (error) =>
|
||||||
this.setState({ status: 'error', message: error.message || '更新失败' })
|
this.setState({
|
||||||
|
status: 'error',
|
||||||
|
message: '检查更新失败',
|
||||||
|
error: error.message || '更新失败'
|
||||||
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
getState(): AppUpdateState {
|
getState(): AppUpdateState {
|
||||||
return { ...this.state, currentVersion: app.getVersion() }
|
return { ...this.state, currentVersion: this.currentVersion() }
|
||||||
}
|
}
|
||||||
|
|
||||||
async check(): Promise<AppUpdateCheckResult> {
|
scheduleStartupCheck(delayMs = STARTUP_CHECK_DELAY_MS): void {
|
||||||
if (!isPackagedRuntime()) {
|
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({
|
const state = this.setState({
|
||||||
status: 'unsupported',
|
status: 'unsupported',
|
||||||
message: '开发模式不执行安装包更新,请在正式安装包中检查更新'
|
source,
|
||||||
|
message: '开发模式不执行安装包更新,请在正式安装包中检查更新',
|
||||||
|
error: undefined
|
||||||
})
|
})
|
||||||
return { success: false, state }
|
return { success: false, state }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await autoUpdater.checkForUpdates()
|
await autoUpdater.checkForUpdates()
|
||||||
if (result?.updateInfo.version) {
|
|
||||||
this.setState({
|
|
||||||
status: 'available',
|
|
||||||
version: result.updateInfo.version,
|
|
||||||
message: '发现新版本'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return { success: true, state: this.getState() }
|
return { success: true, state: this.getState() }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const state = this.setState({
|
const state = this.setState({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: error instanceof Error ? error.message : String(error)
|
message: '检查更新失败',
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
})
|
})
|
||||||
return { success: false, state }
|
return { success: false, state }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async download(): Promise<AppUpdateCheckResult> {
|
download(): Promise<AppUpdateCheckResult> {
|
||||||
if (!isPackagedRuntime()) {
|
if (this.downloadInFlight) return this.downloadInFlight
|
||||||
const state = this.setState({ status: 'unsupported', message: '开发模式不能下载更新' })
|
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 }
|
return { success: false, state }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.setState({ status: 'downloading', percent: 0 })
|
this.setState({ status: 'downloading', percent: 0, error: undefined })
|
||||||
await autoUpdater.downloadUpdate()
|
await autoUpdater.downloadUpdate()
|
||||||
return { success: true, state: this.getState() }
|
return { success: true, state: this.getState() }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const state = this.setState({
|
const state = this.setState({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: error instanceof Error ? error.message : String(error)
|
message: '下载更新失败',
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
})
|
})
|
||||||
return { success: false, state }
|
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') {
|
if (this.state.status !== 'downloaded') {
|
||||||
return { success: false, error: '更新包尚未下载完成' }
|
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()
|
autoUpdater.quitAndInstall()
|
||||||
return { success: true }
|
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 {
|
handleState(callback: (state: AppUpdateState) => void): () => void {
|
||||||
this.listeners.add(callback)
|
this.listeners.add(callback)
|
||||||
callback(this.getState())
|
callback(this.getState())
|
||||||
return () => this.listeners.delete(callback)
|
return () => this.listeners.delete(callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
private listeners = new Set<(state: AppUpdateState) => void>()
|
|
||||||
|
|
||||||
private setState(patch: Partial<AppUpdateState>): AppUpdateState {
|
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()
|
const state = this.getState()
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
if (this.broadcast) {
|
||||||
if (!window.isDestroyed()) window.webContents.send('app-update:state', state)
|
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)
|
for (const listener of this.listeners) listener(state)
|
||||||
return 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()
|
export const appUpdateService = new AppUpdateService()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const SKILL_RELATIVE_PATHS = [
|
|||||||
join('skill', 'tracememo-reader', 'SKILL.md'),
|
join('skill', 'tracememo-reader', 'SKILL.md'),
|
||||||
join('skill', 'wechatexplorer-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'
|
const SKILL_VERSION = 'v1.2'
|
||||||
|
|
||||||
type SkillResourceSource = 'development' | 'bundled'
|
type SkillResourceSource = 'development' | 'bundled'
|
||||||
|
|||||||
Vendored
+8
-2
@@ -66,7 +66,12 @@ import type {
|
|||||||
PersonalWechatRuntimeStatus
|
PersonalWechatRuntimeStatus
|
||||||
} from '../shared/personal-wechat-runtime'
|
} from '../shared/personal-wechat-runtime'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
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 { CacheSummary } from '../shared/cache'
|
||||||
import type { ExportRequest, ExportJobProgress, ExportResult } from '../shared/export'
|
import type { ExportRequest, ExportJobProgress, ExportResult } from '../shared/export'
|
||||||
import type {
|
import type {
|
||||||
@@ -173,7 +178,8 @@ declare global {
|
|||||||
getAppUpdateState: () => Promise<AppUpdateState>
|
getAppUpdateState: () => Promise<AppUpdateState>
|
||||||
checkAppUpdate: () => Promise<AppUpdateCheckResult>
|
checkAppUpdate: () => Promise<AppUpdateCheckResult>
|
||||||
downloadAppUpdate: () => Promise<AppUpdateCheckResult>
|
downloadAppUpdate: () => Promise<AppUpdateCheckResult>
|
||||||
installAppUpdate: () => Promise<{ success: boolean; error?: string }>
|
installAppUpdate: () => Promise<AppUpdateInstallResult>
|
||||||
|
openAppUpdateDownloadPage: () => Promise<AppUpdateOpenDownloadPageResult>
|
||||||
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
|
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
|
||||||
getCacheSummary: () => Promise<CacheSummary>
|
getCacheSummary: () => Promise<CacheSummary>
|
||||||
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all') => Promise<CacheSummary>
|
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all') => Promise<CacheSummary>
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const api = {
|
|||||||
checkAppUpdate: () => ipcRenderer.invoke('app-update:check'),
|
checkAppUpdate: () => ipcRenderer.invoke('app-update:check'),
|
||||||
downloadAppUpdate: () => ipcRenderer.invoke('app-update:download'),
|
downloadAppUpdate: () => ipcRenderer.invoke('app-update:download'),
|
||||||
installAppUpdate: () => ipcRenderer.invoke('app-update:install'),
|
installAppUpdate: () => ipcRenderer.invoke('app-update:install'),
|
||||||
|
openAppUpdateDownloadPage: () => ipcRenderer.invoke('app-update:openDownloadPage'),
|
||||||
onAppUpdateState: (callback: (state: AppUpdateState) => void) => {
|
onAppUpdateState: (callback: (state: AppUpdateState) => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, state: AppUpdateState): void =>
|
const listener = (_event: Electron.IpcRendererEvent, state: AppUpdateState): void =>
|
||||||
callback(state)
|
callback(state)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import type { SelectableReportTemplateId } from '../../shared/report-templates'
|
|||||||
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
||||||
import { runtimePlatform } from './utils/runtime-environment'
|
import { runtimePlatform } from './utils/runtime-environment'
|
||||||
import { useToast } from './components/ui'
|
import { useToast } from './components/ui'
|
||||||
|
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
||||||
|
|
||||||
const SIDEBAR_MIN_WIDTH = 260
|
const SIDEBAR_MIN_WIDTH = 260
|
||||||
const SIDEBAR_MAX_WIDTH = 380
|
const SIDEBAR_MAX_WIDTH = 380
|
||||||
@@ -72,7 +73,7 @@ interface SelfInfo {
|
|||||||
accountRoot: string
|
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 FIRST_USE_WELCOME_SEEN_KEY = 'wxe_first_use_welcome_seen'
|
||||||
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
||||||
const INITIAL_MESSAGE_COUNT = 20
|
const INITIAL_MESSAGE_COUNT = 20
|
||||||
@@ -1468,6 +1469,11 @@ function App(): React.ReactElement {
|
|||||||
setActivePage('settings')
|
setActivePage('settings')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openUpdateSettings = (): void => {
|
||||||
|
setSettingsCategory('about')
|
||||||
|
setActivePage('settings')
|
||||||
|
}
|
||||||
|
|
||||||
const openModelSettings = (): void => {
|
const openModelSettings = (): void => {
|
||||||
setSettingsCategory('ai-model')
|
setSettingsCategory('ai-model')
|
||||||
setActivePage('settings')
|
setActivePage('settings')
|
||||||
@@ -2113,6 +2119,7 @@ function App(): React.ReactElement {
|
|||||||
appearanceTheme={appearanceSettings.theme}
|
appearanceTheme={appearanceSettings.theme}
|
||||||
compactMode={appearanceSettings.compactMode}
|
compactMode={appearanceSettings.compactMode}
|
||||||
>
|
>
|
||||||
|
<AppUpdatePrompt onDownloadStart={openUpdateSettings} onNotice={setReportNotice} />
|
||||||
{showFirstUseWelcome && (
|
{showFirstUseWelcome && (
|
||||||
<FirstUseWelcome
|
<FirstUseWelcome
|
||||||
onDismiss={dismissFirstUseWelcome}
|
onDismiss={dismissFirstUseWelcome}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../../sh
|
|||||||
import { WINDOWS_VC_RUNTIME_DOWNLOAD_URL } from '../../../shared/windows-runtime'
|
import { WINDOWS_VC_RUNTIME_DOWNLOAD_URL } from '../../../shared/windows-runtime'
|
||||||
import { Button, IconButton, Input, Tabs, TabsList, TabsTrigger } from './ui'
|
import { Button, IconButton, Input, Tabs, TabsList, TabsTrigger } from './ui'
|
||||||
|
|
||||||
const GUIDE_URL =
|
const GUIDE_URL = 'https://github.com/Wxw-Gu/TraceMemo/blob/main/docs/user-guide/getting-started.md'
|
||||||
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
|
||||||
|
|
||||||
export type DatabaseConnectionMode = 'automatic' | 'manual'
|
export type DatabaseConnectionMode = 'automatic' | 'manual'
|
||||||
export type DatabaseConnectionStatusKind = 'normal' | 'success' | 'error'
|
export type DatabaseConnectionStatusKind = 'normal' | 'success' | 'error'
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ interface FirstUseWelcomeProps {
|
|||||||
onOpenAISettings: () => void
|
onOpenAISettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const GUIDE_URL =
|
const GUIDE_URL = 'https://github.com/Wxw-Gu/TraceMemo/blob/main/docs/user-guide/getting-started.md'
|
||||||
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
|
||||||
|
|
||||||
export function FirstUseWelcome({
|
export function FirstUseWelcome({
|
||||||
onDismiss,
|
onDismiss,
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ export const AGENT_INSTALL_TARGETS: { value: AgentInstallTarget; label: string }
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const githubLatestUrl =
|
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
|
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 { useMemo, useState } from 'react'
|
||||||
import type { AppUpdateState } from '../../../../../shared/app-update'
|
import { Button, Progress } from '../../../components/ui'
|
||||||
import { Button } 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`
|
const RELEASES_URL = `${REPOSITORY_URL}/releases`
|
||||||
|
|
||||||
function formatBytes(value?: number): string {
|
function formatDataSize(value?: number): string {
|
||||||
if (!value) return ''
|
if (!value) return '0 B'
|
||||||
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB/s`
|
if (value < 1024) return `${value} B`
|
||||||
return `${(value / 1024 / 1024).toFixed(1)} MB/s`
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
|
||||||
|
return `${(value / 1024 / 1024).toFixed(1)} MB`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AboutPage({
|
export function AboutPage({
|
||||||
@@ -16,47 +17,49 @@ export function AboutPage({
|
|||||||
}: {
|
}: {
|
||||||
onNotice: (message: string) => void
|
onNotice: (message: string) => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const [update, setUpdate] = useState<AppUpdateState>({
|
const update = useAppUpdateState()
|
||||||
status: 'idle',
|
|
||||||
currentVersion: '读取中...'
|
|
||||||
})
|
|
||||||
const [busy, setBusy] = useState(false)
|
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(() => {
|
const action = useMemo(() => {
|
||||||
if (update.status === 'downloaded') return '重启并安装'
|
if (update.status === 'available') {
|
||||||
if (update.status === 'available') return '下载更新'
|
return update.delivery === 'release-page' ? '前往下载' : '下载更新'
|
||||||
if (update.status === 'checking' || update.status === 'downloading') return '处理中...'
|
}
|
||||||
|
if (update.status === 'checking') return '正在检查...'
|
||||||
|
if (update.status === 'downloading') return '正在下载...'
|
||||||
|
if (update.status === 'installing') return '正在安装...'
|
||||||
|
if (update.status === 'error') return '重试'
|
||||||
return '检查更新'
|
return '检查更新'
|
||||||
}, [update.status])
|
}, [update.delivery, update.status])
|
||||||
|
|
||||||
const runUpdate = async (): Promise<void> => {
|
const runUpdate = async (): Promise<void> => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
if (update.status === 'downloaded') {
|
const result =
|
||||||
const result = await window.api.installAppUpdate()
|
update.status === 'available' && update.delivery === 'release-page'
|
||||||
if (!result.success) onNotice(result.error || '更新安装失败')
|
? await window.api.openAppUpdateDownloadPage()
|
||||||
} else if (update.status === 'available') {
|
: update.status === 'available'
|
||||||
await window.api.downloadAppUpdate()
|
? await window.api.downloadAppUpdate()
|
||||||
} else {
|
: await window.api.checkAppUpdate()
|
||||||
await window.api.checkAppUpdate()
|
if (result && !result.success) {
|
||||||
|
const message = 'state' in result ? result.state.message : result.error
|
||||||
|
onNotice(message || '更新操作失败')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
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 (
|
return (
|
||||||
<div className="settings-page">
|
<div className="settings-page">
|
||||||
<header className="settings-page-header">
|
<header className="settings-page-header">
|
||||||
@@ -82,29 +85,72 @@ export function AboutPage({
|
|||||||
<section className={`settings-card update-card status-${update.status}`}>
|
<section className={`settings-card update-card status-${update.status}`}>
|
||||||
<div className="update-card-copy">
|
<div className="update-card-copy">
|
||||||
<strong>
|
<strong>
|
||||||
{update.status === 'available' || update.status === 'downloaded'
|
|
||||||
? `发现 v${update.version}`
|
|
||||||
: update.message || '检查 GitHub Releases 获取最新版本'}
|
|
||||||
</strong>
|
|
||||||
<span>
|
|
||||||
{update.status === 'downloading'
|
{update.status === 'downloading'
|
||||||
? `正在下载 ${Math.round(update.percent || 0)}% · ${formatBytes(update.bytesPerSecond)}`
|
? `正在下载 v${update.version}`
|
||||||
: '会根据当前系统和 CPU 自动选择对应安装包,安装前会等待你的确认。'}
|
: update.status === 'downloaded'
|
||||||
</span>
|
? `v${update.version} 已准备完成`
|
||||||
{update.status === 'downloading' && (
|
: update.status === 'available'
|
||||||
<div className="update-progress">
|
? `发现新版本 v${update.version}`
|
||||||
<i style={{ width: `${update.percent || 0}%` }} />
|
: 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>
|
</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>
|
</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>
|
</section>
|
||||||
|
|
||||||
<h2 className="settings-section-heading">支持</h2>
|
<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 {
|
.about-links-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@@ -1,25 +1,47 @@
|
|||||||
|
export const APP_UPDATE_RELEASES_URL = 'https://github.com/Wxw-Gu/TraceMemo/releases/latest'
|
||||||
|
|
||||||
export type AppUpdateStatus =
|
export type AppUpdateStatus =
|
||||||
| 'idle'
|
| 'idle'
|
||||||
| 'checking'
|
| 'checking'
|
||||||
| 'available'
|
| 'available'
|
||||||
| 'not-available'
|
| 'up-to-date'
|
||||||
| 'downloading'
|
| 'downloading'
|
||||||
| 'downloaded'
|
| 'downloaded'
|
||||||
|
| 'installing'
|
||||||
| 'error'
|
| 'error'
|
||||||
| 'unsupported'
|
| 'unsupported'
|
||||||
|
|
||||||
|
export type AppUpdateCheckSource = 'startup' | 'manual'
|
||||||
|
export type AppUpdateDelivery = 'automatic' | 'release-page'
|
||||||
|
|
||||||
export interface AppUpdateState {
|
export interface AppUpdateState {
|
||||||
status: AppUpdateStatus
|
status: AppUpdateStatus
|
||||||
currentVersion: string
|
currentVersion: string
|
||||||
|
delivery: AppUpdateDelivery
|
||||||
version?: string
|
version?: string
|
||||||
percent?: number
|
percent?: number
|
||||||
transferred?: number
|
transferred?: number
|
||||||
total?: number
|
total?: number
|
||||||
bytesPerSecond?: number
|
bytesPerSecond?: number
|
||||||
message?: string
|
message?: string
|
||||||
|
error?: string
|
||||||
|
source?: AppUpdateCheckSource
|
||||||
|
isSimulation?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppUpdateCheckResult {
|
export interface AppUpdateCheckResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
state: AppUpdateState
|
state: AppUpdateState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppUpdateInstallResult {
|
||||||
|
success: boolean
|
||||||
|
simulated?: boolean
|
||||||
|
message?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppUpdateOpenDownloadPageResult {
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -64,7 +64,7 @@ describe('FirstUseWelcome', () => {
|
|||||||
expect(onOpenAISettings).toHaveBeenCalledOnce()
|
expect(onOpenAISettings).toHaveBeenCalledOnce()
|
||||||
expect(screen.getByRole('link', { name: '查看完整使用教程' })).toHaveAttribute(
|
expect(screen.getByRole('link', { name: '查看完整使用教程' })).toHaveAttribute(
|
||||||
'href',
|
'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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
Object.defineProperty(window, 'api', { configurable: true, value: api })
|
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.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.revealAppLog.mockResolvedValue(undefined)
|
||||||
api.getSettings.mockResolvedValue({ settings: { debugEnabled: false } })
|
api.getSettings.mockResolvedValue({ settings: { debugEnabled: false } })
|
||||||
api.setSettings.mockResolvedValue({ settings: { debugEnabled: true } })
|
api.setSettings.mockResolvedValue({ settings: { debugEnabled: true } })
|
||||||
|
|||||||
@@ -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 () => {
|
test('SETTINGS-03 database key actions keep destructive confirmation keyboard-safe', async () => {
|
||||||
const fixture = await launchTestApp()
|
const fixture = await launchTestApp()
|
||||||
const pageErrors: Error[] = []
|
const pageErrors: Error[] = []
|
||||||
|
|||||||
@@ -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 imageData = `data:image/png;base64,${fs.readFileSync(path.join(root, 'resources/icon.png')).toString('base64')}`
|
||||||
const voiceData = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
|
const voiceData = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
|
||||||
const configuredNow = Number(process.env.WXE_E2E_NOW_MS)
|
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 =
|
const fixtureNowMs =
|
||||||
Number.isFinite(configuredNow) && configuredNow > 0 ? configuredNow : Date.now()
|
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 [
|
for (const channel of [
|
||||||
'export:start',
|
'export:start',
|
||||||
@@ -817,8 +895,6 @@ for (const channel of [
|
|||||||
'image:selectDecoder',
|
'image:selectDecoder',
|
||||||
'image:openDecoderDownload',
|
'image:openDecoderDownload',
|
||||||
'app-update:check',
|
'app-update:check',
|
||||||
'app-update:download',
|
|
||||||
'app-update:install',
|
|
||||||
'agent-hub:clearLogs',
|
'agent-hub:clearLogs',
|
||||||
'agent-hub:startLogin',
|
'agent-hub:startLogin',
|
||||||
'agent-hub:cancelLogin',
|
'agent-hub:cancelLogin',
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export async function launchTestApp(
|
|||||||
largeContacts?: number
|
largeContacts?: number
|
||||||
corruptCache?: boolean
|
corruptCache?: boolean
|
||||||
aiFailure?: string
|
aiFailure?: string
|
||||||
|
updateSimulation?: boolean
|
||||||
|
unsignedMacUpdate?: boolean
|
||||||
now?: number
|
now?: number
|
||||||
appearanceTheme?: 'light' | 'dark'
|
appearanceTheme?: 'light' | 'dark'
|
||||||
stableUserData?: string
|
stableUserData?: string
|
||||||
@@ -49,6 +51,8 @@ export async function launchTestApp(
|
|||||||
WXE_E2E_LARGE_CONTACTS: String(options.largeContacts || 0),
|
WXE_E2E_LARGE_CONTACTS: String(options.largeContacts || 0),
|
||||||
WXE_E2E_CORRUPT_CACHE: options.corruptCache ? '1' : '0',
|
WXE_E2E_CORRUPT_CACHE: options.corruptCache ? '1' : '0',
|
||||||
WXE_E2E_AI_FAILURE: options.aiFailure || '',
|
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_NOW_MS: options.now ? String(options.now) : '',
|
||||||
WXE_E2E_APPEARANCE_THEME: options.appearanceTheme || 'light'
|
WXE_E2E_APPEARANCE_THEME: options.appearanceTheme || 'light'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
test('SETTINGS-03 database key controls visual @visual', async () => {
|
||||||
const fixture = await launchTestApp({ now: visualNow })
|
const fixture = await launchTestApp({ now: visualNow })
|
||||||
const pageErrors: Error[] = []
|
const pageErrors: Error[] = []
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user