feat(mcp): add update notification badge

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-08-05 15:52:20 +08:00
1 parent 7243f5eb06
commit 62624786dc
15 files changed
+279 -3

No files matched your search

@@ -0,0 +1,138 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useMcpUpdateBadge } from "@/composables/useMcpUpdateBadge";
import { beginMcpStatusRequest } from "@/lib/mcp/mcpUpdateStatus";
import type { McpServerStatus } from "@/lib/backend/tauri";
const apiMock = vi.hoisted(() => ({
checkMcpServerStatus: vi.fn<() => Promise<McpServerStatus>>(),
}));
vi.mock("@/lib/backend/api", () => apiMock);
const mockedCheck = apiMock.checkMcpServerStatus;
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
}
function deferred<T>(): Deferred<T> {
let resolve!: Deferred<T>["resolve"];
let reject!: Deferred<T>["reject"];
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function makeStatus(update_available: boolean, overrides: Partial<McpServerStatus> = {}): McpServerStatus {
return {
installed: true,
npm_available: true,
node_path: null,
node_version: null,
current_version: "1.0.0",
latest_version: "1.0.0",
update_available,
bin_path: null,
native_bin_path: null,
script_path: null,
data_dir: null,
install_command: "",
update_command: "",
error: null,
...overrides,
};
}
function makeBadge(enabled = true, isDesktop = true) {
return useMcpUpdateBadge({
isDesktop,
updateNotificationsEnabled: () => enabled,
});
}
beforeEach(() => {
mockedCheck.mockReset();
});
describe("useMcpUpdateBadge", () => {
it("更新通知关闭时不发起检查", async () => {
const badge = makeBadge(false);
await badge.refreshMcpUpdateStatus();
expect(mockedCheck).not.toHaveBeenCalled();
expect(badge.mcpUpdateAvailable.value).toBe(false);
});
it("update_available 为真时置亮红点", async () => {
mockedCheck.mockResolvedValue(makeStatus(true));
const badge = makeBadge(true);
await badge.refreshMcpUpdateStatus();
expect(badge.mcpUpdateAvailable.value).toBe(true);
});
it("更新后通过事件 payload 清除红点", () => {
const badge = makeBadge(true);
badge.applyMcpStatus(true);
expect(badge.mcpUpdateAvailable.value).toBe(true);
badge.handleMcpStatusChanged(new CustomEvent("dbx:mcp-status-changed", { detail: { updateAvailable: false } }));
expect(badge.mcpUpdateAvailable.value).toBe(false);
});
it("乱序响应保护:旧请求晚返回不覆盖新结果", async () => {
const pending = deferred<McpServerStatus>();
mockedCheck.mockReturnValueOnce(pending.promise);
const badge = makeBadge(true);
const refreshA = badge.refreshMcpUpdateStatus();
// 用户升级,事件回传 false,使在途的请求 A 失效
badge.handleMcpStatusChanged(new CustomEvent("dbx:mcp-status-changed", { detail: { updateAvailable: false } }));
expect(badge.mcpUpdateAvailable.value).toBe(false);
// 旧请求 A 晚返回 true(升级前的状态),必须被忽略
pending.resolve(makeStatus(true));
await refreshA;
expect(badge.mcpUpdateAvailable.value).toBe(false);
});
it("忽略早于当前根组件请求的设置页事件", async () => {
const settingsRequestId = beginMcpStatusRequest();
const pending = deferred<McpServerStatus>();
mockedCheck.mockReturnValueOnce(pending.promise);
const badge = makeBadge(true);
const refresh = badge.refreshMcpUpdateStatus();
badge.handleMcpStatusChanged(new CustomEvent("dbx-mcp-status-changed", { detail: { updateAvailable: true, requestId: settingsRequestId } }));
expect(badge.mcpUpdateAvailable.value).toBe(false);
pending.resolve(makeStatus(true));
await refresh;
expect(badge.mcpUpdateAvailable.value).toBe(true);
});
it("registry 结果未知时保留已知红点", async () => {
mockedCheck.mockResolvedValue(makeStatus(false, { latest_version: null }));
const badge = makeBadge(true);
badge.applyMcpStatus(true);
await badge.refreshMcpUpdateStatus();
expect(badge.mcpUpdateAvailable.value).toBe(true);
});
it("Web 端(非桌面)不发起检查", async () => {
const badge = makeBadge(true, false);
await badge.refreshMcpUpdateStatus();
expect(mockedCheck).not.toHaveBeenCalled();
expect(badge.mcpUpdateAvailable.value).toBe(false);
});
it("无 payload 的事件回退到重新检查", async () => {
mockedCheck.mockResolvedValue(makeStatus(true));
const badge = makeBadge(true);
badge.handleMcpStatusChanged(new CustomEvent("dbx:mcp-status-changed"));
await vi.waitFor(() => expect(badge.mcpUpdateAvailable.value).toBe(true));
});
});
@@ -0,0 +1,63 @@
import { ref } from "vue";
import * as api from "@/lib/backend/api";
import { beginMcpStatusRequest, isLatestMcpStatusRequest, mcpUpdateAvailability } from "@/lib/mcp/mcpUpdateStatus";
interface UseMcpUpdateBadgeOptions {
isDesktop: boolean;
updateNotificationsEnabled: () => boolean;
}
/**
* MCP server 更新徽章状态。
*
* 照搬 app/驱动两套 badge 模式:后台 silent 轮询 + computed 驱动红点 + 事件回传。
* 通过递增请求序号忽略过期响应,避免“定时检查旧请求晚返回、覆盖升级后新结果”的竞态。
*/
export function useMcpUpdateBadge(options: UseMcpUpdateBadgeOptions) {
const mcpUpdateAvailable = ref(false);
async function refreshMcpUpdateStatus() {
if (!options.isDesktop || !options.updateNotificationsEnabled()) return;
const requestId = beginMcpStatusRequest();
try {
const status = await api.checkMcpServerStatus();
if (!isLatestMcpStatusRequest(requestId)) return;
if (!options.updateNotificationsEnabled()) return;
const updateAvailable = mcpUpdateAvailability(status);
if (updateAvailable !== null) mcpUpdateAvailable.value = updateAvailable;
} catch {
// MCP 状态仅作徽章提示;取不到就保持原值,不打扰用户。
}
}
/**
* EditorSettingsDialog 刷新/升级后通过事件回传已获取的 update_available,
* 避免根组件重复查询 npm registry,同时使在途的定时检查失效。
*/
function applyMcpStatus(updateAvailable: boolean, requestId?: number) {
if (requestId !== undefined) {
if (!isLatestMcpStatusRequest(requestId)) return;
} else {
beginMcpStatusRequest();
}
mcpUpdateAvailable.value = updateAvailable;
}
function handleMcpStatusChanged(event: Event) {
const detail = (event as CustomEvent<{ updateAvailable?: boolean | null; requestId?: number } | null | undefined>).detail;
if (detail && typeof detail.updateAvailable === "boolean") {
applyMcpStatus(detail.updateAvailable, detail.requestId);
} else if (detail && typeof detail.requestId === "number") {
return;
} else {
void refreshMcpUpdateStatus();
}
}
return {
mcpUpdateAvailable,
refreshMcpUpdateStatus,
handleMcpStatusChanged,
applyMcpStatus,
};
}