From 4176a915f56b8aefb7b0f35a0279abb00a3c9768 Mon Sep 17 00:00:00 2001 From: Gerard-Devlin Date: Sun, 14 Jun 2026 02:34:43 +0800 Subject: [PATCH] feat: enhance Claude setup with embedded variant and error handling - Updated `useClaudeSetupStore` to require Claude Code installation before saving provider credentials. - Modified `ClaudeSetup` component to support an embedded variant with improved UI for installation and configuration. - Added detailed status messages for missing Git and installation prompts. - Refactored project picker to include theme toggle and version display. - Improved layout and styling for settings and environment status sections. --- apps/desktop/src-tauri/src/claude.rs | 385 ++++++++++++++++++ .../stores/claude-setup-store.test.ts | 25 +- apps/desktop/src/components/claude-setup.tsx | 209 +++++++--- .../desktop/src/components/project-picker.tsx | 381 +++++++++-------- apps/desktop/src/stores/claude-setup-store.ts | 8 + 5 files changed, 777 insertions(+), 231 deletions(-) diff --git a/apps/desktop/src-tauri/src/claude.rs b/apps/desktop/src-tauri/src/claude.rs index 01c0658..10b1da1 100644 --- a/apps/desktop/src-tauri/src/claude.rs +++ b/apps/desktop/src-tauri/src/claude.rs @@ -108,6 +108,312 @@ pub(crate) fn is_essential_env_var(key: &str) -> bool { ) } +fn normalize_proxy_url_with_default(raw: &str, default_scheme: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + if trimmed.contains("://") { + Some(trimmed.to_string()) + } else { + Some(format!("{}://{}", default_scheme, trimmed)) + } +} + +fn normalize_proxy_url(raw: &str) -> Option { + normalize_proxy_url_with_default(raw, "http") +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProxyEnvVar { + key: &'static str, + value: String, + source: String, +} + +fn first_env_value(names: &[&str]) -> Option<(String, String)> { + for name in names { + let Ok(value) = std::env::var(name) else { + continue; + }; + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(((*name).to_string(), trimmed.to_string())); + } + } + + None +} + +fn explicit_proxy_env_vars() -> Vec { + let mut vars = Vec::new(); + let mut has_https_proxy = false; + let mut has_all_proxy = false; + let mut http_proxy = None; + + if let Some((source, raw)) = first_env_value(&["HTTPS_PROXY", "https_proxy"]) { + if let Some(value) = normalize_proxy_url(&raw) { + vars.push(ProxyEnvVar { + key: "HTTPS_PROXY", + value, + source, + }); + has_https_proxy = true; + } + } + + if let Some((source, raw)) = first_env_value(&["HTTP_PROXY", "http_proxy"]) { + if let Some(value) = normalize_proxy_url(&raw) { + http_proxy = Some((source.clone(), value.clone())); + vars.push(ProxyEnvVar { + key: "HTTP_PROXY", + value, + source, + }); + } + } + + if let Some((source, raw)) = first_env_value(&["ALL_PROXY", "all_proxy"]) { + if let Some(value) = normalize_proxy_url(&raw) { + vars.push(ProxyEnvVar { + key: "ALL_PROXY", + value, + source, + }); + has_all_proxy = true; + } + } + + if !has_https_proxy && !has_all_proxy { + if let Some((source, value)) = http_proxy { + vars.insert( + 0, + ProxyEnvVar { + key: "HTTPS_PROXY", + value, + source: format!("{} (HTTPS fallback)", source), + }, + ); + } + } + + vars +} + +#[cfg(target_os = "windows")] +fn windows_proxy_override_to_no_proxy_env(raw: &str) -> Option { + let entries = raw + .split([';', ',']) + .filter_map(|part| { + let trimmed = part.trim(); + if trimmed.is_empty() { + return None; + } + + if trimmed.eq_ignore_ascii_case("") { + return Some("localhost,127.0.0.1,::1".to_string()); + } + + if trimmed == "*" { + return Some(trimmed.to_string()); + } + + if trimmed.contains('*') { + return trimmed + .strip_prefix("*.") + .map(|domain| format!(".{}", domain.trim_start_matches('.'))); + } + + Some(trimmed.to_string()) + }) + .collect::>(); + + if entries.is_empty() { + None + } else { + Some(entries.join(",")) + } +} + +#[cfg(target_os = "windows")] +fn windows_system_no_proxy_env() -> Option { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let settings = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Internet Settings") + .ok()?; + let raw = settings.get_value::("ProxyOverride").ok()?; + windows_proxy_override_to_no_proxy_env(&raw) +} + +#[cfg(target_os = "windows")] +fn parse_windows_proxy_server_for_env(raw: &str) -> Vec { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + + if !trimmed.contains('=') { + let Some(value) = normalize_proxy_url(trimmed) else { + return Vec::new(); + }; + return vec![ + ProxyEnvVar { + key: "HTTPS_PROXY", + value: value.clone(), + source: "Windows system proxy".to_string(), + }, + ProxyEnvVar { + key: "HTTP_PROXY", + value: value.clone(), + source: "Windows system proxy".to_string(), + }, + ProxyEnvVar { + key: "ALL_PROXY", + value, + source: "Windows system proxy".to_string(), + }, + ]; + } + + let mut vars = Vec::new(); + let mut has_https_proxy = false; + let mut has_all_proxy = false; + let mut http_proxy = None; + + for entry in trimmed.split(';') { + let Some((scheme, value)) = entry.split_once('=') else { + continue; + }; + let scheme = scheme.trim(); + let (key, default_proxy_scheme) = match scheme.to_ascii_lowercase().as_str() { + "http" => ("HTTP_PROXY", "http"), + "https" => ("HTTPS_PROXY", "http"), + "socks" | "socks5" => ("ALL_PROXY", "socks5"), + "socks4" => ("ALL_PROXY", "socks4"), + _ => continue, + }; + let Some(value) = normalize_proxy_url_with_default(value, default_proxy_scheme) else { + continue; + }; + let source = format!("Windows system proxy ({})", scheme); + + if key == "HTTP_PROXY" { + http_proxy = Some((source.clone(), value.clone())); + } else if key == "HTTPS_PROXY" { + has_https_proxy = true; + } else if key == "ALL_PROXY" { + has_all_proxy = true; + } + + vars.push(ProxyEnvVar { key, value, source }); + } + + if !has_https_proxy && !has_all_proxy { + if let Some((source, value)) = http_proxy { + vars.insert( + 0, + ProxyEnvVar { + key: "HTTPS_PROXY", + value, + source: format!("{} (HTTPS fallback)", source), + }, + ); + } + } + + vars +} + +#[cfg(target_os = "windows")] +fn windows_system_proxy_env_vars() -> Vec { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let Ok(settings) = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Internet Settings") + else { + return Vec::new(); + }; + + let proxy_enabled = settings.get_value::("ProxyEnable").unwrap_or(0) != 0; + if !proxy_enabled { + return Vec::new(); + } + + settings + .get_value::("ProxyServer") + .map(|raw| parse_windows_proxy_server_for_env(&raw)) + .unwrap_or_default() +} + +#[cfg(not(target_os = "windows"))] +fn windows_system_proxy_env_vars() -> Vec { + Vec::new() +} + +#[cfg(not(target_os = "windows"))] +fn windows_system_no_proxy_env() -> Option { + None +} + +fn redacted_proxy_url(url: &str) -> String { + let Ok(mut parsed) = reqwest::Url::parse(url) else { + return "".to_string(); + }; + + if !parsed.username().is_empty() { + let _ = parsed.set_username("***"); + if parsed.password().is_some() { + let _ = parsed.set_password(Some("***")); + } + } + + parsed.to_string() +} + +fn apply_proxy_env_to_command(cmd: &mut Command, window: Option<&WebviewWindow>) { + let mut vars = explicit_proxy_env_vars(); + let mut no_proxy = first_env_value(&["NO_PROXY", "no_proxy"]).map(|(_, value)| value); + + if vars.is_empty() { + vars = windows_system_proxy_env_vars(); + if no_proxy.is_none() { + no_proxy = windows_system_no_proxy_env(); + } + } + + if vars.is_empty() { + if let Some(window) = window { + let _ = window.emit( + "install-output", + "Using system proxy settings when available", + ); + } + return; + } + + for var in vars { + if let Some(window) = window { + let _ = window.emit( + "install-output", + format!( + "Using proxy from {}: {}", + var.source, + redacted_proxy_url(&var.value) + ), + ); + } + cmd.env(var.key, var.value); + } + + if let Some(no_proxy) = no_proxy { + cmd.env("NO_PROXY", no_proxy); + } +} + fn get_claude_prism_auth_path() -> Result { let config_dir = dirs::config_dir() .or_else(dirs::home_dir) @@ -2037,6 +2343,7 @@ pub async fn install_claude_cli(window: WebviewWindow) -> Result { cmd.env(&key, &value); } } + apply_proxy_env_to_command(&mut cmd, Some(&window)); let mut child = cmd .spawn() @@ -3931,6 +4238,84 @@ mod tests { } } + #[test] + fn test_normalize_proxy_url_defaults_to_http() { + assert_eq!( + normalize_proxy_url("127.0.0.1:7890"), + Some("http://127.0.0.1:7890".to_string()) + ); + assert_eq!( + normalize_proxy_url("socks5://127.0.0.1:7891"), + Some("socks5://127.0.0.1:7891".to_string()) + ); + assert_eq!(normalize_proxy_url(" "), None); + } + + #[cfg(target_os = "windows")] + #[test] + fn test_parse_windows_proxy_server_single_proxy_for_installer_env() { + let vars = parse_windows_proxy_server_for_env("127.0.0.1:7890"); + + assert_eq!( + vars, + vec![ + ProxyEnvVar { + key: "HTTPS_PROXY", + value: "http://127.0.0.1:7890".to_string(), + source: "Windows system proxy".to_string(), + }, + ProxyEnvVar { + key: "HTTP_PROXY", + value: "http://127.0.0.1:7890".to_string(), + source: "Windows system proxy".to_string(), + }, + ProxyEnvVar { + key: "ALL_PROXY", + value: "http://127.0.0.1:7890".to_string(), + source: "Windows system proxy".to_string(), + }, + ] + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn test_parse_windows_proxy_server_per_scheme_for_installer_env() { + let vars = parse_windows_proxy_server_for_env( + "http=127.0.0.1:7890;https=127.0.0.1:7891;socks=127.0.0.1:7892", + ); + + assert_eq!( + vars, + vec![ + ProxyEnvVar { + key: "HTTP_PROXY", + value: "http://127.0.0.1:7890".to_string(), + source: "Windows system proxy (http)".to_string(), + }, + ProxyEnvVar { + key: "HTTPS_PROXY", + value: "http://127.0.0.1:7891".to_string(), + source: "Windows system proxy (https)".to_string(), + }, + ProxyEnvVar { + key: "ALL_PROXY", + value: "socks5://127.0.0.1:7892".to_string(), + source: "Windows system proxy (socks)".to_string(), + }, + ] + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn test_windows_proxy_override_to_no_proxy_env() { + assert_eq!( + windows_proxy_override_to_no_proxy_env(";*.example.com;api.test"), + Some("localhost,127.0.0.1,::1,.example.com,api.test".to_string()) + ); + } + #[test] fn test_provider_model_override_ignores_claude_model_selectors() { assert_eq!( diff --git a/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts b/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts index ccdeb79..20e1a65 100644 --- a/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts +++ b/apps/desktop/src/__tests__/stores/claude-setup-store.test.ts @@ -102,14 +102,14 @@ describe("useClaudeSetupStore.saveApiKey", () => { beforeEach(() => { vi.clearAllMocks(); useClaudeSetupStore.setState({ - status: "not-installed", + status: "ready", isInstalling: false, isLoggingIn: false, isSavingApiKey: false, isClearingApiKey: false, error: null, - version: null, - providerKind: null, + version: "1.0.0", + providerKind: "claude-code", accountEmail: null, providerModel: null, providerBaseUrl: null, @@ -122,6 +122,25 @@ describe("useClaudeSetupStore.saveApiKey", () => { }); }); + it("requires Claude Code before saving provider credentials", async () => { + useClaudeSetupStore.setState({ status: "not-installed" }); + + const success = await useClaudeSetupStore + .getState() + .saveApiKey( + "sk-test", + "https://api.deepseek.com/anthropic", + "openai-compatible", + "deepseek-v4-pro", + ); + + expect(success).toBe(false); + expect(invoke).not.toHaveBeenCalled(); + expect(useClaudeSetupStore.getState().error).toBe( + "Install Claude Code before configuring an AI provider.", + ); + }); + it("verifies OpenAI-compatible credentials before saving them", async () => { vi.mocked(invoke).mockImplementation(async (command) => { if (command === "check_claude_status") { diff --git a/apps/desktop/src/components/claude-setup.tsx b/apps/desktop/src/components/claude-setup.tsx index 6cc6571..4433d33 100644 --- a/apps/desktop/src/components/claude-setup.tsx +++ b/apps/desktop/src/components/claude-setup.tsx @@ -490,7 +490,7 @@ function InstallLogOutput() { // ─── Main Component ─── interface ClaudeSetupProps { - variant?: "default" | "provider-dialog"; + variant?: "default" | "provider-dialog" | "embedded"; onSaved?: () => void; onCancel?: () => void; } @@ -535,6 +535,21 @@ export function ClaudeSetup({ useInstallEvents(); useLoginEvents(); + const isEmbedded = variant === "embedded"; + const setupSurfaceClass = ( + tone: "default" | "error" | "warning" = "default", + ) => + cn( + "flex w-full flex-col gap-3", + isEmbedded + ? "px-4 py-3" + : tone === "error" + ? "rounded-xl border border-destructive/30 bg-destructive/5 px-5 py-4" + : tone === "warning" + ? "rounded-xl border border-amber-500/30 bg-amber-500/5 px-5 py-4" + : "rounded-xl border border-border bg-muted/30 px-5 py-4", + ); + const handleSaveApiKey = async ( selectedProvider: "claude-code" | "openai-compatible" = provider, credentialLabel?: string, @@ -967,7 +982,14 @@ export function ClaudeSetup({ if (status === "checking") { return ( -
+
Checking Claude Code... @@ -976,6 +998,63 @@ export function ClaudeSetup({ ); } + if (variant === "provider-dialog" && status === "missing-git") { + return ( +
+
+ +
+

Install Git first

+

+ Claude Code needs Git for Windows before providers can be added. +

+
+
+ + +
+ ); + } + + if (variant === "provider-dialog" && status === "not-installed") { + return ( +
+
+ +
+

Install Claude Code first

+

+ AI providers can be configured after the Claude Code CLI is + installed. +

+
+
+ +
+ ); + } + if (variant === "provider-dialog") { return (
@@ -999,13 +1078,24 @@ export function ClaudeSetup({ if (status === "ready") { const isDirectProvider = providerKind === "openai-compatible"; + const configuredProviderCount = isDirectProvider + ? Math.max( + openAiCredentials.length, + providerModel || providerBaseUrl ? 1 : 0, + ) + : 1; const readyDetail = isDirectProvider - ? [version, providerModel, providerBaseUrl].filter(Boolean).join(" / ") + ? [ + `${configuredProviderCount} provider${configuredProviderCount === 1 ? "" : "s"} configured`, + version ? `Claude Code ${version}` : null, + ] + .filter(Boolean) + .join(" / ") : [version, accountEmail].filter(Boolean).join(" / "); if (isEditingProvider) { return ( -
+
@@ -1053,20 +1143,36 @@ export function ClaudeSetup({ } return ( -
-
- - - {isDirectProvider ? "AI Providers" : "Claude Code"} - - - {readyDetail} - +
+
+
+ +
+
+
+ + {isDirectProvider ? "AI Providers" : "Claude Code"} + + {isDirectProvider && ( + + {configuredProviderCount} + + )} +
+

+ {readyDetail} +

+
{isDirectProvider && openAiCredentials.length > 0 && ( -
+
{openAiCredentials.map((credential) => { const displayName = getProviderDisplayName({ label: credential.label, @@ -1106,26 +1212,34 @@ export function ClaudeSetup({ return (
{iconSrc ? ( ) : ( - + )} - {displayName} - - {credential.model} - - +
+
+ + {displayName} + + + {credential.model} + +
+
+
+ +
); })} @@ -1138,7 +1252,7 @@ export function ClaudeSetup({ // Installation in progress if (isInstalling) { return ( -
+

Installing Claude Code

@@ -1158,7 +1272,7 @@ export function ClaudeSetup({ // Login in progress if (isLoggingIn) { return ( -
+

Signing in to Claude

@@ -1181,7 +1295,7 @@ export function ClaudeSetup({ const hasInstallSteps = installSteps.length > 0; return ( -
+

@@ -1235,23 +1349,17 @@ export function ClaudeSetup({ if (status === "missing-git") { return ( -

+
-

Connect AI Provider

+

Install Git first

- OpenAI-compatible providers work without Git. Git for Windows is - only needed if you want Claude Code/browser sign-in. + Git for Windows is required before Claude Code can be installed + and providers can be configured.

- {renderApiKeyForm({ forceOpenAiCompatible: true })} -
-
- Claude Code -
-
+ +
+ + )} +
@@ -330,22 +387,43 @@ export function ProjectPicker() {
{activeSection === "settings" ? ( -
-
-

Application

-
- -
-
-
-

Environment

- {!isClaudeReady ? : } -
+
+ + +
+ {settingsDetailSection === "provider" ? ( + + + + ) : ( + + + + )} +
) : (
@@ -832,20 +910,75 @@ function ProjectNavButton({ ); } -function EnvironmentStatus() { - const [showAiSetup, setShowAiSetup] = useState(false); - const claudeVersion = useClaudeSetupStore((s) => s.version); - const providerKind = useClaudeSetupStore((s) => s.providerKind); - const claudeEmail = useClaudeSetupStore((s) => s.accountEmail); - const providerModel = useClaudeSetupStore((s) => s.providerModel); - const providerBaseUrl = useClaudeSetupStore((s) => s.providerBaseUrl); - const isDirectProvider = providerKind === "openai-compatible"; - const aiDetail = isDirectProvider - ? [claudeVersion, providerModel, providerBaseUrl] - .filter(Boolean) - .join(" · ") - : [claudeVersion, claudeEmail].filter(Boolean).join(" · "); +function SettingsDetailButton({ + active, + icon: Icon, + label, + meta, + onClick, +}: { + active: boolean; + icon: LucideIcon; + label: string; + meta: string; + onClick: () => void; +}) { + return ( + + ); +} +function SettingsPanel({ + title, + icon: Icon, + contentClassName, + children, +}: { + title: string; + icon: LucideIcon; + contentClassName?: string; + children: ReactNode; +}) { + return ( +
+
+
+ +
+
+

{title}

+
+
+
{children}
+
+ ); +} + +function EnvironmentStatus({ appVersion }: { appVersion: string }) { const uvStatus = useUvSetupStore((s) => s.status); const uvVersion = useUvSetupStore((s) => s.version); const uvInstalling = useUvSetupStore((s) => s.isInstalling); @@ -900,19 +1033,7 @@ function EnvironmentStatus() { return ( <> -
- {/* AI provider — always ready here */} - setShowAiSetup((value) => !value), - icon: "key", - }} - /> - +
{/* Python (uv) */} setShowSkillsOnboarding(true), + icon: skillsStatus?.installed ? "settings" : "download", } - : undefined } /> -
- {showAiSetup && } + +
{showSkillsOnboarding && OnboardingComponent && ( void; loading?: boolean; - icon?: "download" | "key"; + icon?: "download" | "key" | "settings"; }; }) { return ( -
- {ok ? ( - - ) : ( - - )} - +
- {label} - - - {detail} - + {ok ? ( + + ) : ( + + )} +
+
+ + {label} + + + {detail} + +
{action && (
); } - -// ─── Version Badge with Update Status ─── - -function VersionBadge({ - version, - updateStatus, - onCheck, - onInstall, -}: { - version: string; - updateStatus: import("@/hooks/use-updater").UpdateStatus; - onCheck: () => void; - onInstall: () => void; -}) { - if (!version) return null; - - switch (updateStatus.state) { - case "available": - return ( - - ); - - case "downloading": - return ( -
- - Downloading... {updateStatus.percent}% -
- ); - - case "installing": - return ( -
- - Installing... -
- ); - - case "ready": - return ( -
- - Update complete — restarting... -
- ); - - case "checking": - return ( -
- v{version} — checking - for updates... -
- ); - - case "error": - return ( -
- v{version} - · - -
- ); - - case "up-to-date": - return ( -
- v{version} - · - -
- ); - - default: - return ( -
- v{version} - · - -
- ); - } -} diff --git a/apps/desktop/src/stores/claude-setup-store.ts b/apps/desktop/src/stores/claude-setup-store.ts index ef49418..fdc7dde 100644 --- a/apps/desktop/src/stores/claude-setup-store.ts +++ b/apps/desktop/src/stores/claude-setup-store.ts @@ -355,6 +355,14 @@ export const useClaudeSetupStore = create((set, get) => ({ model?: string, credentialLabel?: string, ) => { + const status = get().status; + if (status === "missing-git" || status === "not-installed") { + set({ + error: "Install Claude Code before configuring an AI provider.", + }); + return false; + } + const key = apiKey.trim(); const rawUrl = baseUrl?.trim() ?? ""; const url =