Merge pull request #66 from NanmiCoder/codex/fix-windows-window-controls

[codex] Fix Windows desktop startup without Git Bash
This commit is contained in:
程序员阿江-Relakkes 2026-04-19 15:24:04 +08:00 committed by GitHub
commit 0a4cd17f2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 222 additions and 34 deletions

View File

@ -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",

View File

@ -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!())

View File

@ -18,8 +18,6 @@
"minWidth": 960,
"minHeight": 640,
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"transparent": false,
"acceptFirstMouse": true
}

View 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
}
]
}
}

View 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
}
]
}
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View 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)
})
})
})

View File

@ -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">

View File

@ -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,

View File

@ -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',