diff --git a/.gitignore b/.gitignore index 3e661dce..49746ce2 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/desktop/package.json b/desktop/package.json index 05779397..6d64c824 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/scripts/build-windows-x64.ps1 b/desktop/scripts/build-windows-x64.ps1 new file mode 100644 index 00000000..7a7d0cb1 --- /dev/null +++ b/desktop/scripts/build-windows-x64.ps1 @@ -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 +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 110e0315..8a15c4f7 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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::(); 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!()) diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index b7735708..536e0f91 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -43,6 +43,11 @@ "active": true, "targets": "all", "createUpdaterArtifacts": true, + "windows": { + "nsis": { + "installerHooks": "windows-installer-hooks.nsh" + } + }, "externalBin": [ "binaries/claude-sidecar" ], diff --git a/desktop/src-tauri/windows-installer-hooks.nsh b/desktop/src-tauri/windows-installer-hooks.nsh new file mode 100644 index 00000000..d33adbf1 --- /dev/null +++ b/desktop/src-tauri/windows-installer-hooks.nsh @@ -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 diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 17a0af37..37e3e10e 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -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) diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx new file mode 100644 index 00000000..64c93a98 --- /dev/null +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -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: () =>
, +})) + +vi.mock('../../i18n', () => ({ + useTranslation: () => (key: string) => { + const translations: Record = { + '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>) + useUIStore.setState({ + addToast, + } as Partial>) + }) + + 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() + + 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() + + 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([]) + }) +}) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index f2c24a1c..dc5b3bce 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -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 ( -