mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
parent
5016bc0049
commit
5d243b657a
3
.gitignore
vendored
3
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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",
|
||||
|
||||
295
desktop/scripts/build-windows-x64.ps1
Normal file
295
desktop/scripts/build-windows-x64.ps1
Normal 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
|
||||
}
|
||||
@ -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!())
|
||||
|
||||
@ -43,6 +43,11 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installerHooks": "windows-installer-hooks.nsh"
|
||||
}
|
||||
},
|
||||
"externalBin": [
|
||||
"binaries/claude-sidecar"
|
||||
],
|
||||
|
||||
17
desktop/src-tauri/windows-installer-hooks.nsh
Normal file
17
desktop/src-tauri/windows-installer-hooks.nsh
Normal 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
|
||||
@ -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)
|
||||
|
||||
114
desktop/src/components/layout/Sidebar.test.tsx
Normal file
114
desktop/src/components/layout/Sidebar.test.tsx
Normal 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([])
|
||||
})
|
||||
})
|
||||
@ -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 />}
|
||||
|
||||
@ -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]"
|
||||
|
||||
80
desktop/src/components/layout/WindowControls.tsx
Normal file
80
desktop/src/components/layout/WindowControls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
desktop/src/stores/sessionStore.test.ts
Normal file
63
desktop/src/stores/sessionStore.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
},
|
||||
|
||||
|
||||
80
src/server/__tests__/conversation-service.test.ts
Normal file
80
src/server/__tests__/conversation-service.test.ts
Normal 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'))
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -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 () => {
|
||||
|
||||
20
src/server/middleware/cors.test.ts
Normal file
20
src/server/middleware/cors.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -118,34 +118,7 @@ export class ConversationService {
|
||||
// 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.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]
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user