feat(settings): add update check and MCP uninstall

This commit is contained in:
大熊 authored and GitHub committed 2026-08-15 14:44:46 +08:00
1 parent 745483cd6c
commit 2ea5e8e9b9
12 files changed
+189 -9

No files matched your search

+2
View File
@@ -2652,8 +2652,10 @@ onUnmounted(() => {
:initial-section="settingsInitialSection"
:navigation-request-id="settingsNavigationRequestId"
:app-version="appVersion"
:checking-updates="checkingUpdates"
class="flex-1 min-h-0"
@update:open="(open: boolean) => (open ? activateSettingsPage() : closeSettingsPage())"
@check-updates="checkUpdates()"
/>
<div v-if="activeTab" v-show="!driverStoreActive && !settingsStore.settingsPageActive" class="flex flex-col flex-1 min-h-0">
<EditorToolbar
@@ -67,6 +67,7 @@ import {
aiTestConnection,
checkMcpServerStatus,
installMcpServer,
uninstallMcpServer,
forgetSnippetSavedToken,
forgetWebdavSyncSecretsPassphrase,
forgetWebdavSavedPassword,
@@ -196,10 +197,12 @@ const props = defineProps<{
initialSection?: string;
navigationRequestId?: number;
appVersion?: string;
checkingUpdates?: boolean;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
"check-updates": [];
}>();
const isSettingsPage = computed(() => props.variant === "page");
@@ -1723,7 +1726,7 @@ async function exportDebugLogs() {
// ---------- MCP Server ----------
type McpConfigTab = "claude" | "cursor" | "trae" | "vscode" | "windsurf" | "codex" | "opencode" | "cherry-studio";
type McpCopyKind = "install" | `${McpConfigTab}-config`;
type McpCopyKind = "install" | "uninstall" | `${McpConfigTab}-config`;
const mcpStatus = ref<McpServerStatus | null>(null);
const mcpStatusLoading = ref(false);
@@ -1736,6 +1739,7 @@ const mcpPolicyLoading = ref(false);
const mcpPolicySaving = ref(false);
const mcpPolicyLoadError = ref("");
const mcpInstalling = ref(false);
const mcpUninstalling = ref(false);
const mcpInstallMessage = ref("");
const mcpInstallError = ref(false);
const mcpExecutionMode = computed(() => mcpExecutionModeFromPolicy(settingsStore.mcpGlobalPolicy));
@@ -1854,6 +1858,8 @@ const mcpCommand = computed(() => {
return mcpStatus.value.installed ? mcpStatus.value.update_command : mcpStatus.value.install_command;
});
const mcpUninstallCommand = computed(() => mcpStatus.value?.uninstall_command || "npm uninstall -g @dbx-app/mcp-server");
async function refreshMcpStatus() {
if (mcpStatusLoading.value) return;
mcpStatusLoading.value = true;
@@ -1888,7 +1894,7 @@ async function copyMcpText(kind: McpCopyKind, value: string) {
}
async function installMcp() {
if (mcpInstalling.value) return;
if (mcpInstalling.value || mcpUninstalling.value) return;
mcpInstalling.value = true;
mcpInstallMessage.value = "";
mcpInstallError.value = false;
@@ -1911,6 +1917,27 @@ async function installMcp() {
}
}
async function uninstallMcp() {
if (mcpInstalling.value || mcpUninstalling.value || !mcpStatus.value?.installed) return;
if (!window.confirm(t("settings.mcpUninstallConfirm"))) return;
mcpUninstalling.value = true;
mcpInstallMessage.value = "";
mcpInstallError.value = false;
try {
mcpInstallMessage.value = await uninstallMcpServer();
await refreshMcpStatus();
} catch (e: any) {
mcpInstallMessage.value = t("settings.mcpUninstallFailed", { error: e?.message || String(e) });
mcpInstallError.value = true;
} finally {
mcpUninstalling.value = false;
window.setTimeout(() => {
mcpInstallMessage.value = "";
mcpInstallError.value = false;
}, 3000);
}
}
// ---------- WebDAV Sync ----------
const webdavEndpoint = ref(localStorage.getItem("dbx-webdav-endpoint") || "");
const webdavUsername = ref(localStorage.getItem("dbx-webdav-username") || "");
@@ -2873,7 +2900,7 @@ const aiSupportsAnthropicApiStyle = computed(() => aiEditProvider.value === "cus
const aiCliMcpNeedsInstall = computed(() => aiIsCliProvider.value && (!mcpStatus.value || !mcpStatus.value.installed));
const aiCliMcpCanInstall = computed(() => {
const status = mcpStatus.value;
return !mcpInstalling.value && !!status?.npm_available && (!status.installed || status.update_available);
return !mcpInstalling.value && !mcpUninstalling.value && !!status?.npm_available && (!status.installed || status.update_available);
});
const aiCliMcpActionLabel = computed(() => {
if (!mcpStatus.value?.installed) return t("settings.mcpInstallButton");
@@ -6273,7 +6300,7 @@ onUnmounted(() => {
<CheckCircle2 v-if="mcpCopied === 'install'" class="h-4 w-4 text-green-500" />
<Copy v-else class="h-4 w-4" />
</Button>
<Button type="button" variant="default" :disabled="mcpInstalling || !mcpStatus?.npm_available || (mcpStatus?.installed && !mcpStatus?.update_available)" @click="installMcp">
<Button type="button" variant="default" :disabled="mcpInstalling || mcpUninstalling || !mcpStatus?.npm_available || (mcpStatus?.installed && !mcpStatus?.update_available)" @click="installMcp">
<Loader2 v-if="mcpInstalling" class="mr-2 h-4 w-4 animate-spin" />
<CheckCircle2 v-if="!mcpInstalling && mcpStatus?.installed && !mcpStatus?.update_available" class="mr-2 h-4 w-4" />
{{ mcpInstalling ? t("settings.mcpInstalling") : !mcpStatus?.installed ? t("settings.mcpInstallButton") : mcpStatus?.update_available ? t("settings.mcpUpdateButton") : t("settings.mcpUpToDate") }}
@@ -6287,6 +6314,24 @@ onUnmounted(() => {
</div>
</div>
<div v-if="!isWeb && mcpStatus?.installed" class="space-y-2">
<Label>{{ t("settings.mcpUninstallCommand") }}</Label>
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 flex-1 overflow-x-auto rounded-md border bg-background px-3 py-2 font-mono text-xs whitespace-nowrap">
{{ mcpUninstallCommand }}
</div>
<Button type="button" variant="outline" size="icon" :title="t('common.copy')" @click="copyMcpText('uninstall', mcpUninstallCommand)">
<CheckCircle2 v-if="mcpCopied === 'uninstall'" class="h-4 w-4 text-green-500" />
<Copy v-else class="h-4 w-4" />
</Button>
<Button type="button" variant="outline" class="text-destructive hover:text-destructive" :disabled="mcpInstalling || mcpUninstalling || !mcpStatus.npm_available" @click="uninstallMcp">
<Loader2 v-if="mcpUninstalling" class="mr-2 h-4 w-4 animate-spin" />
<Trash2 v-else class="mr-2 h-4 w-4" />
{{ mcpUninstalling ? t("settings.mcpUninstalling") : t("settings.mcpUninstallButton") }}
</Button>
</div>
</div>
<div class="space-y-2">
<p class="text-xs text-muted-foreground">
{{ t("settings.mcpConfigOptionsHint") }}
@@ -6611,7 +6656,7 @@ onUnmounted(() => {
</p>
</div>
<ChangelogPanel />
<ChangelogPanel :checking-updates="props.checkingUpdates" @check-updates="emit('check-updates')" />
<div class="rounded-lg border p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { ChevronDown, ExternalLink, Loader2, RefreshCw, Tag } from "@lucide/vue";
import { ChevronDown, CloudDownload, ExternalLink, Loader2, RefreshCw, Tag } from "@lucide/vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@@ -13,6 +13,19 @@ const PAGE_SIZE = 5;
const { t, locale } = useI18n();
withDefaults(
defineProps<{
checkingUpdates?: boolean;
}>(),
{
checkingUpdates: false,
},
);
const emit = defineEmits<{
"check-updates": [];
}>();
const loading = ref(false);
const error = ref("");
const releases = ref<ChangelogRelease[]>([]);
@@ -123,6 +136,11 @@ watch(changelogLang, (lang) => {
<p class="text-sm text-muted-foreground">{{ t("settings.changelogDescription") }}</p>
</div>
<div class="settings-about-section-actions flex shrink-0 flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" :disabled="checkingUpdates" @click="emit('check-updates')">
<Loader2 v-if="checkingUpdates" class="mr-1 h-3.5 w-3.5 animate-spin" />
<CloudDownload v-else class="mr-1 h-3.5 w-3.5" />
{{ t("updates.check") }}
</Button>
<Button type="button" variant="outline" size="sm" :disabled="loading" @click="openExternalUrl(changelogWebsiteUrl(changelogLang))">
<ExternalLink class="mr-1 h-3.5 w-3.5" />
{{ t("settings.changelogOpenWebsite") }}
+6 -1
View File
@@ -6144,12 +6144,17 @@ export default {
mcpBinPath: "Command path",
mcpInstallCommand: "Install command",
mcpUpdateCommand: "Upgrade command",
mcpUninstallCommand: "Uninstall command",
mcpInstallButton: "Install",
mcpUpdateButton: "Upgrade",
mcpUpToDate: "Up to date",
mcpInstalling: "Installing...",
mcpInstallSuccess: "Installation successful",
mcpInstallFailed: "Installation failed",
mcpUninstallButton: "Uninstall",
mcpUninstalling: "Uninstalling...",
mcpUninstallConfirm: "Uninstall DBX MCP Server? Configured MCP clients will no longer be able to start it.",
mcpUninstallFailed: "Uninstallation failed: {error}",
mcpConfig: "MCP config",
mcpConfigOptionsHint: "DBX centrally controls connection access and execution permissions for every MCP client. Generated configs contain only the runtime settings needed to start the MCP server and connect to this DBX instance, with no permission or connection-scope variables.",
mcpScopeConnection: "Connections allowed for MCP",
@@ -6216,7 +6221,7 @@ export default {
mcpPolicySaveFailed: "Failed to save the MCP access policy: {error}",
mcpPolicyLoadFailed: "Failed to load the MCP access policy: {error}",
mcpDetectionTiming: "DBX checks automatically when this page opens; use Check again to refresh.",
mcpNpmBoundary: "DBX only checks and explains MCP status; installation and upgrades still run through npm.",
mcpNpmBoundary: "DBX checks MCP status; installation, upgrades, and uninstallation run through the detected npm or pnpm runtime.",
mcpRefresh: "Check again",
mcpGuide: "MCP guide",
supportInfoTitle: "Support information",
+6 -1
View File
@@ -6133,12 +6133,17 @@ export default withEnglishFallback({
mcpBinPath: "命令路径",
mcpInstallCommand: "安装命令",
mcpUpdateCommand: "升级命令",
mcpUninstallCommand: "卸载命令",
mcpInstallButton: "安装",
mcpUpdateButton: "升级",
mcpUpToDate: "已是最新",
mcpInstalling: "安装中...",
mcpInstallSuccess: "安装成功",
mcpInstallFailed: "安装失败",
mcpUninstallButton: "卸载",
mcpUninstalling: "卸载中...",
mcpUninstallConfirm: "确定要卸载 DBX MCP Server 吗?卸载后,已配置的 MCP 客户端将无法启动它。",
mcpUninstallFailed: "卸载失败:{error}",
mcpConfig: "MCP 配置",
mcpConfigOptionsHint: "连接范围和执行权限均由 DBX 统一控制并对所有 MCP 客户端立即生效;生成配置只包含启动 MCP Server 和连接当前 DBX 所需的运行参数,不包含权限或连接范围变量。",
mcpScopeConnection: "允许 MCP 访问的连接",
@@ -6205,7 +6210,7 @@ export default withEnglishFallback({
mcpPolicySaveFailed: "保存 MCP 访问策略失败:{error}",
mcpPolicyLoadFailed: "加载 MCP 访问策略失败:{error}",
mcpDetectionTiming: "打开此页时会自动检测,点击重新检查可刷新。",
mcpNpmBoundary: "DBX 只检测和提示 MCP 状态;安装与升级仍由 npm 完成。",
mcpNpmBoundary: "DBX 负责检查 MCP 状态;安装、升级和卸载由检测到的 npm 或 pnpm 运行环境执行。",
mcpRefresh: "重新检查",
mcpGuide: "MCP 指南",
supportInfoTitle: "支持信息",
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const settingsDialogSource = readFileSync(new URL("../../../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8");
const tauriBackendSource = readFileSync(new URL("../../../lib/backend/tauri.ts", import.meta.url), "utf8");
const httpBackendSource = readFileSync(new URL("../../../lib/backend/http.ts", import.meta.url), "utf8");
describe("MCP server management", () => {
it("exposes a confirmed uninstall action only for an installed desktop server", () => {
expect(settingsDialogSource).toContain('v-if="!isWeb && mcpStatus?.installed"');
expect(settingsDialogSource).toContain('window.confirm(t("settings.mcpUninstallConfirm"))');
expect(settingsDialogSource).toContain("await uninstallMcpServer()");
expect(settingsDialogSource).toContain('t("settings.mcpUninstallCommand")');
expect(settingsDialogSource).toContain("copyMcpText('uninstall', mcpUninstallCommand)");
expect(tauriBackendSource).toContain('invoke("uninstall_mcp_server")');
expect(httpBackendSource).toContain("MCP Server uninstallation is only available in the desktop app.");
});
});
+1
View File
@@ -730,6 +730,7 @@ export const deleteHistoryEntry = forward("deleteHistoryEntry");
// Updates
export const checkMcpServerStatus = forward("checkMcpServerStatus");
export const installMcpServer = forward("installMcpServer");
export const uninstallMcpServer = forward("uninstallMcpServer");
export const checkForUpdates = forward("checkForUpdates");
export const fetchChangelog = forward("fetchChangelog");
export const getSystemProxyUrl = forward("getSystemProxyUrl");
+5
View File
@@ -3879,6 +3879,7 @@ export async function checkMcpServerStatus(): Promise<import("@/lib/backend/taur
data_dir: null,
install_command: "npm install -g @dbx-app/mcp-server@latest",
update_command: "npm install -g @dbx-app/mcp-server@latest",
uninstall_command: "npm uninstall -g @dbx-app/mcp-server",
error: "MCP Server status is only available in the desktop app.",
};
}
@@ -3887,6 +3888,10 @@ export async function installMcpServer(): Promise<string> {
throw new Error("MCP Server installation is only available in the desktop app.");
}
export async function uninstallMcpServer(): Promise<string> {
throw new Error("MCP Server uninstallation is only available in the desktop app.");
}
export async function getSystemProxyUrl(): Promise<string | null> {
return null;
}
+5
View File
@@ -2035,6 +2035,7 @@ export interface McpServerStatus {
data_dir: string | null;
install_command: string;
update_command: string;
uninstall_command: string;
error: string | null;
}
@@ -2046,6 +2047,10 @@ export async function installMcpServer(): Promise<string> {
return invoke("install_mcp_server");
}
export async function uninstallMcpServer(): Promise<string> {
return invoke("uninstall_mcp_server");
}
export async function checkForUpdates(locale?: string, source?: UpdateDownloadSource): Promise<UpdateInfo> {
return invoke("check_for_updates", { locale, source });
}
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
const settingsDialogSource = readFileSync(new URL("../../../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8");
const changelogPanelSource = readFileSync(new URL("../../../components/settings/ChangelogPanel.vue", import.meta.url), "utf8");
describe("about page update check", () => {
it("reuses the toolbar update check and pending state", () => {
expect(changelogPanelSource).toContain('"check-updates": []');
expect(changelogPanelSource).toContain("@click=\"emit('check-updates')\"");
expect(changelogPanelSource).toContain(':disabled="checkingUpdates"');
expect(settingsDialogSource).toContain(':checking-updates="props.checkingUpdates"');
expect(settingsDialogSource).toContain("@check-updates=\"emit('check-updates')\"");
expect(appSource).toContain(':checking-updates="checkingUpdates"');
expect(appSource).toContain('@check-updates="checkUpdates()"');
});
});