diff --git a/client/src-tauri/src/commands/system.rs b/client/src-tauri/src/commands/system.rs index 149a29c..29937ea 100644 --- a/client/src-tauri/src/commands/system.rs +++ b/client/src-tauri/src/commands/system.rs @@ -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))?; diff --git a/client/src/components/NoticeBanner.tsx b/client/src/components/NoticeBanner.tsx new file mode 100644 index 0000000..98d6a58 --- /dev/null +++ b/client/src/components/NoticeBanner.tsx @@ -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 = { + 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(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 ( +
+ + +
+

{notice.title}

+ + {(notice.body || hasLink) && ( +

+ {notice.body} + {hasLink && ( + <> + {notice.body ? ' ' : ''} + {/* 链接自然融入正文,普通超链接样式;点击用系统浏览器打开 */} + + + )} +

+ )} +
+ + {canDismiss && ( + + )} +
+ ) +} diff --git a/client/src/features/update/autoUpdate.ts b/client/src/features/update/autoUpdate.ts index b777dfb..1902828 100644 --- a/client/src/features/update/autoUpdate.ts +++ b/client/src/features/update/autoUpdate.ts @@ -54,7 +54,39 @@ export function onAutoUpdateChange(cb: (state: AutoUpdateState) => void) { return () => { listeners.delete(cb) } } -/** 手动触发一次检查更新(关于页“检查更新”按钮使用),与启动自动检查共用同一状态与并发锁 */ +/** + * 手动触发「检查并更新」(关于页“检查更新”按钮使用): + * 检查 → 发现新版本则自动下载 → 自动安装,一步到位,与开机自动更新同一套流程。 + * 与启动自动检查共用并发锁,不会重复触发。 + */ +export async function checkAndUpdateNow(): Promise { + 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 { if (inFlight) { await inFlight diff --git a/client/src/pages/About.tsx b/client/src/pages/About.tsx index e16c837..992d574 100644 --- a/client/src/pages/About.tsx +++ b/client/src/pages/About.tsx @@ -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 && ( - )} diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index a373739..b24a3a5 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -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({ totalDurationSec: 0, totalChars: 0 }) @@ -92,6 +93,8 @@ export default function Home() { 按下 {formatKey(handsFreeKey)} 开始口述,再按一次结束并插入文本。

+ + {isNewUser && (

diff --git a/client/src/services/defaults.ts b/client/src/services/defaults.ts index b7f8b4f..2d2356b 100644 --- a/client/src/services/defaults.ts +++ b/client/src/services/defaults.ts @@ -77,6 +77,9 @@ export const DEFAULTS: Record = { // ── 引导 ── onboardingVersion: '', // 已完成引导的版本号,空字符串 = 未完成 + + // ── 远程公告 ── + dismissedNoticeIds: [], // 已被用户关闭的公告 id 列表 } /** diff --git a/client/src/services/notice.ts b/client/src/services/notice.ts new file mode 100644 index 0000000..f2f3808 --- /dev/null +++ b/client/src/services/notice.ts @@ -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 + 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 { + // 开发预览: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 { + 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(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 { + const dismissed = await getSetting(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 + } +}