feat(desktop): Windows 自定义标题栏 & 服务端改进

- Windows 上隐藏原生菜单栏和窗口装饰,前端渲染自定义窗口控制按钮(最小化/最大化/关闭)
- macOS 保持原生菜单栏不变(条件编译)
- Sidebar 在 Windows 上不再添加 macOS 红绿灯的额外顶部间距
- 新增 Windows 构建脚本和 NSIS 安装钩子
- 服务端 CORS、会话服务、对话服务改进及测试补充

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Relakkes Yang 2026-04-15 21:29:35 +08:00
parent 5016bc0049
commit 5d243b657a
19 changed files with 871 additions and 56 deletions

3
.gitignore vendored
View File

@ -39,6 +39,9 @@ desktop/brand-assets/
# Superpowers plans & specs (local only)
docs/superpowers/
# Temp server/web logs
.tmp-*.log
# Debug / temp screenshots (root dir only)
/*.png
bug_attachments/

View File

@ -8,6 +8,7 @@
"build": "tsc -b && vite build",
"build:sidecars": "bun run ./scripts/build-sidecars.ts",
"build:macos-arm64": "bash ./scripts/build-macos-arm64.sh",
"build:windows-x64": "powershell -ExecutionPolicy Bypass -File ./scripts/build-windows-x64.ps1",
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest",

View File

@ -0,0 +1,295 @@
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$TauriArgs
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$desktopDir = (Resolve-Path (Join-Path $scriptDir '..')).Path
$repoRoot = (Resolve-Path (Join-Path $desktopDir '..')).Path
$targetTriple = 'x86_64-pc-windows-msvc'
$tauriTargetDir = Join-Path $desktopDir 'src-tauri\target'
$canonicalOutputDir = Join-Path $desktopDir 'build-artifacts\windows-x64'
$activeOutputDir = $canonicalOutputDir
function Write-Step {
param([string]$Message)
Write-Host "[build-windows-x64] $Message"
}
function Assert-WindowsHost {
if ($env:OS -ne 'Windows_NT') {
throw '[build-windows-x64] This script must run on Windows.'
}
}
function Assert-Command {
param([string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "[build-windows-x64] Missing required command: $Name"
}
}
function Import-VsDevEnvironment {
$vswhere = 'C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe'
if (-not (Test-Path $vswhere)) {
throw '[build-windows-x64] Could not find vswhere.exe. Install Visual Studio 2022 Build Tools with the C++ workload.'
}
$installationPath = & $vswhere `
-products * `
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 `
-property installationPath |
Select-Object -First 1
if (-not $installationPath) {
throw '[build-windows-x64] Missing Visual C++ build tools. Install the "Desktop development with C++" / VC.Tools.x86.x64 workload first.'
}
$vsDevCmd = Join-Path $installationPath 'Common7\Tools\VsDevCmd.bat'
if (-not (Test-Path $vsDevCmd)) {
throw "[build-windows-x64] Could not find VsDevCmd.bat under $installationPath"
}
Write-Step "Importing MSVC environment from $vsDevCmd"
$env:VSCMD_SKIP_SENDTELEMETRY = '1'
$envDump = & cmd.exe /d /s /c "`"$vsDevCmd`" -arch=x64 -host_arch=x64 >nul && set"
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] Failed to initialize Visual Studio build environment (exit $LASTEXITCODE)"
}
foreach ($line in $envDump) {
if ($line -match '^(.*?)=(.*)$') {
[Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process')
}
}
}
function Get-RustCargoBinDir {
return Join-Path $env:USERPROFILE '.cargo\bin'
}
function Ensure-RustInPath {
$cargoBinDir = Get-RustCargoBinDir
if ((Test-Path $cargoBinDir) -and -not (($env:Path -split ';') -contains $cargoBinDir)) {
$env:Path = "$cargoBinDir;$env:Path"
}
}
function Get-LatestArtifact {
param(
[string[]]$SearchRoots,
[string[]]$Patterns
)
foreach ($root in $SearchRoots) {
if (-not (Test-Path $root)) {
continue
}
foreach ($pattern in $Patterns) {
$match = Get-ChildItem -Path $root -File -Filter $pattern -ErrorAction SilentlyContinue |
Sort-Object Name |
Select-Object -Last 1
if ($match) {
return $match
}
}
}
return $null
}
function Resolve-OutputDirectory {
param([string]$PreferredPath)
New-Item -ItemType Directory -Force -Path $PreferredPath | Out-Null
$existingArtifacts = Get-ChildItem -Path $PreferredPath -Force -ErrorAction SilentlyContinue
foreach ($artifact in $existingArtifacts) {
try {
Remove-Item -LiteralPath $artifact.FullName -Force -Recurse
} catch {
$fallbackPath = "$PreferredPath-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Write-Step "Could not clear locked artifact '$($artifact.FullName)'. Using fallback output directory: $fallbackPath"
New-Item -ItemType Directory -Force -Path $fallbackPath | Out-Null
return $fallbackPath
}
}
return $PreferredPath
}
Assert-WindowsHost
Assert-Command bun
Ensure-RustInPath
Import-VsDevEnvironment
Assert-Command cargo
Assert-Command rustc
Assert-Command bunx
if ($env:SKIP_INSTALL -ne '1') {
Write-Step 'Installing root dependencies...'
Push-Location $repoRoot
try {
& bun install
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] bun install failed in repo root (exit $LASTEXITCODE)"
}
} finally {
Pop-Location
}
Write-Step 'Installing desktop dependencies...'
Push-Location $desktopDir
try {
& bun install
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] bun install failed in desktop (exit $LASTEXITCODE)"
}
} finally {
Pop-Location
}
$adaptersDir = Join-Path $repoRoot 'adapters'
if (Test-Path (Join-Path $adaptersDir 'package.json')) {
Write-Step 'Installing adapter dependencies...'
Push-Location $adaptersDir
try {
& bun install
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] bun install failed in adapters (exit $LASTEXITCODE)"
}
} finally {
Pop-Location
}
}
}
$tauriBuildArgs = @(
'tauri',
'build',
'--target',
$targetTriple,
'--bundles',
'nsis,msi',
'--ci'
)
$tempConfigPath = $null
if (-not $env:TAURI_SIGNING_PRIVATE_KEY) {
$tempConfigPath = Join-Path ([System.IO.Path]::GetTempPath()) 'cc-haha.tauri.local.windows.json'
$tempConfig = @{
bundle = @{
createUpdaterArtifacts = $false
}
} | ConvertTo-Json -Depth 10
Set-Content -Path $tempConfigPath -Value $tempConfig -Encoding UTF8
Write-Step 'TAURI_SIGNING_PRIVATE_KEY not set, disabling updater artifacts for local build'
$tauriBuildArgs += @('--config', $tempConfigPath)
}
if ($null -ne $TauriArgs) {
$remainingArgs = @($TauriArgs)
if ($remainingArgs.Count -gt 0) {
$tauriBuildArgs += $remainingArgs
}
}
Write-Step "Building Windows desktop app for $targetTriple"
Push-Location $desktopDir
try {
$env:TAURI_ENV_TARGET_TRIPLE = $targetTriple
& bunx @tauriBuildArgs
if ($LASTEXITCODE -ne 0) {
throw "[build-windows-x64] tauri build failed (exit $LASTEXITCODE)"
}
} finally {
Pop-Location
if ($tempConfigPath -and (Test-Path $tempConfigPath)) {
Remove-Item -LiteralPath $tempConfigPath -Force
}
}
$activeOutputDir = Resolve-OutputDirectory -PreferredPath $canonicalOutputDir
$bundleRoots = @(
(Join-Path $tauriTargetDir "$targetTriple\release\bundle"),
(Join-Path $tauriTargetDir 'release\bundle')
)
$artifactPatterns = @('*.exe', '*.msi', '*.zip', '*.sig', 'latest.json')
$copiedArtifacts = New-Object System.Collections.Generic.List[string]
foreach ($root in $bundleRoots) {
if (-not (Test-Path $root)) {
continue
}
foreach ($pattern in $artifactPatterns) {
$artifacts = Get-ChildItem -Path $root -Recurse -File -Filter $pattern -ErrorAction SilentlyContinue
foreach ($artifact in $artifacts) {
$destination = Join-Path $activeOutputDir $artifact.Name
Copy-Item -LiteralPath $artifact.FullName -Destination $destination -Force
if (-not $copiedArtifacts.Contains($destination)) {
$copiedArtifacts.Add($destination) | Out-Null
}
}
}
}
$nsisInstaller = Get-LatestArtifact -SearchRoots @(
(Join-Path $tauriTargetDir "$targetTriple\release\bundle\nsis"),
(Join-Path $tauriTargetDir 'release\bundle\nsis')
) -Patterns @('*.exe')
$msiInstaller = Get-LatestArtifact -SearchRoots @(
(Join-Path $tauriTargetDir "$targetTriple\release\bundle\msi"),
(Join-Path $tauriTargetDir 'release\bundle\msi')
) -Patterns @('*.msi')
$nsisInstallerPath = if ($nsisInstaller) { $nsisInstaller.FullName } else { 'not found' }
$msiInstallerPath = if ($msiInstaller) { $msiInstaller.FullName } else { 'not found' }
$buildInfo = @(
"Target triple: $targetTriple"
"Canonical output: $canonicalOutputDir"
"Actual output: $activeOutputDir"
"NSIS installer: $nsisInstallerPath"
"MSI installer: $msiInstallerPath"
"Artifacts copied: $($copiedArtifacts.Count)"
"Built at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')"
)
Set-Content -Path (Join-Path $activeOutputDir 'BUILD_INFO.txt') -Value $buildInfo -Encoding UTF8
Write-Host ''
Write-Step 'Build finished.'
Write-Step "Artifacts output: $activeOutputDir"
if ($nsisInstaller) {
Write-Step "NSIS installer source: $($nsisInstaller.FullName)"
} else {
Write-Step 'No NSIS installer found under bundle directories.'
}
if ($msiInstaller) {
Write-Step "MSI installer source: $($msiInstaller.FullName)"
} else {
Write-Step 'No MSI installer found under bundle directories.'
}
Write-Step "Canonical output: $canonicalOutputDir"
if ($env:OPEN_OUTPUT -eq '1') {
Invoke-Item $canonicalOutputDir
}

View File

@ -6,10 +6,11 @@ use std::{
time::{Duration, Instant},
};
use tauri::{
menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder},
AppHandle, Emitter, Manager, RunEvent, State,
};
#[cfg(target_os = "macos")]
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
#[cfg(target_os = "macos")]
use tauri::Emitter;
use tauri::{AppHandle, Manager, RunEvent, State};
use tauri_plugin_shell::{
process::{CommandChild, CommandEvent},
ShellExt,
@ -294,7 +295,7 @@ fn stop_adapters_sidecar(app: &AppHandle) {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let app = tauri::Builder::default()
let builder = tauri::Builder::default()
.manage(ServerState::default())
.manage(AdapterState::default())
.plugin(tauri_plugin_shell::init())
@ -304,7 +305,11 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
get_server_url,
restart_adapters_sidecar
])
]);
// macOS: native menu bar (traffic-light overlay style)
#[cfg(target_os = "macos")]
let builder = builder
.menu(|app| {
let about_item = MenuItemBuilder::with_id("nav_about", "关于 Claude Code Haha")
.build(app)?;
@ -361,7 +366,9 @@ pub fn run() {
let _ = app.emit("native-menu-navigate", "settings");
}
_ => {}
})
});
let app = builder
.setup(|app| {
let state = app.state::<ServerState>();
let mut guard = state
@ -388,6 +395,11 @@ pub fn run() {
spawn_and_track_adapters_sidecar(&app.handle());
let _window = app.get_webview_window("main").unwrap();
// Windows: hide native decorations — frontend renders custom titlebar
#[cfg(target_os = "windows")]
let _ = _window.set_decorations(false);
Ok(())
})
.build(tauri::generate_context!())

View File

@ -43,6 +43,11 @@
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"windows": {
"nsis": {
"installerHooks": "windows-installer-hooks.nsh"
}
},
"externalBin": [
"binaries/claude-sidecar"
],

View File

@ -0,0 +1,17 @@
!macro NSIS_HOOK_PREINSTALL
DetailPrint "Stopping running Claude Code Haha processes..."
nsExec::ExecToLog 'taskkill /F /T /IM claude-code-desktop.exe'
Pop $0
nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar.exe'
Pop $0
Sleep 1000
!macroend
!macro NSIS_HOOK_PREUNINSTALL
DetailPrint "Stopping running Claude Code Haha processes..."
nsExec::ExecToLog 'taskkill /F /T /IM claude-code-desktop.exe'
Pop $0
nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar.exe'
Pop $0
Sleep 1000
!macroend

View File

@ -11,7 +11,6 @@ import { TabBar } from './TabBar'
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
import { useTranslation } from '../../i18n'
import { providersApi } from '../../api/providers'
export function AppShell() {
const fetchSettings = useSettingsStore((s) => s.fetchAll)

View File

@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
vi.mock('./ProjectFilter', () => ({
ProjectFilter: () => <div data-testid="project-filter" />,
}))
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string) => {
const translations: Record<string, string> = {
'sidebar.newSession': 'New Session',
'sidebar.scheduled': 'Scheduled',
'sidebar.settings': 'Settings',
'sidebar.searchPlaceholder': 'Search sessions',
'sidebar.noSessions': 'No sessions',
'sidebar.noMatching': 'No matching sessions',
'sidebar.sessionListFailed': 'Session list failed',
'common.retry': 'Retry',
'common.delete': 'Delete',
'common.rename': 'Rename',
'sidebar.timeGroup.today': 'Today',
'sidebar.timeGroup.yesterday': 'Yesterday',
'sidebar.timeGroup.last7days': 'Last 7 Days',
'sidebar.timeGroup.last30days': 'Last 30 Days',
'sidebar.timeGroup.older': 'Older',
'sidebar.missingDir': 'Missing',
}
return translations[key] ?? key
},
}))
import { Sidebar } from './Sidebar'
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
describe('Sidebar', () => {
const connectToSession = vi.fn()
const fetchSessions = vi.fn()
const createSession = vi.fn()
const addToast = vi.fn()
beforeEach(() => {
connectToSession.mockReset()
fetchSessions.mockReset()
createSession.mockReset()
addToast.mockReset()
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({
sessions: [],
activeSessionId: null,
isLoading: false,
error: null,
selectedProjects: [],
availableProjects: [],
fetchSessions,
createSession,
})
useChatStore.setState({
connectToSession,
} as Partial<ReturnType<typeof useChatStore.getState>>)
useUIStore.setState({
addToast,
} as Partial<ReturnType<typeof useUIStore.getState>>)
})
afterEach(() => {
useTabStore.setState({ tabs: [], activeTabId: null })
})
it('opens a new tab when creating a session from the sidebar', async () => {
createSession.mockResolvedValue('session-new-1')
render(<Sidebar />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'New Session' }))
})
await waitFor(() => {
expect(createSession).toHaveBeenCalled()
expect(connectToSession).toHaveBeenCalledWith('session-new-1')
})
expect(useTabStore.getState().tabs).toEqual([
{ sessionId: 'session-new-1', title: 'New Session', type: 'session', status: 'idle' },
])
expect(useTabStore.getState().activeTabId).toBe('session-new-1')
expect(screen.getByRole('complementary')).not.toHaveAttribute('data-tauri-drag-region')
})
it('shows a toast when session creation fails', async () => {
createSession.mockRejectedValue(new Error('boom'))
render(<Sidebar />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'New Session' }))
})
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith({
type: 'error',
message: 'boom',
})
})
expect(useTabStore.getState().tabs).toEqual([])
})
})

View File

@ -1,5 +1,6 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
import { useSessionStore } from '../../stores/sessionStore'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
import { ProjectFilter } from './ProjectFilter'
import type { SessionListItem } from '../../types/session'
@ -7,6 +8,7 @@ import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tab
import { useChatStore } from '../../stores/chatStore'
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform)
type TimeGroup = 'today' | 'yesterday' | 'last7days' | 'last30days' | 'older'
@ -19,6 +21,7 @@ export function Sidebar() {
const fetchSessions = useSessionStore((s) => s.fetchSessions)
const deleteSession = useSessionStore((s) => s.deleteSession)
const renameSession = useSessionStore((s) => s.renameSession)
const addToast = useUIStore((s) => s.addToast)
const activeTabId = useTabStore((s) => s.activeTabId)
const [searchQuery, setSearchQuery] = useState('')
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
@ -105,9 +108,9 @@ export function Sidebar() {
}
return (
<aside data-tauri-drag-region onMouseDown={handleSidebarDrag} 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'}`}>
<aside onMouseDown={handleSidebarDrag} 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 (not needed on Windows) */}
<div className={`px-3 pb-1.5 flex items-center justify-between ${isTauri && !isWindows ? '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" }}>
@ -139,8 +142,12 @@ export function Sidebar() {
const sessionId = await useSessionStore.getState().createSession(workDir)
useTabStore.getState().openTab(sessionId, t('sidebar.newSession'))
useChatStore.getState().connectToSession(sessionId)
} catch {
// Session creation failed — no tab opened
} catch (error) {
addToast({
type: 'error',
message:
error instanceof Error ? error.message : t('sidebar.sessionListFailed'),
})
}
}}
icon={<PlusIcon />}

View File

@ -2,8 +2,10 @@ import { useRef, useState, useEffect, useCallback } from 'react'
import { useTabStore, type Tab } from '../../stores/tabStore'
import { useChatStore } from '../../stores/chatStore'
import { useTranslation } from '../../i18n'
import { WindowControls, showWindowControls } from './WindowControls'
const TAB_WIDTH = 180
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
export function TabBar() {
const tabs = useTabStore((s) => s.tabs)
@ -13,6 +15,7 @@ export function TabBar() {
const disconnectSession = useChatStore((s) => s.disconnectSession)
const moveTab = useTabStore((s) => s.moveTab)
const startDraggingRef = useRef<(() => Promise<void>) | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const [canScrollLeft, setCanScrollLeft] = useState(false)
@ -50,6 +53,16 @@ export function TabBar() {
return () => document.removeEventListener('click', close)
}, [contextMenu])
useEffect(() => {
if (!isTauri) return
import(/* @vite-ignore */ '@tauri-apps/api/window')
.then(({ getCurrentWindow }) => {
const win = getCurrentWindow()
startDraggingRef.current = () => win.startDragging()
})
.catch(() => {})
}, [])
const scroll = (direction: 'left' | 'right') => {
const el = scrollRef.current
if (!el) return
@ -145,10 +158,20 @@ export function TabBar() {
setDragOverIndex(null)
}
if (tabs.length === 0) return null
const handleTabBarDrag = useCallback((e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button, input, textarea, select, a, [role="button"], [draggable="true"]')) {
return
}
startDraggingRef.current?.()
}, [])
if (tabs.length === 0 && !showWindowControls) return null
return (
<div className="flex items-stretch bg-[var(--color-surface-container)] min-h-[37px] select-none border-b border-[var(--color-border)]" data-tauri-drag-region>
<div
className="flex items-stretch bg-[var(--color-surface-container)] min-h-[37px] select-none border-b border-[var(--color-border)]"
onMouseDown={handleTabBarDrag}
>
{canScrollLeft && (
<button onClick={() => scroll('left')} className="flex-shrink-0 w-7 h-[37px] flex items-center justify-center text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]">
@ -156,7 +179,7 @@ export function TabBar() {
</button>
)}
<div ref={scrollRef} className="flex-1 flex items-stretch overflow-x-hidden" data-tauri-drag-region onDragOver={(e) => e.preventDefault()}>
<div ref={scrollRef} className="flex-1 flex items-stretch overflow-x-hidden" onDragOver={(e) => e.preventDefault()}>
{tabs.map((tab, index) => (
<TabItem
key={tab.sessionId}
@ -180,6 +203,10 @@ export function TabBar() {
</button>
)}
{/* Windows: drag spacer fills remaining area + custom window controls */}
{showWindowControls && <div className="flex-1" />}
<WindowControls />
{contextMenu && (
<div
className="fixed z-50 bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-md)] py-1 min-w-[160px]"

View File

@ -0,0 +1,80 @@
import { useState, useEffect } from 'react'
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platform)
/** Whether to render custom window controls (Windows + Tauri only) */
export const showWindowControls = isTauri && isWindows
export function WindowControls() {
const [maximized, setMaximized] = useState(false)
const [win, setWin] = useState<{
minimize: () => Promise<void>
toggleMaximize: () => Promise<void>
close: () => Promise<void>
isMaximized: () => Promise<boolean>
onResized: (handler: () => void) => Promise<() => void>
} | null>(null)
useEffect(() => {
if (!showWindowControls) return
let unlisten: (() => void) | undefined
import('@tauri-apps/api/window')
.then(async ({ getCurrentWindow }) => {
const w = getCurrentWindow()
setWin(w as any)
setMaximized(await w.isMaximized())
unlisten = await w.onResized(async () => {
setMaximized(await w.isMaximized())
})
})
.catch(() => {})
return () => { unlisten?.() }
}, [])
if (!showWindowControls || !win) return null
return (
<div className="flex items-stretch flex-shrink-0 -my-px">
{/* Minimize */}
<button
onClick={() => win.minimize()}
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
<svg width="10" height="1" viewBox="0 0 10 1">
<rect width="10" height="1" fill="currentColor" />
</svg>
</button>
{/* Maximize / Restore */}
<button
onClick={() => win.toggleMaximize()}
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
{maximized ? (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1">
<rect x="0" y="3" width="7" height="7" />
<polyline points="3,3 3,0 10,0 10,7 7,7" />
</svg>
) : (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1">
<rect x="0.5" y="0.5" width="9" height="9" />
</svg>
)}
</button>
{/* Close */}
<button
onClick={() => win.close()}
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[#e81123] hover:text-white transition-colors"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
<line x1="0" y1="0" x2="10" y2="10" />
<line x1="10" y1="0" x2="0" y2="10" />
</svg>
</button>
</div>
)
}

View File

@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { createMock, listMock } = vi.hoisted(() => ({
createMock: vi.fn(),
listMock: vi.fn(),
}))
vi.mock('../api/sessions', () => ({
sessionsApi: {
create: createMock,
list: listMock,
delete: vi.fn(),
rename: vi.fn(),
},
}))
import { useSessionStore } from './sessionStore'
const initialState = useSessionStore.getState()
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
describe('sessionStore', () => {
beforeEach(() => {
createMock.mockReset()
listMock.mockReset()
useSessionStore.setState({
...initialState,
sessions: [],
activeSessionId: null,
isLoading: false,
error: null,
selectedProjects: [],
availableProjects: [],
})
})
afterEach(() => {
useSessionStore.setState(initialState)
})
it('returns a new session id before the background refresh completes', async () => {
createMock.mockResolvedValue({ sessionId: 'session-optimistic-1' })
listMock.mockImplementation(() => new Promise(() => {}))
const result = await Promise.race([
useSessionStore.getState().createSession('D:/workspace/code/myself_code/cc-haha'),
delay(100).then(() => 'timed-out'),
])
expect(result).toBe('session-optimistic-1')
expect(useSessionStore.getState().activeSessionId).toBe('session-optimistic-1')
expect(useSessionStore.getState().sessions[0]).toMatchObject({
id: 'session-optimistic-1',
title: 'New Session',
workDir: 'D:/workspace/code/myself_code/cc-haha',
workDirExists: true,
})
expect(listMock).toHaveBeenCalledOnce()
})
})

View File

@ -49,9 +49,26 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
createSession: async (workDir?: string) => {
const { sessionId: id } = await sessionsApi.create(workDir || undefined)
// Refresh session list
await get().fetchSessions()
set({ activeSessionId: id })
const now = new Date().toISOString()
const optimisticSession: SessionListItem = {
id,
title: 'New Session',
createdAt: now,
modifiedAt: now,
messageCount: 0,
projectPath: '',
workDir: workDir ?? null,
workDirExists: true,
}
set((state) => ({
sessions: state.sessions.some((session) => session.id === id)
? state.sessions
: [optimisticSession, ...state.sessions],
activeSessionId: id,
}))
void get().fetchSessions()
return id
},

View File

@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { ConversationService } from '../services/conversationService.js'
describe('ConversationService', () => {
let tmpDir: string
let originalConfigDir: string | undefined
let originalAuthToken: string | undefined
let originalBaseUrl: string | undefined
let originalModel: string | undefined
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN
originalBaseUrl = process.env.ANTHROPIC_BASE_URL
originalModel = process.env.ANTHROPIC_MODEL
process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.ANTHROPIC_AUTH_TOKEN = 'test-token'
process.env.ANTHROPIC_BASE_URL = 'https://example.invalid/anthropic'
process.env.ANTHROPIC_MODEL = 'test-model'
})
afterEach(async () => {
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
if (originalAuthToken === undefined) delete process.env.ANTHROPIC_AUTH_TOKEN
else process.env.ANTHROPIC_AUTH_TOKEN = originalAuthToken
if (originalBaseUrl === undefined) delete process.env.ANTHROPIC_BASE_URL
else process.env.ANTHROPIC_BASE_URL = originalBaseUrl
if (originalModel === undefined) delete process.env.ANTHROPIC_MODEL
else process.env.ANTHROPIC_MODEL = originalModel
await fs.rm(tmpDir, { recursive: true, force: true })
})
test('keeps inherited provider env when no desktop provider config exists', () => {
const service = new ConversationService() as any
const env = service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha') as Record<string, string>
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('test-token')
expect(env.ANTHROPIC_BASE_URL).toBe('https://example.invalid/anthropic')
expect(env.ANTHROPIC_MODEL).toBe('test-model')
})
test('strips inherited provider env when desktop provider config exists', async () => {
const ccHahaDir = path.join(tmpDir, 'cc-haha')
await fs.mkdir(ccHahaDir, { recursive: true })
await fs.writeFile(
path.join(ccHahaDir, 'providers.json'),
JSON.stringify({ activeId: null, providers: [] }),
'utf-8',
)
const service = new ConversationService() as any
const env = service.buildChildEnv('D:\\workspace\\code\\myself_code\\cc-haha') as Record<string, string>
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
expect(env.ANTHROPIC_MODEL).toBeUndefined()
})
test('uses bun entrypoint fallback on Windows dev mode', () => {
const service = new ConversationService() as any
const args = service.resolveCliArgs(['--print'])
if (process.platform === 'win32') {
expect(args[0]).toBe(process.execPath)
expect(args[1]).toContain(path.join('src', 'entrypoints', 'cli.tsx'))
} else {
expect(args[0]).toContain(path.join('bin', 'claude-haha'))
}
})
})

View File

@ -7,6 +7,7 @@ import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
import { SessionService } from '../services/sessionService.js'
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
// ============================================================================
// Test helpers
@ -375,7 +376,7 @@ describe('SessionService', () => {
)
// Verify the file was created
const sanitized = workDir.replace(/\//g, '-')
const sanitized = sanitizePath(workDir)
const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`)
const stat = await fs.stat(filePath)
expect(stat.isFile()).toBe(true)
@ -386,8 +387,30 @@ describe('SessionService', () => {
expect(entry.type).toBe('file-history-snapshot')
})
it('should throw when workDir is missing', async () => {
expect(service.createSession('')).rejects.toThrow('workDir is required')
it('should create a Windows-safe project directory name', async () => {
if (process.platform !== 'win32') return
const workDir = process.cwd()
const { sessionId } = await service.createSession(workDir)
const sanitized = sanitizePath(workDir)
const projectDir = path.join(tmpDir, 'projects', sanitized)
expect(sanitized.includes(':')).toBe(false)
const stat = await fs.stat(path.join(projectDir, `${sessionId}.jsonl`))
expect(stat.isFile()).toBe(true)
})
it('should default to the user home directory when workDir is missing', async () => {
const { sessionId } = await service.createSession('')
const filePath = path.join(
tmpDir,
'projects',
sanitizePath(os.homedir()),
`${sessionId}.jsonl`,
)
const stat = await fs.stat(filePath)
expect(stat.isFile()).toBe(true)
})
it('should throw when workDir does not exist', async () => {
@ -585,13 +608,18 @@ describe('Sessions API', () => {
)
})
it('POST /api/sessions should reject missing workDir', async () => {
it('POST /api/sessions should create a session when workDir is omitted', async () => {
const res = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(400)
expect(res.status).toBe(201)
const body = (await res.json()) as { sessionId: string }
expect(body.sessionId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
)
})
it('GET /api/sessions/:id should return session detail', async () => {

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from 'bun:test'
import { corsHeaders } from './cors'
describe('corsHeaders', () => {
it('allows localhost browser origins', () => {
expect(corsHeaders('http://127.0.0.1:1420')['Access-Control-Allow-Origin']).toBe('http://127.0.0.1:1420')
expect(corsHeaders('http://localhost:3000')['Access-Control-Allow-Origin']).toBe('http://localhost:3000')
})
it('allows tauri webview origins used in production builds', () => {
expect(corsHeaders('http://tauri.localhost')['Access-Control-Allow-Origin']).toBe('http://tauri.localhost')
expect(corsHeaders('https://tauri.localhost')['Access-Control-Allow-Origin']).toBe('https://tauri.localhost')
expect(corsHeaders('tauri://localhost')['Access-Control-Allow-Origin']).toBe('tauri://localhost')
})
it('falls back for unknown origins', () => {
expect(corsHeaders('https://example.com')['Access-Control-Allow-Origin']).toBe('http://localhost:3000')
expect(corsHeaders(null)['Access-Control-Allow-Origin']).toBe('http://localhost:3000')
})
})

View File

@ -3,7 +3,7 @@
*/
const ALLOWED_ORIGIN_RE =
/^(?:https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?|tauri:\/\/localhost|https:\/\/tauri\.localhost)$/
/^(?:https?:\/\/(?:localhost|127\.0\.0\.1|tauri\.localhost)(?::\d+)?|tauri:\/\/localhost|asset:\/\/localhost)$/
export function corsHeaders(origin?: string | null): Record<string, string> {
// Allow localhost origins (http/https) and Tauri WebView origins

View File

@ -118,34 +118,7 @@ export class ConversationService {
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDirpreload.ts
// chdir 后落到正确目录。
//
// Provider isolation: strip provider-managed env vars from the inherited
// process.env so the CLI subprocess reads them fresh from config files
// (cc-haha/settings.json or ~/.claude/settings.json). Without this,
// switching to "Claude 官方" in the desktop UI would still send requests
// to the previous provider because stale env vars linger in the sidecar's
// process.env after activateOfficial() only clears the config file.
const PROVIDER_ENV_KEYS = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
]
const cleanEnv = { ...process.env }
for (const key of PROVIDER_ENV_KEYS) {
delete cleanEnv[key]
}
const childEnv = {
...cleanEnv,
CLAUDE_CODE_ENABLE_TASKS: '1',
CALLER_DIR: workDir,
PWD: workDir,
// Tell bin/claude-haha to skip .env loading — provider env is managed
// by cc-haha/settings.json, not the project's .env file.
CC_HAHA_SKIP_DOTENV: '1',
}
const childEnv = this.buildChildEnv(workDir)
let proc: ReturnType<typeof Bun.spawn>
try {
@ -486,6 +459,72 @@ export class ConversationService {
return args
}
private buildChildEnv(workDir: string): Record<string, string> {
// Provider isolation: when Desktop has its own provider config/index,
// strip inherited provider env vars so the child CLI reads fresh values
// from ~/.claude/cc-haha/settings.json instead of stale process.env.
//
// If the user never configured a Desktop provider and only launched the
// app/server with ANTHROPIC_* env vars, keep those env vars so Windows
// dev-mode and env-only setups can still authenticate successfully.
const PROVIDER_ENV_KEYS = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
] as const
const cleanEnv = { ...process.env }
if (this.shouldStripInheritedProviderEnv()) {
for (const key of PROVIDER_ENV_KEYS) {
delete cleanEnv[key]
}
}
return {
...cleanEnv,
CLAUDE_CODE_ENABLE_TASKS: '1',
CALLER_DIR: workDir,
PWD: workDir,
// Tell the CLI entrypoint to skip project .env loading. Provider env
// should come from Desktop-managed config or inherited launch env, not
// be reintroduced from the repo's .env file.
CC_HAHA_SKIP_DOTENV: '1',
}
}
private shouldStripInheritedProviderEnv(): boolean {
const configDir =
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
const ccHahaDir = path.join(configDir, 'cc-haha')
const providersIndexPath = path.join(ccHahaDir, 'providers.json')
const settingsPath = path.join(ccHahaDir, 'settings.json')
if (fs.existsSync(providersIndexPath)) {
return true
}
try {
const raw = fs.readFileSync(settingsPath, 'utf-8')
const parsed = JSON.parse(raw) as { env?: Record<string, string> }
const env = parsed.env ?? {}
return [
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
].some((key) => typeof env[key] === 'string' && env[key]!.trim().length > 0)
} catch {
return false
}
}
private resolveBundledCliPath(): string | null {
// 桌面端 P0+P2 之后只有一个合并的 sidecar 二进制 —— `claude-sidecar`
// 它通过第一个 positional 参数 (server / cli) 选模式。当前进程要么
@ -515,6 +554,13 @@ export class ConversationService {
private resolveCliArgs(baseArgs: string[]): string[] {
const cliCommand = process.env.CLAUDE_CLI_PATH || this.resolveBundledCliPath()
if (!cliCommand) {
if (process.platform === 'win32') {
return [
process.execPath,
path.resolve(import.meta.dir, '../../entrypoints/cli.tsx'),
...baseArgs,
]
}
return [path.resolve(import.meta.dir, '../../../bin/claude-haha'), ...baseArgs]
}

View File

@ -9,6 +9,7 @@ import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
import { ApiError } from '../middleware/errorHandler.js'
import { sanitizePath as sanitizePortablePath } from '../../utils/sessionStoragePortable.js'
// ============================================================================
// Types
@ -88,11 +89,11 @@ export class SessionService {
}
/**
* Sanitize a path the same way the CLI does: replace path separators with '-'.
* For example, `/Users/nanmi/workspace` becomes `-Users-nanmi-workspace`.
* Sanitize a path the same way the shared session storage does.
* This must remain Windows-safe, so reserved characters such as ':' are normalized too.
*/
private sanitizePath(dirPath: string): string {
return dirPath.replace(/\//g, '-').replace(/\\/g, '-')
return sanitizePortablePath(dirPath)
}
// --------------------------------------------------------------------------