mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
8b3ad9caf9
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.
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { toast } from "sonner";
|
||
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"
|
||
| "gemini"
|
||
| "opencode"
|
||
| "openclaw"
|
||
| "hermes";
|
||
type DirectoryKey = "appConfig" | AppDirectoryKey;
|
||
|
||
export interface ResolvedDirectories {
|
||
appConfig: string;
|
||
claude: string;
|
||
codex: string;
|
||
gemini: string;
|
||
opencode: string;
|
||
openclaw: string;
|
||
hermes: string;
|
||
}
|
||
|
||
// Single source of truth for per-app directory metadata.
|
||
const APP_DIRECTORY_META: Record<
|
||
DirectoryAppId,
|
||
{ key: AppDirectoryKey; defaultFolder: string }
|
||
> = {
|
||
claude: { key: "claude", defaultFolder: ".claude" },
|
||
codex: { key: "codex", defaultFolder: ".codex" },
|
||
gemini: { key: "gemini", defaultFolder: ".gemini" },
|
||
opencode: { key: "opencode", defaultFolder: ".config/opencode" },
|
||
openclaw: { key: "openclaw", defaultFolder: ".openclaw" },
|
||
hermes: { key: "hermes", defaultFolder: ".hermes" },
|
||
};
|
||
|
||
const DIRECTORY_KEY_TO_SETTINGS_FIELD: Record<
|
||
AppDirectoryKey,
|
||
keyof SettingsFormState
|
||
> = {
|
||
claude: "claudeConfigDir",
|
||
codex: "codexConfigDir",
|
||
gemini: "geminiConfigDir",
|
||
opencode: "opencodeConfigDir",
|
||
openclaw: "openclawConfigDir",
|
||
hermes: "hermesConfigDir",
|
||
};
|
||
|
||
const sanitizeDir = (value?: string | null): string | undefined => {
|
||
if (!value) return undefined;
|
||
const trimmed = value.trim();
|
||
return trimmed.length > 0 ? trimmed : undefined;
|
||
};
|
||
|
||
const computeDefaultAppConfigDir = async (): Promise<string | undefined> => {
|
||
try {
|
||
const home = await homeDir();
|
||
return await join(home, ".cc-switch");
|
||
} catch (error) {
|
||
console.error(
|
||
"[useDirectorySettings] Failed to resolve default app config dir",
|
||
error,
|
||
);
|
||
return undefined;
|
||
}
|
||
};
|
||
|
||
const computeDefaultConfigDir = async (
|
||
app: DirectoryAppId,
|
||
): Promise<string | undefined> => {
|
||
try {
|
||
const home = await homeDir();
|
||
return await join(home, APP_DIRECTORY_META[app].defaultFolder);
|
||
} catch (error) {
|
||
console.error(
|
||
"[useDirectorySettings] Failed to resolve default config dir",
|
||
error,
|
||
);
|
||
return undefined;
|
||
}
|
||
};
|
||
|
||
export interface UseDirectorySettingsProps {
|
||
settings: SettingsFormState | null;
|
||
onUpdateSettings: (updates: Partial<SettingsFormState>) => void;
|
||
}
|
||
|
||
export interface UseDirectorySettingsResult {
|
||
appConfigDir?: string;
|
||
resolvedDirs: ResolvedDirectories;
|
||
isLoading: boolean;
|
||
initialAppConfigDir?: string;
|
||
updateDirectory: (app: DirectoryAppId, value?: string) => void;
|
||
updateAppConfigDir: (value?: string) => void;
|
||
browseDirectory: (app: DirectoryAppId) => Promise<void>;
|
||
browseAppConfigDir: () => Promise<void>;
|
||
resetDirectory: (app: DirectoryAppId) => Promise<void>;
|
||
resetAppConfigDir: () => Promise<void>;
|
||
resetAllDirectories: (overrides?: ResolvedAppDirectoryOverrides) => void;
|
||
}
|
||
|
||
export type ResolvedAppDirectoryOverrides = Partial<
|
||
Record<AppDirectoryKey, string | undefined>
|
||
>;
|
||
|
||
/**
|
||
* useDirectorySettings - 目录管理
|
||
* 负责:
|
||
* - appConfigDir 状态
|
||
* - resolvedDirs 状态
|
||
* - 目录选择(browse)
|
||
* - 目录重置
|
||
* - 默认值计算
|
||
*/
|
||
export function useDirectorySettings({
|
||
settings,
|
||
onUpdateSettings,
|
||
}: UseDirectorySettingsProps): UseDirectorySettingsResult {
|
||
const { t } = useTranslation();
|
||
|
||
const [appConfigDir, setAppConfigDir] = useState<string | undefined>(
|
||
undefined,
|
||
);
|
||
const [resolvedDirs, setResolvedDirs] = useState<ResolvedDirectories>({
|
||
appConfig: "",
|
||
claude: "",
|
||
codex: "",
|
||
gemini: "",
|
||
opencode: "",
|
||
openclaw: "",
|
||
hermes: "",
|
||
});
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
|
||
const defaultsRef = useRef<ResolvedDirectories>({
|
||
appConfig: "",
|
||
claude: "",
|
||
codex: "",
|
||
gemini: "",
|
||
opencode: "",
|
||
openclaw: "",
|
||
hermes: "",
|
||
});
|
||
const initialAppConfigDirRef = useRef<string | undefined>(undefined);
|
||
|
||
// 加载目录信息
|
||
useEffect(() => {
|
||
let active = true;
|
||
setIsLoading(true);
|
||
|
||
const load = async () => {
|
||
try {
|
||
const [
|
||
overrideRaw,
|
||
claudeDir,
|
||
codexDir,
|
||
geminiDir,
|
||
opencodeDir,
|
||
openclawDir,
|
||
hermesDir,
|
||
defaultAppConfig,
|
||
defaultClaudeDir,
|
||
defaultCodexDir,
|
||
defaultGeminiDir,
|
||
defaultOpencodeDir,
|
||
defaultOpenclawDir,
|
||
defaultHermesDir,
|
||
] = await Promise.all([
|
||
settingsApi.getAppConfigDirOverride(),
|
||
settingsApi.getConfigDir("claude"),
|
||
settingsApi.getConfigDir("codex"),
|
||
settingsApi.getConfigDir("gemini"),
|
||
settingsApi.getConfigDir("opencode"),
|
||
settingsApi.getConfigDir("openclaw"),
|
||
settingsApi.getConfigDir("hermes"),
|
||
computeDefaultAppConfigDir(),
|
||
computeDefaultConfigDir("claude"),
|
||
computeDefaultConfigDir("codex"),
|
||
computeDefaultConfigDir("gemini"),
|
||
computeDefaultConfigDir("opencode"),
|
||
computeDefaultConfigDir("openclaw"),
|
||
computeDefaultConfigDir("hermes"),
|
||
]);
|
||
|
||
if (!active) return;
|
||
|
||
const normalizedOverride = sanitizeDir(overrideRaw ?? undefined);
|
||
|
||
defaultsRef.current = {
|
||
appConfig: defaultAppConfig ?? "",
|
||
claude: defaultClaudeDir ?? "",
|
||
codex: defaultCodexDir ?? "",
|
||
gemini: defaultGeminiDir ?? "",
|
||
opencode: defaultOpencodeDir ?? "",
|
||
openclaw: defaultOpenclawDir ?? "",
|
||
hermes: defaultHermesDir ?? "",
|
||
};
|
||
|
||
setAppConfigDir(normalizedOverride);
|
||
initialAppConfigDirRef.current = normalizedOverride;
|
||
|
||
setResolvedDirs({
|
||
appConfig: normalizedOverride ?? defaultsRef.current.appConfig,
|
||
claude: claudeDir || defaultsRef.current.claude,
|
||
codex: codexDir || defaultsRef.current.codex,
|
||
gemini: geminiDir || defaultsRef.current.gemini,
|
||
opencode: opencodeDir || defaultsRef.current.opencode,
|
||
openclaw: openclawDir || defaultsRef.current.openclaw,
|
||
hermes: hermesDir || defaultsRef.current.hermes,
|
||
});
|
||
} catch (error) {
|
||
console.error(
|
||
"[useDirectorySettings] Failed to load directory info",
|
||
error,
|
||
);
|
||
} finally {
|
||
if (active) {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
void load();
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, []);
|
||
|
||
const updateDirectoryState = useCallback(
|
||
(key: DirectoryKey, value?: string) => {
|
||
const sanitized = sanitizeDir(value);
|
||
if (key === "appConfig") {
|
||
setAppConfigDir(sanitized);
|
||
} else {
|
||
onUpdateSettings({
|
||
[DIRECTORY_KEY_TO_SETTINGS_FIELD[key]]: sanitized,
|
||
});
|
||
}
|
||
|
||
setResolvedDirs((prev) => {
|
||
const next = sanitized ?? defaultsRef.current[key];
|
||
// Same-ref early-return: unchanged value shouldn't cascade renders
|
||
// through the settings tree.
|
||
if (prev[key] === next) return prev;
|
||
return { ...prev, [key]: next };
|
||
});
|
||
},
|
||
[onUpdateSettings],
|
||
);
|
||
|
||
const updateAppConfigDir = useCallback(
|
||
(value?: string) => {
|
||
updateDirectoryState("appConfig", value);
|
||
},
|
||
[updateDirectoryState],
|
||
);
|
||
|
||
const updateDirectory = useCallback(
|
||
(app: DirectoryAppId, value?: string) => {
|
||
updateDirectoryState(APP_DIRECTORY_META[app].key, value);
|
||
},
|
||
[updateDirectoryState],
|
||
);
|
||
|
||
const browseDirectory = useCallback(
|
||
async (app: DirectoryAppId) => {
|
||
const key = APP_DIRECTORY_META[app].key;
|
||
const settingsField = DIRECTORY_KEY_TO_SETTINGS_FIELD[key];
|
||
const currentValue =
|
||
(settings?.[settingsField] as string | undefined) ?? resolvedDirs[key];
|
||
|
||
try {
|
||
const picked = await settingsApi.selectConfigDirectory(currentValue);
|
||
const sanitized = sanitizeDir(picked ?? undefined);
|
||
if (!sanitized) return;
|
||
updateDirectoryState(key, sanitized);
|
||
} catch (error) {
|
||
console.error("[useDirectorySettings] Failed to pick directory", error);
|
||
toast.error(
|
||
t("settings.selectFileFailed", {
|
||
defaultValue: "选择目录失败",
|
||
}),
|
||
);
|
||
}
|
||
},
|
||
[settings, resolvedDirs, t, updateDirectoryState],
|
||
);
|
||
|
||
const browseAppConfigDir = useCallback(async () => {
|
||
const currentValue = appConfigDir ?? resolvedDirs.appConfig;
|
||
try {
|
||
const picked = await settingsApi.selectConfigDirectory(currentValue);
|
||
const sanitized = sanitizeDir(picked ?? undefined);
|
||
if (!sanitized) return;
|
||
updateDirectoryState("appConfig", sanitized);
|
||
} catch (error) {
|
||
console.error(
|
||
"[useDirectorySettings] Failed to pick app config directory",
|
||
error,
|
||
);
|
||
toast.error(
|
||
t("settings.selectFileFailed", {
|
||
defaultValue: "选择目录失败",
|
||
}),
|
||
);
|
||
}
|
||
}, [appConfigDir, resolvedDirs.appConfig, t, updateDirectoryState]);
|
||
|
||
const resetDirectory = useCallback(
|
||
async (app: DirectoryAppId) => {
|
||
const key = APP_DIRECTORY_META[app].key;
|
||
if (!defaultsRef.current[key]) {
|
||
const fallback = await computeDefaultConfigDir(app);
|
||
if (fallback) {
|
||
defaultsRef.current = {
|
||
...defaultsRef.current,
|
||
[key]: fallback,
|
||
};
|
||
}
|
||
}
|
||
updateDirectoryState(key, undefined);
|
||
},
|
||
[updateDirectoryState],
|
||
);
|
||
|
||
const resetAppConfigDir = useCallback(async () => {
|
||
if (!defaultsRef.current.appConfig) {
|
||
const fallback = await computeDefaultAppConfigDir();
|
||
if (fallback) {
|
||
defaultsRef.current = {
|
||
...defaultsRef.current,
|
||
appConfig: fallback,
|
||
};
|
||
}
|
||
}
|
||
updateDirectoryState("appConfig", undefined);
|
||
}, [updateDirectoryState]);
|
||
|
||
const resetAllDirectories = useCallback(
|
||
(overrides?: ResolvedAppDirectoryOverrides) => {
|
||
setAppConfigDir(initialAppConfigDirRef.current);
|
||
setResolvedDirs({
|
||
appConfig:
|
||
initialAppConfigDirRef.current ?? defaultsRef.current.appConfig,
|
||
claude: overrides?.claude ?? defaultsRef.current.claude,
|
||
codex: overrides?.codex ?? defaultsRef.current.codex,
|
||
gemini: overrides?.gemini ?? defaultsRef.current.gemini,
|
||
opencode: overrides?.opencode ?? defaultsRef.current.opencode,
|
||
openclaw: overrides?.openclaw ?? defaultsRef.current.openclaw,
|
||
hermes: overrides?.hermes ?? defaultsRef.current.hermes,
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
|
||
return {
|
||
appConfigDir,
|
||
resolvedDirs,
|
||
isLoading,
|
||
initialAppConfigDir: initialAppConfigDirRef.current,
|
||
updateDirectory,
|
||
updateAppConfigDir,
|
||
browseDirectory,
|
||
browseAppConfigDir,
|
||
resetDirectory,
|
||
resetAppConfigDir,
|
||
resetAllDirectories,
|
||
};
|
||
}
|