From 1c26d8dac2745cea0edd84d0855ffe70e0a7cb82 Mon Sep 17 00:00:00 2001 From: ILoveBingLu Date: Sat, 4 Apr 2026 00:04:14 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E4=BC=98=E5=8C=96=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=8A=9F=E8=83=BD=E4=B8=8EUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 70 ++++++++++++---- electron/main.ts | 19 ++++- electron/preload.ts | 7 +- src/App.scss | 114 ++++++++++++++++++++++---- src/App.tsx | 148 ++++++++++++++++++++++++++++------ src/pages/SettingsPage.scss | 58 ++++++++----- src/pages/SettingsPage.tsx | 97 +++++++++++++++++++--- src/types/electron.d.ts | 9 ++- 8 files changed, 437 insertions(+), 85 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d23e01..a7d2aac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,7 +125,7 @@ jobs: run: | $version = "${{ needs.prepare-meta.outputs.version }}" $installer = "release/CipherTalk-$version-Setup.exe" - $blockmaps = Get-ChildItem "release" -Filter "*.blockmap" -ErrorAction SilentlyContinue + $blockmap = "release/CipherTalk-$version-Setup.exe.blockmap" if (-not (Test-Path $installer)) { Write-Error "Installer not found: $installer" exit 1 @@ -139,8 +139,28 @@ jobs: Write-Error "latest.yml should contain exactly one size entry, found $($sizeLines.Count)" exit 1 } - if (-not $blockmaps -or $blockmaps.Count -lt 1) { - Write-Error "blockmap not found" + if (-not (Test-Path $blockmap)) { + Write-Error "blockmap not found: $blockmap" + exit 1 + } + + $latestYml = Get-Content "release/latest.yml" -Raw + $shaMatch = [regex]::Match($latestYml, '(?m)^sha512:\s*(.+)$') + if (-not $shaMatch.Success) { + Write-Error "sha512 not found in latest.yml" + exit 1 + } + + $hashHex = (Get-FileHash -Algorithm SHA512 $installer).Hash + $hashBytes = [byte[]]::new($hashHex.Length / 2) + for ($i = 0; $i -lt $hashHex.Length; $i += 2) { + $hashBytes[$i / 2] = [Convert]::ToByte($hashHex.Substring($i, 2), 16) + } + $actualSha512 = [Convert]::ToBase64String($hashBytes) + $expectedSha512 = $shaMatch.Groups[1].Value.Trim() + + if ($actualSha512 -ne $expectedSha512) { + Write-Error "latest.yml sha512 does not match installer" exit 1 } @@ -151,7 +171,7 @@ jobs: path: | release/CipherTalk-${{ needs.prepare-meta.outputs.version }}-Setup.exe release/latest.yml - release/*.blockmap + release/CipherTalk-${{ needs.prepare-meta.outputs.version }}-Setup.exe.blockmap if-no-files-found: error generate-release-body: @@ -238,7 +258,7 @@ jobs: run: | $version = "${{ needs.prepare-meta.outputs.version }}" $installer = "release/CipherTalk-$version-Setup.exe" - $blockmaps = Get-ChildItem "release" -Filter "*.blockmap" -ErrorAction SilentlyContinue + $blockmap = "release/CipherTalk-$version-Setup.exe.blockmap" if (-not (Test-Path $installer)) { Write-Error "Installer not found: $installer" exit 1 @@ -260,8 +280,28 @@ jobs: Write-Error "release-body.md not found" exit 1 } - if (-not $blockmaps -or $blockmaps.Count -lt 1) { - Write-Error "blockmap not found" + if (-not (Test-Path $blockmap)) { + Write-Error "blockmap not found: $blockmap" + exit 1 + } + + $latestYml = Get-Content "release/latest.yml" -Raw + $shaMatch = [regex]::Match($latestYml, '(?m)^sha512:\s*(.+)$') + if (-not $shaMatch.Success) { + Write-Error "sha512 not found in latest.yml" + exit 1 + } + + $hashHex = (Get-FileHash -Algorithm SHA512 $installer).Hash + $hashBytes = [byte[]]::new($hashHex.Length / 2) + for ($i = 0; $i -lt $hashHex.Length; $i += 2) { + $hashBytes[$i / 2] = [Convert]::ToByte($hashHex.Substring($i, 2), 16) + } + $actualSha512 = [Convert]::ToBase64String($hashBytes) + $expectedSha512 = $shaMatch.Groups[1].Value.Trim() + + if ($actualSha512 -ne $expectedSha512) { + Write-Error "latest.yml sha512 does not match installer" exit 1 } @@ -272,11 +312,12 @@ jobs: name: CipherTalk v${{ needs.prepare-meta.outputs.version }} body_path: release/release-body.md fail_on_unmatched_files: false + overwrite_files: false files: | release/CipherTalk-${{ needs.prepare-meta.outputs.version }}-Setup.exe release/latest.yml release/force-update.json - release/*.blockmap + release/CipherTalk-${{ needs.prepare-meta.outputs.version }}-Setup.exe.blockmap mirror-r2: runs-on: windows-latest @@ -325,10 +366,11 @@ jobs: $bucket = "s3://$($env:R2_BUCKET_NAME)" $version = "${{ needs.prepare-meta.outputs.version }}" $currentInstaller = "CipherTalk-$version-Setup.exe" - $currentBlockmaps = Get-ChildItem "release" -Filter "*.blockmap" -ErrorAction SilentlyContinue + $currentBlockmap = "CipherTalk-$version-Setup.exe.blockmap" + $currentBlockmapPath = "release/$currentBlockmap" - if (-not $currentBlockmaps -or $currentBlockmaps.Count -lt 1) { - Write-Error "blockmap not found" + if (-not (Test-Path $currentBlockmapPath)) { + Write-Error "blockmap not found: $currentBlockmapPath" exit 1 } @@ -353,7 +395,7 @@ jobs: } foreach ($blockmap in $existingBlockmaps) { - if ($currentBlockmaps.Name -notcontains $blockmap) { + if ($blockmap -ne $currentBlockmap) { aws s3 rm "$bucket/$blockmap" --endpoint-url $endpoint } } @@ -361,9 +403,7 @@ jobs: aws s3 cp "release/$currentInstaller" "$bucket/$currentInstaller" --endpoint-url $endpoint aws s3 cp "release/latest.yml" "$bucket/latest.yml" --endpoint-url $endpoint aws s3 cp "release/force-update.json" "$bucket/force-update.json" --endpoint-url $endpoint - foreach ($blockmap in $currentBlockmaps) { - aws s3 cp $blockmap.FullName "$bucket/$($blockmap.Name)" --endpoint-url $endpoint - } + aws s3 cp $currentBlockmapPath "$bucket/$currentBlockmap" --endpoint-url $endpoint notify-telegram-success: runs-on: windows-latest diff --git a/electron/main.ts b/electron/main.ts index e9be6ae..1921086 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1425,6 +1425,14 @@ function registerIpcHandlers() { ipcMain.handle('app:downloadAndInstall', async (event) => { const win = BrowserWindow.fromWebContents(event.sender) + + if (isInstallingUpdate) { + logService?.warn('AppUpdate', '下载更新请求被忽略,当前已有下载任务进行中', { + targetVersion: appUpdateService.getCachedUpdateInfo()?.version + }) + return + } + isInstallingUpdate = true const cachedUpdateInfo = appUpdateService.getCachedUpdateInfo() const targetVersion = cachedUpdateInfo?.version @@ -1441,7 +1449,15 @@ function registerIpcHandlers() { logService?.info('AppUpdate', '开始下载更新', { targetVersion, differentialEnabled: !autoUpdater.disableDifferentialDownload }) const onDownloadProgress = (progress: Electron.ProgressInfo) => { - win?.webContents.send('app:downloadProgress', progress.percent) + const payload = { + percent: progress.percent, + transferred: progress.transferred, + total: progress.total, + bytesPerSecond: progress.bytesPerSecond + } + BrowserWindow.getAllWindows().forEach(currentWindow => { + currentWindow.webContents.send('app:downloadProgress', payload) + }) appUpdateService.updateDiagnostics({ phase: 'downloading', progressPercent: progress.percent, @@ -1470,6 +1486,7 @@ function registerIpcHandlers() { } const onUpdaterError = (error: Error) => { + isInstallingUpdate = false appUpdateService.updateDiagnostics({ phase: 'failed', lastError: String(error), diff --git a/electron/preload.ts b/electron/preload.ts index 1e18052..9cff2eb 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -77,7 +77,12 @@ contextBridge.exposeInMainWorld('electronAPI', { downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'), getStartupDbConnected: () => ipcRenderer.invoke('app:getStartupDbConnected'), setAppIcon: (iconName: string) => ipcRenderer.invoke('app:setAppIcon', iconName), - onDownloadProgress: (callback: (progress: number) => void) => { + onDownloadProgress: (callback: (progress: { + percent: number + transferred: number + total: number + bytesPerSecond: number + }) => void) => { ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress)) return () => ipcRenderer.removeAllListeners('app:downloadProgress') }, diff --git a/src/App.scss b/src/App.scss index e917db1..100569d 100644 --- a/src/App.scss +++ b/src/App.scss @@ -12,14 +12,17 @@ right: 24px; display: flex; align-items: center; - gap: 12px; - padding: 16px 20px; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 12px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + gap: 14px; + min-width: 320px; + max-width: 420px; + padding: 16px 18px; + background: color-mix(in srgb, var(--bg-primary) 88%, white 12%); + border: 1px solid color-mix(in srgb, var(--primary) 22%, var(--border-color) 78%); + border-radius: 18px; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.16); z-index: 1000; animation: slideUp 0.3s ease; + backdrop-filter: blur(18px); @keyframes slideUp { from { @@ -33,15 +36,25 @@ } .update-toast-icon { - font-size: 28px; + width: 42px; + height: 42px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 14px; + flex-shrink: 0; + font-size: 22px; + background: color-mix(in srgb, var(--primary) 14%, transparent); + color: var(--primary); } .update-toast-content { flex: 1; + min-width: 0; .update-toast-title { - font-size: 14px; - font-weight: 600; + font-size: 15px; + font-weight: 700; color: var(--text-primary); margin-bottom: 2px; } @@ -49,13 +62,30 @@ .update-toast-version { font-size: 12px; color: var(--text-secondary); + line-height: 1.45; + } + + .update-toast-meta { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 8px; + + span { + font-size: 11px; + color: var(--text-secondary); + padding: 4px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--bg-tertiary) 75%, transparent); + } } } .update-toast-btn { - padding: 8px 16px; + padding: 0 16px; + height: 38px; border: none; - border-radius: 8px; + border-radius: 10px; background: var(--primary); color: white; font-size: 13px; @@ -63,7 +93,12 @@ cursor: pointer; transition: all 0.2s; - &:hover { + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:hover:not(:disabled) { background: var(--primary-hover); } } @@ -86,6 +121,30 @@ color: var(--text-primary); } } + + .update-toast-progress { + width: 88px; + flex-shrink: 0; + } + + .update-toast-progress-bar { + width: 100%; + height: 8px; + border-radius: 999px; + overflow: hidden; + background: color-mix(in srgb, var(--bg-tertiary) 82%, transparent); + } + + .update-toast-progress-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--primary), color-mix(in srgb, var(--primary) 65%, white)); + transition: width 0.2s linear; + } + + &.is-downloading { + min-width: 360px; + } } .force-update-overlay { @@ -374,8 +433,8 @@ transform: translateX(-50%); display: flex; align-items: center; - gap: 12px; - padding: 8px 16px; + gap: 14px; + padding: 10px 16px; background: rgba(30, 30, 30, 0.9); backdrop-filter: blur(8px); border-radius: 20px; @@ -392,8 +451,31 @@ color: var(--primary); } - span { - white-space: nowrap; + .capsule-copy { + display: flex; + flex-direction: column; + gap: 2px; + + span { + white-space: nowrap; + } + + small { + font-size: 11px; + color: rgba(255, 255, 255, 0.72); + } + } + + .capsule-progress { + display: flex; + flex-direction: column; + gap: 4px; + + small { + font-size: 11px; + color: rgba(255, 255, 255, 0.72); + white-space: nowrap; + } } .progress-bar-bg { diff --git a/src/App.tsx b/src/App.tsx index 93fe709..3b6b829 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,6 +51,25 @@ type AppUpdateInfo = { checkedAt: number updateSource: 'github' | 'custom' | 'none' policySource: 'github' | 'custom' | 'none' + diagnostics?: { + phase: 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'installing' | 'failed' + strategy: 'unknown' | 'differential' | 'full' + fallbackToFull: boolean + lastError?: string + lastEvent?: string + progressPercent?: number + downloadedBytes?: number + totalBytes?: number + targetVersion?: string + lastUpdatedAt: number + } +} + +type UpdateDownloadProgressPayload = { + percent: number + transferred: number + total: number + bytesPerSecond: number } function App() { @@ -71,7 +90,25 @@ function App() { // 更新提示状态 const [updateInfo, setUpdateInfo] = useState(null) - const [downloadProgress, setDownloadProgress] = useState(null) + const [downloadProgress, setDownloadProgress] = useState(null) + + const formatSpeed = (bytesPerSecond: number) => { + if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return '计算中' + if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s` + if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s` + return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s` + } + + const formatBytes = (bytes?: number) => { + if (!bytes || bytes <= 0) return '0 B' + if (bytes < 1024) return `${bytes.toFixed(0)} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB` + } + + const isUpdateDownloading = updateInfo?.diagnostics?.phase === 'downloading' || updateInfo?.diagnostics?.phase === 'installing' + const progressPercent = downloadProgress?.percent ?? updateInfo?.diagnostics?.progressPercent ?? null // 加载主题配置 useEffect(() => { @@ -197,6 +234,24 @@ function App() { useEffect(() => { const removeDownloadListener = window.electronAPI.app.onDownloadProgress?.((progress) => { setDownloadProgress(progress) + setUpdateInfo((current) => { + if (!current) return current + return { + ...current, + diagnostics: { + phase: 'downloading', + strategy: current.diagnostics?.strategy || 'unknown', + fallbackToFull: current.diagnostics?.fallbackToFull || false, + lastError: current.diagnostics?.lastError, + lastEvent: current.diagnostics?.lastEvent, + progressPercent: progress.percent, + downloadedBytes: progress.transferred, + totalBytes: progress.total, + targetVersion: current.version || current.diagnostics?.targetVersion, + lastUpdatedAt: Date.now() + } + } + }) }) return () => { removeDownloadListener?.() @@ -204,10 +259,30 @@ function App() { }, []) const dismissUpdate = () => { - if (updateInfo?.forceUpdate) return + if (updateInfo?.forceUpdate || isUpdateDownloading) return setUpdateInfo(null) } + const handleStartUpdate = () => { + if (isUpdateDownloading) return + setUpdateInfo((current) => current ? { + ...current, + diagnostics: { + phase: 'downloading', + strategy: current.diagnostics?.strategy || 'unknown', + fallbackToFull: current.diagnostics?.fallbackToFull || false, + lastError: undefined, + lastEvent: '开始下载更新', + progressPercent: 0, + downloadedBytes: 0, + totalBytes: current.diagnostics?.totalBytes, + targetVersion: current.version || current.diagnostics?.targetVersion, + lastUpdatedAt: Date.now() + } + } : current) + window.electronAPI.app.downloadAndInstall() + } + // 检查是否是独立聊天窗口 const isChatWindow = location.pathname === '/chat-window' const isGroupAnalyticsWindow = location.pathname === '/group-analytics-window' @@ -493,22 +568,41 @@ function App() {
{updateInfo && !updateInfo.forceUpdate && ( -
-
🎉
+
+
{isUpdateDownloading ? : '🎉'}
-
发现新版本
-
v{updateInfo.version} 已发布
-
更新源:{updateInfo.updateSource === 'github' ? 'GitHub Release' : '未知'}
+
{isUpdateDownloading ? '正在下载更新' : '发现新版本'}
+
+ {isUpdateDownloading ? `v${updateInfo.version} ${progressPercent !== null ? `${progressPercent.toFixed(0)}%` : ''}` : `v${updateInfo.version} 已发布`} +
+
+ {isUpdateDownloading + ? `${formatBytes(downloadProgress?.transferred ?? updateInfo.diagnostics?.downloadedBytes)} / ${formatBytes(downloadProgress?.total ?? updateInfo.diagnostics?.totalBytes)}` + : `更新源:${updateInfo.updateSource === 'github' ? 'GitHub Release' : '未知'}`} +
+ {isUpdateDownloading && ( +
+ 速度 {formatSpeed(downloadProgress?.bytesPerSecond ?? 0)} + {updateInfo.diagnostics?.fallbackToFull ? 已回退全量 : null} +
+ )}
- - + {isUpdateDownloading ? ( +
+
+
+
+
+ ) : ( + <> + + + + )}
)} {updateInfo?.forceUpdate && ( @@ -538,20 +632,20 @@ function App() {
)} - {downloadProgress !== null && ( + {progressPercent !== null && (
- 正在下载更新... {downloadProgress.toFixed(0)}% + 正在下载更新... {progressPercent.toFixed(0)}%
-
+
)}
- )} ) : ( - diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 35b1179..dea16aa 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -12,6 +12,13 @@ export interface ImageViewerOpenOptions { imageDatName?: string } +export interface UpdateDownloadProgressPayload { + percent: number + transferred: number + total: number + bytesPerSecond: number +} + export interface ElectronAPI { window: { minimize: () => void @@ -146,7 +153,7 @@ export interface ElectronAPI { downloadAndInstall: () => Promise getStartupDbConnected?: () => Promise setAppIcon: (iconName: string) => Promise - onDownloadProgress: (callback: (progress: number) => void) => () => void + onDownloadProgress: (callback: (progress: UpdateDownloadProgressPayload) => void) => () => void onUpdateAvailable: (callback: (info: { hasUpdate: boolean forceUpdate: boolean