Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
356 changes: 339 additions & 17 deletions desktop/src-tauri/src/lib.rs

Large diffs are not rendered by default.

102 changes: 102 additions & 0 deletions desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,108 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use std::fs;
use std::path::PathBuf;

fn main() {
// Determine if we should start in portable mode and set CLAUDE_CONFIG_DIR
// before any Tauri/WebView2 initialization.
//
// Mode resolution order:
// 1. External CLAUDE_CONFIG_DIR env var (batch script etc.) — always respected
// 2. Persisted app-mode.json saying "portable"
// 3. Auto-detect: default portable dir already has data files
//
// In "default" mode the app does NOT set CLAUDE_CONFIG_DIR itself.
// It relies on the env var (if set externally) or falls back to system dirs.
// All existing std::env::var("CLAUDE_CONFIG_DIR") checks in lib.rs handle this.

if let Some(portable_dir) = determine_startup_portable_dir() {
std::env::set_var("CLAUDE_CONFIG_DIR", portable_dir.to_string_lossy().to_string());
}

// If CLAUDE_CONFIG_DIR is set (either from env or from our startup logic above),
// redirect WebView2 user data folder so EBWebView cache lives alongside it.
if let Ok(config_dir) = std::env::var("CLAUDE_CONFIG_DIR") {
let webview_data = PathBuf::from(&config_dir).join("EBWebView");
if let Err(e) = fs::create_dir_all(&webview_data) {
eprintln!("[desktop] failed to create EBWebView dir: {e}");
}
std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", &webview_data);
}

claude_code_desktop_lib::run()
}

/// Determine if we should start in portable mode.
/// Returns the portable config directory path if yes, None for default mode.
fn determine_startup_portable_dir() -> Option<PathBuf> {
// 1. 如果外部已经设置了 CLAUDE_CONFIG_DIR 环境变量,我们不应该覆盖它,直接返回 None 让 main 保持原样
if std::env::var("CLAUDE_CONFIG_DIR").is_ok() {
return None;
}

let exe = std::env::current_exe().ok()?;
let exe_dir = exe.parent()?;
let mut default_portable = exe_dir.to_path_buf();
default_portable.push("CLAUDE_CONFIG_DIR");

// 辅助函数:读取 app-mode.json 获取模式和自定义便携路径
fn get_mode_from_config(dir: &std::path::Path) -> Option<(String, Option<PathBuf>)> {
let path = dir.join("app-mode.json");
let data = std::fs::read_to_string(&path).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&data).ok()?;
let mode = parsed.get("mode").and_then(|m| m.as_str()).unwrap_or("default").to_string();
let portable_dir = parsed.get("portable_dir").and_then(|v| v.as_str()).map(PathBuf::from);
Some((mode, portable_dir))
}

// 2. 优先检查便携目录本地的配置文件(保证移动便携版到新电脑依然生效,并能正确处理切回默认模式)
if let Some((mode, portable_dir)) = get_mode_from_config(&default_portable) {
if mode == "portable" {
return Some(portable_dir.unwrap_or(default_portable.clone()));
} else {
return None; // 明确设置了 default,直接使用系统默认
}
}

// 3. 检查系统全局配置
#[cfg(target_os = "windows")]
let system_config: Option<PathBuf> = std::env::var("APPDATA").ok().map(PathBuf::from);
#[cfg(target_os = "macos")]
let system_config: Option<PathBuf> = std::env::var("HOME").ok().map(|h| PathBuf::from(h).join("Library").join("Application Support"));
#[cfg(target_os = "linux")]
let system_config: Option<PathBuf> = std::env::var("XDG_CONFIG_HOME")
.ok().map(PathBuf::from)
.or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".config")));

if let Some(ref sys_cfg) = system_config {
// 修复:必须使用 Tauri 默认的 bundle identifier
let app_subdir = sys_cfg.join("com.claude-code-haha.desktop");
if let Some((mode, portable_dir)) = get_mode_from_config(&app_subdir) {
if mode == "portable" {
return Some(portable_dir.unwrap_or(default_portable.clone()));
} else {
return None; // 明确设置了 default
}
}
}

// 4. 自动检测:如果默认便携目录中已经存在数据文件,则自动进入便携模式
fn dir_has_portable_data(dir: &std::path::Path) -> bool {
if !dir.is_dir() {
return false;
}
["window-state.json", "terminal-config.json"]
.iter()
.any(|f| dir.join(f).is_file())
|| dir.join("Cache").is_dir()
|| dir.join("EBWebView").is_dir()
}

if dir_has_portable_data(&default_portable) {
return Some(default_portable);
}

None
}
8 changes: 8 additions & 0 deletions desktop/src/api/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,12 @@ export const terminalApi = {
const events = await import('@tauri-apps/api/event')
return events.listen<TerminalExitPayload>('terminal-exit', (event) => handler(event.payload))
},

getBashPath() {
return invoke<string | null>('get_terminal_bash_path', undefined)
},

setBashPath(path: string | null) {
return invoke<void>('set_terminal_bash_path', { path })
},
}
19 changes: 19 additions & 0 deletions desktop/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ export const en = {
'settings.terminal.status.exited': 'Exited',
'settings.terminal.status.error': 'Error',
'settings.terminal.status.unavailable': 'Unavailable',
'settings.terminal.bashPathLabel': 'Bash Path',
'settings.terminal.bashPathDescription': 'Windows defaults to CMD as the terminal shell. If tool calls use Unix commands (grep, sed, etc.), configure the path to a Bash executable here (e.g., Git Bash).',
'settings.terminal.bashPathSave': 'Save',
'settings.terminal.bashPathReset': 'Reset to default',
'settings.terminal.bashPathSaved': 'Saved',
'settings.terminal.bashPathInvalid': 'Path does not exist. Select a valid Bash executable.',
'terminal.newTab': 'New Terminal',
'terminal.openInTab': 'Open in Tab',
'terminal.closePanel': 'Close terminal panel',
Expand Down Expand Up @@ -744,6 +750,19 @@ export const en = {
'settings.computerUse.flagClipboard': 'Clipboard Access',
'settings.computerUse.flagSystemKeys': 'System Key Combos',

// Settings > General - Mode
'settings.general.modeTitle': 'Mode',
'settings.general.modeDescription': 'Choose where app data is stored. Default mode respects the CLAUDE_CONFIG_DIR env var or uses system directories. Portable mode stores data in a configurable directory.',
'settings.general.modeDefault': 'Default',
'settings.general.modePortable': 'Portable',
'settings.general.modeDefaultHint': 'Uses CLAUDE_CONFIG_DIR env var if set, otherwise system directory (%APPDATA% on Windows).',
'settings.general.modePortableDir': 'Portable config dir',
'settings.general.modeSwitchTitle': 'Switch Mode?',
'settings.general.modeSwitchBody': 'Switch to {mode}? This requires a restart to take effect.',
'settings.general.modeSwitchConfirm': 'Switch and Restart Later',
'settings.general.modeRestartTitle': 'Restart Required',
'settings.general.modeRestartHint': 'Restart the app for the mode change to take effect.',

// Settings > General
'settings.general.appearanceTitle': 'Appearance',
'settings.general.appearanceDescription': 'Switch between the warm classic workspace, dark workspace, and a pure white workspace.',
Expand Down
20 changes: 20 additions & 0 deletions desktop/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ export const zh: Record<TranslationKey, string> = {
'settings.terminal.status.exited': '已退出',
'settings.terminal.status.error': '错误',
'settings.terminal.status.unavailable': '不可用',
'settings.terminal.bashPathLabel': 'Bash 路径',
'settings.terminal.bashPathDescription': 'Windows 下默认使用 CMD 作为终端。如果工具调用 grep、sed 等 Unix 命令,请在此配置 Bash 可执行文件路径(如 Git Bash)。',
'settings.terminal.bashPathSave': '保存',
'settings.terminal.bashPathReset': '恢复默认',
'settings.terminal.bashPathSaved': '已保存',
'settings.terminal.bashPathInvalid': '路径不存在,请选择有效的 Bash 可执行文件',
'terminal.newTab': '新建终端',
'terminal.openInTab': '在 Tab 中打开',
'terminal.closePanel': '关闭终端面板',
Expand Down Expand Up @@ -746,6 +752,20 @@ export const zh: Record<TranslationKey, string> = {
'settings.computerUse.flagClipboard': '剪贴板访问',
'settings.computerUse.flagSystemKeys': '系统快捷键',

// Settings > General
// Settings > General - Mode
'settings.general.modeTitle': '运行模式',
'settings.general.modeDescription': '选择应用程序数据的存储位置。默认模式下,如果设置了 CLAUDE_CONFIG_DIR 环境变量则使用该路径,否则使用系统目录。便携模式将数据存储在自定义目录中。',
'settings.general.modeDefault': '默认模式',
'settings.general.modePortable': '便携模式',
'settings.general.modeDefaultHint': 'CLAUDE_CONFIG_DIR 环境变量指定的路径,或系统目录(Windows 上为 %APPDATA%)。',
'settings.general.modePortableDir': '便携配置目录',
'settings.general.modeSwitchTitle': '切换模式?',
'settings.general.modeSwitchBody': '切换到{mode}?此更改需要重启后生效。',
'settings.general.modeSwitchConfirm': '切换,稍后重启',
'settings.general.modeRestartTitle': '需要重启',
'settings.general.modeRestartHint': '重启应用使模式切换生效。下次启动时将更新配置目录。',

// Settings > General
'settings.general.appearanceTitle': '配色主题',
'settings.general.appearanceDescription': '在经典暖色、暗色与纯白工作区之间切换。',
Expand Down
102 changes: 101 additions & 1 deletion desktop/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import { Dropdown } from '../components/shared/Dropdown'
import type { PermissionMode, EffortLevel, ThemeMode, WebSearchMode } from '../types/settings'
import type { PermissionMode, EffortLevel, ThemeMode, WebSearchMode, AppMode } from '../types/settings'
import type { Locale } from '../i18n'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider'
import type { ProviderPreset } from '../types/providerPreset'
Expand Down Expand Up @@ -1385,13 +1385,19 @@ function GeneralSettings() {
setWebSearch,
responseLanguage,
setResponseLanguage,
appMode,
appModeRequiresRestart,
fetchAppMode,
setAppMode: setAppModeAction,
uiZoom,
setUiZoom,
} = useSettingsStore()
const t = useTranslation()
const [webSearchDraft, setWebSearchDraft] = useState(webSearch)
const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>('default')
const [notificationActionRunning, setNotificationActionRunning] = useState(false)
const [modeSwitchConfirmOpen, setModeSwitchConfirmOpen] = useState(false)
const [pendingMode, setPendingMode] = useState<AppMode | null>(null)
const [uiZoomDraft, setUiZoomDraft] = useState(uiZoom)
const [isUiZoomDragging, setIsUiZoomDragging] = useState(false)
const isUiZoomDraggingRef = useRef(false)
Expand Down Expand Up @@ -1419,6 +1425,11 @@ function GeneralSettings() {
}
}, [])

useEffect(() => {
if (!isTauriRuntime()) return
void fetchAppMode()
}, [fetchAppMode])

const EFFORT_LABELS: Record<EffortLevel, string> = {
low: t('settings.general.effort.low'),
medium: t('settings.general.effort.medium'),
Expand Down Expand Up @@ -1639,6 +1650,79 @@ function GeneralSettings() {

return (
<div className="max-w-xl">
{/* Mode Section */}
{isTauriRuntime() && (
<div className="mb-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.modeTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.modeDescription')}</p>
<div className="flex gap-2">
<button
onClick={() => {
if (appMode.mode === 'default') return
setPendingMode('default')
setModeSwitchConfirmOpen(true)
}}
aria-pressed={appMode.mode === 'default'}
className={`flex-1 py-3 text-sm font-semibold rounded-lg border transition-all ${
appMode.mode === 'default'
? 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] border-transparent shadow-[var(--shadow-button-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<div className="flex flex-col items-center gap-1">
<span className="material-symbols-outlined text-[22px]">settings_applications</span>
<span>{t('settings.general.modeDefault')}</span>
</div>
</button>
<button
onClick={() => {
if (appMode.mode === 'portable') return
setPendingMode('portable')
setModeSwitchConfirmOpen(true)
}}
aria-pressed={appMode.mode === 'portable'}
className={`flex-1 py-3 text-sm font-semibold rounded-lg border transition-all ${
appMode.mode === 'portable'
? 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] border-transparent shadow-[var(--shadow-button-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<div className="flex flex-col items-center gap-1">
<span className="material-symbols-outlined text-[22px]">drive_file_move</span>
<span>{t('settings.general.modePortable')}</span>
</div>
</button>
</div>
{appMode.mode === 'portable' && appMode.portableDir && (
<div className="mt-2 text-xs text-[var(--color-text-tertiary)] font-mono break-all">
{t('settings.general.modePortableDir')}: {appMode.portableDir}
</div>
)}
{appMode.mode === 'default' && (
<div className="mt-2 text-xs text-[var(--color-text-tertiary)]">
{t('settings.general.modeDefaultHint')}
</div>
)}
</div>
)}

{/* Restart Required Banner */}
{appModeRequiresRestart && (
<div className="mb-6 rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning)]/10 px-4 py-3 flex items-center gap-3">
<span className="material-symbols-outlined text-[20px] text-[var(--color-warning)]" style={{ fontVariationSettings: "'FILL' 1" }}>
warning
</span>
<div>
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.modeRestartTitle')}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">
{t('settings.general.modeRestartHint')}
</div>
</div>
</div>
)}

{/* Appearance selector */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.appearanceTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.appearanceDescription')}</p>
Expand Down Expand Up @@ -1906,6 +1990,22 @@ function GeneralSettings() {
</div>
</div>
</div>
{/* Confirm dialog for mode switch */}
<ConfirmDialog
open={modeSwitchConfirmOpen}
onClose={() => { setModeSwitchConfirmOpen(false); setPendingMode(null); }}
onConfirm={() => {
if (pendingMode) { void setAppModeAction(pendingMode); }
setModeSwitchConfirmOpen(false);
setPendingMode(null);
}}
title={t('settings.general.modeSwitchTitle')}
body={t('settings.general.modeSwitchBody', {
mode: pendingMode === 'portable' ? t('settings.general.modePortable') : t('settings.general.modeDefault'),
})}
confirmLabel={t('settings.general.modeSwitchConfirm')}
cancelLabel={t('common.cancel')}
/>
</div>
)
}
Expand Down
Loading
Loading