From 9e90f8140eeaa275fa88a07ab6d4f1ba599998b5 Mon Sep 17 00:00:00 2001 From: maguowei Date: Sat, 4 Jul 2026 11:52:44 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(updater):=20=E9=A1=B6=E9=83=A8?= =?UTF-8?q?=E5=B8=B8=E9=A9=BB=E6=9B=B4=E6=96=B0=E6=A8=AA=E5=B9=85=E4=B8=8E?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E4=B8=AD=E8=87=AA=E5=8A=A8=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 useAppUpdater 状态机提升为 UpdaterProvider 全局单例, 顶部横幅与设置卡片共享同一份状态;启动、窗口聚焦、定时三时机 静默检查,运行中即可发现新版本。新增可点击的顶部横幅承载下载 与重启,替换原来只弹 Toast 却无法操作的启动提示。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.tsx | 186 ++++++++---------- src/components/SettingsDrawer.tsx | 5 +- src/components/UpdateBanner.tsx | 57 ++++++ src/components/UpdaterProvider.tsx | 82 ++++++++ .../__tests__/SettingsDrawer.test.tsx | 7 +- .../__tests__/UpdateBanner.test.tsx | 65 ++++++ .../__tests__/UpdaterProvider.test.tsx | 56 ++++++ src/hooks/__tests__/useAppUpdater.test.tsx | 64 ++++-- src/hooks/useAppUpdater.ts | 86 ++++---- src/i18n.ts | 6 +- 10 files changed, 455 insertions(+), 159 deletions(-) create mode 100644 src/components/UpdateBanner.tsx create mode 100644 src/components/UpdaterProvider.tsx create mode 100644 src/components/__tests__/UpdateBanner.test.tsx create mode 100644 src/components/__tests__/UpdaterProvider.test.tsx diff --git a/src/App.tsx b/src/App.tsx index fb08168..f481de3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,8 @@ import { LIST_PANEL_WIDTH_CLASS, } from "./components/layout-size-classes"; import Sidebar from "./components/Sidebar"; -import { silentCheckForUpdate } from "./hooks/useAppUpdater"; +import { UpdateBanner } from "./components/UpdateBanner"; +import { UpdaterProvider } from "./components/UpdaterProvider"; import useTauriEvent from "./hooks/useTauriEvent"; import { useToast } from "./hooks/useToast"; import { useI18n } from "./i18n"; @@ -92,14 +93,8 @@ function App() { } | null>(null); const previousContentTabRef = useRef("configs"); const editorExitGuardRef = useRef(null); - const updateCheckedRef = useRef(false); const historyProjectRequestIdRef = useRef(0); const usageProjectRequestIdRef = useRef(0); - // 持有最新的 t / showToast,供异步回调读取,避免 promise resolve 前切换语言导致文案陈旧 - const tRef = useRef(t); - tRef.current = t; - const showToastRef = useRef(showToast); - showToastRef.current = showToast; const loadWorkspace = useCallback(async () => { if (!isTauri()) { @@ -123,24 +118,6 @@ function App() { void loadWorkspace(); }, [loadWorkspace]); - // 启动时静默检查更新:发现新版仅 Toast 提示,由用户在设置中决定是否安装;失败静默。 - // 只在挂载时执行一次;通过 tRef/showToastRef 读取最新值,规避语言切换后 Toast 文案陈旧。 - useEffect(() => { - if (updateCheckedRef.current) return; - updateCheckedRef.current = true; - void silentCheckForUpdate().then((version) => { - if (version) { - showToastRef.current( - tRef.current("update.available").replace("{version}", version), - "success", - { - description: tRef.current("update.availableHint"), - }, - ); - } - }); - }, []); - useEffect(() => { document.documentElement.style.setProperty( "--app-sidebar-width", @@ -291,88 +268,95 @@ function App() { } return ( - -
- { - runWithEditorExitGuard(() => activateTab(tab)); - }} - onClaudeOverviewClick={handleClaudeOverviewClick} - onSettingsClick={handleSettingsClick} - /> - -
- {activeTab === "claudeOverview" || hasVisitedClaudeOverview ? ( -
+ + +
+ { + runWithEditorExitGuard(() => activateTab(tab)); + }} + onClaudeOverviewClick={handleClaudeOverviewClick} + onSettingsClick={handleSettingsClick} + /> + +
+ +
+ {activeTab === "claudeOverview" || hasVisitedClaudeOverview ? ( +
+ }> + + +
+ ) : null} }> - - -
- ) : null} - }> - {activeTab === "claudeOverview" ? null : activeTab === "cheatsheet" ? ( - - ) : activeTab === "stats" ? ( - - ) : activeTab === "usage" ? ( - - ) : activeTab === "projects" ? ( - - ) : activeTab === "history" ? ( - - ) : activeTab === "configs" ? ( - - ) : ( -
- {activeTab === "memory" && ( - + ) : activeTab === "stats" ? ( + + ) : activeTab === "usage" ? ( + - )} - {activeTab === "skills" && ( - + ) : activeTab === "history" ? ( + + ) : activeTab === "configs" ? ( + + ) : ( +
+ {activeTab === "memory" && ( + + )} + {activeTab === "skills" && ( + + )} +
)} -
- )} -
-
+ +
+
- {isSettingsOpen && ( - - - - )} -
- - + {isSettingsOpen && ( + + + + )} +
+ +
+ ); } diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index b753d5a..3c23126 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -21,7 +21,6 @@ import { } from "lucide-react"; import { type ReactNode, useEffect, useMemo, useState } from "react"; import { showOperationError } from "@/lib/user-facing-error"; -import { useAppUpdater } from "../hooks/useAppUpdater"; import { useToast } from "../hooks/useToast"; import { type Language, type TranslationKey, useI18n } from "../i18n"; import { ipc } from "../ipc"; @@ -44,6 +43,7 @@ import { keyEventToAccelerator, } from "./shortcut-utils"; import { type Theme, useTheme } from "./theme-provider"; +import { useUpdater } from "./UpdaterProvider"; import { Button } from "./ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"; import { Checkbox } from "./ui/checkbox"; @@ -294,8 +294,7 @@ function SettingsSectionCard({ // 应用更新设置卡片:展示当前版本,手动检查更新并下载安装(状态机见 useAppUpdater) function UpdateSettingsCard() { const { t } = useI18n(); - const { status, availableVersion, progress, checkForUpdate, downloadAndRestart } = - useAppUpdater(); + const { status, availableVersion, progress, checkForUpdate, downloadAndRestart } = useUpdater(); const [currentVersion, setCurrentVersion] = useState(null); useEffect(() => { diff --git a/src/components/UpdateBanner.tsx b/src/components/UpdateBanner.tsx new file mode 100644 index 0000000..86768ac --- /dev/null +++ b/src/components/UpdateBanner.tsx @@ -0,0 +1,57 @@ +import { Download, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useI18n } from "../i18n"; +import { useUpdater } from "./UpdaterProvider"; + +/** + * 顶部常驻更新横幅:仅在发现新版本 / 下载中 / 待重启时渲染,否则隐藏。 + * 整条承载「立即更新」动作,点击即走下载 → 进度 → 重启流程;克制细条,使用 shadcn 语义色。 + */ +export function UpdateBanner() { + const { t } = useI18n(); + const { status, availableVersion, progress, downloadAndRestart } = useUpdater(); + + // 只有这三种状态需要常驻提示;其余(idle/checking/upToDate/error)不打扰用户 + if (status !== "available" && status !== "downloading" && status !== "ready") { + return null; + } + + const isDownloading = status === "downloading"; + + let message: string; + let actionLabel: string; + if (status === "downloading") { + message = t("update.downloading").replace("{percent}", String(progress)); + actionLabel = t("update.downloading").replace("{percent}", String(progress)); + } else if (status === "ready") { + message = t("update.readyBanner"); + actionLabel = t("update.restartNow"); + } else { + message = t("update.available").replace("{version}", availableVersion ?? ""); + actionLabel = t("update.updateNow"); + } + + return ( +
+ {status === "ready" ? ( +
+ ); +} diff --git a/src/components/UpdaterProvider.tsx b/src/components/UpdaterProvider.tsx new file mode 100644 index 0000000..886fd67 --- /dev/null +++ b/src/components/UpdaterProvider.tsx @@ -0,0 +1,82 @@ +import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef } from "react"; +import { type AppUpdaterState, useAppUpdater } from "../hooks/useAppUpdater"; +import { isTauri } from "../types"; + +// 定时轮询间隔:6 小时。桌面应用运行期间无需更频繁,避免打扰用户与 endpoint 压力。 +const POLL_INTERVAL_MS = 6 * 60 * 60 * 1000; +// 窗口聚焦触发的检查节流:距上次检查不足 30 分钟则跳过,避免频繁切窗反复请求。 +const FOCUS_THROTTLE_MS = 30 * 60 * 1000; + +const UpdaterContext = createContext(null); + +/** + * 全局唯一的应用更新状态:把 useAppUpdater 状态机提升为 Context, + * 供顶部横幅与设置页卡片共享同一份状态,并在启动 / 窗口聚焦 / 定时三种时机自动静默检查。 + * 运行中即可发现新版本,无需重启应用。 + */ +export function UpdaterProvider({ children }: { children: ReactNode }) { + const updater = useAppUpdater(); + // 用 ref 读取最新的 status / checkForUpdate,让自动检查回调保持稳定、不随状态变化重建 + const statusRef = useRef(updater.status); + statusRef.current = updater.status; + const checkRef = useRef(updater.checkForUpdate); + checkRef.current = updater.checkForUpdate; + const lastCheckRef = useRef(0); + + // 静默自动检查:仅在空闲态触发,避免打断正在进行的检查 / 下载 / 待重启流程 + const autoCheck = useCallback(() => { + const status = statusRef.current; + if ( + status === "checking" || + status === "available" || + status === "downloading" || + status === "ready" + ) { + return; + } + lastCheckRef.current = nowMs(); + void checkRef.current({ silent: true }); + }, []); + + // 启动时检查一次 + const didInitialCheck = useRef(false); + useEffect(() => { + if (didInitialCheck.current) return; + didInitialCheck.current = true; + autoCheck(); + }, [autoCheck]); + + // 定时轮询 + useEffect(() => { + if (!isTauri()) return; + const id = window.setInterval(autoCheck, POLL_INTERVAL_MS); + return () => window.clearInterval(id); + }, [autoCheck]); + + // 窗口重新聚焦时检查(带节流) + useEffect(() => { + if (!isTauri()) return; + const onFocus = () => { + if (nowMs() - lastCheckRef.current < FOCUS_THROTTLE_MS) return; + autoCheck(); + }; + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [autoCheck]); + + return {children}; +} + +/** 读取全局更新状态;必须在 UpdaterProvider 内使用 */ +export function useUpdater(): AppUpdaterState { + const ctx = useContext(UpdaterContext); + if (!ctx) { + throw new Error("useUpdater 必须在 UpdaterProvider 内使用"); + } + return ctx; +} + +// 抽出取时函数,便于测试;生产环境即 Date.now +function nowMs(): number { + return Date.now(); +} diff --git a/src/components/__tests__/SettingsDrawer.test.tsx b/src/components/__tests__/SettingsDrawer.test.tsx index 01200b9..ca07bc4 100644 --- a/src/components/__tests__/SettingsDrawer.test.tsx +++ b/src/components/__tests__/SettingsDrawer.test.tsx @@ -4,6 +4,7 @@ import { I18nProvider } from "../../i18n"; import type { ConfigWorkspace } from "../../types"; import SettingsDrawer from "../SettingsDrawer"; import { ThemeProvider } from "../theme-provider"; +import { UpdaterProvider } from "../UpdaterProvider"; import { Toaster } from "../ui/sonner"; const { invokeMock, isPermissionGrantedMock, platformMock, requestPermissionMock } = vi.hoisted( @@ -57,8 +58,10 @@ function renderSettingsDrawer() { render( - - + + + + , ); diff --git a/src/components/__tests__/UpdateBanner.test.tsx b/src/components/__tests__/UpdateBanner.test.tsx new file mode 100644 index 0000000..4e0dd7e --- /dev/null +++ b/src/components/__tests__/UpdateBanner.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// 注入可控的 updater 状态,专注验证横幅的三态渲染与隐藏逻辑 +const { useUpdaterMock, downloadAndRestartMock } = vi.hoisted(() => ({ + useUpdaterMock: vi.fn(), + downloadAndRestartMock: vi.fn(), +})); + +vi.mock("../UpdaterProvider", () => ({ useUpdater: useUpdaterMock })); +vi.mock("../../i18n", () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})); + +import { UpdateBanner } from "../UpdateBanner"; + +function mockUpdater(overrides: Record) { + useUpdaterMock.mockReturnValue({ + status: "idle", + availableVersion: null, + progress: 0, + checkForUpdate: vi.fn(), + downloadAndRestart: downloadAndRestartMock, + ...overrides, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("UpdateBanner", () => { + it("idle / upToDate / checking / error 状态下不渲染", () => { + for (const status of ["idle", "upToDate", "checking", "error"]) { + mockUpdater({ status }); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + } + }); + + it("available 时展示版本与「立即更新」按钮,点击触发下载", async () => { + mockUpdater({ status: "available", availableVersion: "1.5.0" }); + render(); + const button = screen.getByRole("button", { name: "update.updateNow" }); + button.click(); + expect(downloadAndRestartMock).toHaveBeenCalledTimes(1); + }); + + it("downloading 时按钮禁用", () => { + mockUpdater({ status: "downloading", availableVersion: "1.5.0", progress: 42 }); + render(); + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + }); + + it("ready 时展示重启按钮,点击触发重启", () => { + mockUpdater({ status: "ready", availableVersion: "1.5.0" }); + render(); + const button = screen.getByRole("button", { name: "update.restartNow" }); + button.click(); + expect(downloadAndRestartMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/__tests__/UpdaterProvider.test.tsx b/src/components/__tests__/UpdaterProvider.test.tsx new file mode 100644 index 0000000..7af8bf4 --- /dev/null +++ b/src/components/__tests__/UpdaterProvider.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { checkForUpdateMock, isTauriMock, updaterState } = vi.hoisted(() => ({ + checkForUpdateMock: vi.fn(), + isTauriMock: vi.fn(() => true), + updaterState: { + status: "idle", + availableVersion: null, + progress: 0, + checkForUpdate: vi.fn(), + downloadAndRestart: vi.fn(), + }, +})); + +vi.mock("../../hooks/useAppUpdater", () => ({ + useAppUpdater: () => ({ ...updaterState, checkForUpdate: checkForUpdateMock }), +})); +vi.mock("../../types", () => ({ isTauri: isTauriMock })); + +import { UpdaterProvider } from "../UpdaterProvider"; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-04T00:00:00Z")); + vi.clearAllMocks(); + updaterState.status = "idle"; + isTauriMock.mockReturnValue(true); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("UpdaterProvider", () => { + it("已发现更新时不再因窗口聚焦发起自动检查", () => { + const { rerender } = render( + +
+ , + ); + expect(checkForUpdateMock).toHaveBeenCalledTimes(1); + + checkForUpdateMock.mockClear(); + updaterState.status = "available"; + rerender( + +
+ , + ); + vi.advanceTimersByTime(31 * 60 * 1000); + fireEvent.focus(window); + + expect(checkForUpdateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/__tests__/useAppUpdater.test.tsx b/src/hooks/__tests__/useAppUpdater.test.tsx index 9686721..35d9670 100644 --- a/src/hooks/__tests__/useAppUpdater.test.tsx +++ b/src/hooks/__tests__/useAppUpdater.test.tsx @@ -22,7 +22,7 @@ vi.mock("../../utils/logger", () => ({ logger: { warn: warnMock, info: vi.fn(), error: vi.fn(), debug: vi.fn(), trace: vi.fn() }, })); -import { silentCheckForUpdate, useAppUpdater } from "../useAppUpdater"; +import { useAppUpdater } from "../useAppUpdater"; /** 构造一个模拟 Update 句柄,downloadAndInstall 回放 Started/Progress/Finished 事件 */ function makeUpdate(version = "1.0.1", contentLength: number | null = 100) { @@ -176,26 +176,60 @@ describe("useAppUpdater.downloadAndRestart", () => { }); }); -describe("silentCheckForUpdate", () => { - it("发现新版返回版本号", async () => { - checkMock.mockResolvedValue({ version: "2.0.0" }); - await expect(silentCheckForUpdate()).resolves.toBe("2.0.0"); +describe("useAppUpdater.checkForUpdate silent 模式", () => { + it("复用进行中的静默检查,避免与手动检查并发", async () => { + let resolveCheck: ((update: ReturnType) => void) | undefined; + checkMock.mockReturnValue( + new Promise((resolve) => { + resolveCheck = resolve; + }), + ); + const { result } = renderHook(() => useAppUpdater()); + + let silentCheck: Promise | undefined; + let manualCheck: Promise | undefined; + act(() => { + silentCheck = result.current.checkForUpdate({ silent: true }); + manualCheck = result.current.checkForUpdate(); + }); + + expect(checkMock).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveCheck?.(makeUpdate("3.0.0")); + await Promise.all([silentCheck, manualCheck]); + }); + expect(result.current.status).toBe("available"); + expect(result.current.availableVersion).toBe("3.0.0"); + }); + + it("静默检查发现新版本时进入 available", async () => { + checkMock.mockResolvedValue(makeUpdate("3.1.0")); + const { result } = renderHook(() => useAppUpdater()); + await act(async () => { + await result.current.checkForUpdate({ silent: true }); + }); + expect(result.current.status).toBe("available"); + expect(result.current.availableVersion).toBe("3.1.0"); }); - it("无更新返回 null", async () => { + it("静默检查无更新时保持 idle,不覆盖为 upToDate", async () => { checkMock.mockResolvedValue(null); - await expect(silentCheckForUpdate()).resolves.toBeNull(); + const { result } = renderHook(() => useAppUpdater()); + await act(async () => { + await result.current.checkForUpdate({ silent: true }); + }); + expect(result.current.status).toBe("idle"); }); - it("检查抛错时返回 null 并记 warn", async () => { + it("静默检查失败时保持原状态并只记 warn,不弹 Toast", async () => { checkMock.mockRejectedValue(new Error("offline")); - await expect(silentCheckForUpdate()).resolves.toBeNull(); + const { result } = renderHook(() => useAppUpdater()); + await act(async () => { + await result.current.checkForUpdate({ silent: true }); + }); + expect(result.current.status).toBe("idle"); expect(warnMock).toHaveBeenCalled(); - }); - - it("非 Tauri 环境返回 null,不触发 check", async () => { - isTauriMock.mockReturnValue(false); - await expect(silentCheckForUpdate()).resolves.toBeNull(); - expect(checkMock).not.toHaveBeenCalled(); + expect(showOperationErrorMock).not.toHaveBeenCalled(); }); }); diff --git a/src/hooks/useAppUpdater.ts b/src/hooks/useAppUpdater.ts index b8af5d2..768ec8b 100644 --- a/src/hooks/useAppUpdater.ts +++ b/src/hooks/useAppUpdater.ts @@ -26,17 +26,25 @@ export type AppUpdaterStatus = | "ready" | "error"; +export interface CheckForUpdateOptions { + /** + * 静默检查:用于启动 / 聚焦 / 定时等自动触发。 + * 失败仅记日志、不弹 Toast、不翻转为 error;也不把状态过渡到 checking,避免打扰用户。 + */ + silent?: boolean; +} + export interface AppUpdaterState { status: AppUpdaterStatus; /** 发现的新版本号,仅在 available/downloading/ready 时有意义 */ availableVersion: string | null; /** 下载进度百分比 0-100;总长未知时回退为 0 */ progress: number; - checkForUpdate: () => Promise; + checkForUpdate: (options?: CheckForUpdateOptions) => Promise; downloadAndRestart: () => Promise; } -/** 封装 @tauri-apps/plugin-updater 的检查 / 下载 / 安装 / 重启流程,供设置页手动更新使用 */ +/** 封装 @tauri-apps/plugin-updater 的检查 / 下载 / 安装 / 重启流程,供 UpdaterProvider 统一驱动 */ export function useAppUpdater(): AppUpdaterState { const { t } = useI18n(); const { showToast } = useToast(); @@ -45,27 +53,48 @@ export function useAppUpdater(): AppUpdaterState { const [progress, setProgress] = useState(0); // 暂存 check() 返回的 Update 句柄,供随后的 downloadAndInstall 复用 const pendingUpdateRef = useRef(null); + // 复用进行中的检查,避免自动检查与手动检查并发写入同一份更新状态 + const checkRequestRef = useRef | null>(null); - const checkForUpdate = useCallback(async () => { - if (!isTauri()) return; - setStatus("checking"); - try { - const update = await check(); - if (update) { - pendingUpdateRef.current = update; - setAvailableVersion(update.version); - setProgress(0); - setStatus("available"); - } else { - pendingUpdateRef.current = null; - setAvailableVersion(null); - setStatus("upToDate"); + const checkForUpdate = useCallback( + async (options?: CheckForUpdateOptions) => { + if (!isTauri()) return; + // 静默检查不进入 checking,避免顶部横幅 / 设置按钮出现无意义的加载态闪烁 + if (!options?.silent) setStatus("checking"); + let request = checkRequestRef.current; + const ownsRequest = request === null; + if (!request) { + request = check(); + checkRequestRef.current = request; } - } catch (error) { - setStatus("error"); - showOperationError(showToast, t("update.checkFailed"), error); - } - }, [showToast, t]); + try { + const update = await request; + if (update) { + pendingUpdateRef.current = update; + setAvailableVersion(update.version); + setProgress(0); + setStatus("available"); + } else if (!options?.silent) { + // 静默检查无更新时保持既有状态,不覆盖为 upToDate(否则会清掉横幅已发现的版本) + pendingUpdateRef.current = null; + setAvailableVersion(null); + setStatus("upToDate"); + } + } catch (error) { + if (options?.silent) { + logger.warn(`updater: 自动检查更新失败 ${String(error)}`); + return; + } + setStatus("error"); + showOperationError(showToast, t("update.checkFailed"), error); + } finally { + if (ownsRequest && checkRequestRef.current === request) { + checkRequestRef.current = null; + } + } + }, + [showToast, t], + ); // 安装完成后重启进入新版本;重启失败不应回退为下载失败,保留 ready 让用户重试 const restartApp = useCallback(async () => { @@ -119,18 +148,3 @@ export function useAppUpdater(): AppUpdaterState { return { status, availableVersion, progress, checkForUpdate, downloadAndRestart }; } - -/** - * 启动时静默检查更新:发现新版仅返回版本号,失败仅记日志、不打扰用户。 - * 与设置页的 useAppUpdater 相互独立,避免在 App 壳层挂载完整状态机。 - */ -export async function silentCheckForUpdate(): Promise { - if (!isTauri()) return null; - try { - const update = await check(); - return update?.version ?? null; - } catch (error) { - logger.warn(`updater: 启动静默检查失败 ${String(error)}`); - return null; - } -} diff --git a/src/i18n.ts b/src/i18n.ts index 2738458..8b41525 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1363,7 +1363,8 @@ const translations = { "update.checking": "正在检查…", "update.upToDate": "已是最新版本", "update.available": "发现新版本 {version}", - "update.availableHint": "可在「设置 - 应用更新」中下载并安装。", + "update.updateNow": "立即更新", + "update.readyBanner": "新版本已下载,重启即可完成更新", "update.downloadAndInstall": "下载并安装", "update.downloading": "正在下载… {percent}%", "update.installing": "正在安装…", @@ -3089,7 +3090,8 @@ const translations = { "update.checking": "Checking…", "update.upToDate": "You're on the latest version", "update.available": "New version {version} available", - "update.availableHint": "Open Settings → App Update to download and install.", + "update.updateNow": "Update Now", + "update.readyBanner": "New version downloaded, restart to finish updating", "update.downloadAndInstall": "Download & Install", "update.downloading": "Downloading… {percent}%", "update.installing": "Installing…", From b283ca721d65238112b1021af45369f649feab10 Mon Sep 17 00:00:00 2001 From: maguowei Date: Sat, 4 Jul 2026 18:14:40 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(config):=20=E5=90=AF=E7=94=A8=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E7=8A=B6=E6=80=81=E8=A1=8C=E9=A2=84=E8=AE=BE=E5=90=8E?= =?UTF-8?q?=E8=A1=A5=E6=88=90=E5=8A=9F=20Toast=20=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 成功分支原先只把命令写入输入框,脚本已存在或重复点击时 onChange diff 为空、无可见反馈。现按后端返回的 result.installed 区分「已安装」 与「已是最新」各弹一条成功 Toast,并补充对应测试。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/ProfileEditor.test.tsx | 46 +++++++++++++++++++ .../profile-editor/StatusLineEditor.tsx | 8 ++++ src/i18n.ts | 7 +++ 3 files changed, 61 insertions(+) diff --git a/src/components/__tests__/ProfileEditor.test.tsx b/src/components/__tests__/ProfileEditor.test.tsx index e8be6ea..f65889f 100644 --- a/src/components/__tests__/ProfileEditor.test.tsx +++ b/src/components/__tests__/ProfileEditor.test.tsx @@ -3416,6 +3416,7 @@ describe("ProfileEditor", () => { expect(within(statusLineSection).getByLabelText("状态行命令")).toHaveValue( "~/.claude/statusline.sh", ); + expect(showToastMock).toHaveBeenCalledWith("默认状态行脚本已安装;配置更改需保存后生效"); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "保存" })); @@ -3433,6 +3434,51 @@ describe("ProfileEditor", () => { ); }); + it("reports when the default status line script is already up to date", async () => { + renderEditor(); + invokeMock.mockImplementation(async (command: string, payload?: unknown) => { + if (command === "install_status_line_preset") { + expect(payload).toEqual({ + presetId: "default", + overwrite: false, + }); + return { + presetId: "default", + targetPath: "/Users/test/.claude/statusline.sh", + commandPath: "~/.claude/statusline.sh", + installed: false, + needsOverwrite: false, + }; + } + if (command === "preview_profile") { + const settings = + (payload as { data?: { settings?: Record } } | undefined)?.data + ?.settings ?? {}; + return JSON.stringify( + { + $schema: "https://json.schemastore.org/claude-code-settings.json", + ...settings, + }, + null, + 2, + ); + } + return null; + }); + + const statusLineSection = getSection("状态行"); + toggleAccordionSection("状态行"); + + await act(async () => { + fireEvent.click( + within(statusLineSection).getByRole("button", { name: "启用默认状态行预设" }), + ); + await Promise.resolve(); + }); + + expect(showToastMock).toHaveBeenCalledWith("默认状态行脚本已是最新;配置更改需保存后生效"); + }); + it("confirms before overwriting a different status line script", async () => { const { onSave } = renderEditor(); invokeMock.mockImplementation(async (command: string, payload?: unknown) => { diff --git a/src/components/profile-editor/StatusLineEditor.tsx b/src/components/profile-editor/StatusLineEditor.tsx index 4f1cb67..b025261 100644 --- a/src/components/profile-editor/StatusLineEditor.tsx +++ b/src/components/profile-editor/StatusLineEditor.tsx @@ -90,6 +90,14 @@ function StatusLineEditor({ value, onChange, onError, showTitle = true }: Status setOverwriteDialogOpen(false); applyInstalledPreset(result.commandPath); + // 成功分支补一条 Toast:区分「新安装」与「已是最新」,避免重复点击时无反馈 + showToast( + t( + result.installed + ? "profileEditor.statusLine.installPresetSuccess" + : "profileEditor.statusLine.installPresetAlreadyUpToDate", + ), + ); } catch (error) { showToast(t(getInstallPresetErrorKey(error)), "error"); } finally { diff --git a/src/i18n.ts b/src/i18n.ts index 8b41525..bb8a508 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -751,6 +751,9 @@ const translations = { "profileEditor.statusLine.installDefaultPreset": "启用默认状态行预设", "profileEditor.statusLine.installPresetError": "启用状态行预设失败", "profileEditor.statusLine.installPresetUnsupportedPlatform": "当前平台不支持默认状态行预设", + "profileEditor.statusLine.installPresetSuccess": "默认状态行脚本已安装;配置更改需保存后生效", + "profileEditor.statusLine.installPresetAlreadyUpToDate": + "默认状态行脚本已是最新;配置更改需保存后生效", "profileEditor.statusLine.overwriteDialogTitle": "覆盖状态行脚本", "profileEditor.statusLine.overwriteDialogMessage": "~/.claude/statusline.sh 已存在且内容不同。确认后会覆盖该脚本并设为可执行。", @@ -2440,6 +2443,10 @@ const translations = { "profileEditor.statusLine.installPresetError": "Failed to enable status line preset", "profileEditor.statusLine.installPresetUnsupportedPlatform": "The default status line preset is not supported on the current platform", + "profileEditor.statusLine.installPresetSuccess": + "Default status line script installed; configuration changes take effect after saving", + "profileEditor.statusLine.installPresetAlreadyUpToDate": + "Default status line script is already up to date; configuration changes take effect after saving", "profileEditor.statusLine.overwriteDialogTitle": "Overwrite Status Line Script", "profileEditor.statusLine.overwriteDialogMessage": "~/.claude/statusline.sh already exists with different content. Confirm to overwrite it and make it executable.", From 725307cebad3de973ef470c27c6071bb95b08429 Mon Sep 17 00:00:00 2001 From: maguowei Date: Sun, 5 Jul 2026 10:40:37 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(tray):=20=E9=80=80=E5=87=BA=E5=89=8D?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=89=98=E7=9B=98=E5=9B=BE=E6=A0=87=E9=81=BF?= =?UTF-8?q?=E5=85=8D=20macOS=20=E8=8F=9C=E5=8D=95=E6=A0=8F=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E8=A2=AB=E5=A4=8D=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/src/tray.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 1a90452..d219692 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1262,6 +1262,11 @@ pub fn setup_tray(app: &tauri::App) -> tauri::Result<()> { let _ = crate::widget::toggle_floating_widget(app.clone(), !visible); } "quit" => { + // macOS 上菜单栏(status item)应用若以非正常路径退出,FrontBoard 会把它标记为 + // after-life.interrupted 并通过 MenuBarAgent 复活以恢复状态栏图标,表现为“退出后自动重开”。 + // 退出前先销毁两个托盘状态栏图标,让系统没有可复活的菜单栏进程。 + let _ = app.remove_tray_by_id(MAIN_TRAY_ID); + let _ = app.remove_tray_by_id(SESSIONS_TRAY_ID); app.exit(0); } _ => {} From b7283145b174a4d5493872aa1c6acf13d0f322ba Mon Sep 17 00:00:00 2001 From: maguowei Date: Sun, 5 Jul 2026 12:01:16 +0800 Subject: [PATCH 4/5] =?UTF-8?q?refactor(updater):=20=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=A8=AA=E5=B9=85=E7=83=AD=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E8=AE=A1=E7=AE=97=E5=B9=B6=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=86=97=E4=BD=99=E5=8F=96=E6=97=B6=E5=B0=81=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/UpdateBanner.tsx | 3 ++- src/components/UpdaterProvider.tsx | 9 ++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/components/UpdateBanner.tsx b/src/components/UpdateBanner.tsx index 86768ac..3f1bba1 100644 --- a/src/components/UpdateBanner.tsx +++ b/src/components/UpdateBanner.tsx @@ -22,7 +22,8 @@ export function UpdateBanner() { let actionLabel: string; if (status === "downloading") { message = t("update.downloading").replace("{percent}", String(progress)); - actionLabel = t("update.downloading").replace("{percent}", String(progress)); + // 下载中横幅文案与按钮文案一致,复用同一份已算好的字符串,避免热路径重复计算 + actionLabel = message; } else if (status === "ready") { message = t("update.readyBanner"); actionLabel = t("update.restartNow"); diff --git a/src/components/UpdaterProvider.tsx b/src/components/UpdaterProvider.tsx index 886fd67..23d9541 100644 --- a/src/components/UpdaterProvider.tsx +++ b/src/components/UpdaterProvider.tsx @@ -34,7 +34,7 @@ export function UpdaterProvider({ children }: { children: ReactNode }) { ) { return; } - lastCheckRef.current = nowMs(); + lastCheckRef.current = Date.now(); void checkRef.current({ silent: true }); }, []); @@ -57,7 +57,7 @@ export function UpdaterProvider({ children }: { children: ReactNode }) { useEffect(() => { if (!isTauri()) return; const onFocus = () => { - if (nowMs() - lastCheckRef.current < FOCUS_THROTTLE_MS) return; + if (Date.now() - lastCheckRef.current < FOCUS_THROTTLE_MS) return; autoCheck(); }; window.addEventListener("focus", onFocus); @@ -75,8 +75,3 @@ export function useUpdater(): AppUpdaterState { } return ctx; } - -// 抽出取时函数,便于测试;生产环境即 Date.now -function nowMs(): number { - return Date.now(); -} From 146dc11f677986c8688ab5578b48c6130aa112e5 Mon Sep 17 00:00:00 2001 From: maguowei Date: Sun, 5 Jul 2026 12:17:16 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(tray):=20=E5=9C=A8=20RunEvent::Exit=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=A7=BB=E9=99=A4=E6=89=98=E7=9B=98=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E8=A6=86=E7=9B=96=20Cmd+Q=20=E4=B8=8E=20relaunch=20?= =?UTF-8?q?=E9=80=80=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/lib.rs | 11 +++++++++-- src-tauri/src/tray.rs | 14 +++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 100d32c..61a301d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -307,8 +307,15 @@ pub fn run() { api.prevent_close(); } }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while running tauri application") + .run(|app_handle, event| { + // 所有退出路径(托盘 Quit / macOS Cmd+Q / 自更新 relaunch)最终都汇聚到 + // RunEvent::Exit,在此统一移除托盘图标,规避 macOS 菜单栏进程被系统复活。 + if let tauri::RunEvent::Exit = event { + tray::remove_trays(app_handle); + } + }); } /// debug/test 专用集成测试入口:让 `src-tauri/tests/` 能调用内部 command 实现与共享 helper。 diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index d219692..cad4fc8 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1262,11 +1262,6 @@ pub fn setup_tray(app: &tauri::App) -> tauri::Result<()> { let _ = crate::widget::toggle_floating_widget(app.clone(), !visible); } "quit" => { - // macOS 上菜单栏(status item)应用若以非正常路径退出,FrontBoard 会把它标记为 - // after-life.interrupted 并通过 MenuBarAgent 复活以恢复状态栏图标,表现为“退出后自动重开”。 - // 退出前先销毁两个托盘状态栏图标,让系统没有可复活的菜单栏进程。 - let _ = app.remove_tray_by_id(MAIN_TRAY_ID); - let _ = app.remove_tray_by_id(SESSIONS_TRAY_ID); app.exit(0); } _ => {} @@ -1278,6 +1273,15 @@ pub fn setup_tray(app: &tauri::App) -> tauri::Result<()> { Ok(()) } +/// 移除两个状态栏托盘图标。macOS 上应用退出前必须调用:否则 FrontBoard 会把菜单栏 +/// 应用标记为 after-life.interrupted 并通过 MenuBarAgent 复活以恢复状态栏图标, +/// 表现为“退出后自动重开”。挂在 RunEvent::Exit 上覆盖托盘 Quit / Cmd+Q / 自更新 +/// relaunch 等所有退出路径;非 macOS 平台调用无副作用(进程本就在退出)。 +pub(crate) fn remove_trays(app: &tauri::AppHandle) { + let _ = app.remove_tray_by_id(MAIN_TRAY_ID); + let _ = app.remove_tray_by_id(SESSIONS_TRAY_ID); +} + #[cfg(test)] mod tests { #[cfg(target_os = "macos")]