mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
feat: 增加更新页
This commit is contained in:
@@ -12,13 +12,13 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/stars/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="GitHub stars" />
|
||||
<img src="https://img.shields.io/github/downloads/Wxw-Gu/WechatExplorer/total?style=for-the-badge" alt="GitHub downloads" />
|
||||
<img src="https://img.shields.io/github/v/release/Wxw-Gu/WechatExplorer?style=for-the-badge" alt="Latest release" />
|
||||
<img src="https://img.shields.io/github/stars/Wxw-Gu/TraceMemo?style=for-the-badge" alt="GitHub stars" />
|
||||
<img src="https://img.shields.io/github/downloads/Wxw-Gu/TraceMemo/total?style=for-the-badge" alt="GitHub downloads" />
|
||||
<img src="https://img.shields.io/github/v/release/Wxw-Gu/TraceMemo?style=for-the-badge" alt="Latest release" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Wxw-Gu/WechatExplorer/releases"><b>下载 TraceMemo</b></a>
|
||||
<a href="https://github.com/Wxw-Gu/TraceMemo/releases"><b>下载 TraceMemo</b></a>
|
||||
·
|
||||
<a href="./docs/user-guide/getting-started.md"><b>第一次使用</b></a>
|
||||
·
|
||||
@@ -309,7 +309,7 @@ flowchart LR
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载安装包。
|
||||
1. 从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载安装包。
|
||||
2. 启动 TraceMemo,按照“第一次使用”页面选择微信数据目录。
|
||||
3. 第一次使用请先点击“开始连接”,按页面提示准备连接组件并获取数据库密钥;只有已经有密钥的高级用户才需要“手动连接”。
|
||||
4. 连接成功后打开“档案”,确认联系人和聊天消息已经出现。
|
||||
@@ -447,7 +447,6 @@ TraceMemo 在支持微信 4.x 时,参考并使用了 **[WeFlow](https://github
|
||||
|
||||
也因此,这个项目随时可能继续折腾,也可能因为其他事情暂时搁置。如果你有想要的功能,可以提Issue;如果觉得现有实现不符合你的需求,也欢迎直接 Fork 后自己改。
|
||||
|
||||
|
||||
<p align="center">
|
||||
<b>TraceMemo(迹忆)</b>
|
||||
<br />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
provider: github
|
||||
owner: Wxw-Gu
|
||||
repo: TraceMemo
|
||||
releaseType: release
|
||||
updaterCacheDirName: tracememo-updater
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
## 2. 安装并启动
|
||||
|
||||
安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases) 下载。
|
||||
安装包统一从 [GitHub Releases](https://github.com/Wxw-Gu/TraceMemo/releases) 下载。
|
||||
|
||||
### Windows
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,25 +1,108 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { autoUpdater, type ProgressInfo } from 'electron-updater'
|
||||
import type { AppUpdateCheckResult, AppUpdateState } from '../../shared/app-update'
|
||||
import type {
|
||||
AppUpdateCheckResult,
|
||||
AppUpdateCheckSource,
|
||||
AppUpdateDelivery,
|
||||
AppUpdateInstallResult,
|
||||
AppUpdateOpenDownloadPageResult,
|
||||
AppUpdateState
|
||||
} from '../../shared/app-update'
|
||||
import { APP_UPDATE_RELEASES_URL } from '../../shared/app-update'
|
||||
import { isPackagedRuntime } from '../runtime-mode'
|
||||
|
||||
const SIMULATION_VERSION = '2.0.0'
|
||||
const SIMULATION_TOTAL_BYTES = 60 * 1024 * 1024
|
||||
const SIMULATION_PROGRESS = [0, 5, 12, 21, 33, 46, 58, 69, 78, 86, 93, 97, 100]
|
||||
const DEFAULT_SIMULATION_DURATION_MS = 8_000
|
||||
const STARTUP_CHECK_DELAY_MS = 1_500
|
||||
// Enable only after macOS packages use Developer ID Application signing and notarization.
|
||||
const MAC_AUTO_UPDATE_ENABLED = false
|
||||
|
||||
interface AppUpdateServiceOptions {
|
||||
currentVersion?: () => string
|
||||
packagedRuntime?: () => boolean
|
||||
simulationEnabled?: boolean
|
||||
simulationDurationMs?: number
|
||||
platform?: NodeJS.Platform
|
||||
macAutoUpdateEnabled?: boolean
|
||||
broadcast?: (state: AppUpdateState) => void
|
||||
}
|
||||
|
||||
export class AppUpdateService {
|
||||
private state: AppUpdateState = {
|
||||
status: 'idle',
|
||||
currentVersion: app.getVersion()
|
||||
}
|
||||
private readonly currentVersion: () => string
|
||||
private readonly packagedRuntime: () => boolean
|
||||
private readonly simulationEnabled: boolean
|
||||
private readonly simulationDurationMs: number
|
||||
private readonly delivery: AppUpdateDelivery
|
||||
private readonly broadcast?: (state: AppUpdateState) => void
|
||||
private state: AppUpdateState
|
||||
private listeners = new Set<(state: AppUpdateState) => void>()
|
||||
private activeCheckSource: AppUpdateCheckSource = 'manual'
|
||||
private startupCheckScheduled = false
|
||||
private checkInFlight?: Promise<AppUpdateCheckResult>
|
||||
private downloadInFlight?: Promise<AppUpdateCheckResult>
|
||||
|
||||
constructor(options: AppUpdateServiceOptions = {}) {
|
||||
this.currentVersion = options.currentVersion || (() => app.getVersion())
|
||||
this.packagedRuntime = options.packagedRuntime || isPackagedRuntime
|
||||
this.simulationEnabled =
|
||||
options.simulationEnabled ??
|
||||
(!app.isPackaged && process.env['TRACEMEMO_UPDATE_SIMULATION'] === 'true')
|
||||
const configuredDuration = Number(
|
||||
options.simulationDurationMs ?? process.env['TRACEMEMO_UPDATE_SIMULATION_DURATION_MS']
|
||||
)
|
||||
this.simulationDurationMs = Number.isFinite(configuredDuration)
|
||||
? Math.max(1_000, configuredDuration)
|
||||
: DEFAULT_SIMULATION_DURATION_MS
|
||||
const platform = options.platform || process.platform
|
||||
const macAutoUpdateEnabled = options.macAutoUpdateEnabled ?? MAC_AUTO_UPDATE_ENABLED
|
||||
this.delivery =
|
||||
!this.simulationEnabled && platform === 'darwin' && !macAutoUpdateEnabled
|
||||
? 'release-page'
|
||||
: 'automatic'
|
||||
this.broadcast = options.broadcast
|
||||
this.state = {
|
||||
status: 'idle',
|
||||
currentVersion: this.currentVersion(),
|
||||
delivery: this.delivery,
|
||||
isSimulation: this.simulationEnabled
|
||||
}
|
||||
|
||||
constructor() {
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
autoUpdater.on('checking-for-update', () => this.setState({ status: 'checking' }))
|
||||
autoUpdater.autoInstallOnAppQuit = this.delivery === 'automatic'
|
||||
autoUpdater.on('checking-for-update', () =>
|
||||
this.setState({
|
||||
status: 'checking',
|
||||
source: this.activeCheckSource,
|
||||
message: '正在检查更新',
|
||||
version: undefined,
|
||||
percent: undefined,
|
||||
transferred: undefined,
|
||||
total: undefined,
|
||||
bytesPerSecond: undefined,
|
||||
error: undefined
|
||||
})
|
||||
)
|
||||
autoUpdater.on('update-available', (info) =>
|
||||
this.setState({ status: 'available', version: info.version, message: '发现新版本' })
|
||||
this.setState({
|
||||
status: 'available',
|
||||
source: this.activeCheckSource,
|
||||
version: info.version,
|
||||
message: '发现新版本',
|
||||
error: undefined
|
||||
})
|
||||
)
|
||||
autoUpdater.on('update-not-available', () =>
|
||||
this.setState({ status: 'not-available', message: '当前已是最新版本' })
|
||||
this.setState({
|
||||
status: 'up-to-date',
|
||||
source: this.activeCheckSource,
|
||||
message: '已是最新版本',
|
||||
error: undefined
|
||||
})
|
||||
)
|
||||
autoUpdater.on('download-progress', (progress: ProgressInfo) =>
|
||||
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
|
||||
if (this.delivery !== 'automatic') return
|
||||
this.setState({
|
||||
status: 'downloading',
|
||||
percent: progress.percent,
|
||||
@@ -27,94 +110,222 @@ export class AppUpdateService {
|
||||
total: progress.total,
|
||||
bytesPerSecond: progress.bytesPerSecond
|
||||
})
|
||||
)
|
||||
autoUpdater.on('update-downloaded', (info) =>
|
||||
})
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
if (this.delivery !== 'automatic') return
|
||||
this.setState({
|
||||
status: 'downloaded',
|
||||
version: info.version,
|
||||
version: info.version || this.state.version,
|
||||
percent: 100,
|
||||
transferred: this.state.total,
|
||||
message: '更新已下载'
|
||||
})
|
||||
)
|
||||
})
|
||||
autoUpdater.on('error', (error) =>
|
||||
this.setState({ status: 'error', message: error.message || '更新失败' })
|
||||
this.setState({
|
||||
status: 'error',
|
||||
message: '检查更新失败',
|
||||
error: error.message || '更新失败'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
getState(): AppUpdateState {
|
||||
return { ...this.state, currentVersion: app.getVersion() }
|
||||
return { ...this.state, currentVersion: this.currentVersion() }
|
||||
}
|
||||
|
||||
async check(): Promise<AppUpdateCheckResult> {
|
||||
if (!isPackagedRuntime()) {
|
||||
scheduleStartupCheck(delayMs = STARTUP_CHECK_DELAY_MS): void {
|
||||
if (this.startupCheckScheduled) return
|
||||
if (!this.simulationEnabled && !this.packagedRuntime()) return
|
||||
this.startupCheckScheduled = true
|
||||
setTimeout(() => void this.check('startup'), Math.max(0, delayMs))
|
||||
}
|
||||
|
||||
check(source: AppUpdateCheckSource = 'manual'): Promise<AppUpdateCheckResult> {
|
||||
if (this.checkInFlight) return this.checkInFlight
|
||||
this.checkInFlight = this.runCheck(source).finally(() => {
|
||||
this.checkInFlight = undefined
|
||||
})
|
||||
return this.checkInFlight
|
||||
}
|
||||
|
||||
private async runCheck(source: AppUpdateCheckSource): Promise<AppUpdateCheckResult> {
|
||||
this.activeCheckSource = source
|
||||
if (this.simulationEnabled) {
|
||||
this.setState({
|
||||
status: 'checking',
|
||||
source,
|
||||
isSimulation: true,
|
||||
message: '正在检查模拟更新',
|
||||
version: undefined,
|
||||
percent: undefined,
|
||||
transferred: undefined,
|
||||
total: undefined,
|
||||
bytesPerSecond: undefined,
|
||||
error: undefined
|
||||
})
|
||||
await this.delay(350)
|
||||
const state = this.setState({
|
||||
status: 'available',
|
||||
source,
|
||||
version: SIMULATION_VERSION,
|
||||
message: '发现模拟更新',
|
||||
error: undefined
|
||||
})
|
||||
return { success: true, state }
|
||||
}
|
||||
|
||||
if (!this.packagedRuntime()) {
|
||||
const state = this.setState({
|
||||
status: 'unsupported',
|
||||
message: '开发模式不执行安装包更新,请在正式安装包中检查更新'
|
||||
source,
|
||||
message: '开发模式不执行安装包更新,请在正式安装包中检查更新',
|
||||
error: undefined
|
||||
})
|
||||
return { success: false, state }
|
||||
}
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates()
|
||||
if (result?.updateInfo.version) {
|
||||
this.setState({
|
||||
status: 'available',
|
||||
version: result.updateInfo.version,
|
||||
message: '发现新版本'
|
||||
})
|
||||
}
|
||||
await autoUpdater.checkForUpdates()
|
||||
return { success: true, state: this.getState() }
|
||||
} catch (error) {
|
||||
const state = this.setState({
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
message: '检查更新失败',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return { success: false, state }
|
||||
}
|
||||
}
|
||||
|
||||
async download(): Promise<AppUpdateCheckResult> {
|
||||
if (!isPackagedRuntime()) {
|
||||
const state = this.setState({ status: 'unsupported', message: '开发模式不能下载更新' })
|
||||
download(): Promise<AppUpdateCheckResult> {
|
||||
if (this.downloadInFlight) return this.downloadInFlight
|
||||
this.downloadInFlight = this.runDownload().finally(() => {
|
||||
this.downloadInFlight = undefined
|
||||
})
|
||||
return this.downloadInFlight
|
||||
}
|
||||
|
||||
private async runDownload(): Promise<AppUpdateCheckResult> {
|
||||
if (this.simulationEnabled) return this.simulateDownload()
|
||||
|
||||
if (this.delivery === 'release-page') {
|
||||
const state = this.setState({
|
||||
status: 'available',
|
||||
message: '有新版本可用,前往 GitHub 下载最新版本。'
|
||||
})
|
||||
return { success: false, state }
|
||||
}
|
||||
|
||||
if (!this.packagedRuntime()) {
|
||||
const state = this.setState({
|
||||
status: 'unsupported',
|
||||
message: '开发模式不能下载更新',
|
||||
error: undefined
|
||||
})
|
||||
return { success: false, state }
|
||||
}
|
||||
try {
|
||||
this.setState({ status: 'downloading', percent: 0 })
|
||||
this.setState({ status: 'downloading', percent: 0, error: undefined })
|
||||
await autoUpdater.downloadUpdate()
|
||||
return { success: true, state: this.getState() }
|
||||
} catch (error) {
|
||||
const state = this.setState({
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
message: '下载更新失败',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return { success: false, state }
|
||||
}
|
||||
}
|
||||
|
||||
install(): { success: boolean; error?: string } {
|
||||
install(): AppUpdateInstallResult {
|
||||
if (this.delivery !== 'automatic') {
|
||||
return { success: false, error: '当前 macOS 版本不支持自动安装,请前往 GitHub 下载更新。' }
|
||||
}
|
||||
if (this.state.status !== 'downloaded') {
|
||||
return { success: false, error: '更新包尚未下载完成' }
|
||||
}
|
||||
if (this.simulationEnabled) {
|
||||
const message = '开发模拟模式:更新安装动作已模拟,未实际退出应用。'
|
||||
this.setState({ status: 'downloaded', message })
|
||||
return { success: true, simulated: true, message }
|
||||
}
|
||||
this.setState({ status: 'installing', message: '正在重启并安装更新' })
|
||||
autoUpdater.quitAndInstall()
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
async openDownloadPage(): Promise<AppUpdateOpenDownloadPageResult> {
|
||||
try {
|
||||
await shell.openExternal(APP_UPDATE_RELEASES_URL)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
handleState(callback: (state: AppUpdateState) => void): () => void {
|
||||
this.listeners.add(callback)
|
||||
callback(this.getState())
|
||||
return () => this.listeners.delete(callback)
|
||||
}
|
||||
|
||||
private listeners = new Set<(state: AppUpdateState) => void>()
|
||||
|
||||
private setState(patch: Partial<AppUpdateState>): AppUpdateState {
|
||||
this.state = { ...this.state, ...patch, currentVersion: app.getVersion() }
|
||||
this.state = { ...this.state, ...patch, currentVersion: this.currentVersion() }
|
||||
const state = this.getState()
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send('app-update:state', state)
|
||||
if (this.broadcast) {
|
||||
this.broadcast(state)
|
||||
} else {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send('app-update:state', state)
|
||||
}
|
||||
}
|
||||
for (const listener of this.listeners) listener(state)
|
||||
return state
|
||||
}
|
||||
|
||||
private async simulateDownload(): Promise<AppUpdateCheckResult> {
|
||||
const stepDuration = this.simulationDurationMs / (SIMULATION_PROGRESS.length - 1)
|
||||
let previousTransferred = 0
|
||||
this.setState({
|
||||
status: 'downloading',
|
||||
version: SIMULATION_VERSION,
|
||||
percent: 0,
|
||||
transferred: 0,
|
||||
total: SIMULATION_TOTAL_BYTES,
|
||||
bytesPerSecond: 0,
|
||||
message: '正在下载模拟更新'
|
||||
})
|
||||
|
||||
for (const percent of SIMULATION_PROGRESS.slice(1)) {
|
||||
await this.delay(stepDuration)
|
||||
const transferred = Math.round((SIMULATION_TOTAL_BYTES * percent) / 100)
|
||||
const bytesPerSecond = Math.round(((transferred - previousTransferred) * 1000) / stepDuration)
|
||||
previousTransferred = transferred
|
||||
this.setState({
|
||||
status: 'downloading',
|
||||
percent,
|
||||
transferred,
|
||||
total: SIMULATION_TOTAL_BYTES,
|
||||
bytesPerSecond
|
||||
})
|
||||
}
|
||||
|
||||
const state = this.setState({
|
||||
status: 'downloaded',
|
||||
version: SIMULATION_VERSION,
|
||||
percent: 100,
|
||||
transferred: SIMULATION_TOTAL_BYTES,
|
||||
total: SIMULATION_TOTAL_BYTES,
|
||||
bytesPerSecond: undefined,
|
||||
message: '模拟更新已下载'
|
||||
})
|
||||
return { success: true, state }
|
||||
}
|
||||
|
||||
private delay(durationMs: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, durationMs))
|
||||
}
|
||||
}
|
||||
|
||||
export const appUpdateService = new AppUpdateService()
|
||||
|
||||
@@ -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'
|
||||
|
||||
Vendored
+8
-2
@@ -66,7 +66,12 @@ import type {
|
||||
PersonalWechatRuntimeStatus
|
||||
} from '../shared/personal-wechat-runtime'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
import type { AppUpdateCheckResult, AppUpdateState } from '../shared/app-update'
|
||||
import type {
|
||||
AppUpdateCheckResult,
|
||||
AppUpdateInstallResult,
|
||||
AppUpdateOpenDownloadPageResult,
|
||||
AppUpdateState
|
||||
} from '../shared/app-update'
|
||||
import type { CacheSummary } from '../shared/cache'
|
||||
import type { ExportRequest, ExportJobProgress, ExportResult } from '../shared/export'
|
||||
import type {
|
||||
@@ -173,7 +178,8 @@ declare global {
|
||||
getAppUpdateState: () => Promise<AppUpdateState>
|
||||
checkAppUpdate: () => Promise<AppUpdateCheckResult>
|
||||
downloadAppUpdate: () => Promise<AppUpdateCheckResult>
|
||||
installAppUpdate: () => Promise<{ success: boolean; error?: string }>
|
||||
installAppUpdate: () => Promise<AppUpdateInstallResult>
|
||||
openAppUpdateDownloadPage: () => Promise<AppUpdateOpenDownloadPageResult>
|
||||
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
|
||||
getCacheSummary: () => Promise<CacheSummary>
|
||||
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all') => Promise<CacheSummary>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -39,6 +39,7 @@ import type { SelectableReportTemplateId } from '../../shared/report-templates'
|
||||
import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
||||
import { runtimePlatform } from './utils/runtime-environment'
|
||||
import { useToast } from './components/ui'
|
||||
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 260
|
||||
const SIDEBAR_MAX_WIDTH = 380
|
||||
@@ -72,7 +73,7 @@ interface SelfInfo {
|
||||
accountRoot: string
|
||||
}
|
||||
|
||||
const MAC_KEY_FAQ_URL = 'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/mac-disable-sip.md'
|
||||
const MAC_KEY_FAQ_URL = 'https://github.com/Wxw-Gu/TraceMemo/blob/main/docs/mac-disable-sip.md'
|
||||
const FIRST_USE_WELCOME_SEEN_KEY = 'wxe_first_use_welcome_seen'
|
||||
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
||||
const INITIAL_MESSAGE_COUNT = 20
|
||||
@@ -1468,6 +1469,11 @@ function App(): React.ReactElement {
|
||||
setActivePage('settings')
|
||||
}
|
||||
|
||||
const openUpdateSettings = (): void => {
|
||||
setSettingsCategory('about')
|
||||
setActivePage('settings')
|
||||
}
|
||||
|
||||
const openModelSettings = (): void => {
|
||||
setSettingsCategory('ai-model')
|
||||
setActivePage('settings')
|
||||
@@ -2113,6 +2119,7 @@ function App(): React.ReactElement {
|
||||
appearanceTheme={appearanceSettings.theme}
|
||||
compactMode={appearanceSettings.compactMode}
|
||||
>
|
||||
<AppUpdatePrompt onDownloadStart={openUpdateSettings} onNotice={setReportNotice} />
|
||||
{showFirstUseWelcome && (
|
||||
<FirstUseWelcome
|
||||
onDismiss={dismissFirstUseWelcome}
|
||||
|
||||
@@ -3,8 +3,7 @@ import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../../sh
|
||||
import { WINDOWS_VC_RUNTIME_DOWNLOAD_URL } from '../../../shared/windows-runtime'
|
||||
import { Button, IconButton, Input, Tabs, TabsList, TabsTrigger } from './ui'
|
||||
|
||||
const GUIDE_URL =
|
||||
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
||||
const GUIDE_URL = 'https://github.com/Wxw-Gu/TraceMemo/blob/main/docs/user-guide/getting-started.md'
|
||||
|
||||
export type DatabaseConnectionMode = 'automatic' | 'manual'
|
||||
export type DatabaseConnectionStatusKind = 'normal' | 'success' | 'error'
|
||||
|
||||
@@ -8,8 +8,7 @@ interface FirstUseWelcomeProps {
|
||||
onOpenAISettings: () => void
|
||||
}
|
||||
|
||||
const GUIDE_URL =
|
||||
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
||||
const GUIDE_URL = 'https://github.com/Wxw-Gu/TraceMemo/blob/main/docs/user-guide/getting-started.md'
|
||||
|
||||
export function FirstUseWelcome({
|
||||
onDismiss,
|
||||
|
||||
@@ -12,6 +12,6 @@ export const AGENT_INSTALL_TARGETS: { value: AgentInstallTarget; label: string }
|
||||
]
|
||||
|
||||
export const githubLatestUrl =
|
||||
'https://github.com/Wxw-Gu/WechatExplorer/tree/main/docs/skill/tracememo-reader'
|
||||
'https://github.com/Wxw-Gu/TraceMemo/tree/main/docs/skill/tracememo-reader'
|
||||
|
||||
export const githubVersionedInstallUrl: string | null = null
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button
|
||||
} from '../../components/ui'
|
||||
import { useAppUpdateState } from './useAppUpdateState'
|
||||
|
||||
export function AppUpdatePrompt({
|
||||
onDownloadStart,
|
||||
onNotice
|
||||
}: {
|
||||
onDownloadStart: () => void
|
||||
onNotice: (message: string) => void
|
||||
}): React.ReactElement {
|
||||
const update = useAppUpdateState()
|
||||
const [dismissedVersion, setDismissedVersion] = useState<string>()
|
||||
const shouldPrompt =
|
||||
update.status === 'available' &&
|
||||
update.source === 'startup' &&
|
||||
Boolean(update.version) &&
|
||||
update.version !== dismissedVersion
|
||||
|
||||
const dismiss = (): void => setDismissedVersion(update.version)
|
||||
|
||||
const continueUpdate = async (): Promise<void> => {
|
||||
dismiss()
|
||||
if (update.delivery === 'release-page') {
|
||||
const result = await window.api.openAppUpdateDownloadPage()
|
||||
if (!result.success) onNotice(result.error || '无法打开下载页面')
|
||||
return
|
||||
}
|
||||
onDownloadStart()
|
||||
const result = await window.api.downloadAppUpdate()
|
||||
if (result && !result.success) onNotice(result.state.message || '更新下载失败')
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={shouldPrompt}>
|
||||
<AlertDialogContent aria-label="发现新版本">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>发现新版本 v{update.version}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{update.isSimulation
|
||||
? '开发模拟更新已准备好。'
|
||||
: update.delivery === 'release-page'
|
||||
? '有新版本可用,前往 GitHub 下载最新版本。'
|
||||
: '是否立即下载?'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button variant="outline" onClick={dismiss}>
|
||||
取消
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button onClick={() => void continueUpdate()}>
|
||||
{update.delivery === 'release-page' ? '前往下载' : '立即下载'}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { AppUpdateState } from '../../../../shared/app-update'
|
||||
|
||||
const INITIAL_UPDATE_STATE: AppUpdateState = {
|
||||
status: 'idle',
|
||||
currentVersion: '读取中...',
|
||||
delivery: 'automatic'
|
||||
}
|
||||
|
||||
export function useAppUpdateState(): AppUpdateState {
|
||||
const [state, setState] = useState<AppUpdateState>(INITIAL_UPDATE_STATE)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api.getAppUpdateState().then((nextState) => {
|
||||
if (active) setState(nextState)
|
||||
})
|
||||
const unsubscribe = window.api.onAppUpdateState((nextState) => {
|
||||
if (active) setState(nextState)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { AppUpdateState } from '../../../../../shared/app-update'
|
||||
import { Button } from '../../../components/ui'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, Progress } from '../../../components/ui'
|
||||
import { useAppUpdateState } from '../../app-update/useAppUpdateState'
|
||||
|
||||
const REPOSITORY_URL = 'https://github.com/Wxw-Gu/WechatExplorer'
|
||||
const REPOSITORY_URL = 'https://github.com/Wxw-Gu/TraceMemo'
|
||||
const RELEASES_URL = `${REPOSITORY_URL}/releases`
|
||||
|
||||
function formatBytes(value?: number): string {
|
||||
if (!value) return ''
|
||||
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB/s`
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB/s`
|
||||
function formatDataSize(value?: number): string {
|
||||
if (!value) return '0 B'
|
||||
if (value < 1024) return `${value} B`
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function AboutPage({
|
||||
@@ -16,47 +17,49 @@ export function AboutPage({
|
||||
}: {
|
||||
onNotice: (message: string) => void
|
||||
}): React.ReactElement {
|
||||
const [update, setUpdate] = useState<AppUpdateState>({
|
||||
status: 'idle',
|
||||
currentVersion: '读取中...'
|
||||
})
|
||||
const update = useAppUpdateState()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api.getAppUpdateState().then((state) => active && setUpdate(state))
|
||||
const unsubscribe = window.api.onAppUpdateState((state) => {
|
||||
if (active) setUpdate(state)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const action = useMemo(() => {
|
||||
if (update.status === 'downloaded') return '重启并安装'
|
||||
if (update.status === 'available') return '下载更新'
|
||||
if (update.status === 'checking' || update.status === 'downloading') return '处理中...'
|
||||
if (update.status === 'available') {
|
||||
return update.delivery === 'release-page' ? '前往下载' : '下载更新'
|
||||
}
|
||||
if (update.status === 'checking') return '正在检查...'
|
||||
if (update.status === 'downloading') return '正在下载...'
|
||||
if (update.status === 'installing') return '正在安装...'
|
||||
if (update.status === 'error') return '重试'
|
||||
return '检查更新'
|
||||
}, [update.status])
|
||||
}, [update.delivery, update.status])
|
||||
|
||||
const runUpdate = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (update.status === 'downloaded') {
|
||||
const result = await window.api.installAppUpdate()
|
||||
if (!result.success) onNotice(result.error || '更新安装失败')
|
||||
} else if (update.status === 'available') {
|
||||
await window.api.downloadAppUpdate()
|
||||
} else {
|
||||
await window.api.checkAppUpdate()
|
||||
const result =
|
||||
update.status === 'available' && update.delivery === 'release-page'
|
||||
? await window.api.openAppUpdateDownloadPage()
|
||||
: update.status === 'available'
|
||||
? await window.api.downloadAppUpdate()
|
||||
: await window.api.checkAppUpdate()
|
||||
if (result && !result.success) {
|
||||
const message = 'state' in result ? result.state.message : result.error
|
||||
onNotice(message || '更新操作失败')
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const installUpdate = async (): Promise<void> => {
|
||||
const result = await window.api.installAppUpdate()
|
||||
if (!result.success) {
|
||||
onNotice(result.error || '更新安装失败')
|
||||
return
|
||||
}
|
||||
if (result.message) onNotice(result.message)
|
||||
}
|
||||
|
||||
const percent = Math.max(0, Math.min(100, Math.round(update.percent || 0)))
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<header className="settings-page-header">
|
||||
@@ -82,29 +85,72 @@ export function AboutPage({
|
||||
<section className={`settings-card update-card status-${update.status}`}>
|
||||
<div className="update-card-copy">
|
||||
<strong>
|
||||
{update.status === 'available' || update.status === 'downloaded'
|
||||
? `发现 v${update.version}`
|
||||
: update.message || '检查 GitHub Releases 获取最新版本'}
|
||||
</strong>
|
||||
<span>
|
||||
{update.status === 'downloading'
|
||||
? `正在下载 ${Math.round(update.percent || 0)}% · ${formatBytes(update.bytesPerSecond)}`
|
||||
: '会根据当前系统和 CPU 自动选择对应安装包,安装前会等待你的确认。'}
|
||||
</span>
|
||||
{update.status === 'downloading' && (
|
||||
<div className="update-progress">
|
||||
<i style={{ width: `${update.percent || 0}%` }} />
|
||||
? `正在下载 v${update.version}`
|
||||
: update.status === 'downloaded'
|
||||
? `v${update.version} 已准备完成`
|
||||
: update.status === 'available'
|
||||
? `发现新版本 v${update.version}`
|
||||
: update.message || '检查 GitHub Releases 获取最新版本'}
|
||||
</strong>
|
||||
{update.status === 'downloading' ? (
|
||||
<div className="grid gap-2 pt-1">
|
||||
<span className="text-sm font-semibold text-foreground">{percent}%</span>
|
||||
<Progress value={percent} aria-label={`更新下载进度 ${percent}%`} />
|
||||
<div className="flex justify-between gap-4 text-xs">
|
||||
<span>
|
||||
{formatDataSize(update.transferred)} / {formatDataSize(update.total)}
|
||||
</span>
|
||||
<span>{formatDataSize(update.bytesPerSecond)}/s</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span>
|
||||
{update.status === 'downloaded' ? (
|
||||
`更新将在重启 TraceMemo 后生效。${update.isSimulation ? ' 当前为开发模拟模式。' : ''}`
|
||||
) : update.status === 'available' && update.delivery === 'release-page' ? (
|
||||
<span className="grid gap-1">
|
||||
<span>当前版本:v{update.currentVersion}</span>
|
||||
<span>最新版本:v{update.version}</span>
|
||||
</span>
|
||||
) : (
|
||||
'会根据当前系统和 CPU 自动选择对应安装包,安装前会等待你的确认。'
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{update.status === 'downloaded' ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onNotice('更新已保留,稍后可在此安装')}
|
||||
>
|
||||
稍后
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void installUpdate()}>
|
||||
立即重启更新
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
busy ||
|
||||
update.status === 'checking' ||
|
||||
update.status === 'downloading' ||
|
||||
update.status === 'installing'
|
||||
}
|
||||
aria-busy={
|
||||
busy || update.status === 'checking' || update.status === 'downloading'
|
||||
}
|
||||
onClick={() => void runUpdate()}
|
||||
>
|
||||
{action}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy || update.status === 'checking' || update.status === 'downloading'}
|
||||
aria-busy={busy || update.status === 'checking' || update.status === 'downloading'}
|
||||
onClick={() => void runUpdate()}
|
||||
>
|
||||
{action}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<h2 className="settings-section-heading">支持</h2>
|
||||
|
||||
@@ -629,21 +629,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.update-progress {
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--wxex-border);
|
||||
|
||||
i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--wxex-brand);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.about-links-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(screen.getByRole('link', { name: '查看完整使用教程' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
|
||||
'https://github.com/Wxw-Gu/TraceMemo/blob/main/docs/user-guide/getting-started.md'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,9 +20,16 @@ describe('basic settings pages', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: api })
|
||||
api.getAppUpdateState.mockResolvedValue({ status: 'idle', currentVersion: '2.2.2' })
|
||||
api.getAppUpdateState.mockResolvedValue({
|
||||
status: 'idle',
|
||||
currentVersion: '2.2.2',
|
||||
delivery: 'automatic'
|
||||
})
|
||||
api.onAppUpdateState.mockReturnValue(vi.fn())
|
||||
api.checkAppUpdate.mockResolvedValue(undefined)
|
||||
api.checkAppUpdate.mockResolvedValue({
|
||||
success: true,
|
||||
state: { status: 'up-to-date', currentVersion: '2.2.2' }
|
||||
})
|
||||
api.revealAppLog.mockResolvedValue(undefined)
|
||||
api.getSettings.mockResolvedValue({ settings: { debugEnabled: false } })
|
||||
api.setSettings.mockResolvedValue({ settings: { debugEnabled: true } })
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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