feat(claude-desktop): add 3P provider switching with proxy gateway

Adds a new ClaudeDesktop AppType that writes Claude Desktop's third-party
inference profile under configLibrary/, sharing _meta.json with other
launchers (Ollama-compatible) so cc-switch can coexist with them.

Two switch modes:
- direct: provider already exposes claude-* / anthropic/claude-* model
  ids on Anthropic Messages, Claude Desktop connects to it directly.
- proxy: cc-switch's local proxy acts as the inference gateway,
  presenting only claude-* route names to Claude Desktop and mapping
  them to real upstream models. Required after Anthropic restricted
  Claude Desktop to claude-family ids.

Backend:
- New module claude_desktop_config with snapshot/rollback, official seed
  bypass, /claude-desktop/v1/{models,messages} routes, and a single
  source of truth for default proxy routes.
- Gateway token persisted in SQLite, validated on every proxied request.
- get_claude_desktop_status surfaces drift signals (stale models,
  missing routes, proxy stopped, base URL mismatch, missing token).

Frontend:
- Slim ClaudeDesktopProviderForm independent from ProviderForm,
  controlled by a top-level appId guard.
- ProviderList banner consumes the status query (5s polling) and
  renders actionable diagnostics.
- ClaudeDesktopRouteToggle in the header to start/stop the local
  gateway without touching takeover state.
- Three-locale i18n synchronised.
This commit is contained in:
Jason
2026-05-08 11:50:01 +08:00
parent e15bfbfe7a
commit 8b3ad9caf9
64 changed files with 3328 additions and 109 deletions
+9 -8
View File
@@ -5,6 +5,7 @@ import { homeDir, join } from "@tauri-apps/api/path";
import { settingsApi, type AppId } from "@/lib/api";
import type { SettingsFormState } from "./useSettingsForm";
export type DirectoryAppId = Exclude<AppId, "claude-desktop">;
type AppDirectoryKey =
| "claude"
| "codex"
@@ -26,7 +27,7 @@ export interface ResolvedDirectories {
// Single source of truth for per-app directory metadata.
const APP_DIRECTORY_META: Record<
AppId,
DirectoryAppId,
{ key: AppDirectoryKey; defaultFolder: string }
> = {
claude: { key: "claude", defaultFolder: ".claude" },
@@ -69,7 +70,7 @@ const computeDefaultAppConfigDir = async (): Promise<string | undefined> => {
};
const computeDefaultConfigDir = async (
app: AppId,
app: DirectoryAppId,
): Promise<string | undefined> => {
try {
const home = await homeDir();
@@ -93,11 +94,11 @@ export interface UseDirectorySettingsResult {
resolvedDirs: ResolvedDirectories;
isLoading: boolean;
initialAppConfigDir?: string;
updateDirectory: (app: AppId, value?: string) => void;
updateDirectory: (app: DirectoryAppId, value?: string) => void;
updateAppConfigDir: (value?: string) => void;
browseDirectory: (app: AppId) => Promise<void>;
browseDirectory: (app: DirectoryAppId) => Promise<void>;
browseAppConfigDir: () => Promise<void>;
resetDirectory: (app: AppId) => Promise<void>;
resetDirectory: (app: DirectoryAppId) => Promise<void>;
resetAppConfigDir: () => Promise<void>;
resetAllDirectories: (overrides?: ResolvedAppDirectoryOverrides) => void;
}
@@ -259,14 +260,14 @@ export function useDirectorySettings({
);
const updateDirectory = useCallback(
(app: AppId, value?: string) => {
(app: DirectoryAppId, value?: string) => {
updateDirectoryState(APP_DIRECTORY_META[app].key, value);
},
[updateDirectoryState],
);
const browseDirectory = useCallback(
async (app: AppId) => {
async (app: DirectoryAppId) => {
const key = APP_DIRECTORY_META[app].key;
const settingsField = DIRECTORY_KEY_TO_SETTINGS_FIELD[key];
const currentValue =
@@ -310,7 +311,7 @@ export function useDirectorySettings({
}, [appConfigDir, resolvedDirs.appConfig, t, updateDirectoryState]);
const resetDirectory = useCallback(
async (app: AppId) => {
async (app: DirectoryAppId) => {
const key = APP_DIRECTORY_META[app].key;
if (!defaultsRef.current[key]) {
const fallback = await computeDefaultConfigDir(app);
+23 -6
View File
@@ -171,6 +171,13 @@ export function useProviderActions(
proxyRequiredReason = t("notifications.proxyReasonOpenAIResponses", {
defaultValue: "使用 OpenAI Responses 接口格式",
});
} else if (
activeApp === "claude-desktop" &&
provider.meta?.claudeDesktopMode === "proxy"
) {
proxyRequiredReason = t("notifications.proxyReasonClaudeDesktop", {
defaultValue: "使用 Claude Desktop 本地路由模式",
});
} else if (
provider.meta?.isFullUrl &&
(activeApp === "claude" || activeApp === "codex")
@@ -223,12 +230,22 @@ export function useProviderActions(
// OpenCode/OpenClaw: show "added to config" message instead of "switched"
const isMultiProviderApp =
activeApp === "opencode" || activeApp === "openclaw";
const messageKey = isMultiProviderApp
? "notifications.addToConfigSuccess"
: "notifications.switchSuccess";
const defaultMessage = isMultiProviderApp
? "已添加到配置"
: "切换成功!";
const messageKey =
activeApp === "claude-desktop"
? provider.meta?.claudeDesktopMode === "proxy"
? "notifications.claudeDesktopProxyRestartRequired"
: "notifications.claudeDesktopRestartRequired"
: isMultiProviderApp
? "notifications.addToConfigSuccess"
: "notifications.switchSuccess";
const defaultMessage =
activeApp === "claude-desktop"
? provider.meta?.claudeDesktopMode === "proxy"
? "切换成功,请保持 CC Switch 运行,并重启 Claude Desktop 后生效"
: "切换成功,重启 Claude Desktop 后生效"
: isMultiProviderApp
? "已添加到配置"
: "切换成功!";
toast.success(t(messageKey, { defaultValue: defaultMessage }), {
closeButton: true,
+28
View File
@@ -64,6 +64,31 @@ export function useProxyStatus() {
},
});
// 停止服务器(仅停止服务,不改写/恢复其它应用接管状态)
const stopProxyServerMutation = useMutation({
mutationFn: () => invoke("stop_proxy_server"),
onSuccess: () => {
toast.success(
t("proxy.server.stopped", {
defaultValue: "代理服务已停止",
}),
{ closeButton: true },
);
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
},
onError: (error: Error) => {
const detail =
extractErrorMessage(error) ||
t("common.unknown", { defaultValue: "未知错误" });
toast.error(
t("proxy.server.stopFailed", {
detail,
defaultValue: `停止代理服务失败: ${detail}`,
}),
);
},
});
// 停止服务器(总开关关闭:强制恢复所有已接管的 Live 配置)
const stopWithRestoreMutation = useMutation({
mutationFn: () => invoke("stop_proxy_with_restore"),
@@ -194,6 +219,7 @@ export function useProxyStatus() {
// 启动/停止(总开关)
startProxyServer: startProxyServerMutation.mutateAsync,
stopProxyServer: stopProxyServerMutation.mutateAsync,
stopWithRestore: stopWithRestoreMutation.mutateAsync,
// 按应用接管开关
@@ -208,9 +234,11 @@ export function useProxyStatus() {
// 加载状态
isStarting: startProxyServerMutation.isPending,
isStoppingServer: stopProxyServerMutation.isPending,
isStopping: stopWithRestoreMutation.isPending,
isPending:
startProxyServerMutation.isPending ||
stopProxyServerMutation.isPending ||
stopWithRestoreMutation.isPending ||
setTakeoverForAppMutation.isPending,
};
+5 -4
View File
@@ -2,13 +2,14 @@ import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { providersApi, settingsApi, type AppId } from "@/lib/api";
import { providersApi, settingsApi } from "@/lib/api";
import { syncCurrentProvidersLiveSafe } from "@/utils/postChangeSync";
import { useSettingsQuery, useSaveSettingsMutation } from "@/lib/query";
import type { Settings } from "@/types";
import { useSettingsForm, type SettingsFormState } from "./useSettingsForm";
import {
useDirectorySettings,
type DirectoryAppId,
type ResolvedDirectories,
} from "./useDirectorySettings";
import { useSettingsMetadata } from "./useSettingsMetadata";
@@ -28,11 +29,11 @@ export interface UseSettingsResult {
resolvedDirs: ResolvedDirectories;
requiresRestart: boolean;
updateSettings: (updates: Partial<SettingsFormState>) => void;
updateDirectory: (app: AppId, value?: string) => void;
updateDirectory: (app: DirectoryAppId, value?: string) => void;
updateAppConfigDir: (value?: string) => void;
browseDirectory: (app: AppId) => Promise<void>;
browseDirectory: (app: DirectoryAppId) => Promise<void>;
browseAppConfigDir: () => Promise<void>;
resetDirectory: (app: AppId) => Promise<void>;
resetDirectory: (app: DirectoryAppId) => Promise<void>;
resetAppConfigDir: () => Promise<void>;
saveSettings: (
overrides?: Partial<SettingsFormState>,