feat: 一键检查更新、修复更新后自动重开、首页远程公告

- 关于页「检查更新」改为一步到位:检查→下载→安装
- 修复自动更新安装完成后无法自动重新打开应用的问题
- 新增首页公告栏:可由服务端下发通知,引导用户(如手动下载新版)
This commit is contained in:
crosswk committed 2026-07-15 23:29:42 +08:00
1 parent edcc0fc3a1
commit 4680003a11
7 files changed
+313 -9

No files matched your search

+22 -6
View File
@@ -368,14 +368,30 @@ pub fn install_downloaded_update(file_path: String, app: AppHandle) -> Result<()
.map_err(|e| format!("获取当前程序路径失败: {}", e))?;
let exe_str = current_exe.to_string_lossy().to_string();
// start /wait 会等安装程序进程退出后才继续,/S 走静默安装。
// 安装完成后拉起新 exe,带 --open-about 让新实例自动跳转到关于页。
let watchdog_cmd = format!(
"start /wait \"\" \"{}\" /S && start \"\" \"{}\" --open-about",
file_path, exe_str
// 用一个临时 .bat 脚本接管"等安装完成再拉起新版本"。
// 关键:不要把带引号、含 && 的复合命令直接塞给 `cmd /C`——Rust 会把这个
// 含空格的参数整体再套引号并把内部 " 转义成 \",而 cmd.exe 不认识 \" 转义,
// 导致路径被啃坏(历史 bug:装完自动重启报「找不到 \ 文件」)。
// 脚本文件里的引号是文件字面量,不经过参数转义层,最稳;给 cmd /C 传单个
// 脚本路径是 cmd 唯一能干净处理的情形。
// - ping 兜 ~1s,等旧进程完全退出,避免安装程序覆盖 exe 时撞文件锁
// - start /wait 等静默安装结束,再拉起新 exe(--open-about 跳转关于页)
// - del "%~f0" 让脚本运行完自删
let script = format!(
"@echo off\r\n\
ping -n 2 127.0.0.1 >nul\r\n\
start /wait \"\" \"{installer}\" /S\r\n\
start \"\" \"{exe}\" --open-about\r\n\
del \"%~f0\"\r\n",
installer = file_path,
exe = exe_str,
);
let script_path = std::env::temp_dir().join("sayit-update-relaunch.bat");
std::fs::write(&script_path, script)
.map_err(|e| format!("写入更新重启脚本失败: {}", e))?;
Command::new("cmd")
.args(["/C", &watchdog_cmd])
.args(["/C", &script_path.to_string_lossy()])
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| format!("启动安装看门人进程失败: {}", e))?;
+117
View File
@@ -0,0 +1,117 @@
// 远程公告 banner — 展示服务端下发的通知,与更新机制解耦。
// 文案纯文本渲染,链接仅 https 且用系统浏览器打开(详见 services/notice.ts 的安全约束)。
import { useEffect, useState } from 'react'
import { Info, AlertTriangle, Megaphone, X } from 'lucide-react'
import { open as shellOpen } from '@tauri-apps/plugin-shell'
import {
fetchActiveNotice,
dismissNotice,
isSafeHttpsUrl,
type NoticeLevel,
type RemoteNotice,
} from '@/services/notice'
// 每 3 小时刷新一次,长期开着也能收到新公告
const REFRESH_MS = 3 * 60 * 60 * 1000
const LEVEL_STYLES: Record<NoticeLevel, { wrap: string; accent: string; Icon: typeof Info }> = {
info: {
wrap: 'border-primary/30 bg-primary/5',
accent: 'text-primary',
Icon: Info,
},
warning: {
wrap: 'border-amber-500/40 bg-amber-500/10',
accent: 'text-amber-500',
Icon: AlertTriangle,
},
critical: {
wrap: 'border-red-500/40 bg-red-500/10',
accent: 'text-red-500',
Icon: Megaphone,
},
}
/** 去掉末尾斜杠,展示更干净的网址 */
function prettyUrl(url: string): string {
return url.replace(/\/+$/, '')
}
export default function NoticeBanner() {
const [notice, setNotice] = useState<RemoteNotice | null>(null)
useEffect(() => {
let alive = true
const load = () => {
void fetchActiveNotice(__APP_VERSION__).then((n) => {
if (alive) setNotice(n)
})
}
load()
const timer = setInterval(load, REFRESH_MS)
return () => {
alive = false
clearInterval(timer)
}
}, [])
if (!notice) return null
const style = LEVEL_STYLES[notice.level] ?? LEVEL_STYLES.info
const Icon = style.Icon
const canDismiss = notice.dismissible !== false
const hasLink = isSafeHttpsUrl(notice.linkUrl)
const url = hasLink ? (notice.linkUrl as string) : ''
const linkText = notice.linkLabel || prettyUrl(url)
const handleDismiss = () => {
void dismissNotice(notice.id)
setNotice(null)
}
const handleOpen = () => {
if (hasLink) void shellOpen(url)
}
return (
<div className={`mb-6 flex items-start gap-3 rounded-xl border px-4 py-3.5 ${style.wrap}`}>
<Icon className={`mt-0.5 h-5 w-5 shrink-0 ${style.accent}`} />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">{notice.title}</p>
{(notice.body || hasLink) && (
<p className="mt-1 whitespace-pre-line text-xs leading-relaxed text-muted-foreground">
{notice.body}
{hasLink && (
<>
{notice.body ? ' ' : ''}
{/* 链接自然融入正文,普通超链接样式;点击用系统浏览器打开 */}
<button
type="button"
onClick={handleOpen}
title={url}
className="font-medium text-primary underline underline-offset-2 transition-opacity hover:opacity-80"
>
{linkText}
</button>
</>
)}
</p>
)}
</div>
{canDismiss && (
<button
type="button"
onClick={handleDismiss}
className="shrink-0 rounded-full p-1 text-muted-foreground/60 transition-colors hover:bg-foreground/10 hover:text-foreground"
aria-label="关闭通知"
>
<X className="h-4 w-4" />
</button>
)}
</div>
)
}
+33 -1
View File
@@ -54,7 +54,39 @@ export function onAutoUpdateChange(cb: (state: AutoUpdateState) => void) {
return () => { listeners.delete(cb) }
}
/** 手动触发一次检查更新(关于页“检查更新”按钮使用),与启动自动检查共用同一状态与并发锁 */
/**
* 手动触发「检查并更新」(关于页“检查更新”按钮使用):
* 检查 → 发现新版本则自动下载 → 自动安装,一步到位,与开机自动更新同一套流程。
* 与启动自动检查共用并发锁,不会重复触发。
*/
export async function checkAndUpdateNow(): Promise<void> {
if (inFlight) { await inFlight; return }
const task = (async () => {
setState({ phase: 'checking', error: null })
const info = await checkVersionUpdate(__APP_VERSION__)
setState({ phase: 'checked', versionInfo: info, checkedAt: Date.now() })
if (!info?.hasUpdate || !info.downloadUrl) return
setState({ phase: 'downloading', version: info.latestVersion || undefined, downloadPercent: 0 })
try {
const filePath = await bridge.downloadUpdate(info.downloadUrl)
setState({ phase: 'downloaded', downloadedFilePath: filePath, downloadPercent: 100 })
// 通知用户即将安装,给 3 秒缓冲
setState({ phase: 'installing', version: info.latestVersion || undefined })
await new Promise((r) => setTimeout(r, 3000))
await bridge.installDownloadedUpdate(filePath)
} catch (err) {
setState({ phase: 'checked', error: String(err) })
}
})()
inFlight = task.finally(() => { inFlight = null })
await inFlight
}
/** 手动触发一次检查更新(仅检查、不下载),与启动自动检查共用同一状态与并发锁 */
export async function checkNow(): Promise<VersionInfo> {
if (inFlight) {
await inFlight
+2 -2
View File
@@ -3,7 +3,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Github, Download, Loader2, CheckCircle } from 'lucide-react'
import { open as shellOpen } from '@tauri-apps/plugin-shell'
import { getAutoUpdateState, onAutoUpdateChange, checkNow, downloadNow, installNow, type AutoUpdateState } from '@/features/update/autoUpdate'
import { getAutoUpdateState, onAutoUpdateChange, checkAndUpdateNow, downloadNow, installNow, type AutoUpdateState } from '@/features/update/autoUpdate'
import { RELEASE_HIGHLIGHTS } from '@/features/update/releaseHighlights'
import appIcon from '@/assets/icon-128.png'
@@ -116,7 +116,7 @@ export default function About() {
)}
{/* 检查更新按钮 */}
{!downloading && !installing && !downloaded && (
<Button variant="outline" size="sm" onClick={() => void checkNow()} disabled={checking}>
<Button variant="outline" size="sm" onClick={() => void checkAndUpdateNow()} disabled={checking}>
{checking ? '检查中...' : '检查更新'}
</Button>
)}
+3
View File
@@ -4,6 +4,7 @@ import { Mic, Clock, Type, Zap } from 'lucide-react'
import { getStats, type Stats, getSetting } from '@/services/store'
import { SHORTCUTS_CHANGED_EVENT } from '@/services/bridge'
import FeedbackSection from '@/components/FeedbackSection'
import NoticeBanner from '@/components/NoticeBanner'
export default function Home() {
const [stats, setStats] = useState<Stats>({ totalDurationSec: 0, totalChars: 0 })
@@ -92,6 +93,8 @@ export default function Home() {
按下 <span className="px-1.5 py-0.5 text-muted-foreground bg-secondary border border-border rounded">{formatKey(handsFreeKey)}</span> 开始口述,再按一次结束并插入文本。
</p>
<NoticeBanner />
{isNewUser && (
<div className="mb-6 rounded-xl border border-border bg-muted/30 px-5 py-5 text-center">
<p className="text-sm text-muted-foreground">
+3
View File
@@ -77,6 +77,9 @@ export const DEFAULTS: Record<string, unknown> = {
// ── 引导 ──
onboardingVersion: '', // 已完成引导的版本号,空字符串 = 未完成
// ── 远程公告 ──
dismissedNoticeIds: [], // 已被用户关闭的公告 id 列表
}
/**
+133
View File
@@ -0,0 +1,133 @@
// 远程公告服务 — 与更新机制完全解耦的服务端可控通知通道。
//
// 即使自动更新出问题、或不想让用户更新软件,也能通过后端下发一条公告,
// 在 App 内提示用户(例如引导去官网/GitHub 手动下载新版)。
//
// 安全约束(远程内容视为不可信输入):
// - 文案一律按纯文本渲染(React 默认转义),绝不当 HTML 注入。
// - 链接只允许 https,且用系统浏览器打开,绝不导航 WebView 自身。
import { getBackendBaseUrl } from './runtimeConfig'
import { getSetting, setSetting } from './store'
export type NoticeLevel = 'info' | 'warning' | 'critical'
export interface RemoteNotice {
/** 唯一 id:用户关闭后按 id 记忆,不再重复弹 */
id: string
/** 级别,决定 banner 配色与图标 */
level: NoticeLevel
title: string
body?: string
/** 行动按钮链接(仅 https) */
linkUrl?: string
linkLabel?: string
/** 只对 >= minVersion 的客户端显示 */
minVersion?: string | null
/** 只对 <= maxVersion 的客户端显示(例如只提示旧版用户去手动更新) */
maxVersion?: string | null
/** 生效时间窗(ISO 字符串),可选 */
startAt?: string | null
endAt?: string | null
/** 是否允许关闭,默认 true */
dismissible?: boolean
}
const DISMISSED_KEY = 'dismissedNoticeIds'
function compareVersions(a: string, b: string): number {
const pa = a.split('.').map(Number)
const pb = b.split('.').map(Number)
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0)
if (d !== 0) return d
}
return 0
}
function isValidNotice(v: unknown): v is RemoteNotice {
if (!v || typeof v !== 'object') return false
const n = v as Record<string, unknown>
const levelOk = n.level === 'info' || n.level === 'warning' || n.level === 'critical'
return typeof n.id === 'string' && n.id.length > 0
&& typeof n.title === 'string' && n.title.length > 0
&& levelOk
}
function matchesVersion(notice: RemoteNotice, current: string): boolean {
if (notice.minVersion && compareVersions(current, notice.minVersion) < 0) return false
if (notice.maxVersion && compareVersions(current, notice.maxVersion) > 0) return false
return true
}
function withinTimeWindow(notice: RemoteNotice, now = Date.now()): boolean {
if (notice.startAt) {
const t = Date.parse(notice.startAt)
if (!Number.isNaN(t) && now < t) return false
}
if (notice.endAt) {
const t = Date.parse(notice.endAt)
if (!Number.isNaN(t) && now > t) return false
}
return true
}
async function loadRaw(): Promise<unknown> {
// 开发预览:dev 环境下可在控制台执行
// localStorage.setItem('__devNotice', JSON.stringify({ id:'t1', level:'warning', title:'测试公告', body:'内容…', linkUrl:'https://sayitapp.site', linkLabel:'前往官网' }))
// 刷新即可看到 banner 效果,无需后端。清除:localStorage.removeItem('__devNotice')
if (import.meta.env.DEV) {
try {
const dev = localStorage.getItem('__devNotice')
if (dev) return JSON.parse(dev)
} catch {
/* ignore */
}
}
const baseUrl = getBackendBaseUrl()
const resp = await fetch(`${baseUrl}/api/notice`, {
cache: 'no-store',
signal: AbortSignal.timeout(8000),
})
if (!resp.ok) return null
return await resp.json()
}
/** 拉取当前应展示的公告;无公告/不适用当前版本/不在时间窗/已被用户关闭 → 返回 null。 */
export async function fetchActiveNotice(currentVersion: string): Promise<RemoteNotice | null> {
try {
const raw = await loadRaw()
if (!isValidNotice(raw)) return null
const notice = raw
if (!matchesVersion(notice, currentVersion)) return null
if (!withinTimeWindow(notice)) return null
if (notice.dismissible !== false) {
const dismissed = await getSetting<string[]>(DISMISSED_KEY, [])
if (Array.isArray(dismissed) && dismissed.includes(notice.id)) return null
}
return notice
} catch {
return null
}
}
/** 记住某条公告已被用户关闭(按 id)。 */
export async function dismissNotice(id: string): Promise<void> {
const dismissed = await getSetting<string[]>(DISMISSED_KEY, [])
const list = Array.isArray(dismissed) ? dismissed : []
if (!list.includes(id)) {
// 只保留最近 50 条,避免无限增长
await setSetting(DISMISSED_KEY, [...list, id].slice(-50))
}
}
/** 校验是否为安全的 https 外链。 */
export function isSafeHttpsUrl(url: string | undefined): url is string {
if (!url) return false
try {
return new URL(url).protocol === 'https:'
} catch {
return false
}
}