feat(docker): add standalone mode and Docker support

- Introduced a new Docker setup for running claude-devtools in standalone mode without Electron.
- Added Dockerfile and docker-compose.yml for easy deployment.
- Implemented .dockerignore to exclude unnecessary files from the Docker context.
- Updated package.json with new scripts for building and running the standalone server.
- Enhanced README with Docker usage instructions and environment variable configurations.
- Modified HttpServer to support serving static files and API in standalone mode.
- Updated various components to ensure compatibility with standalone operation.
This commit is contained in:
matt 2026-02-16 22:57:48 +09:00
parent dd2b81acec
commit ce4116dd85
17 changed files with 976 additions and 318 deletions

13
.dockerignore Normal file
View file

@ -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

1
.gitignore vendored
View file

@ -4,6 +4,7 @@ node_modules/
# Build output
dist/
dist-electron/
dist-standalone/
out/
release/
coverage/

55
Dockerfile Normal file
View file

@ -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"]

View file

@ -17,7 +17,7 @@
<a href="https://github.com/matt1398/claude-devtools/releases/latest"><img src="https://img.shields.io/github/v/release/matt1398/claude-devtools?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>&nbsp;
<a href="https://github.com/matt1398/claude-devtools/actions/workflows/ci.yml"><img src="https://github.com/matt1398/claude-devtools/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>&nbsp;
<a href="https://github.com/matt1398/claude-devtools/releases"><img src="https://img.shields.io/github/downloads/matt1398/claude-devtools/total?style=flat-square&color=green" alt="Downloads" /></a>&nbsp;
<img src="https://img.shields.io/badge/platform-macOS%20(Apple%20Silicon%20%2B%20Intel)%20%7C%20Linux%20%7C%20Windows-lightgrey?style=flat-square" alt="Platform" />
<img src="https://img.shields.io/badge/platform-macOS%20(Apple%20Silicon%20%2B%20Intel)%20%7C%20Linux%20%7C%20Windows%20%7C%20Docker-lightgrey?style=flat-square" alt="Platform" />
</p>
<br />
@ -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
<details>

View file

@ -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.

29
docker-compose.yml Normal file
View file

@ -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"

View file

@ -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}"
],

View file

@ -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",

View file

@ -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<void>,
preferredPort: number = 3456
preferredPort: number = 3456,
host: string = '127.0.0.1'
): Promise<number> {
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;

193
src/main/standalone.ts Normal file
View file

@ -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<void> {
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<void> {
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();

View file

@ -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<string, Set<(...args: any[]) => void>>();
constructor(port: number) {
this.baseUrl = `http://127.0.0.1:${port}`;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
this.initEventSource();
}

View file

@ -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;
}

View file

@ -392,22 +392,20 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
)}
</button>
{/* Settings gear icon (Electron only - browser can't access native settings) */}
{isElectronMode() && (
<button
onClick={() => openSettingsTab()}
onMouseEnter={() => setSettingsHover(true)}
onMouseLeave={() => setSettingsHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: settingsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: settingsHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Settings"
>
<Settings className="size-4" />
</button>
)}
{/* Settings gear icon */}
<button
onClick={() => openSettingsTab()}
onMouseEnter={() => setSettingsHover(true)}
onMouseLeave={() => setSettingsHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: settingsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: settingsHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Settings"
>
<Settings className="size-4" />
</button>
</div>
{/* Context menu */}

View file

@ -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<SettingsTabsProps>): React.JSX.Element => {
const [hoveredTab, setHoveredTab] = useState<SettingsSection | null>(null);
const isElectron = useMemo(() => isElectronMode(), []);
const visibleTabs = useMemo(
() => tabs.filter((tab) => !tab.electronOnly || isElectron),
[isElectron]
);
return (
<div className="inline-flex gap-1 border-b" style={{ borderColor: 'var(--color-border)' }}>
{tabs.map((tab) => {
{visibleTabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeSection === tab.id;
const isHovered = hoveredTab === tab.id;

View file

@ -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<string>('');
const updateStatus = useStore((s) => s.updateStatus);
const availableVersion = useStore((s) => s.availableVersion);
@ -128,17 +129,19 @@ export const AdvancedSection = ({
<Upload className="size-4" />
Import Config
</button>
<button
onClick={onOpenInEditor}
className="flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2.5 text-sm font-medium transition-all duration-150"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<Code2 className="size-4" />
Open in Editor
</button>
{isElectron && (
<button
onClick={onOpenInEditor}
className="flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2.5 text-sm font-medium transition-all duration-150"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<Code2 className="size-4" />
Open in Editor
</button>
)}
</div>
<SettingsSectionHeader title="About" />
@ -149,22 +152,35 @@ export const AdvancedSection = ({
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
claude-devtools
</p>
<button
onClick={handleCheckForUpdates}
disabled={updateStatus === 'checking'}
className="flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color:
updateStatus === 'not-available'
? 'var(--color-text-muted)'
: updateStatus === 'available' || updateStatus === 'downloaded'
? '#60a5fa'
: 'var(--color-text-secondary)',
}}
>
{getUpdateButtonContent()}
</button>
{isElectron && (
<button
onClick={handleCheckForUpdates}
disabled={updateStatus === 'checking'}
className="flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color:
updateStatus === 'not-available'
? 'var(--color-text-muted)'
: updateStatus === 'available' || updateStatus === 'downloaded'
? '#60a5fa'
: 'var(--color-text-secondary)',
}}
>
{getUpdateButtonContent()}
</button>
)}
{!isElectron && (
<span
className="rounded-md border px-2.5 py-1 text-xs font-medium"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-muted)',
}}
>
Standalone
</span>
)}
</div>
<p className="mt-0.5 text-xs" style={{ color: 'var(--color-text-muted)' }}>
Version {version || '...'}

View file

@ -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 (
<div>
<SettingsSectionHeader title="Startup" />
<SettingRow label="Launch at login" description="Automatically start the app when you log in">
<SettingsToggle
enabled={safeConfig.general.launchAtLogin}
onChange={(v) => onGeneralToggle('launchAtLogin', v)}
disabled={saving}
/>
</SettingRow>
{window.navigator.userAgent.includes('Macintosh') && (
<SettingRow label="Show dock icon" description="Display the app icon in the dock (macOS)">
<SettingsToggle
enabled={safeConfig.general.showDockIcon}
onChange={(v) => onGeneralToggle('showDockIcon', v)}
disabled={saving}
/>
</SettingRow>
{isElectron && (
<>
<SettingsSectionHeader title="Startup" />
<SettingRow
label="Launch at login"
description="Automatically start the app when you log in"
>
<SettingsToggle
enabled={safeConfig.general.launchAtLogin}
onChange={(v) => onGeneralToggle('launchAtLogin', v)}
disabled={saving}
/>
</SettingRow>
{window.navigator.userAgent.includes('Macintosh') && (
<SettingRow
label="Show dock icon"
description="Display the app icon in the dock (macOS)"
>
<SettingsToggle
enabled={safeConfig.general.showDockIcon}
onChange={(v) => onGeneralToggle('showDockIcon', v)}
disabled={saving}
/>
</SettingRow>
)}
</>
)}
<SettingsSectionHeader title="Appearance" />
@ -275,222 +287,283 @@ export const GeneralSection = ({
/>
</SettingRow>
<SettingsSectionHeader title="Local Claude Root" />
<p className="mb-4 text-sm" style={{ color: 'var(--color-text-muted)' }}>
Choose which local folder is treated as your Claude data root
</p>
{isElectron && (
<>
<SettingsSectionHeader title="Local Claude Root" />
<p className="mb-4 text-sm" style={{ color: 'var(--color-text-muted)' }}>
Choose which local folder is treated as your Claude data root
</p>
<SettingRow
label="Current Local Root"
description={isCustomClaudeRoot ? 'Using custom path' : 'Using auto-detected path'}
>
<div className="max-w-96 text-right">
<div className="truncate font-mono text-xs" style={{ color: 'var(--color-text)' }}>
{resolvedClaudeRootPath}
</div>
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
Auto-detected: {defaultClaudeRootPath}
</div>
</div>
</SettingRow>
<SettingRow
label="Current Local Root"
description={isCustomClaudeRoot ? 'Using custom path' : 'Using auto-detected path'}
>
<div className="max-w-96 text-right">
<div className="truncate font-mono text-xs" style={{ color: 'var(--color-text)' }}>
{resolvedClaudeRootPath}
</div>
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
Auto-detected: {defaultClaudeRootPath}
</div>
</div>
</SettingRow>
<div className="flex items-center gap-3 py-2">
<button
onClick={() => void handleSelectClaudeRootFolder()}
disabled={updatingClaudeRoot}
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text)',
}}
>
<span className="flex items-center gap-2">
{updatingClaudeRoot ? (
<Loader2 className="size-3 animate-spin" />
) : (
<FolderOpen className="size-3" />
<div className="flex items-center gap-3 py-2">
<button
onClick={() => void handleSelectClaudeRootFolder()}
disabled={updatingClaudeRoot}
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text)',
}}
>
<span className="flex items-center gap-2">
{updatingClaudeRoot ? (
<Loader2 className="size-3 animate-spin" />
) : (
<FolderOpen className="size-3" />
)}
Select Folder
</span>
</button>
<button
onClick={() => void handleResetClaudeRoot()}
disabled={updatingClaudeRoot || !isCustomClaudeRoot}
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text-secondary)',
}}
>
<span className="flex items-center gap-2">
<RotateCcw className="size-3" />
Use Auto-Detect
</span>
</button>
{isWindowsStyleDefaultPath && (
<button
onClick={() => void handleUseWslForClaude()}
disabled={updatingClaudeRoot || findingWslRoots}
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text-secondary)',
}}
>
<span className="flex items-center gap-2">
{findingWslRoots ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Laptop className="size-3" />
)}
Using Linux/WSL?
</span>
</button>
)}
Select Folder
</span>
</button>
</div>
<button
onClick={() => void handleResetClaudeRoot()}
disabled={updatingClaudeRoot || !isCustomClaudeRoot}
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text-secondary)',
}}
>
<span className="flex items-center gap-2">
<RotateCcw className="size-3" />
Use Auto-Detect
</span>
</button>
{claudeRootError && (
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-4 py-3">
<p className="text-sm text-red-400">{claudeRootError}</p>
</div>
)}
{isWindowsStyleDefaultPath && (
<button
onClick={() => void handleUseWslForClaude()}
disabled={updatingClaudeRoot || findingWslRoots}
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text-secondary)',
}}
>
<span className="flex items-center gap-2">
{findingWslRoots ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Laptop className="size-3" />
)}
Using Linux/WSL?
</span>
</button>
)}
</div>
{showWslModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<button
className="absolute inset-0 cursor-default"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
onClick={() => setShowWslModal(false)}
aria-label="Close WSL path modal"
tabIndex={-1}
/>
<div
className="relative mx-4 w-full max-w-2xl rounded-lg border p-5 shadow-xl"
style={{
backgroundColor: 'var(--color-surface-overlay)',
borderColor: 'var(--color-border-emphasis)',
}}
>
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
Select WSL Claude Root
</h3>
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
Detected WSL distributions and Claude root candidates
</p>
{claudeRootError && (
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-4 py-3">
<p className="text-sm text-red-400">{claudeRootError}</p>
</div>
)}
{showWslModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<button
className="absolute inset-0 cursor-default"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
onClick={() => setShowWslModal(false)}
aria-label="Close WSL path modal"
tabIndex={-1}
/>
<div
className="relative mx-4 w-full max-w-2xl rounded-lg border p-5 shadow-xl"
style={{
backgroundColor: 'var(--color-surface-overlay)',
borderColor: 'var(--color-border-emphasis)',
}}
>
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
Select WSL Claude Root
</h3>
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
Detected WSL distributions and Claude root candidates
</p>
<div className="mt-4 space-y-2">
{wslCandidates.map((candidate) => (
<div
key={`${candidate.distro}:${candidate.path}`}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
style={{ borderColor: 'var(--color-border)' }}
>
<div className="min-w-0">
<p className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
{candidate.distro}
</p>
<p
className="truncate font-mono text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
<div className="mt-4 space-y-2">
{wslCandidates.map((candidate) => (
<div
key={`${candidate.distro}:${candidate.path}`}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
style={{ borderColor: 'var(--color-border)' }}
>
{candidate.path}
</p>
{!candidate.hasProjectsDir && (
<p className="text-[11px] text-amber-400">No projects directory detected</p>
)}
</div>
<div className="min-w-0">
<p className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
{candidate.distro}
</p>
<p
className="truncate font-mono text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
{candidate.path}
</p>
{!candidate.hasProjectsDir && (
<p className="text-[11px] text-amber-400">
No projects directory detected
</p>
)}
</div>
<button
onClick={() => void applyWslCandidate(candidate)}
className="rounded-md px-3 py-1.5 text-xs transition-colors"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text)',
}}
>
Use This Path
</button>
</div>
))}
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<button
onClick={() => void applyWslCandidate(candidate)}
onClick={() => setShowWslModal(false)}
className="rounded-md border px-3 py-1.5 text-xs transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
Cancel
</button>
<button
onClick={() => {
setShowWslModal(false);
void handleSelectClaudeRootFolder();
}}
className="rounded-md px-3 py-1.5 text-xs transition-colors"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text)',
}}
>
Use This Path
Select Folder Manually
</button>
</div>
))}
</div>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<button
onClick={() => setShowWslModal(false)}
className="rounded-md border px-3 py-1.5 text-xs transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
Cancel
</button>
<button
onClick={() => {
setShowWslModal(false);
void handleSelectClaudeRootFolder();
}}
className="rounded-md px-3 py-1.5 text-xs transition-colors"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text)',
}}
>
Select Folder Manually
</button>
</div>
</div>
</div>
)}
</>
)}
<SettingsSectionHeader title="Browser Access" />
<SettingRow
label="Enable server mode"
description="Start an HTTP server to access the UI from a browser or embed in iframes"
>
{serverLoading ? (
<Loader2 className="size-5 animate-spin" style={{ color: 'var(--color-text-muted)' }} />
) : (
<SettingsToggle
enabled={serverStatus.running}
onChange={handleServerToggle}
disabled={saving}
/>
)}
</SettingRow>
{isElectron ? (
<>
<SettingsSectionHeader title="Browser Access" />
<SettingRow
label="Enable server mode"
description="Start an HTTP server to access the UI from a browser or embed in iframes"
>
{serverLoading ? (
<Loader2
className="size-5 animate-spin"
style={{ color: 'var(--color-text-muted)' }}
/>
) : (
<SettingsToggle
enabled={serverStatus.running}
onChange={handleServerToggle}
disabled={saving}
/>
)}
</SettingRow>
{serverStatus.running && (
<div
className="mb-2 flex items-center gap-3 rounded-md px-3 py-2.5"
style={{ backgroundColor: 'var(--color-surface-raised)' }}
>
<div className="size-2 shrink-0 rounded-full" style={{ backgroundColor: '#22c55e' }} />
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
Running on
</span>
<code
className="rounded px-1.5 py-0.5 font-mono text-xs"
style={{
backgroundColor: 'var(--color-surface)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
}}
{serverStatus.running && (
<div
className="mb-2 flex items-center gap-3 rounded-md px-3 py-2.5"
style={{ backgroundColor: 'var(--color-surface-raised)' }}
>
<div
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: '#22c55e' }}
/>
<span
className="text-xs font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
Running on
</span>
<code
className="rounded px-1.5 py-0.5 font-mono text-xs"
style={{
backgroundColor: 'var(--color-surface)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
}}
>
{serverUrl}
</code>
<button
onClick={handleCopyUrl}
className="ml-auto flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: copied ? '#22c55e' : 'var(--color-text-secondary)',
}}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? 'Copied' : 'Copy URL'}
</button>
</div>
)}
</>
) : (
<>
<SettingsSectionHeader title="Server" />
<div
className="mb-2 flex items-center gap-3 rounded-md px-3 py-2.5"
style={{ backgroundColor: 'var(--color-surface-raised)' }}
>
{serverUrl}
</code>
<button
onClick={handleCopyUrl}
className="ml-auto flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: copied ? '#22c55e' : 'var(--color-text-secondary)',
}}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? 'Copied' : 'Copy URL'}
</button>
</div>
<div className="size-2 shrink-0 rounded-full" style={{ backgroundColor: '#22c55e' }} />
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
Running on
</span>
<code
className="rounded px-1.5 py-0.5 font-mono text-xs"
style={{
backgroundColor: 'var(--color-surface)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
}}
>
{window.location.origin}
</code>
<button
onClick={() => {
void navigator.clipboard.writeText(window.location.origin);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="ml-auto flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: copied ? '#22c55e' : 'var(--color-text-secondary)',
}}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? 'Copied' : 'Copy URL'}
</button>
</div>
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Running in standalone mode. The HTTP server is always active. System notifications are
not available notification triggers are logged in-app only.
</p>
</>
)}
</div>
);

115
vite.standalone.config.ts Normal file
View file

@ -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
}
})