feat: 一键检查更新、修复更新后自动重开、首页远程公告
- 关于页「检查更新」改为一步到位:检查→下载→安装 - 修复自动更新安装完成后无法自动重新打开应用的问题 - 新增首页公告栏:可由服务端下发通知,引导用户(如手动下载新版)
This commit is contained in:
1 parent
edcc0fc3a1
commit
4680003a11
7 files changed
+313
-9
No files matched your search
@@ -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))?;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -77,6 +77,9 @@ export const DEFAULTS: Record<string, unknown> = {
|
||||
|
||||
// ── 引导 ──
|
||||
onboardingVersion: '', // 已完成引导的版本号,空字符串 = 未完成
|
||||
|
||||
// ── 远程公告 ──
|
||||
dismissedNoticeIds: [], // 已被用户关闭的公告 id 列表
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user