feat: Tauri desktop app with sidecar, brand identity, and CORS fix
- Add Tauri sidecar architecture: Rust shell spawns claude-server binary, dynamic port allocation, health-check wait loop, graceful shutdown - Fix CORS middleware to accept `tauri://localhost` and `https://tauri.localhost` origins from Tauri WebView, and add CORS headers to /health endpoint - Enable native macOS window decorations (traffic lights) with Overlay title bar, add data-tauri-drag-region on sidebar for window dragging - Conditionally apply desktop-only padding (44px for traffic lights) vs web (12px) - Generate brand identity: light-background app icon, horizontal logo, full icon set (icns/ico/png) for Tauri bundle - Add brand mark + GitHub link in sidebar, replace mascot SVG with app icon in EmptySession page - Update README (zh/en) and docs hero image with new branding - Add sidecar build scripts and launcher entry points - Gitignore Rust target/, Tauri gen/, and brand-assets candidates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
6
.gitignore
vendored
@ -18,6 +18,12 @@ e2e-*.png
|
||||
|
||||
# Desktop build output
|
||||
desktop/dist/
|
||||
desktop/src-tauri/binaries/
|
||||
desktop/src-tauri/target/
|
||||
desktop/src-tauri/gen/
|
||||
|
||||
# Desktop brand asset candidates (keep only selected ones in public/)
|
||||
desktop/brand-assets/
|
||||
|
||||
# Claude worktrees
|
||||
.claude/worktrees/
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
# Claude Code Haha
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/banner.jpg" alt="Claude Code Haha Banner" width="800">
|
||||
<img src="docs/images/app-icon.jpg" alt="Claude Code Haha" width="128" style="border-radius: 22px;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/logo-horizontal.jpg" alt="Claude Code Haha" width="480">
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
# Claude Code Haha
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/banner.jpg" alt="Claude Code Haha Banner" width="800">
|
||||
<img src="docs/images/app-icon.jpg" alt="Claude Code Haha" width="128" style="border-radius: 22px;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/logo-horizontal.jpg" alt="Claude Code Haha" width="480">
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:sidecars": "bun run ./scripts/build-sidecars.ts",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest",
|
||||
|
||||
BIN
desktop/public/app-icon.jpg
Normal file
|
After Width: | Height: | Size: 351 KiB |
BIN
desktop/public/logo-horizontal.jpg
Normal file
|
After Width: | Height: | Size: 206 KiB |
120
desktop/scripts/build-sidecars.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const desktopRoot = path.resolve(import.meta.dir, '..')
|
||||
const repoRoot = path.resolve(desktopRoot, '..')
|
||||
const binariesDir = path.join(desktopRoot, 'src-tauri', 'binaries')
|
||||
|
||||
const targetTriple =
|
||||
process.env.TAURI_ENV_TARGET_TRIPLE ||
|
||||
process.env.CARGO_BUILD_TARGET ||
|
||||
(await detectHostTriple())
|
||||
|
||||
const bunTarget = mapTargetTripleToBun(targetTriple)
|
||||
|
||||
await mkdir(binariesDir, { recursive: true })
|
||||
|
||||
await compileExecutable({
|
||||
entrypoint: path.join(desktopRoot, 'sidecars/server-launcher.ts'),
|
||||
outfileBase: path.join(binariesDir, `claude-server-${targetTriple}`),
|
||||
productName: 'Claude Code Server',
|
||||
bunTarget,
|
||||
})
|
||||
|
||||
await compileExecutable({
|
||||
entrypoint: path.join(desktopRoot, 'sidecars/cli-launcher.ts'),
|
||||
outfileBase: path.join(binariesDir, `claude-cli-${targetTriple}`),
|
||||
productName: 'Claude Code CLI',
|
||||
bunTarget,
|
||||
})
|
||||
|
||||
console.log(`[build-sidecars] Built desktop sidecars for ${targetTriple} (${bunTarget})`)
|
||||
|
||||
async function detectHostTriple() {
|
||||
const proc = Bun.spawn(['rustc', '-vV'], {
|
||||
cwd: repoRoot,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`[build-sidecars] rustc -vV failed: ${stderr || stdout}`)
|
||||
}
|
||||
|
||||
const hostLine = stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith('host: '))
|
||||
|
||||
if (!hostLine) {
|
||||
throw new Error('[build-sidecars] Could not detect Rust host triple')
|
||||
}
|
||||
|
||||
return hostLine.replace('host: ', '')
|
||||
}
|
||||
|
||||
function mapTargetTripleToBun(triple: string) {
|
||||
switch (triple) {
|
||||
case 'aarch64-apple-darwin':
|
||||
return 'bun-darwin-arm64'
|
||||
case 'x86_64-apple-darwin':
|
||||
return 'bun-darwin-x64'
|
||||
case 'x86_64-pc-windows-msvc':
|
||||
return 'bun-windows-x64'
|
||||
case 'aarch64-pc-windows-msvc':
|
||||
return 'bun-windows-arm64'
|
||||
case 'x86_64-unknown-linux-gnu':
|
||||
return 'bun-linux-x64-baseline'
|
||||
case 'aarch64-unknown-linux-gnu':
|
||||
return 'bun-linux-arm64'
|
||||
case 'x86_64-unknown-linux-musl':
|
||||
return 'bun-linux-x64-musl'
|
||||
case 'aarch64-unknown-linux-musl':
|
||||
return 'bun-linux-arm64-musl'
|
||||
default:
|
||||
throw new Error(`[build-sidecars] Unsupported target triple: ${triple}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function compileExecutable({
|
||||
entrypoint,
|
||||
outfileBase,
|
||||
productName,
|
||||
bunTarget,
|
||||
}: {
|
||||
entrypoint: string
|
||||
outfileBase: string
|
||||
productName: string
|
||||
bunTarget: string
|
||||
}) {
|
||||
const result = await Bun.build({
|
||||
entrypoints: [entrypoint],
|
||||
minify: false,
|
||||
sourcemap: 'none',
|
||||
target: 'bun',
|
||||
compile: {
|
||||
target: bunTarget,
|
||||
outfile: outfileBase,
|
||||
autoloadTsconfig: true,
|
||||
autoloadPackageJson: true,
|
||||
windows: {
|
||||
title: productName,
|
||||
publisher: 'Claude Code',
|
||||
description: productName,
|
||||
hideConsole: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
const logs = result.logs.map((log) => log.message).join('\n')
|
||||
throw new Error(`[build-sidecars] Failed to compile ${productName}:\n${logs}`)
|
||||
}
|
||||
|
||||
const outputPath = result.outputs[0]?.path
|
||||
console.log(`[build-sidecars] ${productName} -> ${outputPath ?? outfileBase}`)
|
||||
}
|
||||
35
desktop/sidecars/cli-launcher.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const { appRoot, args } = parseLauncherArgs()
|
||||
|
||||
process.env.CLAUDE_APP_ROOT = appRoot
|
||||
process.env.CALLER_DIR ||= process.cwd()
|
||||
process.argv = [process.argv[0]!, process.argv[1]!, ...args]
|
||||
|
||||
const preloadEntrypoint = pathToFileURL(path.join(appRoot, 'preload.ts')).href
|
||||
const cliEntrypoint = pathToFileURL(path.join(appRoot, 'src/entrypoints/cli.tsx')).href
|
||||
await import(preloadEntrypoint)
|
||||
await import(cliEntrypoint)
|
||||
|
||||
function parseLauncherArgs() {
|
||||
const rawArgs = process.argv.slice(2)
|
||||
const nextArgs: string[] = []
|
||||
let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null
|
||||
|
||||
for (let index = 0; index < rawArgs.length; index++) {
|
||||
const arg = rawArgs[index]
|
||||
if (arg === '--app-root') {
|
||||
appRoot = rawArgs[index + 1] ?? null
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
nextArgs.push(arg)
|
||||
}
|
||||
|
||||
if (!appRoot) {
|
||||
throw new Error('Missing --app-root for claude-cli sidecar')
|
||||
}
|
||||
|
||||
return { appRoot, args: nextArgs }
|
||||
}
|
||||
37
desktop/sidecars/server-launcher.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const { appRoot, args } = parseLauncherArgs()
|
||||
|
||||
process.env.CLAUDE_APP_ROOT = appRoot
|
||||
process.chdir(appRoot)
|
||||
process.argv = [process.argv[0]!, process.argv[1]!, ...args]
|
||||
|
||||
const preloadEntrypoint = pathToFileURL(path.join(appRoot, 'preload.ts')).href
|
||||
const serverEntrypoint = pathToFileURL(path.join(appRoot, 'src/server/index.ts')).href
|
||||
await import(preloadEntrypoint)
|
||||
const { startServer } = await import(serverEntrypoint)
|
||||
|
||||
startServer()
|
||||
|
||||
function parseLauncherArgs() {
|
||||
const rawArgs = process.argv.slice(2)
|
||||
const nextArgs: string[] = []
|
||||
let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null
|
||||
|
||||
for (let index = 0; index < rawArgs.length; index++) {
|
||||
const arg = rawArgs[index]
|
||||
if (arg === '--app-root') {
|
||||
appRoot = rawArgs[index + 1] ?? null
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
nextArgs.push(arg)
|
||||
}
|
||||
|
||||
if (!appRoot) {
|
||||
throw new Error('Missing --app-root for claude-server sidecar')
|
||||
}
|
||||
|
||||
return { appRoot, args: nextArgs }
|
||||
}
|
||||
5172
desktop/src-tauri/Cargo.lock
generated
Normal file
13
desktop/src-tauri/app-icon.svg
Normal file
@ -0,0 +1,13 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="132" fill="#1F1714"/>
|
||||
<rect x="42" y="42" width="428" height="428" rx="112" fill="url(#surface)"/>
|
||||
<path d="M352 178C327 153 292 140 252 140C166 140 108 197 108 256C108 314 166 372 252 372C291 372 326 360 351 335L316 298C300 315 278 324 253 324C197 324 159 287 159 256C159 224 197 188 253 188C279 188 301 198 317 215L352 178Z" fill="#FFF7F0"/>
|
||||
<path d="M381 166L401 146L438 183L418 203L381 166Z" fill="#D07A57"/>
|
||||
<path d="M369 224L395 198L419 222L393 248L369 224Z" fill="#F0B08E"/>
|
||||
<defs>
|
||||
<linearGradient id="surface" x1="66" y1="78" x2="432" y2="436" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#A85E42"/>
|
||||
<stop offset="1" stop-color="#7A4330"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 855 B |
@ -6,7 +6,26 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
"shell:allow-execute",
|
||||
{
|
||||
"identifier": "shell:allow-execute",
|
||||
"allow": [
|
||||
{
|
||||
"args": true,
|
||||
"name": "binaries/claude-server",
|
||||
"sidecar": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "shell:allow-spawn",
|
||||
"allow": [
|
||||
{
|
||||
"args": true,
|
||||
"name": "binaries/claude-server",
|
||||
"sidecar": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"process:allow-exit",
|
||||
|
||||
BIN
desktop/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
desktop/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
desktop/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
desktop/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
desktop/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
desktop/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
desktop/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
desktop/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
desktop/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
desktop/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
desktop/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
desktop/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
desktop/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
desktop/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
BIN
desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
BIN
desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
BIN
desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
BIN
desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 12 KiB |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
desktop/src-tauri/icons/icon.icns
Normal file
BIN
desktop/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
desktop/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 783 B |
BIN
desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
@ -1,24 +1,185 @@
|
||||
use tauri::Manager;
|
||||
use std::{
|
||||
io::{Error as IoError, ErrorKind},
|
||||
net::{SocketAddr, TcpListener, TcpStream},
|
||||
path::PathBuf,
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tauri::{path::BaseDirectory, AppHandle, Manager, RunEvent, State};
|
||||
use tauri_plugin_shell::{
|
||||
process::{CommandChild, CommandEvent},
|
||||
ShellExt,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct ServerState(Mutex<ServerStatus>);
|
||||
|
||||
struct ServerRuntime {
|
||||
url: String,
|
||||
child: CommandChild,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ServerStatus {
|
||||
runtime: Option<ServerRuntime>,
|
||||
startup_error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_server_url() -> String {
|
||||
let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "3456".to_string());
|
||||
let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
format!("http://{}:{}", host, port)
|
||||
fn get_server_url(state: State<'_, ServerState>) -> Result<String, String> {
|
||||
let guard = state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "desktop server state is unavailable".to_string())?;
|
||||
|
||||
if let Some(runtime) = guard.runtime.as_ref() {
|
||||
return Ok(runtime.url.clone());
|
||||
}
|
||||
|
||||
Err(guard
|
||||
.startup_error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "desktop server did not start".to_string()))
|
||||
}
|
||||
|
||||
fn reserve_local_port() -> Result<u16, String> {
|
||||
let listener =
|
||||
TcpListener::bind("127.0.0.1:0").map_err(|err| format!("bind local port: {err}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|err| format!("read local port: {err}"))?
|
||||
.port();
|
||||
drop(listener);
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
fn wait_for_server(url_host: &str, port: u16) -> Result<(), String> {
|
||||
let addr: SocketAddr = format!("{url_host}:{port}")
|
||||
.parse()
|
||||
.map_err(|err| format!("parse server address: {err}"))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
|
||||
while Instant::now() < deadline {
|
||||
if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"desktop server did not start listening on {url_host}:{port} within 10 seconds"
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_app_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
if cfg!(debug_assertions) {
|
||||
return PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.map_err(|err| format!("resolve development app root: {err}"));
|
||||
}
|
||||
|
||||
app.path()
|
||||
.resolve("app", BaseDirectory::Resource)
|
||||
.map_err(|err| format!("resolve bundled app root: {err}"))
|
||||
}
|
||||
|
||||
fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
|
||||
let host = "127.0.0.1";
|
||||
let port = reserve_local_port()?;
|
||||
let url = format!("http://{host}:{port}");
|
||||
let app_root = resolve_app_root(app)?;
|
||||
let app_root_arg = app_root.to_string_lossy().to_string();
|
||||
|
||||
let sidecar = app
|
||||
.shell()
|
||||
.sidecar("claude-server")
|
||||
.map_err(|err| format!("resolve server sidecar: {err}"))?
|
||||
.args([
|
||||
"--app-root",
|
||||
&app_root_arg,
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
&port.to_string(),
|
||||
]);
|
||||
|
||||
let (mut rx, child) = sidecar
|
||||
.spawn()
|
||||
.map_err(|err| format!("spawn server sidecar: {err}"))?;
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
CommandEvent::Stdout(line) => {
|
||||
let line = String::from_utf8_lossy(&line);
|
||||
println!("[claude-server] {}", line.trim_end());
|
||||
}
|
||||
CommandEvent::Stderr(line) => {
|
||||
let line = String::from_utf8_lossy(&line);
|
||||
eprintln!("[claude-server] {}", line.trim_end());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
wait_for_server(host, port)?;
|
||||
|
||||
Ok(ServerRuntime { url, child })
|
||||
}
|
||||
|
||||
fn stop_server_sidecar(app: &AppHandle) {
|
||||
let Some(state) = app.try_state::<ServerState>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(mut guard) = state.0.lock() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(runtime) = guard.runtime.take() {
|
||||
let _ = runtime.child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
let app = tauri::Builder::default()
|
||||
.manage(ServerState::default())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.invoke_handler(tauri::generate_handler![get_server_url])
|
||||
.setup(|app| {
|
||||
// Window is configured in tauri.conf.json
|
||||
let state = app.state::<ServerState>();
|
||||
let mut guard = state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| IoError::new(ErrorKind::Other, "server state lock poisoned"))?;
|
||||
|
||||
match start_server_sidecar(&app.handle()) {
|
||||
Ok(runtime) => {
|
||||
guard.runtime = Some(runtime);
|
||||
guard.startup_error = None;
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[desktop] failed to start local server: {err}");
|
||||
guard.runtime = None;
|
||||
guard.startup_error = Some(err);
|
||||
}
|
||||
}
|
||||
|
||||
let _window = app.get_webview_window("main").unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application");
|
||||
|
||||
app.run(|app_handle, event| {
|
||||
if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) {
|
||||
stop_server_sidecar(app_handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"beforeBuildCommand": "bun run build"
|
||||
"beforeDevCommand": "bun run build:sidecars && bun run dev",
|
||||
"beforeBuildCommand": "bun run build && bun run build:sidecars"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
@ -17,7 +17,7 @@
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"decorations": false,
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"transparent": false
|
||||
@ -30,6 +30,19 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"externalBin": [
|
||||
"binaries/claude-server",
|
||||
"binaries/claude-cli"
|
||||
],
|
||||
"resources": {
|
||||
"../../src/": "app/src/",
|
||||
"../../node_modules/": "app/node_modules/",
|
||||
"../../package.json": "app/package.json",
|
||||
"../../tsconfig.json": "app/tsconfig.json",
|
||||
"../../bunfig.toml": "app/bunfig.toml",
|
||||
"../../preload.ts": "app/preload.ts",
|
||||
"../../stubs/": "app/stubs/"
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
@ -10,6 +10,10 @@ export function getBaseUrl() {
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
export function getDefaultBaseUrl() {
|
||||
return DEFAULT_BASE_URL
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
|
||||
@ -85,7 +85,10 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
|
||||
|
||||
// All questions must be answered (via selection or free text) to enable submit
|
||||
const allAnswered = freeText.trim().length > 0 || questions.every((_, i) => selections[i] !== undefined)
|
||||
const activeQuestion = questions[activeTab]
|
||||
const safeActiveTab = Math.min(activeTab, questions.length - 1)
|
||||
const activeQuestion = questions[safeActiveTab]
|
||||
|
||||
if (!activeQuestion) return null
|
||||
|
||||
return (
|
||||
<div className={`mb-4 ml-10 rounded-[var(--radius-lg)] border overflow-hidden ${
|
||||
@ -120,7 +123,7 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
|
||||
{questions.length > 1 && (
|
||||
<div className="flex px-4 border-b border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)] overflow-x-auto">
|
||||
{questions.map((q, i) => {
|
||||
const isActive = activeTab === i
|
||||
const isActive = safeActiveTab === i
|
||||
const isAnswered = selections[i] !== undefined
|
||||
const tabLabel = q.header || `Q${i + 1}`
|
||||
return (
|
||||
@ -160,7 +163,7 @@ export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
|
||||
return (
|
||||
<button
|
||||
key={optIndex}
|
||||
onClick={() => handleSelect(activeTab, opt.label)}
|
||||
onClick={() => handleSelect(safeActiveTab, opt.label)}
|
||||
disabled={submitted}
|
||||
className={`w-full text-left px-4 py-3 rounded-[var(--radius-md)] border transition-all duration-150 cursor-pointer ${
|
||||
isSelected
|
||||
|
||||
@ -117,10 +117,11 @@ export function MessageList() {
|
||||
<AssistantMessage content={streamingText} isStreaming />
|
||||
)}
|
||||
|
||||
{/* Only show StreamingIndicator for tool_executing state.
|
||||
During 'thinking', the active ThinkingBlock already shows animation.
|
||||
During 'streaming', the AssistantMessage shows the cursor. */}
|
||||
{chatState === 'tool_executing' && (
|
||||
{/* Show StreamingIndicator when:
|
||||
- tool_executing: tool is running
|
||||
- thinking but no active ThinkingBlock yet: the gap between
|
||||
sending a message and receiving the first thinking delta */}
|
||||
{(chatState === 'tool_executing' || (chatState === 'thinking' && !activeThinkingId)) && (
|
||||
<StreamingIndicator />
|
||||
)}
|
||||
|
||||
|
||||
@ -29,56 +29,59 @@ export function SessionTaskBar() {
|
||||
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="border-t border-[var(--color-border)] bg-[var(--color-surface)] shrink-0">
|
||||
{/* Header — always visible, clickable to toggle */}
|
||||
<button
|
||||
onClick={toggleExpanded}
|
||||
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[16px]"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
<div className="shrink-0 px-8">
|
||||
<div className="mx-auto max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] overflow-hidden mb-2">
|
||||
{/* Header — always visible, clickable to toggle */}
|
||||
<button
|
||||
onClick={toggleExpanded}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-[var(--color-surface-container-low)] transition-colors bg-[var(--color-surface-container)]"
|
||||
>
|
||||
checklist
|
||||
</span>
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-[var(--radius-md)] bg-[var(--color-secondary)]/10">
|
||||
<span
|
||||
className="material-symbols-outlined text-[14px] text-[var(--color-secondary)]"
|
||||
>
|
||||
checklist
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
|
||||
Tasks
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
|
||||
Tasks
|
||||
</span>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden max-w-[200px]">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${progressPercent}%`,
|
||||
backgroundColor: completedCount === totalCount
|
||||
? 'var(--color-success)'
|
||||
: 'var(--color-brand)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden max-w-[200px]">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${progressPercent}%`,
|
||||
backgroundColor: completedCount === totalCount
|
||||
? 'var(--color-success)'
|
||||
: 'var(--color-brand)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">
|
||||
{completedCount}/{totalCount}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">
|
||||
{completedCount}/{totalCount}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)] transition-transform duration-200"
|
||||
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
expand_less
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)] transition-transform duration-200"
|
||||
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
expand_less
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded task list */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-2 flex flex-col gap-0.5 max-h-[240px] overflow-y-auto">
|
||||
{tasks.map((task) => (
|
||||
<TaskItem key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Expanded task list */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-2 pt-1 flex flex-col gap-0.5 max-h-[240px] overflow-y-auto border-t border-[var(--color-outline-variant)]/20">
|
||||
{tasks.map((task) => (
|
||||
<TaskItem key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,23 +1,80 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { ContentRouter } from './ContentRouter'
|
||||
import { ToastContainer } from '../shared/Toast'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
|
||||
import { initializeDesktopServerUrl } from '../../lib/desktopRuntime'
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
||||
|
||||
export function AppShell() {
|
||||
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [startupError, setStartupError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
let cancelled = false
|
||||
|
||||
const bootstrap = async () => {
|
||||
try {
|
||||
await initializeDesktopServerUrl()
|
||||
await fetchSettings()
|
||||
if (!cancelled) {
|
||||
setReady(true)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setStartupError(error instanceof Error ? error.message : String(error))
|
||||
setReady(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [fetchSettings])
|
||||
|
||||
useKeyboardShortcuts()
|
||||
|
||||
if (startupError) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
|
||||
<div className="max-w-xl rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6">
|
||||
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
Local server failed to start
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
|
||||
{startupError}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] text-[var(--color-text-secondary)]">
|
||||
Launching local workspace...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<div className="h-screen flex overflow-hidden relative">
|
||||
{/* Drag region for macOS Overlay title bar — only relevant in Tauri */}
|
||||
{isTauri && (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="absolute top-0 right-0 h-[38px] z-10"
|
||||
style={{ left: 'var(--sidebar-width)' }}
|
||||
/>
|
||||
)}
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
<main className={`flex-1 flex flex-col overflow-hidden ${isTauri ? 'pt-[38px]' : ''}`}>
|
||||
<ContentRouter />
|
||||
</main>
|
||||
<ToastContainer />
|
||||
|
||||
@ -4,12 +4,23 @@ import { useUIStore } from '../../stores/uiStore'
|
||||
import { ProjectFilter } from './ProjectFilter'
|
||||
import type { SessionListItem } from '../../types/session'
|
||||
|
||||
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
||||
|
||||
type TimeGroup = 'Today' | 'Yesterday' | 'Last 7 days' | 'Last 30 days' | 'Older'
|
||||
|
||||
const TIME_GROUP_ORDER: TimeGroup[] = ['Today', 'Yesterday', 'Last 7 days', 'Last 30 days', 'Older']
|
||||
|
||||
export function Sidebar() {
|
||||
const { sessions, activeSessionId, selectedProjects, setActiveSession, fetchSessions, deleteSession, renameSession } = useSessionStore()
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
selectedProjects,
|
||||
error,
|
||||
setActiveSession,
|
||||
fetchSessions,
|
||||
deleteSession,
|
||||
renameSession,
|
||||
} = useSessionStore()
|
||||
const { activeView, setActiveView } = useUIStore()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
|
||||
@ -69,9 +80,27 @@ export function Sidebar() {
|
||||
}, [renamingId, renameValue, renameSession])
|
||||
|
||||
return (
|
||||
<aside className="w-[var(--sidebar-width)] h-full flex flex-col bg-[var(--color-surface-sidebar)] border-r border-[var(--color-border)] select-none">
|
||||
<aside data-tauri-drag-region className="w-[var(--sidebar-width)] h-full flex flex-col bg-[var(--color-surface-sidebar)] border-r border-[var(--color-border)] select-none">
|
||||
{/* Brand logo — extra top padding in desktop to clear macOS traffic lights */}
|
||||
<div className={`px-3 pb-1.5 flex items-center justify-between ${isTauri ? 'pt-[44px]' : 'pt-3'}`}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src="/app-icon.jpg" alt="" className="h-8 w-8 rounded-lg flex-shrink-0" />
|
||||
<span className="text-[13px] font-semibold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: "'Manrope', sans-serif" }}>
|
||||
Claude Code <span className="text-[#D97757]">Haha</span>
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/NanmiCoder/cc-haha"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-md p-1 text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
|
||||
title="GitHub"
|
||||
>
|
||||
<GitHubIcon />
|
||||
</a>
|
||||
</div>
|
||||
{/* Navigation */}
|
||||
<div className="p-3 flex flex-col gap-0.5">
|
||||
<div className="px-3 pb-3 flex flex-col gap-0.5">
|
||||
<NavItem
|
||||
active={activeView === 'code' && !activeSessionId}
|
||||
onClick={() => { setActiveView('code'); setActiveSession(null) }}
|
||||
@ -107,6 +136,18 @@ export function Sidebar() {
|
||||
|
||||
{/* Session list — grouped by time */}
|
||||
<div className="flex-1 overflow-y-auto px-3">
|
||||
{error && (
|
||||
<div className="mx-1 mt-2 rounded-[var(--radius-md)] border border-[var(--color-error)]/20 bg-[var(--color-error)]/5 px-3 py-2">
|
||||
<div className="text-xs font-medium text-[var(--color-error)]">Session list failed to load</div>
|
||||
<div className="mt-1 text-[11px] text-[var(--color-text-secondary)] break-words">{error}</div>
|
||||
<button
|
||||
onClick={() => fetchSessions()}
|
||||
className="mt-2 text-[11px] font-medium text-[var(--color-brand)] hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{filteredSessions.length === 0 && (
|
||||
<div className="px-3 py-4 text-xs text-[var(--color-text-tertiary)] text-center">
|
||||
{searchQuery ? 'No matching sessions' : 'No sessions yet'}
|
||||
@ -263,6 +304,14 @@ function formatRelativeTime(dateStr: string): string {
|
||||
return `${Math.floor(day / 30)}mo`
|
||||
}
|
||||
|
||||
function GitHubIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PlusIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@ -280,4 +329,3 @@ function ClockIcon() {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
193
desktop/src/config/spinnerVerbs.ts
Normal file
@ -0,0 +1,193 @@
|
||||
export const SPINNER_VERBS = [
|
||||
'Accomplishing',
|
||||
'Actioning',
|
||||
'Actualizing',
|
||||
'Architecting',
|
||||
'Baking',
|
||||
'Beaming',
|
||||
"Beboppin'",
|
||||
'Befuddling',
|
||||
'Billowing',
|
||||
'Blanching',
|
||||
'Bloviating',
|
||||
'Boogieing',
|
||||
'Boondoggling',
|
||||
'Booping',
|
||||
'Bootstrapping',
|
||||
'Brewing',
|
||||
'Bunning',
|
||||
'Burrowing',
|
||||
'Calculating',
|
||||
'Canoodling',
|
||||
'Caramelizing',
|
||||
'Cascading',
|
||||
'Catapulting',
|
||||
'Cerebrating',
|
||||
'Channeling',
|
||||
'Channelling',
|
||||
'Choreographing',
|
||||
'Churning',
|
||||
'Clauding',
|
||||
'Coalescing',
|
||||
'Cogitating',
|
||||
'Combobulating',
|
||||
'Composing',
|
||||
'Computing',
|
||||
'Concocting',
|
||||
'Considering',
|
||||
'Contemplating',
|
||||
'Cooking',
|
||||
'Crafting',
|
||||
'Creating',
|
||||
'Crunching',
|
||||
'Crystallizing',
|
||||
'Cultivating',
|
||||
'Deciphering',
|
||||
'Deliberating',
|
||||
'Determining',
|
||||
'Dilly-dallying',
|
||||
'Discombobulating',
|
||||
'Doing',
|
||||
'Doodling',
|
||||
'Drizzling',
|
||||
'Ebbing',
|
||||
'Effecting',
|
||||
'Elucidating',
|
||||
'Embellishing',
|
||||
'Enchanting',
|
||||
'Envisioning',
|
||||
'Evaporating',
|
||||
'Fermenting',
|
||||
'Fiddle-faddling',
|
||||
'Finagling',
|
||||
'Flambéing',
|
||||
'Flibbertigibbeting',
|
||||
'Flowing',
|
||||
'Flummoxing',
|
||||
'Fluttering',
|
||||
'Forging',
|
||||
'Forming',
|
||||
'Frolicking',
|
||||
'Frosting',
|
||||
'Gallivanting',
|
||||
'Galloping',
|
||||
'Garnishing',
|
||||
'Generating',
|
||||
'Gesticulating',
|
||||
'Germinating',
|
||||
'Gitifying',
|
||||
'Grooving',
|
||||
'Gusting',
|
||||
'Harmonizing',
|
||||
'Hashing',
|
||||
'Hatching',
|
||||
'Herding',
|
||||
'Honking',
|
||||
'Hullaballooing',
|
||||
'Hyperspacing',
|
||||
'Ideating',
|
||||
'Imagining',
|
||||
'Improvising',
|
||||
'Incubating',
|
||||
'Inferring',
|
||||
'Infusing',
|
||||
'Ionizing',
|
||||
'Jitterbugging',
|
||||
'Julienning',
|
||||
'Kneading',
|
||||
'Leavening',
|
||||
'Levitating',
|
||||
'Lollygagging',
|
||||
'Manifesting',
|
||||
'Marinating',
|
||||
'Meandering',
|
||||
'Metamorphosing',
|
||||
'Misting',
|
||||
'Moonwalking',
|
||||
'Moseying',
|
||||
'Mulling',
|
||||
'Mustering',
|
||||
'Musing',
|
||||
'Nebulizing',
|
||||
'Nesting',
|
||||
'Newspapering',
|
||||
'Noodling',
|
||||
'Nucleating',
|
||||
'Orbiting',
|
||||
'Orchestrating',
|
||||
'Osmosing',
|
||||
'Perambulating',
|
||||
'Percolating',
|
||||
'Perusing',
|
||||
'Philosophising',
|
||||
'Photosynthesizing',
|
||||
'Pollinating',
|
||||
'Pondering',
|
||||
'Pontificating',
|
||||
'Pouncing',
|
||||
'Precipitating',
|
||||
'Prestidigitating',
|
||||
'Processing',
|
||||
'Proofing',
|
||||
'Propagating',
|
||||
'Puttering',
|
||||
'Puzzling',
|
||||
'Quantumizing',
|
||||
'Razzle-dazzling',
|
||||
'Razzmatazzing',
|
||||
'Recombobulating',
|
||||
'Reticulating',
|
||||
'Roosting',
|
||||
'Ruminating',
|
||||
'Sautéing',
|
||||
'Scampering',
|
||||
'Schlepping',
|
||||
'Scurrying',
|
||||
'Seasoning',
|
||||
'Shenaniganing',
|
||||
'Shimmying',
|
||||
'Simmering',
|
||||
'Skedaddling',
|
||||
'Sketching',
|
||||
'Slithering',
|
||||
'Smooshing',
|
||||
'Sock-hopping',
|
||||
'Spelunking',
|
||||
'Spinning',
|
||||
'Sprouting',
|
||||
'Stewing',
|
||||
'Sublimating',
|
||||
'Swirling',
|
||||
'Swooping',
|
||||
'Symbioting',
|
||||
'Synthesizing',
|
||||
'Tempering',
|
||||
'Thinking',
|
||||
'Thundering',
|
||||
'Tinkering',
|
||||
'Tomfoolering',
|
||||
'Topsy-turvying',
|
||||
'Transfiguring',
|
||||
'Transmuting',
|
||||
'Twisting',
|
||||
'Undulating',
|
||||
'Unfurling',
|
||||
'Unravelling',
|
||||
'Vibing',
|
||||
'Waddling',
|
||||
'Wandering',
|
||||
'Warping',
|
||||
'Whatchamacalliting',
|
||||
'Whirlpooling',
|
||||
'Whirring',
|
||||
'Whisking',
|
||||
'Wibbling',
|
||||
'Working',
|
||||
'Wrangling',
|
||||
'Zesting',
|
||||
'Zigzagging',
|
||||
]
|
||||
|
||||
export function randomSpinnerVerb(): string {
|
||||
return SPINNER_VERBS[Math.floor(Math.random() * SPINNER_VERBS.length)] ?? 'Thinking'
|
||||
}
|
||||
54
desktop/src/lib/desktopRuntime.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { getDefaultBaseUrl, setBaseUrl } from '../api/client'
|
||||
|
||||
function isTauriRuntime() {
|
||||
if (typeof window === 'undefined') return false
|
||||
return '__TAURI_INTERNALS__' in window || '__TAURI__' in window
|
||||
}
|
||||
|
||||
export async function initializeDesktopServerUrl() {
|
||||
const fallbackUrl = getDefaultBaseUrl()
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
setBaseUrl(fallbackUrl)
|
||||
return fallbackUrl
|
||||
}
|
||||
|
||||
try {
|
||||
const { invoke } = await import(/* @vite-ignore */ '@tauri-apps/api/core')
|
||||
const serverUrl = await invoke<string>('get_server_url')
|
||||
setBaseUrl(serverUrl)
|
||||
await waitForHealth(serverUrl)
|
||||
return serverUrl
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : `desktop server startup failed: ${String(error)}`
|
||||
console.error('[desktop] Failed to initialize desktop server URL', error)
|
||||
throw new Error(message || `desktop server startup failed (fallback would be ${fallbackUrl})`)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHealth(serverUrl: string) {
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/health`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (response.ok) {
|
||||
return
|
||||
}
|
||||
lastError = new Error(`healthcheck returned ${response.status}`)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
lastError instanceof Error
|
||||
? `Local server healthcheck failed: ${lastError.message}`
|
||||
: 'Local server healthcheck failed',
|
||||
)
|
||||
}
|
||||
@ -266,9 +266,7 @@ export function EmptySession() {
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden bg-[#FAF9F5]">
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
|
||||
<div className="flex max-w-md flex-col items-center text-center">
|
||||
<div className="mb-6 h-24 w-24 opacity-40 grayscale contrast-125">
|
||||
<MascotIllustration />
|
||||
</div>
|
||||
<img src="/app-icon.jpg" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[18px]" />
|
||||
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[#1B1C1A]" style={{ fontFamily: "'Manrope', sans-serif" }}>
|
||||
New session
|
||||
</h1>
|
||||
@ -390,29 +388,3 @@ export function EmptySession() {
|
||||
)
|
||||
}
|
||||
|
||||
function MascotIllustration() {
|
||||
return (
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="24" y="30" width="48" height="36" rx="8" fill="#C47A5A" />
|
||||
<rect x="28" y="18" width="40" height="16" rx="6" fill="#B86B4A" />
|
||||
<line x1="48" y1="10" x2="48" y2="18" stroke="#A0796A" strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="48" cy="8" r="3" fill="#D4936F" />
|
||||
<circle cx="40" cy="26" r="2.5" fill="#1B1C1A" />
|
||||
<circle cx="56" cy="26" r="2.5" fill="#1B1C1A" />
|
||||
<circle cx="41" cy="25" r="1" fill="white" opacity="0.7" />
|
||||
<circle cx="57" cy="25" r="1" fill="white" opacity="0.7" />
|
||||
<path d="M43 31 Q48 35 53 31" stroke="#1B1C1A" strokeWidth="1.5" strokeLinecap="round" fill="none" />
|
||||
<rect x="36" y="40" width="24" height="16" rx="4" fill="#D4936F" opacity="0.5" />
|
||||
<line x1="40" y1="44" x2="56" y2="44" stroke="#B86B4A" strokeWidth="1" strokeLinecap="round" opacity="0.6" />
|
||||
<line x1="40" y1="48" x2="56" y2="48" stroke="#B86B4A" strokeWidth="1" strokeLinecap="round" opacity="0.6" />
|
||||
<line x1="40" y1="52" x2="52" y2="52" stroke="#B86B4A" strokeWidth="1" strokeLinecap="round" opacity="0.6" />
|
||||
<rect x="14" y="36" width="10" height="22" rx="5" fill="#C47A5A" />
|
||||
<rect x="72" y="36" width="10" height="22" rx="5" fill="#C47A5A" />
|
||||
<rect x="32" y="66" width="12" height="16" rx="5" fill="#C47A5A" />
|
||||
<rect x="52" y="66" width="12" height="16" rx="5" fill="#C47A5A" />
|
||||
<rect x="30" y="78" width="16" height="6" rx="3" fill="#B86B4A" />
|
||||
<rect x="50" y="78" width="16" height="6" rx="3" fill="#B86B4A" />
|
||||
<rect x="28" y="30" width="40" height="4" rx="2" fill="#D4936F" opacity="0.3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { Input } from '../components/shared/Input'
|
||||
import { Button } from '../components/shared/Button'
|
||||
import type { PermissionMode, EffortLevel } from '../types/settings'
|
||||
import { PROVIDER_PRESETS } from '../config/providerPresets'
|
||||
import type { ProviderPreset } from '../config/providerPresets'
|
||||
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider'
|
||||
|
||||
type SettingsTab = 'providers' | 'permissions' | 'general'
|
||||
@ -214,14 +215,28 @@ type ProviderFormProps = {
|
||||
provider?: SavedProvider
|
||||
}
|
||||
|
||||
function requirePreset(preset: ProviderPreset | undefined): ProviderPreset {
|
||||
if (!preset) {
|
||||
throw new Error('Provider presets are not configured')
|
||||
}
|
||||
return preset
|
||||
}
|
||||
|
||||
function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) {
|
||||
const { createProvider, updateProvider, testConfig } = useProviderStore()
|
||||
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
||||
|
||||
const availablePresets = PROVIDER_PRESETS.filter((p) => p.id !== 'official')
|
||||
const initialPreset = provider ? availablePresets.find((p) => p.id === provider.presetId) || availablePresets.at(-1)! : availablePresets[0]
|
||||
const fallbackPreset = requirePreset(
|
||||
availablePresets[availablePresets.length - 1] ?? PROVIDER_PRESETS[0],
|
||||
)
|
||||
const initialPreset = requirePreset(
|
||||
provider
|
||||
? availablePresets.find((p) => p.id === provider.presetId) ?? fallbackPreset
|
||||
: availablePresets[0] ?? fallbackPreset,
|
||||
)
|
||||
|
||||
const [selectedPreset, setSelectedPreset] = useState(initialPreset)
|
||||
const [selectedPreset, setSelectedPreset] = useState<ProviderPreset>(initialPreset)
|
||||
const [name, setName] = useState(provider?.name ?? initialPreset.name)
|
||||
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
@ -263,7 +278,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedPreset.id])
|
||||
|
||||
const handlePresetChange = (preset: typeof initialPreset) => {
|
||||
const handlePresetChange = (preset: ProviderPreset) => {
|
||||
setSelectedPreset(preset)
|
||||
setName(preset.name)
|
||||
setBaseUrl(preset.baseUrl)
|
||||
@ -442,7 +457,9 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
|
||||
// Auto-switch to matching preset or Custom
|
||||
if (mode === 'create') {
|
||||
const matchedPreset = availablePresets.find((p) => p.id !== 'custom' && p.baseUrl === env.ANTHROPIC_BASE_URL)
|
||||
const targetPreset = matchedPreset || availablePresets.find((p) => p.id === 'custom')!
|
||||
const targetPreset = requirePreset(
|
||||
matchedPreset ?? availablePresets.find((p) => p.id === 'custom'),
|
||||
)
|
||||
if (targetPreset.id !== selectedPreset.id) {
|
||||
jsonPastedRef.current = true
|
||||
setSelectedPreset(targetPreset)
|
||||
|
||||
@ -3,6 +3,7 @@ import { wsManager } from '../api/websocket'
|
||||
import { sessionsApi } from '../api/sessions'
|
||||
import { useTeamStore } from './teamStore'
|
||||
import { useCLITaskStore } from './cliTaskStore'
|
||||
import { randomSpinnerVerb } from '../config/spinnerVerbs'
|
||||
import type { MessageEntry } from '../types/session'
|
||||
import type { PermissionMode } from '../types/settings'
|
||||
import type { AttachmentRef, ChatState, UIAttachment, UIMessage, ServerMessage, TokenUsage } from '../types/chat'
|
||||
@ -147,6 +148,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
chatState: 'thinking',
|
||||
elapsedSeconds: 0,
|
||||
streamingText: '',
|
||||
statusVerb: randomSpinnerVerb(),
|
||||
}))
|
||||
|
||||
// Start elapsed timer
|
||||
@ -200,7 +202,10 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
case 'status':
|
||||
set({
|
||||
chatState: msg.state,
|
||||
...(msg.verb ? { statusVerb: msg.verb } : {}),
|
||||
// Only override statusVerb if the server sends something other than
|
||||
// the generic 'Thinking' — otherwise keep the random verb we picked
|
||||
// in sendMessage so the user sees fun loading text.
|
||||
...(msg.verb && msg.verb !== 'Thinking' ? { statusVerb: msg.verb } : {}),
|
||||
...(msg.tokens ? { tokenUsage: { ...get().tokenUsage, output_tokens: msg.tokens } } : {}),
|
||||
...(msg.state === 'idle' ? { activeThinkingId: null, statusVerb: '' } : {}),
|
||||
})
|
||||
|
||||
@ -10,6 +10,7 @@ type SettingsStore = {
|
||||
availableModels: ModelInfo[]
|
||||
activeProviderName: string | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
fetchAll: () => Promise<void>
|
||||
setPermissionMode: (mode: PermissionMode) => Promise<void>
|
||||
@ -24,9 +25,10 @@ export const useSettingsStore = create<SettingsStore>((set) => ({
|
||||
availableModels: [],
|
||||
activeProviderName: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchAll: async () => {
|
||||
set({ isLoading: true })
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const [{ mode }, modelsRes, { model }, { level }] = await Promise.all([
|
||||
settingsApi.getPermissionMode(),
|
||||
@ -41,9 +43,13 @@ export const useSettingsStore = create<SettingsStore>((set) => ({
|
||||
currentModel: model,
|
||||
effortLevel: level,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
} catch {
|
||||
set({ isLoading: false })
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to load desktop settings'
|
||||
set({ isLoading: false, error: message })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/__tests__/pages.test.tsx","./src/api/client.ts","./src/api/filesystem.ts","./src/api/models.ts","./src/api/search.ts","./src/api/sessions.ts","./src/api/settings.ts","./src/api/tasks.ts","./src/api/teams.ts","./src/api/websocket.ts","./src/components/chat/askuserquestion.tsx","./src/components/chat/assistantmessage.tsx","./src/components/chat/attachmentgallery.tsx","./src/components/chat/chatinput.tsx","./src/components/chat/codeviewer.tsx","./src/components/chat/diffviewer.tsx","./src/components/chat/imagegallerymodal.tsx","./src/components/chat/messagelist.tsx","./src/components/chat/permissiondialog.tsx","./src/components/chat/streamingindicator.tsx","./src/components/chat/terminalchrome.tsx","./src/components/chat/thinkingblock.tsx","./src/components/chat/toolcallblock.tsx","./src/components/chat/toolresultblock.tsx","./src/components/chat/usermessage.tsx","./src/components/chat/clipboard.ts","./src/components/chat/composerutils.test.ts","./src/components/chat/composerutils.ts","./src/components/chat/highlightcode.ts","./src/components/controls/modelselector.tsx","./src/components/controls/permissionmodeselector.tsx","./src/components/layout/appshell.tsx","./src/components/layout/contentrouter.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/statusbar.tsx","./src/components/layout/titlebar.tsx","./src/components/markdown/markdownrenderer.tsx","./src/components/shared/button.tsx","./src/components/shared/copybutton.tsx","./src/components/shared/directorypicker.tsx","./src/components/shared/dropdown.tsx","./src/components/shared/input.tsx","./src/components/shared/modal.tsx","./src/components/shared/spinner.tsx","./src/components/shared/textarea.tsx","./src/components/shared/toast.tsx","./src/components/tasks/newtaskmodal.tsx","./src/components/tasks/taskemptystate.tsx","./src/components/tasks/tasklist.tsx","./src/components/tasks/taskrow.tsx","./src/components/teams/backtoleader.tsx","./src/components/teams/membertag.tsx","./src/components/teams/teamstatusbar.tsx","./src/components/teams/transcriptview.tsx","./src/hooks/usekeyboardshortcuts.ts","./src/mocks/data.ts","./src/pages/activesession.tsx","./src/pages/agentteams.tsx","./src/pages/agenttranscript.tsx","./src/pages/emptysession.tsx","./src/pages/newtaskmodal.tsx","./src/pages/scheduledtasks.tsx","./src/pages/scheduledtasksempty.tsx","./src/pages/scheduledtaskslist.tsx","./src/pages/sessioncontrols.tsx","./src/pages/settings.tsx","./src/pages/toolinspection.tsx","./src/stores/chatstore.ts","./src/stores/sessionstore.ts","./src/stores/settingsstore.ts","./src/stores/taskstore.ts","./src/stores/teamstore.ts","./src/stores/uistore.ts","./src/types/chat.ts","./src/types/session.ts","./src/types/settings.ts","./src/types/task.ts","./src/types/team.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/__tests__/pages.test.tsx","./src/api/clitasks.ts","./src/api/client.ts","./src/api/filesystem.ts","./src/api/models.ts","./src/api/providers.ts","./src/api/search.ts","./src/api/sessions.ts","./src/api/settings.ts","./src/api/tasks.ts","./src/api/teams.ts","./src/api/websocket.ts","./src/components/chat/askuserquestion.tsx","./src/components/chat/assistantmessage.tsx","./src/components/chat/attachmentgallery.tsx","./src/components/chat/chatinput.tsx","./src/components/chat/codeviewer.tsx","./src/components/chat/diffviewer.tsx","./src/components/chat/imagegallerymodal.tsx","./src/components/chat/messagelist.tsx","./src/components/chat/permissiondialog.tsx","./src/components/chat/sessiontaskbar.tsx","./src/components/chat/streamingindicator.tsx","./src/components/chat/terminalchrome.tsx","./src/components/chat/thinkingblock.tsx","./src/components/chat/toolcallblock.tsx","./src/components/chat/toolcallgroup.tsx","./src/components/chat/toolresultblock.tsx","./src/components/chat/usermessage.tsx","./src/components/chat/chatblocks.test.tsx","./src/components/chat/clipboard.ts","./src/components/chat/composerutils.test.ts","./src/components/chat/composerutils.ts","./src/components/controls/modelselector.tsx","./src/components/controls/permissionmodeselector.tsx","./src/components/layout/appshell.tsx","./src/components/layout/contentrouter.tsx","./src/components/layout/projectfilter.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/statusbar.tsx","./src/components/layout/titlebar.tsx","./src/components/markdown/markdownrenderer.tsx","./src/components/shared/button.tsx","./src/components/shared/copybutton.tsx","./src/components/shared/directorypicker.tsx","./src/components/shared/dropdown.tsx","./src/components/shared/input.tsx","./src/components/shared/modal.tsx","./src/components/shared/projectcontextchip.tsx","./src/components/shared/spinner.tsx","./src/components/shared/textarea.tsx","./src/components/shared/toast.tsx","./src/components/tasks/newtaskmodal.tsx","./src/components/tasks/taskemptystate.tsx","./src/components/tasks/tasklist.tsx","./src/components/tasks/taskrow.tsx","./src/components/teams/backtoleader.tsx","./src/components/teams/membertag.tsx","./src/components/teams/teamstatusbar.tsx","./src/components/teams/transcriptview.tsx","./src/config/providerpresets.ts","./src/config/spinnerverbs.ts","./src/hooks/usekeyboardshortcuts.ts","./src/lib/desktopruntime.ts","./src/mocks/data.ts","./src/pages/activesession.tsx","./src/pages/agentteams.tsx","./src/pages/agenttranscript.tsx","./src/pages/emptysession.tsx","./src/pages/newtaskmodal.tsx","./src/pages/scheduledtasks.tsx","./src/pages/scheduledtasksempty.tsx","./src/pages/scheduledtaskslist.tsx","./src/pages/sessioncontrols.tsx","./src/pages/settings.tsx","./src/pages/toolinspection.tsx","./src/stores/chatstore.test.ts","./src/stores/chatstore.ts","./src/stores/clitaskstore.ts","./src/stores/providerstore.ts","./src/stores/sessionstore.ts","./src/stores/settingsstore.ts","./src/stores/taskstore.ts","./src/stores/teamstore.ts","./src/stores/uistore.ts","./src/types/chat.ts","./src/types/clitask.ts","./src/types/provider.ts","./src/types/session.ts","./src/types/settings.ts","./src/types/task.ts","./src/types/team.ts"],"version":"5.9.3"}
|
||||
BIN
docs/images/app-icon.jpg
Normal file
|
After Width: | Height: | Size: 351 KiB |
BIN
docs/images/logo-horizontal.jpg
Normal file
|
After Width: | Height: | Size: 206 KiB |
@ -6,7 +6,7 @@ hero:
|
||||
text: 本地可运行的 Claude Code
|
||||
tagline: 基于泄露源码修复,支持接入任意 Anthropic 兼容 API(MiniMax、OpenRouter 等)
|
||||
image:
|
||||
src: /images/banner.jpg
|
||||
src: /images/app-icon.jpg
|
||||
alt: Claude Code Haha
|
||||
actions:
|
||||
- theme: brand
|
||||
|
||||
@ -12,8 +12,34 @@ import { requireAuth } from './middleware/auth.js'
|
||||
import { teamWatcher } from './services/teamWatcher.js'
|
||||
import { cronScheduler } from './services/cronScheduler.js'
|
||||
|
||||
const PORT = parseInt(process.env.SERVER_PORT || '3456', 10)
|
||||
const HOST = process.env.SERVER_HOST || '127.0.0.1'
|
||||
function readArgValue(flag: string): string | undefined {
|
||||
const args = process.argv.slice(2)
|
||||
const index = args.indexOf(flag)
|
||||
if (index === -1) return undefined
|
||||
return args[index + 1]
|
||||
}
|
||||
|
||||
function hasArgFlag(flag: string): boolean {
|
||||
return process.argv.slice(2).includes(flag)
|
||||
}
|
||||
|
||||
function resolveServerOptions() {
|
||||
const portArg = readArgValue('--port')
|
||||
const port = Number.parseInt(portArg || process.env.SERVER_PORT || '3456', 10)
|
||||
const host = readArgValue('--host') || process.env.SERVER_HOST || '127.0.0.1'
|
||||
const cliPath = readArgValue('--cli-path')
|
||||
const authRequired = hasArgFlag('--auth-required')
|
||||
|
||||
if (cliPath) {
|
||||
process.env.CLAUDE_CLI_PATH = cliPath
|
||||
}
|
||||
|
||||
return { port, host, authRequired }
|
||||
}
|
||||
|
||||
const SERVER_OPTIONS = resolveServerOptions()
|
||||
const PORT = SERVER_OPTIONS.port
|
||||
const HOST = SERVER_OPTIONS.host
|
||||
|
||||
export function startServer(port = PORT, host = HOST) {
|
||||
const localConnectHost =
|
||||
@ -27,7 +53,10 @@ export function startServer(port = PORT, host = HOST) {
|
||||
* - Production / non-localhost (e.g. 0.0.0.0): auth enforced automatically.
|
||||
* - Explicit opt-in: SERVER_AUTH_REQUIRED=1 forces auth even on localhost.
|
||||
*/
|
||||
const authRequired = process.env.SERVER_AUTH_REQUIRED === '1' || host !== '127.0.0.1'
|
||||
const authRequired =
|
||||
SERVER_OPTIONS.authRequired ||
|
||||
process.env.SERVER_AUTH_REQUIRED === '1' ||
|
||||
host !== '127.0.0.1'
|
||||
|
||||
const server = Bun.serve<WebSocketData>({
|
||||
port,
|
||||
@ -132,7 +161,10 @@ export function startServer(port = PORT, host = HOST) {
|
||||
|
||||
// Health check
|
||||
if (url.pathname === '/health') {
|
||||
return Response.json({ status: 'ok', timestamp: new Date().toISOString() })
|
||||
return Response.json(
|
||||
{ status: 'ok', timestamp: new Date().toISOString() },
|
||||
{ headers: corsHeaders(origin) },
|
||||
)
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 })
|
||||
|
||||
@ -2,12 +2,13 @@
|
||||
* CORS middleware for local desktop app communication
|
||||
*/
|
||||
|
||||
const ALLOWED_ORIGIN_RE =
|
||||
/^(?:https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?|tauri:\/\/localhost|https:\/\/tauri\.localhost)$/
|
||||
|
||||
export function corsHeaders(origin?: string | null): Record<string, string> {
|
||||
// Only allow localhost origins for security
|
||||
// Allow localhost origins (http/https) and Tauri WebView origins
|
||||
const allowedOrigin =
|
||||
origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)
|
||||
? origin
|
||||
: 'http://localhost:3000'
|
||||
origin && ALLOWED_ORIGIN_RE.test(origin) ? origin : 'http://localhost:3000'
|
||||
return {
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
||||
|
||||
@ -447,8 +447,24 @@ export class ConversationService {
|
||||
return args
|
||||
}
|
||||
|
||||
private resolveBundledCliPath(): string | null {
|
||||
const execPath = process.execPath
|
||||
const execName = path.basename(execPath)
|
||||
|
||||
if (!execName.startsWith('claude-server')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const bundledCliPath = path.join(
|
||||
path.dirname(execPath),
|
||||
execName.replace(/^claude-server/, 'claude-cli'),
|
||||
)
|
||||
|
||||
return fs.existsSync(bundledCliPath) ? bundledCliPath : null
|
||||
}
|
||||
|
||||
private resolveCliArgs(baseArgs: string[]): string[] {
|
||||
const cliCommand = process.env.CLAUDE_CLI_PATH
|
||||
const cliCommand = process.env.CLAUDE_CLI_PATH || this.resolveBundledCliPath()
|
||||
if (!cliCommand) {
|
||||
return [path.resolve(import.meta.dir, '../../../bin/claude-haha'), ...baseArgs]
|
||||
}
|
||||
@ -457,6 +473,18 @@ export class ConversationService {
|
||||
return ['bun', cliCommand, ...baseArgs]
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.CLAUDE_APP_ROOT &&
|
||||
path.basename(cliCommand).startsWith('claude-cli')
|
||||
) {
|
||||
return [
|
||||
cliCommand,
|
||||
'--app-root',
|
||||
process.env.CLAUDE_APP_ROOT,
|
||||
...baseArgs,
|
||||
]
|
||||
}
|
||||
|
||||
return [cliCommand, ...baseArgs]
|
||||
}
|
||||
|
||||
|
||||