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:
parent
dd2b81acec
commit
ce4116dd85
17 changed files with 976 additions and 318 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
1
.gitignore
vendored
|
|
@ -4,6 +4,7 @@ node_modules/
|
|||
# Build output
|
||||
dist/
|
||||
dist-electron/
|
||||
dist-standalone/
|
||||
out/
|
||||
release/
|
||||
coverage/
|
||||
|
|
|
|||
55
Dockerfile
Normal file
55
Dockerfile
Normal 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"]
|
||||
62
README.md
62
README.md
|
|
@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
44
SECURITY.md
44
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.
|
||||
|
|
|
|||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal 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"
|
||||
|
|
@ -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}"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
193
src/main/standalone.ts
Normal 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();
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 || '...'}
|
||||
|
|
|
|||
|
|
@ -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
115
vite.standalone.config.ts
Normal 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
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue