diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..b1fae625 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +dist +dist-electron +dist-standalone +out +release +.git +.claude +*.md +!README.md +!SECURITY.md +!CONTRIBUTING.md +!CODE_OF_CONDUCT.md diff --git a/.gitignore b/.gitignore index 14014352..28223886 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # Build output dist/ dist-electron/ +dist-standalone/ out/ release/ coverage/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..540aaafe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# ============================================================================= +# claude-devtools standalone Docker image +# +# Runs the HTTP server without Electron, serving the full UI over HTTP. +# Mount your ~/.claude directory to make session data available. +# +# Build: docker build -t claude-devtools . +# Run: docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-devtools +# ============================================================================= + +FROM node:20-slim AS builder + +WORKDIR /app + +# Enable corepack for pnpm +RUN corepack enable + +# Install dependencies first (better layer caching) +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +# Copy source and build +COPY . . +RUN pnpm standalone:build + +# ============================================================================= +# Production stage — minimal image with only the built output +# ============================================================================= +FROM node:20-slim + +WORKDIR /app + +# Enable corepack for pnpm +RUN corepack enable + +# Copy package files and install production-only dependencies +# (fastify, @fastify/cors, @fastify/static are externalized from the bundle) +COPY --from=builder /app/package.json /app/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --prod + +# Copy built standalone server and renderer output +COPY --from=builder /app/dist-standalone ./dist-standalone +COPY --from=builder /app/out/renderer ./out/renderer + +# Create data directory for Claude session mount +RUN mkdir -p /data/.claude + +ENV NODE_ENV=production +ENV CLAUDE_ROOT=/data/.claude +ENV HOST=0.0.0.0 +ENV PORT=3456 + +EXPOSE 3456 + +CMD ["node", "dist-standalone/index.cjs"] diff --git a/README.md b/README.md index fc4d222b..15ddbe55 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Latest Release  CI Status  Downloads  - Platform + Platform


@@ -185,6 +185,66 @@ Every tool call is paired with its result in an expandable card. Specialized vie --- +## Docker / Standalone Deployment + +Run claude-devtools without Electron — in Docker, on a remote server, or anywhere Node.js runs. + +### Quick Start (Docker Compose) + +```bash +docker compose up +``` + +Open `http://localhost:3456` in your browser. + +### Quick Start (Docker) + +```bash +docker build -t claude-devtools . +docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-devtools +``` + +### Quick Start (Node.js) + +```bash +pnpm install +pnpm standalone:build +node dist-standalone/index.cjs +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `CLAUDE_ROOT` | `~/.claude` | Path to the `.claude` data directory | +| `HOST` | `0.0.0.0` | Bind address | +| `PORT` | `3456` | Listen port | +| `CORS_ORIGIN` | `*` (standalone) | CORS origin policy (`*`, specific origin, or comma-separated list) | + +### Notes + +- **Real-time updates may be slower than Electron.** The Electron app uses native file system watchers with IPC for instant updates. The Docker/standalone server uses SSE (Server-Sent Events) over HTTP, which may introduce slight delays when sessions are actively being written to. +- **Custom Claude root path.** If your `.claude` directory is not at `~/.claude`, update the volume mount to point to the correct location: + ```bash + # Example: Claude root at /home/user/custom-claude-dir + docker run -p 3456:3456 -v /home/user/custom-claude-dir:/data/.claude:ro claude-devtools + + # Or with docker compose, set the CLAUDE_DIR env variable: + CLAUDE_DIR=/home/user/custom-claude-dir docker compose up + ``` + +### Security-Focused Deployment + +The standalone server has **zero** outbound network calls. For maximum isolation: + +```bash +docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-devtools +``` + +See [SECURITY.md](SECURITY.md) for a full audit of network activity. + +--- + ## Development
diff --git a/SECURITY.md b/SECURITY.md index ce3dc57e..a886bfa9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,9 +1,50 @@ -# Security Policy +# Security & Privacy + +## Network Activity + +claude-devtools makes **zero** outbound network calls to third-party servers. There is no telemetry, analytics, tracking, or data exfiltration of any kind. + +| Network activity | When | Mode | User-initiated | +|---|---|---|---| +| GitHub Releases API (auto-updater) | App launch | Electron only | No (automatic) | +| SSH connections | Settings > SSH | Electron only | Yes | +| HTTP server (`127.0.0.1` or `0.0.0.0`) | When enabled | Both | Yes | + +### Standalone / Docker mode + +In standalone mode (Docker or `node dist-standalone/index.cjs`), the auto-updater and SSH features are disabled entirely. The only network activity is the HTTP server listening for incoming connections on the configured port. + +## Data Handling + +- All session data is read **locally** from `~/.claude/` — it never leaves your machine. +- The app does not write to session files. Volume mounts in Docker use `:ro` (read-only) by default. +- Configuration is stored at `~/.claude/claude-devtools-config.json` on the local filesystem. +- No data is sent to Anthropic, GitHub (other than the auto-updater in Electron mode), or any other third party. + +## Docker Network Isolation + +For maximum trust, run the Docker container with `--network none`: + +```bash +docker build -t claude-devtools . +docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-devtools +``` + +Or with Docker Compose, uncomment `network_mode: "none"` in `docker-compose.yml`. + +## IPC & Input Validation + +- All IPC handlers validate inputs with strict path containment checks +- File reads are constrained to the project root and `~/.claude/` +- Path traversal attacks are blocked +- Sensitive credential paths are rejected ## Supported Versions + Only the latest release is supported with security fixes. ## Reporting a Vulnerability + Please report vulnerabilities privately and do not open public issues for undisclosed security problems. Include: @@ -15,6 +56,7 @@ Include: If you do not have a private contact path yet, open a minimal GitHub issue asking for a secure reporting channel without disclosing technical details. ## Disclosure Process + - We will acknowledge reports as quickly as possible. - We will validate, triage severity, and prepare a fix. - We will coordinate a release and publish advisories when appropriate. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..846f3710 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +# ============================================================================= +# claude-devtools — Docker Compose +# +# Quick start: +# docker compose up +# +# Then open http://localhost:3456 in your browser. +# +# Security note: +# The standalone server has zero outbound network calls — no telemetry, +# no analytics, no auto-updater. For maximum isolation, uncomment +# network_mode below. +# ============================================================================= + +services: + claude-devtools: + build: . + ports: + - "3456:3456" + volumes: + - ${CLAUDE_DIR:-~/.claude}:/data/.claude:ro + environment: + - NODE_ENV=production + - CLAUDE_ROOT=/data/.claude + - HOST=0.0.0.0 + - PORT=3456 + restart: unless-stopped + # Uncomment for maximum network isolation (no outbound connections): + # network_mode: "none" diff --git a/knip.json b/knip.json index 76204072..07f40ecf 100644 --- a/knip.json +++ b/knip.json @@ -2,9 +2,11 @@ "$schema": "https://unpkg.com/knip@next/schema.json", "entry": [ "src/main/index.ts", + "src/main/standalone.ts", "src/preload/index.ts", "src/renderer/main.tsx", "electron.vite.config.ts", + "vite.standalone.config.ts", "remotion/index.ts", "remotion/**/*.{ts,tsx}" ], diff --git a/package.json b/package.json index c8805d1d..e6f6cb72 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,10 @@ "test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts", "remotion:preview": "remotion studio remotion/index.ts", "remotion:render": "remotion render remotion/index.ts DemoVideo out/demo.mp4", - "remotion:render:gif": "remotion render remotion/index.ts DemoVideo out/demo.gif --image-format png" + "remotion:render:gif": "remotion render remotion/index.ts DemoVideo out/demo.gif --image-format png", + "standalone": "tsx src/main/standalone.ts", + "standalone:build": "electron-vite build && vite build --config vite.standalone.config.ts", + "standalone:start": "node dist-standalone/index.cjs" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/src/main/services/infrastructure/HttpServer.ts b/src/main/services/infrastructure/HttpServer.ts index 1f2b9bde..5e0a19cc 100644 --- a/src/main/services/infrastructure/HttpServer.ts +++ b/src/main/services/infrastructure/HttpServer.ts @@ -13,11 +13,34 @@ import { type HttpServices, registerHttpRoutes } from '@main/http'; import { broadcastEvent } from '@main/http/events'; import { createLogger } from '@shared/utils/logger'; import Fastify, { type FastifyInstance } from 'fastify'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; const logger = createLogger('Service:HttpServer'); +/** + * Resolves the renderer output directory from multiple candidate paths. + * Returns the first path that exists on disk. + */ +function resolveRendererPath(): string | null { + const candidates = [ + // Electron production paths + join(__dirname, '../../../out/renderer'), + join(__dirname, '../../renderer'), + // Standalone: dist-standalone/index.cjs → ../out/renderer + join(__dirname, '../out/renderer'), + // Fallback: relative to cwd (for standalone bundles) + join(process.cwd(), 'out/renderer'), + ]; + + // Allow explicit override via env + if (process.env.RENDERER_PATH) { + candidates.unshift(process.env.RENDERER_PATH); + } + + return candidates.find((candidate) => existsSync(candidate)) ?? null; +} + export class HttpServer { private app: FastifyInstance | null = null; private port: number = 3456; @@ -28,70 +51,87 @@ export class HttpServer { * @param services - Service instances to pass to route handlers * @param sshModeSwitchCallback - Callback for SSH mode switching * @param preferredPort - Port to try first (default 3456) + * @param host - Host to bind to (default '127.0.0.1') */ async start( services: HttpServices, sshModeSwitchCallback: (mode: 'local' | 'ssh') => Promise, - preferredPort: number = 3456 + preferredPort: number = 3456, + host: string = '127.0.0.1' ): Promise { this.app = Fastify({ logger: false }); - // Register CORS - allow all localhost origins - const localhostPattern = /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/; - await this.app.register(cors, { - origin: (origin, cb) => { - // Allow requests with no origin (same-origin, curl, etc.) - if (!origin) { - cb(null, true); - return; - } - // Allow any localhost origin - if (localhostPattern.test(origin)) { - cb(null, true); - return; - } - cb(new Error('Not allowed by CORS'), false); - }, - credentials: true, - }); - - // Register static file serving (production only) - const isDev = process.env.NODE_ENV === 'development'; - if (!isDev) { - const rendererPathCandidates = [ - join(__dirname, '../../../out/renderer'), - join(__dirname, '../../renderer'), - ]; - const rendererPath = - rendererPathCandidates.find((candidate) => existsSync(candidate)) ?? - rendererPathCandidates[0]; - await this.app.register(fastifyStatic, { - root: rendererPath, - prefix: '/', - // Don't serve index.html for API routes - wildcard: false, - }); - - // Serve index.html for all non-API routes (SPA fallback) - this.app.setNotFoundHandler(async (request, reply) => { - if (request.url.startsWith('/api/')) { - return reply.status(404).send({ error: 'Not found' }); - } - return reply.sendFile('index.html'); + // Register CORS + const corsOrigin = process.env.CORS_ORIGIN; + if (corsOrigin === '*') { + // Standalone/Docker mode: allow all origins (Docker network isolation replaces CORS) + await this.app.register(cors, { origin: true, credentials: true }); + } else if (corsOrigin) { + // Custom origin(s) from env + const origins = corsOrigin.split(',').map((o) => o.trim()); + await this.app.register(cors, { origin: origins, credentials: true }); + } else { + // Default: allow all localhost origins + const localhostPattern = /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/; + await this.app.register(cors, { + origin: (origin, cb) => { + if (!origin) { + cb(null, true); + return; + } + if (localhostPattern.test(origin)) { + cb(null, true); + return; + } + cb(new Error('Not allowed by CORS'), false); + }, + credentials: true, }); } - // Register all API routes - registerHttpRoutes(this.app, services, sshModeSwitchCallback); + // Register static file serving and SPA fallback (production only) + const isDev = process.env.NODE_ENV === 'development'; + if (!isDev) { + const rendererPath = resolveRendererPath(); + if (rendererPath) { + logger.info(`Serving static files from: ${rendererPath}`); + + // Cache index.html for SPA fallback + const indexHtml = readFileSync(join(rendererPath, 'index.html'), 'utf-8'); + + await this.app.register(fastifyStatic, { + root: rendererPath, + prefix: '/', + wildcard: false, + }); + + // Register all API routes BEFORE the not-found handler + registerHttpRoutes(this.app, services, sshModeSwitchCallback); + + // SPA fallback: serve index.html for all non-API routes + this.app.setNotFoundHandler(async (request, reply) => { + if (request.url.startsWith('/api/')) { + return reply.status(404).send({ error: 'Not found' }); + } + return reply.type('text/html').send(indexHtml); + }); + } else { + logger.warn('Renderer output directory not found, serving API only'); + registerHttpRoutes(this.app, services, sshModeSwitchCallback); + } + } else { + // Dev mode: no static serving, just API routes + registerHttpRoutes(this.app, services, sshModeSwitchCallback); + } // Try ports starting from preferredPort for (let attempt = 0; attempt <= 10; attempt++) { const tryPort = preferredPort + attempt; try { - await this.app.listen({ host: '127.0.0.1', port: tryPort }); + await this.app.listen({ host, port: tryPort }); this.port = tryPort; this.running = true; - logger.info(`HTTP server started on http://127.0.0.1:${tryPort}`); + logger.info(`HTTP server started on http://${host}:${tryPort}`); return tryPort; } catch (err: unknown) { const error = err as NodeJS.ErrnoException; diff --git a/src/main/standalone.ts b/src/main/standalone.ts new file mode 100644 index 00000000..27cd29ac --- /dev/null +++ b/src/main/standalone.ts @@ -0,0 +1,193 @@ +/** + * Standalone (non-Electron) entry point for claude-devtools. + * + * Runs the HTTP server + API without Electron, suitable for Docker + * or any headless/remote environment. The renderer is served as + * static files over HTTP. + * + * Environment variables: + * - HOST: Bind address (default '0.0.0.0') + * - PORT: Listen port (default 3456) + * - CLAUDE_ROOT: Path to .claude directory (default ~/.claude) + * - CORS_ORIGIN: CORS origin policy (default '*') + */ + +import { createLogger } from '@shared/utils/logger'; + +import { HttpServer } from './services/infrastructure/HttpServer'; +import { + getProjectsBasePath, + getTodosBasePath, + setClaudeBasePathOverride, +} from './utils/pathDecoder'; +import { + LocalFileSystemProvider, + NotificationManager, + ServiceContext, +} from './services'; + +import type { HttpServices } from './http'; +import type { SshConnectionManager } from './services/infrastructure/SshConnectionManager'; +import type { UpdaterService } from './services/infrastructure/UpdaterService'; + +const logger = createLogger('Standalone'); + +// ============================================================================= +// Configuration +// ============================================================================= + +const HOST = process.env.HOST ?? '0.0.0.0'; +const PORT = parseInt(process.env.PORT ?? '3456', 10); +const CLAUDE_ROOT = process.env.CLAUDE_ROOT; + +// Default CORS to allow all in standalone mode (Docker isolation replaces CORS) +if (!process.env.CORS_ORIGIN) { + process.env.CORS_ORIGIN = '*'; +} + +// ============================================================================= +// Stub services (Electron-only features unavailable in standalone) +// ============================================================================= + +/** No-op UpdaterService stub — auto-updater requires Electron. */ +const updaterServiceStub = { + checkForUpdates: async () => {}, + downloadUpdate: async () => {}, + quitAndInstall: () => {}, + setMainWindow: () => {}, +} as unknown as UpdaterService; + +/** No-op SshConnectionManager stub — SSH is managed per-user in the Electron app. */ +const sshConnectionManagerStub = { + getStatus: () => ({ state: 'disconnected' as const, host: null, error: null, remoteProjectsPath: null }), + getProvider: () => new LocalFileSystemProvider(), + isRemote: () => false, + connect: async () => {}, + disconnect: () => {}, + testConnection: async () => ({ success: false, error: 'SSH not available in standalone mode' }), + getConfigHosts: async () => [], + resolveHostConfig: async () => null, + dispose: () => {}, + on: () => sshConnectionManagerStub, + off: () => sshConnectionManagerStub, + emit: () => false, +} as unknown as SshConnectionManager; + +// ============================================================================= +// Application State +// ============================================================================= + +let localContext: ServiceContext; +let notificationManager: NotificationManager; +let httpServer: HttpServer; + +// ============================================================================= +// Lifecycle +// ============================================================================= + +async function start(): Promise { + logger.info('Starting standalone server...'); + + // Apply Claude root override if set + if (CLAUDE_ROOT) { + setClaudeBasePathOverride(CLAUDE_ROOT); + logger.info(`Using CLAUDE_ROOT: ${CLAUDE_ROOT}`); + } + + const projectsDir = getProjectsBasePath(); + const todosDir = getTodosBasePath(); + + logger.info(`Projects directory: ${projectsDir}`); + logger.info(`Todos directory: ${todosDir}`); + + // Create local context (the only context in standalone mode) + localContext = new ServiceContext({ + id: 'local', + type: 'local', + fsProvider: new LocalFileSystemProvider(), + projectsDir, + todosDir, + }); + localContext.start(); + + // Initialize notification manager + notificationManager = NotificationManager.getInstance(); + localContext.fileWatcher.setNotificationManager(notificationManager); + + // Create HTTP server + httpServer = new HttpServer(); + + // Wire file watcher events to SSE broadcast + localContext.fileWatcher.on('file-change', (event: unknown) => { + httpServer.broadcast('file-change', event); + }); + localContext.fileWatcher.on('todo-change', (event: unknown) => { + httpServer.broadcast('todo-change', event); + }); + + // Forward notification events to SSE + notificationManager.on('notification-new', (notification: unknown) => { + httpServer.broadcast('notification:new', notification); + }); + notificationManager.on('notification-updated', (data: unknown) => { + httpServer.broadcast('notification:updated', data); + }); + notificationManager.on('notification-clicked', (data: unknown) => { + httpServer.broadcast('notification:clicked', data); + }); + + // Build services for HTTP routes + const services: HttpServices = { + projectScanner: localContext.projectScanner, + sessionParser: localContext.sessionParser, + subagentResolver: localContext.subagentResolver, + chunkBuilder: localContext.chunkBuilder, + dataCache: localContext.dataCache, + updaterService: updaterServiceStub, + sshConnectionManager: sshConnectionManagerStub, + }; + + // No-op mode switch handler (no SSH in standalone) + const modeSwitchHandler = async () => {}; + + // Start the server + const port = await httpServer.start(services, modeSwitchHandler, PORT, HOST); + logger.info(`Standalone server running at http://${HOST}:${port}`); + logger.info('Open in your browser to view Claude Code sessions'); +} + +async function shutdown(): Promise { + logger.info('Shutting down...'); + + if (httpServer?.isRunning()) { + await httpServer.stop(); + } + + if (localContext) { + localContext.dispose(); + } + + logger.info('Shutdown complete'); + process.exit(0); +} + +// ============================================================================= +// Signal Handlers +// ============================================================================= + +process.on('SIGTERM', () => void shutdown()); +process.on('SIGINT', () => void shutdown()); + +process.on('unhandledRejection', (reason) => { + logger.error('Unhandled promise rejection:', reason); +}); + +process.on('uncaughtException', (error) => { + logger.error('Uncaught exception:', error); +}); + +// ============================================================================= +// Start +// ============================================================================= + +void start(); diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 0d038926..26f904ac 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -48,8 +48,8 @@ export class HttpAPIClient implements ElectronAPI { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures private eventListeners = new Map void>>(); - constructor(port: number) { - this.baseUrl = `http://127.0.0.1:${port}`; + constructor(baseUrl: string) { + this.baseUrl = baseUrl; this.initEventSource(); } diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index 1d59170e..c314cd12 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -16,9 +16,20 @@ import { HttpAPIClient } from './httpClient'; import type { ElectronAPI } from '@shared/types/api'; -function getHttpPort(): number { +/** + * Resolves the base URL for the HTTP API client. + * + * - Electron "server mode" (browser opened via ?port=XXXX): use explicit port on 127.0.0.1 + * - Standalone/Docker (page served by the same server): use window.location.origin + * to avoid cross-origin issues (localhost vs 127.0.0.1) + */ +function getHttpBaseUrl(): string { const params = new URLSearchParams(window.location.search); - return parseInt(params.get('port') ?? '3456', 10); + const explicitPort = params.get('port'); + if (explicitPort) { + return `http://127.0.0.1:${parseInt(explicitPort, 10)}`; + } + return window.location.origin; } let httpClient: HttpAPIClient | null = null; @@ -28,7 +39,7 @@ function getImpl(): ElectronAPI { // Lazily create the HTTP client only when actually needed (browser mode). // Caching avoids creating multiple EventSource connections. if (!httpClient) { - httpClient = new HttpAPIClient(getHttpPort()); + httpClient = new HttpAPIClient(getHttpBaseUrl()); } return httpClient; } diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 25726017..6748a0a5 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -392,22 +392,20 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { )} - {/* Settings gear icon (Electron only - browser can't access native settings) */} - {isElectronMode() && ( - - )} + {/* Settings gear icon */} + {/* Context menu */} diff --git a/src/renderer/components/settings/SettingsTabs.tsx b/src/renderer/components/settings/SettingsTabs.tsx index 2eb1e9f2..4995d64e 100644 --- a/src/renderer/components/settings/SettingsTabs.tsx +++ b/src/renderer/components/settings/SettingsTabs.tsx @@ -1,5 +1,6 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; +import { isElectronMode } from '@renderer/api'; import { Bell, HardDrive, Server, Settings, Wrench } from 'lucide-react'; export type SettingsSection = 'general' | 'connection' | 'workspace' | 'notifications' | 'advanced'; @@ -13,12 +14,13 @@ interface TabConfig { id: SettingsSection; label: string; icon: React.ComponentType<{ className?: string }>; + electronOnly?: boolean; } const tabs: TabConfig[] = [ { id: 'general', label: 'General', icon: Settings }, - { id: 'connection', label: 'Connection', icon: Server }, - { id: 'workspace', label: 'Workspaces', icon: HardDrive }, + { id: 'connection', label: 'Connection', icon: Server, electronOnly: true }, + { id: 'workspace', label: 'Workspaces', icon: HardDrive, electronOnly: true }, { id: 'notifications', label: 'Notifications', icon: Bell }, { id: 'advanced', label: 'Advanced', icon: Wrench }, ]; @@ -28,10 +30,15 @@ export const SettingsTabs = ({ onSectionChange, }: Readonly): React.JSX.Element => { const [hoveredTab, setHoveredTab] = useState(null); + const isElectron = useMemo(() => isElectronMode(), []); + const visibleTabs = useMemo( + () => tabs.filter((tab) => !tab.electronOnly || isElectron), + [isElectron] + ); return (
- {tabs.map((tab) => { + {visibleTabs.map((tab) => { const Icon = tab.icon; const isActive = activeSection === tab.id; const isHovered = hoveredTab === tab.id; diff --git a/src/renderer/components/settings/sections/AdvancedSection.tsx b/src/renderer/components/settings/sections/AdvancedSection.tsx index 3511067b..85925a7d 100644 --- a/src/renderer/components/settings/sections/AdvancedSection.tsx +++ b/src/renderer/components/settings/sections/AdvancedSection.tsx @@ -2,9 +2,9 @@ * AdvancedSection - Advanced settings including config management and about info. */ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { api } from '@renderer/api'; +import { api, isElectronMode } from '@renderer/api'; import appIcon from '@renderer/favicon.png'; import { useStore } from '@renderer/store'; import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide-react'; @@ -26,6 +26,7 @@ export const AdvancedSection = ({ onImportConfig, onOpenInEditor, }: AdvancedSectionProps): React.JSX.Element => { + const isElectron = useMemo(() => isElectronMode(), []); const [version, setVersion] = useState(''); const updateStatus = useStore((s) => s.updateStatus); const availableVersion = useStore((s) => s.availableVersion); @@ -128,17 +129,19 @@ export const AdvancedSection = ({ Import Config - + {isElectron && ( + + )}
@@ -149,22 +152,35 @@ export const AdvancedSection = ({

claude-devtools

- + {isElectron && ( + + )} + {!isElectron && ( + + Standalone + + )}

Version {version || '...'} diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index 492ef7d3..cd28a176 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -2,9 +2,9 @@ * GeneralSection - General settings including startup, appearance, browser access, and local Claude root. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; -import { api } from '@renderer/api'; +import { api, isElectronMode } from '@renderer/api'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { useStore } from '@renderer/store'; import { getFullResetState } from '@renderer/store/utils/stateResetHelpers'; @@ -245,24 +245,36 @@ export const GeneralSection = ({ const isWindowsStyleDefaultPath = /^[a-zA-Z]:\\/.test(defaultClaudeRootPath) || defaultClaudeRootPath.startsWith('\\\\'); + const isElectron = useMemo(() => isElectronMode(), []); + return (

- - - onGeneralToggle('launchAtLogin', v)} - disabled={saving} - /> - - {window.navigator.userAgent.includes('Macintosh') && ( - - onGeneralToggle('showDockIcon', v)} - disabled={saving} - /> - + {isElectron && ( + <> + + + onGeneralToggle('launchAtLogin', v)} + disabled={saving} + /> + + {window.navigator.userAgent.includes('Macintosh') && ( + + onGeneralToggle('showDockIcon', v)} + disabled={saving} + /> + + )} + )} @@ -275,222 +287,283 @@ export const GeneralSection = ({ /> - -

- Choose which local folder is treated as your Claude data root -

+ {isElectron && ( + <> + +

+ Choose which local folder is treated as your Claude data root +

- -
-
- {resolvedClaudeRootPath} -
-
- Auto-detected: {defaultClaudeRootPath} -
-
-
+ +
+
+ {resolvedClaudeRootPath} +
+
+ Auto-detected: {defaultClaudeRootPath} +
+
+
-
- + + + + {isWindowsStyleDefaultPath && ( + )} - Select Folder - - +
- + {claudeRootError && ( +
+

{claudeRootError}

+
+ )} - {isWindowsStyleDefaultPath && ( - - )} -
+ {showWslModal && ( +
+ +
+ ))} + + +
+
- ))} + - -
- - -
- - + )} + )} - - - {serverLoading ? ( - - ) : ( - - )} - + {isElectron ? ( + <> + + + {serverLoading ? ( + + ) : ( + + )} + - {serverStatus.running && ( -
-
- - Running on - - +
+ + Running on + + + {serverUrl} + + +
+ )} + + ) : ( + <> + +
- {serverUrl} - - -
+
+ + Running on + + + {window.location.origin} + + +
+

+ Running in standalone mode. The HTTP server is always active. System notifications are + not available — notification triggers are logged in-app only. +

+ )}
); diff --git a/vite.standalone.config.ts b/vite.standalone.config.ts new file mode 100644 index 00000000..ac24a4d7 --- /dev/null +++ b/vite.standalone.config.ts @@ -0,0 +1,115 @@ +/** + * Vite build config for the standalone (non-Electron) server. + * + * Produces a single CJS bundle at dist-standalone/index.cjs that can be + * run with `node dist-standalone/index.cjs`. + */ + +import { resolve } from 'path' +import { defineConfig } from 'vite' + +import type { Plugin } from 'vite' + +// Node.js built-in modules that should be externalized +const nodeBuiltins = new Set([ + 'fs', 'path', 'os', 'events', 'stream', 'util', 'net', 'tls', + 'http', 'https', 'crypto', 'zlib', 'url', 'querystring', + 'child_process', 'buffer', 'dns', 'dgram', 'assert', 'constants', + 'readline', 'string_decoder', 'timers', 'tty', 'worker_threads' +]) + +// Packages that must be externalized because they break when bundled +// (fastify ecosystem uses internal file resolution that doesn't survive bundling) +const externalPackages = [ + 'fastify', '@fastify/cors', '@fastify/static' +] + +// Stub native .node addons (ssh2/cpu-features have JS fallbacks) +function nativeModuleStub(): Plugin { + const STUB_ID = '\0native-stub' + return { + name: 'native-module-stub', + resolveId(source) { + if (source.endsWith('.node')) return STUB_ID + return null + }, + load(id) { + if (id === STUB_ID) return 'export default {}' + return null + } + } +} + +// Stub out Electron imports with empty modules +const electronModules = new Set(['electron', 'electron-updater']) + +function electronStub(): Plugin { + const ELECTRON_STUB_ID = '\0electron-stub' + // Comprehensive stub covering all electron exports used in the codebase + const electronStubCode = ` +const noop = () => {}; +const noopClass = class {}; +const handler = { get: () => noop }; +const proxyObj = new Proxy({}, handler); +export const app = proxyObj; +export const BrowserWindow = noopClass; +export const ipcMain = { handle: noop, on: noop, removeHandler: noop }; +export const shell = { openPath: noop, openExternal: noop }; +export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) }; +export const Notification = class { show() {} }; +export default proxyObj; +` + return { + name: 'electron-stub', + // Use enforce: 'pre' to intercept before Vite's SSR externalization + enforce: 'pre', + resolveId(source) { + if (electronModules.has(source)) return ELECTRON_STUB_ID + return null + }, + load(id) { + if (id === ELECTRON_STUB_ID) return electronStubCode + return null + } + } +} + +export default defineConfig({ + plugins: [nativeModuleStub(), electronStub()], + resolve: { + alias: { + '@main': resolve(__dirname, 'src/main'), + '@shared': resolve(__dirname, 'src/shared'), + '@preload': resolve(__dirname, 'src/preload') + } + }, + ssr: { + // Force Vite to bundle these instead of externalizing them + // (SSR mode externalizes all node_modules by default) + noExternal: true + }, + build: { + outDir: 'dist-standalone', + target: 'node20', + ssr: true, + rollupOptions: { + input: { + index: resolve(__dirname, 'src/main/standalone.ts') + }, + output: { + format: 'cjs', + entryFileNames: '[name].cjs' + }, + external: (id) => { + // Externalize Node.js built-ins + if (id.startsWith('node:')) return true + if (nodeBuiltins.has(id)) return true + // Externalize packages that break when bundled + if (externalPackages.some(pkg => id === pkg || id.startsWith(pkg + '/'))) return true + return false + } + }, + minify: false, + sourcemap: true + } +})