mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Merge pull request #66 from NanmiCoder/codex/fix-windows-window-controls
[codex] Fix Windows desktop startup without Git Bash
This commit is contained in:
commit
0a4cd17f2e
@ -5,7 +5,10 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"shell:allow-open",
|
||||
{
|
||||
"identifier": "shell:allow-execute",
|
||||
|
||||
@ -394,12 +394,6 @@ pub fn run() {
|
||||
// 让 adapter 连上动态端口。
|
||||
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!())
|
||||
|
||||
@ -18,8 +18,6 @@
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"transparent": false,
|
||||
"acceptFirstMouse": true
|
||||
}
|
||||
|
||||
18
desktop/src-tauri/tauri.macos.conf.json
Normal file
18
desktop/src-tauri/tauri.macos.conf.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Claude Code Haha",
|
||||
"width": 1440,
|
||||
"height": 960,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"transparent": false,
|
||||
"acceptFirstMouse": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
16
desktop/src-tauri/tauri.windows.conf.json
Normal file
16
desktop/src-tauri/tauri.windows.conf.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Claude Code Haha",
|
||||
"width": 1440,
|
||||
"height": 960,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"decorations": false,
|
||||
"transparent": false,
|
||||
"acceptFirstMouse": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -281,4 +281,32 @@ describe('MessageList nested tool calls', () => {
|
||||
'先看 CLI 和服务端入口。\n再看 desktop 前后端边界。'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows raw startup details under translated CLI startup errors', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'error-1',
|
||||
type: 'error',
|
||||
code: 'CLI_START_FAILED',
|
||||
message:
|
||||
'CLI exited during startup (code 1): Claude Code on Windows requires git-bash (https://git-scm.com/downloads/win).',
|
||||
timestamp: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
expect(screen.getByText('Failed to start CLI process.')).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'CLI exited during startup (code 1): Claude Code on Windows requires git-bash (https://git-scm.com/downloads/win).',
|
||||
),
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@ -222,9 +222,18 @@ export const MessageBlock = memo(function MessageBlock({
|
||||
const errorKey = message.code ? `error.${message.code}` as TranslationKey : null
|
||||
const errorText = errorKey ? t(errorKey) : null
|
||||
const displayMessage = (errorText && errorText !== errorKey) ? errorText : message.message
|
||||
const showRawDetail =
|
||||
Boolean(message.message) &&
|
||||
message.message.trim() !== '' &&
|
||||
message.message !== displayMessage
|
||||
return (
|
||||
<div className="mb-3 px-4 py-2.5 rounded-lg bg-red-50 border border-red-200 text-sm text-[var(--color-error)]">
|
||||
<strong>Error:</strong> {displayMessage}
|
||||
{showRawDetail && (
|
||||
<div className="mt-1 whitespace-pre-wrap text-xs text-red-700/85">
|
||||
{message.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
69
desktop/src/components/layout/WindowControls.test.tsx
Normal file
69
desktop/src/components/layout/WindowControls.test.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
const minimize = vi.fn().mockResolvedValue(undefined)
|
||||
const toggleMaximize = vi.fn().mockResolvedValue(undefined)
|
||||
const close = vi.fn().mockResolvedValue(undefined)
|
||||
const isMaximized = vi.fn().mockResolvedValue(false)
|
||||
const onResized = vi.fn().mockResolvedValue(() => {})
|
||||
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => ({
|
||||
minimize,
|
||||
toggleMaximize,
|
||||
close,
|
||||
isMaximized,
|
||||
onResized,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('WindowControls', () => {
|
||||
const originalPlatform = navigator.platform
|
||||
|
||||
beforeEach(async () => {
|
||||
minimize.mockClear()
|
||||
toggleMaximize.mockClear()
|
||||
close.mockClear()
|
||||
isMaximized.mockClear()
|
||||
onResized.mockClear()
|
||||
|
||||
Object.defineProperty(window, '__TAURI_INTERNALS__', {
|
||||
configurable: true,
|
||||
value: {},
|
||||
})
|
||||
Object.defineProperty(navigator, 'platform', {
|
||||
configurable: true,
|
||||
value: 'Win32',
|
||||
})
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
|
||||
Object.defineProperty(navigator, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform,
|
||||
})
|
||||
})
|
||||
|
||||
it('invokes Tauri window APIs for custom controls on Windows', async () => {
|
||||
const { WindowControls } = await import('./WindowControls')
|
||||
|
||||
render(<WindowControls />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Minimize window' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Minimize window' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Maximize window' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close window' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(minimize).toHaveBeenCalledTimes(1)
|
||||
expect(toggleMaximize).toHaveBeenCalledTimes(1)
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -34,13 +34,20 @@ export function WindowControls() {
|
||||
return () => { unlisten?.() }
|
||||
}, [])
|
||||
|
||||
const runWindowAction = (action: () => Promise<void>) => {
|
||||
void action().catch((error) => {
|
||||
console.error('Window control action failed', error)
|
||||
})
|
||||
}
|
||||
|
||||
if (!showWindowControls || !win) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch flex-shrink-0 -my-px">
|
||||
{/* Minimize */}
|
||||
<button
|
||||
onClick={() => win.minimize()}
|
||||
onClick={() => runWindowAction(() => win.minimize())}
|
||||
aria-label="Minimize window"
|
||||
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">
|
||||
@ -50,7 +57,8 @@ export function WindowControls() {
|
||||
|
||||
{/* Maximize / Restore */}
|
||||
<button
|
||||
onClick={() => win.toggleMaximize()}
|
||||
onClick={() => runWindowAction(() => win.toggleMaximize())}
|
||||
aria-label={maximized ? 'Restore window' : 'Maximize window'}
|
||||
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
>
|
||||
{maximized ? (
|
||||
@ -67,7 +75,8 @@ export function WindowControls() {
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={() => win.close()}
|
||||
onClick={() => runWindowAction(() => win.close())}
|
||||
aria-label="Close window"
|
||||
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">
|
||||
|
||||
@ -17,7 +17,10 @@ import {
|
||||
} from './sessionEnvironment.js'
|
||||
import { subprocessEnv } from './subprocessEnv.js'
|
||||
import { getPlatform } from './platform.js'
|
||||
import { findGitBashPath, windowsPathToPosixPath } from './windowsPaths.js'
|
||||
import {
|
||||
tryFindGitBashPath,
|
||||
windowsPathToPosixPath,
|
||||
} from './windowsPaths.js'
|
||||
import { getCachedPowerShellPath } from './shell/powershellDetection.js'
|
||||
import { DEFAULT_HOOK_SHELL } from './shell/shellProvider.js'
|
||||
import { buildPowerShellArgs } from './shell/powershellProvider.js'
|
||||
@ -787,9 +790,20 @@ async function execCommandHook(
|
||||
// PowerShell path deliberately skips the Windows-specific bash
|
||||
// accommodations (cygpath conversion, .sh auto-prepend, POSIX-quoted
|
||||
// SHELL_PREFIX).
|
||||
const shellType = hook.shell ?? DEFAULT_HOOK_SHELL
|
||||
let shellType = hook.shell ?? DEFAULT_HOOK_SHELL
|
||||
|
||||
const isPowerShell = shellType === 'powershell'
|
||||
if (isWindows && !hook.shell && shellType === 'bash' && !tryFindGitBashPath()) {
|
||||
const pwshPath = await getCachedPowerShellPath()
|
||||
if (pwshPath) {
|
||||
shellType = 'powershell'
|
||||
logForDebugging(
|
||||
`Hooks: Git Bash unavailable on Windows, falling back to PowerShell for default shell on hook "${hook.command}"`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const usesPowerShell = shellType === 'powershell'
|
||||
|
||||
// --
|
||||
// Windows bash path: hooks run via Git Bash (Cygwin), NOT cmd.exe.
|
||||
@ -806,7 +820,7 @@ async function execCommandHook(
|
||||
// PowerShell expects Windows paths on Windows (and native paths on
|
||||
// Unix where pwsh is also available).
|
||||
const toHookPath =
|
||||
isWindows && !isPowerShell
|
||||
isWindows && !usesPowerShell
|
||||
? (p: string) => windowsPathToPosixPath(p)
|
||||
: (p: string) => p
|
||||
|
||||
@ -859,7 +873,7 @@ async function execCommandHook(
|
||||
// On Windows (bash only), auto-prepend `bash` for .sh scripts so they
|
||||
// execute instead of opening in the default file handler. PowerShell
|
||||
// runs .ps1 files natively — no prepend needed.
|
||||
if (isWindows && !isPowerShell && command.trim().match(/\.sh(\s|$|")/)) {
|
||||
if (isWindows && !usesPowerShell && command.trim().match(/\.sh(\s|$|")/)) {
|
||||
if (!command.trim().startsWith('bash ')) {
|
||||
command = `bash ${command}`
|
||||
}
|
||||
@ -870,7 +884,7 @@ async function execCommandHook(
|
||||
// PowerShell — see design §8.1. For now PS hooks ignore the prefix;
|
||||
// a CLAUDE_CODE_PS_SHELL_PREFIX (or shell-aware prefix) is a follow-up.
|
||||
const finalCommand =
|
||||
!isPowerShell && process.env.CLAUDE_CODE_SHELL_PREFIX
|
||||
!usesPowerShell && process.env.CLAUDE_CODE_SHELL_PREFIX
|
||||
? formatShellPrefixCommand(process.env.CLAUDE_CODE_SHELL_PREFIX, command)
|
||||
: command
|
||||
|
||||
@ -915,7 +929,7 @@ async function execCommandHook(
|
||||
// Skip for PS — consistent with how .sh prepend and SHELL_PREFIX are
|
||||
// already bash-only above.
|
||||
if (
|
||||
!isPowerShell &&
|
||||
!usesPowerShell &&
|
||||
(hookEvent === 'SessionStart' ||
|
||||
hookEvent === 'Setup' ||
|
||||
hookEvent === 'CwdChanged' ||
|
||||
@ -948,12 +962,9 @@ async function execCommandHook(
|
||||
// skips user profile scripts (faster, deterministic).
|
||||
// -NonInteractive fails fast instead of prompting.
|
||||
//
|
||||
// The Git Bash hard-exit in findGitBashPath() is still in place for
|
||||
// bash hooks. PowerShell hooks never call it, so a Windows user with
|
||||
// only pwsh and shell: 'powershell' on every hook could in theory run
|
||||
// without Git Bash — but init.ts still calls setShellIfWindows() on
|
||||
// startup, which will exit first. Relaxing that is phase 1 of the
|
||||
// design's implementation order (separate PR).
|
||||
// On Windows, default-shell hooks now degrade to PowerShell when Git Bash
|
||||
// is unavailable. Explicit bash hooks still surface an actionable error,
|
||||
// but they no longer hard-exit the entire process.
|
||||
let child: ChildProcessWithoutNullStreams
|
||||
if (shellType === 'powershell') {
|
||||
const pwshPath = await getCachedPowerShellPath()
|
||||
@ -973,7 +984,14 @@ async function execCommandHook(
|
||||
} else {
|
||||
// On Windows, use Git Bash explicitly (cmd.exe can't run bash syntax).
|
||||
// On other platforms, shell: true uses /bin/sh.
|
||||
const shell = isWindows ? findGitBashPath() : true
|
||||
const shell = isWindows
|
||||
? tryFindGitBashPath() ??
|
||||
(() => {
|
||||
throw new Error(
|
||||
'Git Bash is required to run bash hooks on Windows. Install Git for Windows or configure the hook with shell: "powershell".',
|
||||
)
|
||||
})()
|
||||
: true
|
||||
child = spawn(finalCommand, [], {
|
||||
env: envVars,
|
||||
cwd: safeCwd,
|
||||
|
||||
@ -86,26 +86,31 @@ function findExecutable(executable: string): string | null {
|
||||
*/
|
||||
export function setShellIfWindows(): void {
|
||||
if (getPlatform() === 'windows') {
|
||||
const gitBashPath = findGitBashPath()
|
||||
const gitBashPath = tryFindGitBashPath()
|
||||
if (!gitBashPath) {
|
||||
logForDebugging(
|
||||
'Git Bash not found on Windows; leaving SHELL unset for lazy fallback handling',
|
||||
{ level: 'warn' },
|
||||
)
|
||||
return
|
||||
}
|
||||
process.env.SHELL = gitBashPath
|
||||
logForDebugging(`Using bash path: "${gitBashPath}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the path where `bash.exe` included with git-bash exists, exiting the process if not found.
|
||||
* Best-effort Git Bash resolution on Windows.
|
||||
*
|
||||
* Returns null when Git Bash is unavailable so callers can decide whether to
|
||||
* fall back (for example, to PowerShell) instead of hard-exiting the process.
|
||||
*/
|
||||
export const findGitBashPath = memoize((): string => {
|
||||
export const tryFindGitBashPath = memoize((): string | null => {
|
||||
if (process.env.CLAUDE_CODE_GIT_BASH_PATH) {
|
||||
if (checkPathExists(process.env.CLAUDE_CODE_GIT_BASH_PATH)) {
|
||||
return process.env.CLAUDE_CODE_GIT_BASH_PATH
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`Claude Code was unable to find CLAUDE_CODE_GIT_BASH_PATH path "${process.env.CLAUDE_CODE_GIT_BASH_PATH}"`,
|
||||
)
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(1)
|
||||
return null
|
||||
}
|
||||
|
||||
const gitPath = findExecutable('git')
|
||||
@ -116,6 +121,27 @@ export const findGitBashPath = memoize((): string => {
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
/**
|
||||
* Find the path where `bash.exe` included with git-bash exists, exiting the process if not found.
|
||||
*/
|
||||
export const findGitBashPath = memoize((): string => {
|
||||
const gitBashPath = tryFindGitBashPath()
|
||||
if (gitBashPath) {
|
||||
return gitBashPath
|
||||
}
|
||||
|
||||
if (process.env.CLAUDE_CODE_GIT_BASH_PATH) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`Claude Code was unable to find CLAUDE_CODE_GIT_BASH_PATH path "${process.env.CLAUDE_CODE_GIT_BASH_PATH}"`,
|
||||
)
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
'Claude Code on Windows requires git-bash (https://git-scm.com/downloads/win). If installed but not in PATH, set environment variable pointing to your bash.exe, similar to: CLAUDE_CODE_GIT_BASH_PATH=C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user