Files
CC-Switch/src/hooks/useDirectorySettings.ts
T
Jason 8b3ad9caf9 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.
2026-05-08 22:34:46 +08:00

374 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}