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
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
dist-electron/
|
dist-electron/
|
||||||
|
dist-standalone/
|
||||||
out/
|
out/
|
||||||
release/
|
release/
|
||||||
coverage/
|
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/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/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>
|
<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>
|
</p>
|
||||||
|
|
||||||
<br />
|
<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
|
## Development
|
||||||
|
|
||||||
<details>
|
<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
|
## Supported Versions
|
||||||
|
|
||||||
Only the latest release is supported with security fixes.
|
Only the latest release is supported with security fixes.
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Please report vulnerabilities privately and do not open public issues for undisclosed security problems.
|
Please report vulnerabilities privately and do not open public issues for undisclosed security problems.
|
||||||
|
|
||||||
Include:
|
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.
|
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
|
## Disclosure Process
|
||||||
|
|
||||||
- We will acknowledge reports as quickly as possible.
|
- We will acknowledge reports as quickly as possible.
|
||||||
- We will validate, triage severity, and prepare a fix.
|
- We will validate, triage severity, and prepare a fix.
|
||||||
- We will coordinate a release and publish advisories when appropriate.
|
- 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",
|
"$schema": "https://unpkg.com/knip@next/schema.json",
|
||||||
"entry": [
|
"entry": [
|
||||||
"src/main/index.ts",
|
"src/main/index.ts",
|
||||||
|
"src/main/standalone.ts",
|
||||||
"src/preload/index.ts",
|
"src/preload/index.ts",
|
||||||
"src/renderer/main.tsx",
|
"src/renderer/main.tsx",
|
||||||
"electron.vite.config.ts",
|
"electron.vite.config.ts",
|
||||||
|
"vite.standalone.config.ts",
|
||||||
"remotion/index.ts",
|
"remotion/index.ts",
|
||||||
"remotion/**/*.{ts,tsx}"
|
"remotion/**/*.{ts,tsx}"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,10 @@
|
||||||
"test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts",
|
"test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts",
|
||||||
"remotion:preview": "remotion studio remotion/index.ts",
|
"remotion:preview": "remotion studio remotion/index.ts",
|
||||||
"remotion:render": "remotion render remotion/index.ts DemoVideo out/demo.mp4",
|
"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": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,34 @@ import { type HttpServices, registerHttpRoutes } from '@main/http';
|
||||||
import { broadcastEvent } from '@main/http/events';
|
import { broadcastEvent } from '@main/http/events';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import Fastify, { type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync, readFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
const logger = createLogger('Service:HttpServer');
|
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 {
|
export class HttpServer {
|
||||||
private app: FastifyInstance | null = null;
|
private app: FastifyInstance | null = null;
|
||||||
private port: number = 3456;
|
private port: number = 3456;
|
||||||
|
|
@ -28,70 +51,87 @@ export class HttpServer {
|
||||||
* @param services - Service instances to pass to route handlers
|
* @param services - Service instances to pass to route handlers
|
||||||
* @param sshModeSwitchCallback - Callback for SSH mode switching
|
* @param sshModeSwitchCallback - Callback for SSH mode switching
|
||||||
* @param preferredPort - Port to try first (default 3456)
|
* @param preferredPort - Port to try first (default 3456)
|
||||||
|
* @param host - Host to bind to (default '127.0.0.1')
|
||||||
*/
|
*/
|
||||||
async start(
|
async start(
|
||||||
services: HttpServices,
|
services: HttpServices,
|
||||||
sshModeSwitchCallback: (mode: 'local' | 'ssh') => Promise<void>,
|
sshModeSwitchCallback: (mode: 'local' | 'ssh') => Promise<void>,
|
||||||
preferredPort: number = 3456
|
preferredPort: number = 3456,
|
||||||
|
host: string = '127.0.0.1'
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
this.app = Fastify({ logger: false });
|
this.app = Fastify({ logger: false });
|
||||||
|
|
||||||
// Register CORS - allow all localhost origins
|
// Register CORS
|
||||||
const localhostPattern = /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
|
const corsOrigin = process.env.CORS_ORIGIN;
|
||||||
await this.app.register(cors, {
|
if (corsOrigin === '*') {
|
||||||
origin: (origin, cb) => {
|
// Standalone/Docker mode: allow all origins (Docker network isolation replaces CORS)
|
||||||
// Allow requests with no origin (same-origin, curl, etc.)
|
await this.app.register(cors, { origin: true, credentials: true });
|
||||||
if (!origin) {
|
} else if (corsOrigin) {
|
||||||
cb(null, true);
|
// Custom origin(s) from env
|
||||||
return;
|
const origins = corsOrigin.split(',').map((o) => o.trim());
|
||||||
}
|
await this.app.register(cors, { origin: origins, credentials: true });
|
||||||
// Allow any localhost origin
|
} else {
|
||||||
if (localhostPattern.test(origin)) {
|
// Default: allow all localhost origins
|
||||||
cb(null, true);
|
const localhostPattern = /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
|
||||||
return;
|
await this.app.register(cors, {
|
||||||
}
|
origin: (origin, cb) => {
|
||||||
cb(new Error('Not allowed by CORS'), false);
|
if (!origin) {
|
||||||
},
|
cb(null, true);
|
||||||
credentials: true,
|
return;
|
||||||
});
|
}
|
||||||
|
if (localhostPattern.test(origin)) {
|
||||||
// Register static file serving (production only)
|
cb(null, true);
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
return;
|
||||||
if (!isDev) {
|
}
|
||||||
const rendererPathCandidates = [
|
cb(new Error('Not allowed by CORS'), false);
|
||||||
join(__dirname, '../../../out/renderer'),
|
},
|
||||||
join(__dirname, '../../renderer'),
|
credentials: true,
|
||||||
];
|
|
||||||
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 all API routes
|
// Register static file serving and SPA fallback (production only)
|
||||||
registerHttpRoutes(this.app, services, sshModeSwitchCallback);
|
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
|
// Try ports starting from preferredPort
|
||||||
for (let attempt = 0; attempt <= 10; attempt++) {
|
for (let attempt = 0; attempt <= 10; attempt++) {
|
||||||
const tryPort = preferredPort + attempt;
|
const tryPort = preferredPort + attempt;
|
||||||
try {
|
try {
|
||||||
await this.app.listen({ host: '127.0.0.1', port: tryPort });
|
await this.app.listen({ host, port: tryPort });
|
||||||
this.port = tryPort;
|
this.port = tryPort;
|
||||||
this.running = true;
|
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;
|
return tryPort;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as NodeJS.ErrnoException;
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures
|
||||||
private eventListeners = new Map<string, Set<(...args: any[]) => void>>();
|
private eventListeners = new Map<string, Set<(...args: any[]) => void>>();
|
||||||
|
|
||||||
constructor(port: number) {
|
constructor(baseUrl: string) {
|
||||||
this.baseUrl = `http://127.0.0.1:${port}`;
|
this.baseUrl = baseUrl;
|
||||||
this.initEventSource();
|
this.initEventSource();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,20 @@ import { HttpAPIClient } from './httpClient';
|
||||||
|
|
||||||
import type { ElectronAPI } from '@shared/types/api';
|
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);
|
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;
|
let httpClient: HttpAPIClient | null = null;
|
||||||
|
|
@ -28,7 +39,7 @@ function getImpl(): ElectronAPI {
|
||||||
// Lazily create the HTTP client only when actually needed (browser mode).
|
// Lazily create the HTTP client only when actually needed (browser mode).
|
||||||
// Caching avoids creating multiple EventSource connections.
|
// Caching avoids creating multiple EventSource connections.
|
||||||
if (!httpClient) {
|
if (!httpClient) {
|
||||||
httpClient = new HttpAPIClient(getHttpPort());
|
httpClient = new HttpAPIClient(getHttpBaseUrl());
|
||||||
}
|
}
|
||||||
return httpClient;
|
return httpClient;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -392,22 +392,20 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Settings gear icon (Electron only - browser can't access native settings) */}
|
{/* Settings gear icon */}
|
||||||
{isElectronMode() && (
|
<button
|
||||||
<button
|
onClick={() => openSettingsTab()}
|
||||||
onClick={() => openSettingsTab()}
|
onMouseEnter={() => setSettingsHover(true)}
|
||||||
onMouseEnter={() => setSettingsHover(true)}
|
onMouseLeave={() => setSettingsHover(false)}
|
||||||
onMouseLeave={() => setSettingsHover(false)}
|
className="rounded-md p-2 transition-colors"
|
||||||
className="rounded-md p-2 transition-colors"
|
style={{
|
||||||
style={{
|
color: settingsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||||
color: settingsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
backgroundColor: settingsHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||||
backgroundColor: settingsHover ? 'var(--color-surface-raised)' : 'transparent',
|
}}
|
||||||
}}
|
title="Settings"
|
||||||
title="Settings"
|
>
|
||||||
>
|
<Settings className="size-4" />
|
||||||
<Settings className="size-4" />
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context menu */}
|
{/* 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';
|
import { Bell, HardDrive, Server, Settings, Wrench } from 'lucide-react';
|
||||||
|
|
||||||
export type SettingsSection = 'general' | 'connection' | 'workspace' | 'notifications' | 'advanced';
|
export type SettingsSection = 'general' | 'connection' | 'workspace' | 'notifications' | 'advanced';
|
||||||
|
|
@ -13,12 +14,13 @@ interface TabConfig {
|
||||||
id: SettingsSection;
|
id: SettingsSection;
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
electronOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs: TabConfig[] = [
|
const tabs: TabConfig[] = [
|
||||||
{ id: 'general', label: 'General', icon: Settings },
|
{ id: 'general', label: 'General', icon: Settings },
|
||||||
{ id: 'connection', label: 'Connection', icon: Server },
|
{ id: 'connection', label: 'Connection', icon: Server, electronOnly: true },
|
||||||
{ id: 'workspace', label: 'Workspaces', icon: HardDrive },
|
{ id: 'workspace', label: 'Workspaces', icon: HardDrive, electronOnly: true },
|
||||||
{ id: 'notifications', label: 'Notifications', icon: Bell },
|
{ id: 'notifications', label: 'Notifications', icon: Bell },
|
||||||
{ id: 'advanced', label: 'Advanced', icon: Wrench },
|
{ id: 'advanced', label: 'Advanced', icon: Wrench },
|
||||||
];
|
];
|
||||||
|
|
@ -28,10 +30,15 @@ export const SettingsTabs = ({
|
||||||
onSectionChange,
|
onSectionChange,
|
||||||
}: Readonly<SettingsTabsProps>): React.JSX.Element => {
|
}: Readonly<SettingsTabsProps>): React.JSX.Element => {
|
||||||
const [hoveredTab, setHoveredTab] = useState<SettingsSection | null>(null);
|
const [hoveredTab, setHoveredTab] = useState<SettingsSection | null>(null);
|
||||||
|
const isElectron = useMemo(() => isElectronMode(), []);
|
||||||
|
const visibleTabs = useMemo(
|
||||||
|
() => tabs.filter((tab) => !tab.electronOnly || isElectron),
|
||||||
|
[isElectron]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex gap-1 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
<div className="inline-flex gap-1 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
||||||
{tabs.map((tab) => {
|
{visibleTabs.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
const isActive = activeSection === tab.id;
|
const isActive = activeSection === tab.id;
|
||||||
const isHovered = hoveredTab === tab.id;
|
const isHovered = hoveredTab === tab.id;
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
* AdvancedSection - Advanced settings including config management and about info.
|
* 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 appIcon from '@renderer/favicon.png';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide-react';
|
import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide-react';
|
||||||
|
|
@ -26,6 +26,7 @@ export const AdvancedSection = ({
|
||||||
onImportConfig,
|
onImportConfig,
|
||||||
onOpenInEditor,
|
onOpenInEditor,
|
||||||
}: AdvancedSectionProps): React.JSX.Element => {
|
}: AdvancedSectionProps): React.JSX.Element => {
|
||||||
|
const isElectron = useMemo(() => isElectronMode(), []);
|
||||||
const [version, setVersion] = useState<string>('');
|
const [version, setVersion] = useState<string>('');
|
||||||
const updateStatus = useStore((s) => s.updateStatus);
|
const updateStatus = useStore((s) => s.updateStatus);
|
||||||
const availableVersion = useStore((s) => s.availableVersion);
|
const availableVersion = useStore((s) => s.availableVersion);
|
||||||
|
|
@ -128,17 +129,19 @@ export const AdvancedSection = ({
|
||||||
<Upload className="size-4" />
|
<Upload className="size-4" />
|
||||||
Import Config
|
Import Config
|
||||||
</button>
|
</button>
|
||||||
<button
|
{isElectron && (
|
||||||
onClick={onOpenInEditor}
|
<button
|
||||||
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"
|
onClick={onOpenInEditor}
|
||||||
style={{
|
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"
|
||||||
borderColor: 'var(--color-border)',
|
style={{
|
||||||
color: 'var(--color-text-secondary)',
|
borderColor: 'var(--color-border)',
|
||||||
}}
|
color: 'var(--color-text-secondary)',
|
||||||
>
|
}}
|
||||||
<Code2 className="size-4" />
|
>
|
||||||
Open in Editor
|
<Code2 className="size-4" />
|
||||||
</button>
|
Open in Editor
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSectionHeader title="About" />
|
<SettingsSectionHeader title="About" />
|
||||||
|
|
@ -149,22 +152,35 @@ export const AdvancedSection = ({
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||||
claude-devtools
|
claude-devtools
|
||||||
</p>
|
</p>
|
||||||
<button
|
{isElectron && (
|
||||||
onClick={handleCheckForUpdates}
|
<button
|
||||||
disabled={updateStatus === 'checking'}
|
onClick={handleCheckForUpdates}
|
||||||
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"
|
disabled={updateStatus === 'checking'}
|
||||||
style={{
|
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"
|
||||||
borderColor: 'var(--color-border)',
|
style={{
|
||||||
color:
|
borderColor: 'var(--color-border)',
|
||||||
updateStatus === 'not-available'
|
color:
|
||||||
? 'var(--color-text-muted)'
|
updateStatus === 'not-available'
|
||||||
: updateStatus === 'available' || updateStatus === 'downloaded'
|
? 'var(--color-text-muted)'
|
||||||
? '#60a5fa'
|
: updateStatus === 'available' || updateStatus === 'downloaded'
|
||||||
: 'var(--color-text-secondary)',
|
? '#60a5fa'
|
||||||
}}
|
: 'var(--color-text-secondary)',
|
||||||
>
|
}}
|
||||||
{getUpdateButtonContent()}
|
>
|
||||||
</button>
|
{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>
|
</div>
|
||||||
<p className="mt-0.5 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
<p className="mt-0.5 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
Version {version || '...'}
|
Version {version || '...'}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
* GeneralSection - General settings including startup, appearance, browser access, and local Claude root.
|
* 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 { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
|
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
|
||||||
|
|
@ -245,24 +245,36 @@ export const GeneralSection = ({
|
||||||
const isWindowsStyleDefaultPath =
|
const isWindowsStyleDefaultPath =
|
||||||
/^[a-zA-Z]:\\/.test(defaultClaudeRootPath) || defaultClaudeRootPath.startsWith('\\\\');
|
/^[a-zA-Z]:\\/.test(defaultClaudeRootPath) || defaultClaudeRootPath.startsWith('\\\\');
|
||||||
|
|
||||||
|
const isElectron = useMemo(() => isElectronMode(), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsSectionHeader title="Startup" />
|
{isElectron && (
|
||||||
<SettingRow label="Launch at login" description="Automatically start the app when you log in">
|
<>
|
||||||
<SettingsToggle
|
<SettingsSectionHeader title="Startup" />
|
||||||
enabled={safeConfig.general.launchAtLogin}
|
<SettingRow
|
||||||
onChange={(v) => onGeneralToggle('launchAtLogin', v)}
|
label="Launch at login"
|
||||||
disabled={saving}
|
description="Automatically start the app when you log in"
|
||||||
/>
|
>
|
||||||
</SettingRow>
|
<SettingsToggle
|
||||||
{window.navigator.userAgent.includes('Macintosh') && (
|
enabled={safeConfig.general.launchAtLogin}
|
||||||
<SettingRow label="Show dock icon" description="Display the app icon in the dock (macOS)">
|
onChange={(v) => onGeneralToggle('launchAtLogin', v)}
|
||||||
<SettingsToggle
|
disabled={saving}
|
||||||
enabled={safeConfig.general.showDockIcon}
|
/>
|
||||||
onChange={(v) => onGeneralToggle('showDockIcon', v)}
|
</SettingRow>
|
||||||
disabled={saving}
|
{window.navigator.userAgent.includes('Macintosh') && (
|
||||||
/>
|
<SettingRow
|
||||||
</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" />
|
<SettingsSectionHeader title="Appearance" />
|
||||||
|
|
@ -275,222 +287,283 @@ export const GeneralSection = ({
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
<SettingsSectionHeader title="Local Claude Root" />
|
{isElectron && (
|
||||||
<p className="mb-4 text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
<>
|
||||||
Choose which local folder is treated as your Claude data root
|
<SettingsSectionHeader title="Local Claude Root" />
|
||||||
</p>
|
<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
|
<SettingRow
|
||||||
label="Current Local Root"
|
label="Current Local Root"
|
||||||
description={isCustomClaudeRoot ? 'Using custom path' : 'Using auto-detected path'}
|
description={isCustomClaudeRoot ? 'Using custom path' : 'Using auto-detected path'}
|
||||||
>
|
>
|
||||||
<div className="max-w-96 text-right">
|
<div className="max-w-96 text-right">
|
||||||
<div className="truncate font-mono text-xs" style={{ color: 'var(--color-text)' }}>
|
<div className="truncate font-mono text-xs" style={{ color: 'var(--color-text)' }}>
|
||||||
{resolvedClaudeRootPath}
|
{resolvedClaudeRootPath}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
Auto-detected: {defaultClaudeRootPath}
|
Auto-detected: {defaultClaudeRootPath}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 py-2">
|
<div className="flex items-center gap-3 py-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleSelectClaudeRootFolder()}
|
onClick={() => void handleSelectClaudeRootFolder()}
|
||||||
disabled={updatingClaudeRoot}
|
disabled={updatingClaudeRoot}
|
||||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
color: 'var(--color-text)',
|
color: 'var(--color-text)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{updatingClaudeRoot ? (
|
{updatingClaudeRoot ? (
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Loader2 className="size-3 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<FolderOpen className="size-3" />
|
<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
|
</div>
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
{claudeRootError && (
|
||||||
onClick={() => void handleResetClaudeRoot()}
|
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-4 py-3">
|
||||||
disabled={updatingClaudeRoot || !isCustomClaudeRoot}
|
<p className="text-sm text-red-400">{claudeRootError}</p>
|
||||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
</div>
|
||||||
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 && (
|
{showWslModal && (
|
||||||
<button
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
onClick={() => void handleUseWslForClaude()}
|
<button
|
||||||
disabled={updatingClaudeRoot || findingWslRoots}
|
className="absolute inset-0 cursor-default"
|
||||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
|
||||||
style={{
|
onClick={() => setShowWslModal(false)}
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
aria-label="Close WSL path modal"
|
||||||
color: 'var(--color-text-secondary)',
|
tabIndex={-1}
|
||||||
}}
|
/>
|
||||||
>
|
<div
|
||||||
<span className="flex items-center gap-2">
|
className="relative mx-4 w-full max-w-2xl rounded-lg border p-5 shadow-xl"
|
||||||
{findingWslRoots ? (
|
style={{
|
||||||
<Loader2 className="size-3 animate-spin" />
|
backgroundColor: 'var(--color-surface-overlay)',
|
||||||
) : (
|
borderColor: 'var(--color-border-emphasis)',
|
||||||
<Laptop className="size-3" />
|
}}
|
||||||
)}
|
>
|
||||||
Using Linux/WSL?
|
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||||
</span>
|
Select WSL Claude Root
|
||||||
</button>
|
</h3>
|
||||||
)}
|
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
</div>
|
Detected WSL distributions and Claude root candidates
|
||||||
|
</p>
|
||||||
|
|
||||||
{claudeRootError && (
|
<div className="mt-4 space-y-2">
|
||||||
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-4 py-3">
|
{wslCandidates.map((candidate) => (
|
||||||
<p className="text-sm text-red-400">{claudeRootError}</p>
|
<div
|
||||||
</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)' }}
|
||||||
{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)' }}
|
|
||||||
>
|
>
|
||||||
{candidate.path}
|
<div className="min-w-0">
|
||||||
</p>
|
<p className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
|
||||||
{!candidate.hasProjectsDir && (
|
{candidate.distro}
|
||||||
<p className="text-[11px] text-amber-400">No projects directory detected</p>
|
</p>
|
||||||
)}
|
<p
|
||||||
</div>
|
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
|
<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"
|
className="rounded-md px-3 py-1.5 text-xs transition-colors"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
color: 'var(--color-text)',
|
color: 'var(--color-text)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Use This Path
|
Select Folder Manually
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</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" />
|
{isElectron ? (
|
||||||
<SettingRow
|
<>
|
||||||
label="Enable server mode"
|
<SettingsSectionHeader title="Browser Access" />
|
||||||
description="Start an HTTP server to access the UI from a browser or embed in iframes"
|
<SettingRow
|
||||||
>
|
label="Enable server mode"
|
||||||
{serverLoading ? (
|
description="Start an HTTP server to access the UI from a browser or embed in iframes"
|
||||||
<Loader2 className="size-5 animate-spin" style={{ color: 'var(--color-text-muted)' }} />
|
>
|
||||||
) : (
|
{serverLoading ? (
|
||||||
<SettingsToggle
|
<Loader2
|
||||||
enabled={serverStatus.running}
|
className="size-5 animate-spin"
|
||||||
onChange={handleServerToggle}
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
disabled={saving}
|
/>
|
||||||
/>
|
) : (
|
||||||
)}
|
<SettingsToggle
|
||||||
</SettingRow>
|
enabled={serverStatus.running}
|
||||||
|
onChange={handleServerToggle}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
{serverStatus.running && (
|
{serverStatus.running && (
|
||||||
<div
|
<div
|
||||||
className="mb-2 flex items-center gap-3 rounded-md px-3 py-2.5"
|
className="mb-2 flex items-center gap-3 rounded-md px-3 py-2.5"
|
||||||
style={{ backgroundColor: 'var(--color-surface-raised)' }}
|
style={{ backgroundColor: 'var(--color-surface-raised)' }}
|
||||||
>
|
>
|
||||||
<div className="size-2 shrink-0 rounded-full" style={{ backgroundColor: '#22c55e' }} />
|
<div
|
||||||
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
className="size-2 shrink-0 rounded-full"
|
||||||
Running on
|
style={{ backgroundColor: '#22c55e' }}
|
||||||
</span>
|
/>
|
||||||
<code
|
<span
|
||||||
className="rounded px-1.5 py-0.5 font-mono text-xs"
|
className="text-xs font-medium"
|
||||||
style={{
|
style={{ color: 'var(--color-text-secondary)' }}
|
||||||
backgroundColor: 'var(--color-surface)',
|
>
|
||||||
color: 'var(--color-text)',
|
Running on
|
||||||
border: '1px solid var(--color-border)',
|
</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}
|
<div className="size-2 shrink-0 rounded-full" style={{ backgroundColor: '#22c55e' }} />
|
||||||
</code>
|
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
<button
|
Running on
|
||||||
onClick={handleCopyUrl}
|
</span>
|
||||||
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"
|
<code
|
||||||
style={{
|
className="rounded px-1.5 py-0.5 font-mono text-xs"
|
||||||
borderColor: 'var(--color-border)',
|
style={{
|
||||||
color: copied ? '#22c55e' : 'var(--color-text-secondary)',
|
backgroundColor: 'var(--color-surface)',
|
||||||
}}
|
color: 'var(--color-text)',
|
||||||
>
|
border: '1px solid var(--color-border)',
|
||||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
}}
|
||||||
{copied ? 'Copied' : 'Copy URL'}
|
>
|
||||||
</button>
|
{window.location.origin}
|
||||||
</div>
|
</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>
|
</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