diff --git a/src/main/ipc/config.ts b/src/main/ipc/config.ts index 569b305e..d4cba3cc 100644 --- a/src/main/ipc/config.ts +++ b/src/main/ipc/config.ts @@ -27,6 +27,7 @@ import { execFile } from 'child_process'; import { BrowserWindow, dialog, type IpcMain, type IpcMainInvokeEvent } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; +import { promisify } from 'util'; import { type AppConfig, @@ -42,9 +43,14 @@ import { validateConfigUpdatePayload } from './configValidation'; import { validateTriggerId } from './guards'; import type { TriggerColor } from '@shared/constants/triggerColors'; -import type { ClaudeRootFolderSelection, ClaudeRootInfo } from '@shared/types'; +import type { + ClaudeRootFolderSelection, + ClaudeRootInfo, + WslClaudeRootCandidate, +} from '@shared/types'; const logger = createLogger('IPC:config'); +const execFileAsync = promisify(execFile); // Get singleton instance const configManager = ConfigManager.getInstance(); @@ -109,6 +115,7 @@ export function registerConfigHandlers(ipcMain: IpcMain): void { ipcMain.handle('config:selectFolders', handleSelectFolders); ipcMain.handle('config:selectClaudeRootFolder', handleSelectClaudeRootFolder); ipcMain.handle('config:getClaudeRootInfo', handleGetClaudeRootInfo); + ipcMain.handle('config:findWslClaudeRoots', handleFindWslClaudeRoots); // Editor handlers ipcMain.handle('config:openInEditor', handleOpenInEditor); @@ -724,6 +731,97 @@ async function handleGetClaudeRootInfo( } } +function normalizeWslHomePath(home: string): string | null { + const trimmed = home.trim(); + if (!trimmed.startsWith('/')) { + return null; + } + + let normalized = path.posix.normalize(trimmed); + if (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; +} + +function toWslUncPath(distro: string, posixPath: string): string { + const uncSuffix = posixPath.replace(/\//g, '\\'); + return `\\\\wsl.localhost\\${distro}${uncSuffix}`; +} + +async function listWslDistros(): Promise { + const { stdout } = await execFileAsync('wsl.exe', ['-l', '-q'], { timeout: 4000 }); + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +async function resolveWslHome(distro: string): Promise { + try { + const { stdout } = await execFileAsync( + 'wsl.exe', + ['-d', distro, '--', 'sh', '-lc', 'printf %s "$HOME"'], + { timeout: 4000 } + ); + return normalizeWslHomePath(stdout); + } catch { + return null; + } +} + +/** + * Handler for 'config:findWslClaudeRoots' - Find Windows UNC candidates for WSL Claude roots. + */ +async function handleFindWslClaudeRoots( + _event: IpcMainInvokeEvent +): Promise> { + try { + if (process.platform !== 'win32') { + return { success: true, data: [] }; + } + + const distros = await listWslDistros(); + if (distros.length === 0) { + return { success: true, data: [] }; + } + + const candidates: WslClaudeRootCandidate[] = []; + for (const distro of distros) { + const homePath = await resolveWslHome(distro); + if (!homePath) { + continue; + } + + const claudePosixPath = path.posix.join(homePath, '.claude'); + const claudeUncPath = toWslUncPath(distro, claudePosixPath); + const projectsPath = path.join(claudeUncPath, 'projects'); + + const hasProjectsDir = (() => { + try { + return fs.existsSync(projectsPath) && fs.statSync(projectsPath).isDirectory(); + } catch { + return false; + } + })(); + + candidates.push({ + distro, + path: claudeUncPath, + hasProjectsDir, + }); + } + + return { success: true, data: candidates }; + } catch (error) { + logger.error('Error in config:findWslClaudeRoots:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to detect WSL Claude paths', + }; + } +} + // ============================================================================= // Cleanup // ============================================================================= @@ -751,6 +849,7 @@ export function removeConfigHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler('config:selectFolders'); ipcMain.removeHandler('config:selectClaudeRootFolder'); ipcMain.removeHandler('config:getClaudeRootInfo'); + ipcMain.removeHandler('config:findWslClaudeRoots'); ipcMain.removeHandler('config:openInEditor'); logger.info('Config handlers removed'); } diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index c11afc6f..e889a8b2 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -1,4 +1,3 @@ -import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -236,75 +235,7 @@ function getHomeDir(): string { let claudeBasePathOverride: string | null = null; -function isWslEnvironment(): boolean { - if (process.platform !== 'linux') { - return false; - } - - if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) { - return true; - } - - // Fallback for environments where WSL vars are not exported. - return os.release().toLowerCase().includes('microsoft'); -} - -function toWslPathFromWindowsPath(windowsPath: string): string | null { - const normalized = windowsPath.trim().replace(/\\/g, '/'); - if (!normalized) { - return null; - } - - if (normalized.startsWith('/mnt/')) { - return normalized; - } - - const match = /^([a-zA-Z]):\/(.+)$/.exec(normalized); - if (!match) { - return null; - } - - const drive = match[1].toLowerCase(); - const rest = match[2]; - return `/mnt/${drive}/${rest}`; -} - -function getWslClaudeBaseCandidates(): string[] { - const candidates = new Set(); - - const addCandidate = (baseHome: string | null | undefined): void => { - if (!baseHome) return; - const withClaude = path.posix.join(baseHome, '.claude'); - candidates.add(path.posix.normalize(withClaude)); - }; - - // WSL-native home (e.g. /home/) should be preferred when present. - addCandidate(getHomeDir()); - - addCandidate(toWslPathFromWindowsPath(process.env.USERPROFILE ?? '')); - - const homeDrivePath = - process.env.HOMEDRIVE && process.env.HOMEPATH - ? `${process.env.HOMEDRIVE}${process.env.HOMEPATH}` - : ''; - addCandidate(toWslPathFromWindowsPath(homeDrivePath)); - - if (process.env.USER) { - addCandidate(`/mnt/c/Users/${process.env.USER}`); - } - - return Array.from(candidates); -} - function getDefaultClaudeBasePath(): string { - if (isWslEnvironment()) { - const candidates = getWslClaudeBaseCandidates(); - const existing = candidates.find((candidate) => fs.existsSync(candidate)); - if (existing) { - return existing; - } - } - return path.join(getHomeDir(), '.claude'); } diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 50e3b8ce..8d730cd4 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -56,6 +56,9 @@ export const CONFIG_SELECT_CLAUDE_ROOT_FOLDER = 'config:selectClaudeRootFolder'; /** Get effective/default Claude root folder info */ export const CONFIG_GET_CLAUDE_ROOT_INFO = 'config:getClaudeRootInfo'; +/** Find WSL Claude root candidates (Windows only) */ +export const CONFIG_FIND_WSL_CLAUDE_ROOTS = 'config:findWslClaudeRoots'; + /** Open config file in external editor */ export const CONFIG_OPEN_IN_EDITOR = 'config:openInEditor'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 1a9fa4e4..0eb5e6b9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -32,6 +32,7 @@ import { CONFIG_ADD_IGNORE_REPOSITORY, CONFIG_ADD_TRIGGER, CONFIG_CLEAR_SNOOZE, + CONFIG_FIND_WSL_CLAUDE_ROOTS, CONFIG_GET, CONFIG_GET_CLAUDE_ROOT_INFO, CONFIG_GET_TRIGGERS, @@ -64,6 +65,7 @@ import type { SshConnectionStatus, SshLastConnection, TriggerTestResult, + WslClaudeRootCandidate, } from '@shared/types'; // ============================================================================= @@ -278,6 +280,9 @@ const electronAPI: ElectronAPI = { getClaudeRootInfo: async (): Promise => { return invokeIpcWithResult(CONFIG_GET_CLAUDE_ROOT_INFO); }, + findWslClaudeRoots: async (): Promise => { + return invokeIpcWithResult(CONFIG_FIND_WSL_CLAUDE_ROOTS); + }, openInEditor: async (): Promise => { return invokeIpcWithResult(CONFIG_OPEN_IN_EDITOR); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 918e1431..b5389dcf 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -39,6 +39,7 @@ import type { TriggerTestResult, UpdaterAPI, WaterfallData, + WslClaudeRootCandidate, } from '@shared/types'; export class HttpAPIClient implements ElectronAPI { @@ -424,6 +425,10 @@ export class HttpAPIClient implements ElectronAPI { customPath: config.general.claudeRootPath, }; }, + findWslClaudeRoots: async (): Promise => { + console.warn('[HttpAPIClient] findWslClaudeRoots is not available in browser mode'); + return []; + }, openInEditor: async (): Promise => { console.warn('[HttpAPIClient] openInEditor is not available in browser mode'); }, diff --git a/src/renderer/components/settings/sections/ConnectionSection.tsx b/src/renderer/components/settings/sections/ConnectionSection.tsx index 0610eacd..b44f8341 100644 --- a/src/renderer/components/settings/sections/ConnectionSection.tsx +++ b/src/renderer/components/settings/sections/ConnectionSection.tsx @@ -14,7 +14,7 @@ import { api } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { useStore } from '@renderer/store'; import { getFullResetState } from '@renderer/store/utils/stateResetHelpers'; -import { FolderOpen, Loader2, Monitor, RotateCcw, Server, Wifi, WifiOff } from 'lucide-react'; +import { FolderOpen, Laptop, Loader2, Monitor, RotateCcw, Server, Wifi, WifiOff } from 'lucide-react'; import { SettingRow } from '../components/SettingRow'; import { SettingsSectionHeader } from '../components/SettingsSectionHeader'; @@ -26,6 +26,7 @@ import type { SshConfigHostEntry, SshConnectionConfig, SshConnectionProfile, + WslClaudeRootCandidate, } from '@shared/types'; const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [ @@ -71,6 +72,9 @@ export const ConnectionSection = (): React.JSX.Element => { const [claudeRootInfo, setClaudeRootInfo] = useState(null); const [updatingClaudeRoot, setUpdatingClaudeRoot] = useState(false); const [claudeRootError, setClaudeRootError] = useState(null); + const [findingWslRoots, setFindingWslRoots] = useState(false); + const [wslCandidates, setWslCandidates] = useState([]); + const [showWslModal, setShowWslModal] = useState(false); const loadProfiles = useCallback(async () => { try { @@ -281,11 +285,67 @@ export const ConnectionSection = (): React.JSX.Element => { await applyClaudeRootPath(null); }, [applyClaudeRootPath]); + const applyWslCandidate = useCallback( + async (candidate: WslClaudeRootCandidate): Promise => { + if (!candidate.hasProjectsDir) { + const proceed = await confirm({ + title: 'WSL path missing projects directory', + message: `"${candidate.path}" does not contain a "projects" directory. Continue anyway?`, + confirmLabel: 'Use Path', + }); + if (!proceed) { + return; + } + } + + await applyClaudeRootPath(candidate.path); + setShowWslModal(false); + }, + [applyClaudeRootPath] + ); + + const handleUseWslForClaude = useCallback(async (): Promise => { + try { + setFindingWslRoots(true); + setClaudeRootError(null); + const candidates = await api.config.findWslClaudeRoots(); + setWslCandidates(candidates); + + if (candidates.length === 0) { + const pickManually = await confirm({ + title: 'No WSL Claude paths found', + message: 'Could not find WSL distros with Claude data automatically. Select folder manually?', + confirmLabel: 'Select Folder', + }); + if (pickManually) { + await handleSelectClaudeRootFolder(); + } + return; + } + + const candidatesWithProjects = candidates.filter((candidate) => candidate.hasProjectsDir); + if (candidatesWithProjects.length === 1) { + await applyWslCandidate(candidatesWithProjects[0]); + return; + } + + setShowWslModal(true); + } catch (error) { + setClaudeRootError( + error instanceof Error ? error.message : 'Failed to detect WSL Claude root paths' + ); + } finally { + setFindingWslRoots(false); + } + }, [applyWslCandidate, handleSelectClaudeRootFolder]); + const isConnecting = connectionState === 'connecting'; const isConnected = connectionState === 'connected'; const isCustomClaudeRoot = Boolean(claudeRootInfo?.customPath); const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude'; const defaultClaudeRootPath = claudeRootInfo?.defaultPath ?? '~/.claude'; + const isWindowsStyleDefaultPath = + /^[a-zA-Z]:\\/.test(defaultClaudeRootPath) || defaultClaudeRootPath.startsWith('\\\\'); const inputClass = 'w-full rounded-md border px-3 py-1.5 text-sm focus:outline-none focus:ring-1'; const inputStyle = { @@ -349,6 +409,27 @@ export const ConnectionSection = (): React.JSX.Element => { Use Auto-Detect + + {isWindowsStyleDefaultPath && ( + + )} {claudeRootError && ( @@ -357,6 +438,93 @@ export const ConnectionSection = (): React.JSX.Element => { )} + {showWslModal && ( +
+ +
+ ))} + + +
+ + +
+ + + )} +

Connect to a remote machine to view Claude Code sessions running there diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index a54b8113..47a00364 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -91,6 +91,8 @@ export interface ConfigAPI { selectClaudeRootFolder: () => Promise; /** Get resolved Claude root path info for local mode */ getClaudeRootInfo: () => Promise; + /** Find Windows WSL Claude root candidates (UNC paths) */ + findWslClaudeRoots: () => Promise; /** Opens the config JSON file in an external editor */ openInEditor: () => Promise; /** Pin a session for a project */ @@ -117,6 +119,15 @@ export interface ClaudeRootFolderSelection { hasProjectsDir: boolean; } +export interface WslClaudeRootCandidate { + /** WSL distribution name (e.g. Ubuntu) */ + distro: string; + /** Candidate Claude root path in UNC format */ + path: string; + /** True if this root contains "projects" directory */ + hasProjectsDir: boolean; +} + // ============================================================================= // Session API // ============================================================================= diff --git a/test/mocks/electronAPI.ts b/test/mocks/electronAPI.ts index a5e2ee33..fe6fd666 100644 --- a/test/mocks/electronAPI.ts +++ b/test/mocks/electronAPI.ts @@ -70,6 +70,7 @@ export interface MockElectronAPI { selectFolders: ReturnType; selectClaudeRootFolder: ReturnType; getClaudeRootInfo: ReturnType; + findWslClaudeRoots: ReturnType; openInEditor: ReturnType; pinSession: ReturnType; unpinSession: ReturnType; @@ -171,6 +172,7 @@ export function createMockElectronAPI(): MockElectronAPI { resolvedPath: '~/.claude', customPath: null, }), + findWslClaudeRoots: vi.fn().mockResolvedValue([]), openInEditor: vi.fn(), pinSession: vi.fn(), unpinSession: vi.fn(),