mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
fix: improve windows computer use and desktop rendering
This commit is contained in:
parent
3dfcdbb405
commit
4b513271ee
2
desktop/src-tauri/Cargo.lock
generated
2
desktop/src-tauri/Cargo.lock
generated
@ -323,7 +323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "claude-code-desktop"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
27
desktop/src/components/chat/CodeViewer.test.tsx
Normal file
27
desktop/src/components/chat/CodeViewer.test.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { CodeViewer } from './CodeViewer'
|
||||
|
||||
vi.mock('react-shiki', () => ({
|
||||
ShikiHighlighter: ({ children }: { children: string }) => (
|
||||
<div data-testid="shiki-container">
|
||||
<code>{children}</code>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('CodeViewer', () => {
|
||||
it('keeps the same inner padding for highlighted code content', async () => {
|
||||
const { container } = render(
|
||||
<CodeViewer code={'cd testb\nnpm run dev'} language="bash" />,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('shiki-container')).toBeTruthy()
|
||||
})
|
||||
|
||||
const contentWrapper = container.querySelector('[data-code-viewer-content]') as HTMLElement | null
|
||||
expect(contentWrapper).toBeTruthy()
|
||||
expect(contentWrapper?.style.padding).toBe('0.5rem 12px')
|
||||
})
|
||||
})
|
||||
@ -45,6 +45,8 @@ const warmCodeTheme = {
|
||||
],
|
||||
}
|
||||
|
||||
const CODE_AREA_PADDING = '0.5rem 12px'
|
||||
|
||||
/**
|
||||
* Wraps ShikiHighlighter with a plain-text fallback so the code area
|
||||
* is never empty while the async WASM / language-grammar load is in-flight,
|
||||
@ -74,13 +76,13 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language?
|
||||
}, [code, language])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="code-viewer-area max-h-[420px] overflow-auto bg-[var(--color-code-bg)]">
|
||||
<div ref={containerRef} className="code-viewer-area relative max-h-[420px] overflow-auto bg-[var(--color-code-bg)]">
|
||||
{/* Plain-text fallback shown until Shiki finishes highlighting */}
|
||||
{!loaded && (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: '0.5rem 12px',
|
||||
padding: CODE_AREA_PADDING,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.45',
|
||||
@ -92,7 +94,20 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language?
|
||||
{code}
|
||||
</pre>
|
||||
)}
|
||||
<div style={loaded ? undefined : { position: 'absolute', opacity: 0, pointerEvents: 'none' }}>
|
||||
<div
|
||||
data-code-viewer-content=""
|
||||
style={
|
||||
loaded
|
||||
? { padding: CODE_AREA_PADDING }
|
||||
: {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
padding: CODE_AREA_PADDING,
|
||||
}
|
||||
}
|
||||
>
|
||||
<ShikiHighlighter
|
||||
language={language || 'text'}
|
||||
theme={warmCodeTheme}
|
||||
@ -101,7 +116,6 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language?
|
||||
addDefaultStyles={false}
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: '0.5rem 0',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.45',
|
||||
|
||||
@ -37,7 +37,12 @@ export function ToolCallBlock({ toolName, input, result, compact = false }: Prop
|
||||
const icon = TOOL_ICONS[toolName] || 'build'
|
||||
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
|
||||
const summary = getToolSummary(toolName, obj, t)
|
||||
const outputSummary = getToolResultSummary(toolName, result?.content, t)
|
||||
const outputSummary = getToolResultSummary(
|
||||
toolName,
|
||||
result?.content,
|
||||
result?.isError ?? false,
|
||||
t,
|
||||
)
|
||||
|
||||
const preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t])
|
||||
const details = useMemo(() => renderDetails(toolName, obj, t), [obj, toolName, t])
|
||||
@ -73,11 +78,17 @@ export function ToolCallBlock({ toolName, input, result, compact = false }: Prop
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
{result && outputSummary && (
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-outline)]">
|
||||
<span
|
||||
className={`shrink-0 text-[10px] ${
|
||||
result.isError
|
||||
? 'text-[var(--color-error)]'
|
||||
: 'text-[var(--color-outline)]'
|
||||
}`}
|
||||
>
|
||||
{outputSummary}
|
||||
</span>
|
||||
)}
|
||||
{result?.isError && (
|
||||
{result?.isError && (
|
||||
<span className="material-symbols-outlined shrink-0 text-[14px] text-[var(--color-error)]">error</span>
|
||||
)}
|
||||
{expandable && (
|
||||
@ -175,12 +186,30 @@ function renderDetails(toolName: string, obj: Record<string, unknown>, t?: (key:
|
||||
)
|
||||
}
|
||||
|
||||
function getToolResultSummary(toolName: string, content: unknown, t?: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
|
||||
if (toolName === 'Bash') return ''
|
||||
|
||||
function getToolResultSummary(
|
||||
toolName: string,
|
||||
content: unknown,
|
||||
isError: boolean,
|
||||
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const text = extractTextContent(content)
|
||||
if (!text) return ''
|
||||
|
||||
if (isError) {
|
||||
const firstLine = text
|
||||
.split('\n')
|
||||
.map((line) => stripAnsi(line).replace(/\s+/g, ' ').trim())
|
||||
.find(Boolean)
|
||||
|
||||
if (!firstLine) {
|
||||
return t?.('tool.error') ?? 'Error'
|
||||
}
|
||||
|
||||
return firstLine.length <= 72 ? firstLine : `${firstLine.slice(0, 72)}…`
|
||||
}
|
||||
|
||||
if (toolName === 'Bash') return ''
|
||||
|
||||
const lineCount = text.split('\n').length
|
||||
if (lineCount > 1) {
|
||||
return t?.('tool.linesOutput', { count: lineCount }) ?? `${lineCount} lines output`
|
||||
@ -192,6 +221,10 @@ function getToolResultSummary(toolName: string, content: unknown, t?: (key: Tran
|
||||
return `${compact.slice(0, 36)}…`
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(/\x1B\[[0-9;]*m/g, '')
|
||||
}
|
||||
|
||||
function getToolSummary(toolName: string, obj: Record<string, unknown>, t?: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
|
||||
switch (toolName) {
|
||||
case 'Bash':
|
||||
|
||||
@ -62,6 +62,19 @@ describe('chat blocks', () => {
|
||||
expect(container.textContent).not.toContain('file-a')
|
||||
})
|
||||
|
||||
it('shows a collapsed error summary for failed bash commands', () => {
|
||||
const { container } = render(
|
||||
<ToolCallBlock
|
||||
toolName="Bash"
|
||||
input={{ command: 'git show 5016bc0 --no-stat', description: 'Show full diff of latest commit' }}
|
||||
result={{ content: 'fatal: unrecognized argument: --no-stat\nExit code 128', isError: true }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.textContent).toContain('Bash')
|
||||
expect(container.textContent).toContain('fatal: unrecognized argument: --no-stat')
|
||||
})
|
||||
|
||||
it('expands tool errors so full Computer Use gate messages are readable', () => {
|
||||
const { container } = render(
|
||||
<ToolCallBlock
|
||||
|
||||
@ -51,6 +51,19 @@ describe('MarkdownRenderer', () => {
|
||||
expect(screen.getByText('Body copy.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses semantic code colors for inline code so both themes stay readable', () => {
|
||||
const { container } = render(
|
||||
<MarkdownRenderer content={'Use `claude-sonnet-4-6` for balanced speed.'} />,
|
||||
)
|
||||
|
||||
const root = container.firstChild as HTMLDivElement
|
||||
expect(root).toBeInTheDocument()
|
||||
expect(root.className).toContain('prose-code:text-[var(--color-code-fg)]')
|
||||
expect(root.className).toContain('prose-code:bg-[var(--color-code-bg)]')
|
||||
expect(root.className).not.toContain('prose-code:text-[var(--color-primary-fixed)]')
|
||||
expect(screen.getByText('claude-sonnet-4-6')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders mermaid fenced blocks with the Mermaid renderer', () => {
|
||||
render(<MarkdownRenderer content={'```mermaid\ngraph TB\nA-->B\n```'} />)
|
||||
|
||||
|
||||
@ -109,7 +109,7 @@ const BASE_PROSE_CLASSES = `markdown-prose prose prose-sm max-w-none text-[var(-
|
||||
prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold
|
||||
prose-p:my-2 prose-p:leading-relaxed
|
||||
prose-p:break-words
|
||||
prose-code:text-[13px] prose-code:text-[var(--color-primary-fixed)] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-surface-container-high)] prose-code:border prose-code:border-[var(--color-border)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:before:hidden prose-code:after:hidden
|
||||
prose-code:text-[13px] prose-code:text-[var(--color-code-fg)] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-code-bg)] prose-code:border prose-code:border-[var(--color-border)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:before:hidden prose-code:after:hidden
|
||||
prose-pre:!bg-transparent prose-pre:!p-0 prose-pre:!shadow-none
|
||||
prose-a:text-[var(--color-text-accent)] prose-a:no-underline hover:prose-a:underline
|
||||
prose-strong:text-[var(--color-text-primary)]
|
||||
|
||||
@ -272,6 +272,7 @@ export const en = {
|
||||
'settings.computerUse.setupSuccess': 'Environment setup complete!',
|
||||
'settings.computerUse.setupFail': 'Setup failed',
|
||||
'settings.computerUse.allReady': 'All checks passed. Computer Use is ready.',
|
||||
'settings.computerUse.downloadPython': 'Download Python 3',
|
||||
'settings.computerUse.recheckBtn': 'Recheck Status',
|
||||
'settings.computerUse.requirementsLabel': 'Required packages',
|
||||
'settings.computerUse.appsTitle': 'Authorized Apps',
|
||||
|
||||
@ -274,6 +274,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.computerUse.setupSuccess': '环境安装完成!',
|
||||
'settings.computerUse.setupFail': '安装失败',
|
||||
'settings.computerUse.allReady': '所有检查通过,Computer Use 已就绪。',
|
||||
'settings.computerUse.downloadPython': '下载 Python 3',
|
||||
'settings.computerUse.recheckBtn': '重新检测',
|
||||
'settings.computerUse.requirementsLabel': '需要的 Python 包',
|
||||
'settings.computerUse.appsTitle': '已授权应用',
|
||||
|
||||
@ -3,6 +3,10 @@ import { computerUseApi, type ComputerUseStatus, type SetupResult, type Installe
|
||||
import { useTranslation } from '../i18n'
|
||||
|
||||
type CheckState = 'loading' | 'ready' | 'error'
|
||||
const PYTHON_DOWNLOAD_URLS: Record<string, string> = {
|
||||
darwin: 'https://www.python.org/downloads/macos/',
|
||||
win32: 'https://www.python.org/downloads/windows/',
|
||||
}
|
||||
|
||||
function StatusIcon({ ok }: { ok: boolean | null }) {
|
||||
if (ok === null) {
|
||||
@ -31,6 +35,15 @@ async function openSystemSettings(pane: 'Privacy_ScreenCapture' | 'Privacy_Acces
|
||||
await computerUseApi.openSettings(pane)
|
||||
}
|
||||
|
||||
async function openExternalUrl(url: string) {
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-shell')
|
||||
await open(url)
|
||||
} catch {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
export function ComputerUseSettings() {
|
||||
const t = useTranslation()
|
||||
const [status, setStatus] = useState<ComputerUseStatus | null>(null)
|
||||
@ -153,6 +166,9 @@ export function ComputerUseSettings() {
|
||||
const accessibilityNeedsAttention = status?.permissions.accessibility === false
|
||||
const screenRecordingNeedsAttention = status?.permissions.screenRecording === false
|
||||
const screenRecordingReady = status ? status.permissions.screenRecording !== false : null
|
||||
const pythonDownloadUrl = status
|
||||
? PYTHON_DOWNLOAD_URLS[status.platform] ?? 'https://www.python.org/downloads/'
|
||||
: 'https://www.python.org/downloads/'
|
||||
|
||||
// Filter apps by search query
|
||||
const filteredApps = useMemo(() => {
|
||||
@ -297,6 +313,15 @@ export function ComputerUseSettings() {
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-3">
|
||||
{!status.python.installed && (
|
||||
<button
|
||||
onClick={() => openExternalUrl(pythonDownloadUrl)}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-[var(--color-brand)] text-white text-sm font-semibold rounded-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">open_in_new</span>
|
||||
{t('settings.computerUse.downloadPython')}
|
||||
</button>
|
||||
)}
|
||||
{!envReady && status.python.installed && (
|
||||
<button
|
||||
onClick={handleSetup}
|
||||
|
||||
@ -25,6 +25,15 @@ os.environ.setdefault("PYAUTOGUI_HIDE_SUPPORT_PROMPT", "1")
|
||||
|
||||
import pyautogui # noqa: E402
|
||||
|
||||
# The desktop app decodes helper stdout as UTF-8. On Windows, redirected Python
|
||||
# stdout defaults to the active ANSI code page (for example GBK), which mangles
|
||||
# localized app names from the registry. Force UTF-8 at process start so JSON
|
||||
# responses stay stable regardless of the user's system locale.
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="strict")
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
pyautogui.FAILSAFE = False
|
||||
pyautogui.PAUSE = 0
|
||||
|
||||
@ -311,13 +320,24 @@ def installed_apps() -> list[dict[str, Any]]:
|
||||
display_icon = winreg.QueryValueEx(app_key, "DisplayIcon")[0]
|
||||
except OSError:
|
||||
display_icon = ""
|
||||
# Use registry key name as bundleId equivalent
|
||||
normalized_icon = str(display_icon).split(",")[0].strip().strip('"')
|
||||
normalized_install_location = str(install_location).strip().strip('"')
|
||||
|
||||
bundle_id = name
|
||||
for candidate in (normalized_icon, normalized_install_location):
|
||||
if not candidate:
|
||||
continue
|
||||
candidate_path = Path(candidate)
|
||||
if candidate_path.suffix.lower() == ".exe":
|
||||
bundle_id = candidate_path.stem
|
||||
break
|
||||
|
||||
app_path = normalized_icon or normalized_install_location or ""
|
||||
if bundle_id not in results:
|
||||
results[bundle_id] = {
|
||||
"bundleId": bundle_id,
|
||||
"displayName": str(display_name),
|
||||
"path": str(install_location or display_icon or ""),
|
||||
"path": app_path,
|
||||
}
|
||||
winreg.CloseKey(app_key)
|
||||
finally:
|
||||
@ -469,17 +489,43 @@ def open_app(bundle_id: str) -> None:
|
||||
for hive, sub_key in reg_paths:
|
||||
try:
|
||||
key = winreg.OpenKey(hive, sub_key)
|
||||
app_key = winreg.OpenKey(key, bundle_id)
|
||||
try:
|
||||
exe_path = winreg.QueryValueEx(app_key, "DisplayIcon")[0]
|
||||
if exe_path and "," in exe_path:
|
||||
exe_path = exe_path.split(",")[0]
|
||||
except OSError:
|
||||
i = 0
|
||||
while True:
|
||||
try:
|
||||
exe_path = winreg.QueryValueEx(app_key, "InstallLocation")[0]
|
||||
name = winreg.EnumKey(key, i)
|
||||
i += 1
|
||||
except OSError:
|
||||
pass
|
||||
winreg.CloseKey(app_key)
|
||||
break
|
||||
try:
|
||||
app_key = winreg.OpenKey(key, name)
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
display_icon = winreg.QueryValueEx(app_key, "DisplayIcon")[0]
|
||||
except OSError:
|
||||
display_icon = ""
|
||||
try:
|
||||
install_location = winreg.QueryValueEx(app_key, "InstallLocation")[0]
|
||||
except OSError:
|
||||
install_location = ""
|
||||
|
||||
normalized_icon = str(display_icon).split(",")[0].strip().strip('"')
|
||||
normalized_install_location = str(install_location).strip().strip('"')
|
||||
|
||||
derived_bundle_id = name
|
||||
for candidate in (normalized_icon, normalized_install_location):
|
||||
if not candidate:
|
||||
continue
|
||||
candidate_path = Path(candidate)
|
||||
if candidate_path.suffix.lower() == ".exe":
|
||||
derived_bundle_id = candidate_path.stem
|
||||
break
|
||||
|
||||
if name == bundle_id or derived_bundle_id == bundle_id:
|
||||
exe_path = normalized_icon or normalized_install_location or None
|
||||
winreg.CloseKey(app_key)
|
||||
break
|
||||
winreg.CloseKey(app_key)
|
||||
winreg.CloseKey(key)
|
||||
if exe_path:
|
||||
break
|
||||
|
||||
@ -1605,7 +1605,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
// `type: 'stdio'`. An enterprise-config ant with the GB gate on would
|
||||
// otherwise process.exit(1). Chrome has the same latent issue but has
|
||||
// shipped without incident; chicago places itself correctly.
|
||||
if (getPlatform() === 'macos') {
|
||||
if (process.platform === 'darwin' || process.platform === 'win32') {
|
||||
try {
|
||||
const {
|
||||
getChicagoEnabled
|
||||
|
||||
75
src/server/__tests__/computer-use-python.test.ts
Normal file
75
src/server/__tests__/computer-use-python.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { detectPythonRuntime } from '../api/computer-use-python.js'
|
||||
|
||||
describe('detectPythonRuntime', () => {
|
||||
test('prefers python3 on Windows when available', async () => {
|
||||
const calls: string[] = []
|
||||
const result = await detectPythonRuntime(
|
||||
'win32',
|
||||
async (cmd, args) => {
|
||||
calls.push(`${cmd} ${args.join(' ')}`.trim())
|
||||
if (cmd === 'python3' && args.join(' ') === '--version') {
|
||||
return { ok: true, stdout: 'Python 3.12.11', stderr: '', code: 0 }
|
||||
}
|
||||
if (cmd === 'where' && args[0] === 'python3') {
|
||||
return { ok: true, stdout: 'C:\\Python312\\python3.exe', stderr: '', code: 0 }
|
||||
}
|
||||
return { ok: false, stdout: '', stderr: '', code: 1 }
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.installed).toBe(true)
|
||||
expect(result.version).toBe('3.12.11')
|
||||
expect(result.path).toBe('C:\\Python312\\python3.exe')
|
||||
expect(result.command).toBe('python3')
|
||||
expect(result.prefixArgs).toEqual([])
|
||||
expect(result.source).toBe('system')
|
||||
expect(calls).toEqual(['python3 --version', 'where python3'])
|
||||
})
|
||||
|
||||
test('falls back to py -3 on Windows', async () => {
|
||||
const result = await detectPythonRuntime(
|
||||
'win32',
|
||||
async (cmd, args) => {
|
||||
if (cmd === 'python3' || cmd === 'python') {
|
||||
return { ok: false, stdout: '', stderr: '', code: 1 }
|
||||
}
|
||||
if (cmd === 'py' && args.join(' ') === '-3 --version') {
|
||||
return { ok: true, stdout: '', stderr: 'Python 3.12.11', code: 0 }
|
||||
}
|
||||
if (cmd === 'where' && args[0] === 'py') {
|
||||
return { ok: true, stdout: 'C:\\Windows\\py.exe', stderr: '', code: 0 }
|
||||
}
|
||||
return { ok: false, stdout: '', stderr: '', code: 1 }
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.installed).toBe(true)
|
||||
expect(result.version).toBe('3.12.11')
|
||||
expect(result.path).toBe('C:\\Windows\\py.exe')
|
||||
expect(result.command).toBe('py')
|
||||
expect(result.prefixArgs).toEqual(['-3'])
|
||||
expect(result.source).toBe('system')
|
||||
})
|
||||
|
||||
test('falls back to venv python when system python is not discoverable', async () => {
|
||||
const venvPython = 'C:\\Users\\Relakkes\\.claude\\.runtime\\venv\\Scripts\\python.exe'
|
||||
const result = await detectPythonRuntime(
|
||||
'win32',
|
||||
async (cmd, args) => {
|
||||
if (cmd === venvPython && args.join(' ') === '--version') {
|
||||
return { ok: true, stdout: 'Python 3.12.11', stderr: '', code: 0 }
|
||||
}
|
||||
return { ok: false, stdout: '', stderr: '', code: 1 }
|
||||
},
|
||||
venvPython,
|
||||
)
|
||||
|
||||
expect(result.installed).toBe(true)
|
||||
expect(result.version).toBe('3.12.11')
|
||||
expect(result.path).toBe(venvPython)
|
||||
expect(result.command).toBe(venvPython)
|
||||
expect(result.prefixArgs).toEqual([])
|
||||
expect(result.source).toBe('venv')
|
||||
})
|
||||
})
|
||||
131
src/server/api/computer-use-python.ts
Normal file
131
src/server/api/computer-use-python.ts
Normal file
@ -0,0 +1,131 @@
|
||||
export type CommandResult = {
|
||||
ok: boolean
|
||||
stdout: string
|
||||
stderr: string
|
||||
code: number
|
||||
}
|
||||
|
||||
export type CommandRunner = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
) => Promise<CommandResult>
|
||||
|
||||
type PythonCandidate = {
|
||||
command: string
|
||||
prefixArgs: string[]
|
||||
locator: {
|
||||
command: string
|
||||
args: string[]
|
||||
} | null
|
||||
}
|
||||
|
||||
export type PythonRuntimeResolution = {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
path: string | null
|
||||
command: string | null
|
||||
prefixArgs: string[]
|
||||
source: 'system' | 'venv' | null
|
||||
}
|
||||
|
||||
function getPythonCandidates(platform: NodeJS.Platform): PythonCandidate[] {
|
||||
if (platform === 'win32') {
|
||||
return [
|
||||
{
|
||||
command: 'python3',
|
||||
prefixArgs: [],
|
||||
locator: { command: 'where', args: ['python3'] },
|
||||
},
|
||||
{
|
||||
command: 'python',
|
||||
prefixArgs: [],
|
||||
locator: { command: 'where', args: ['python'] },
|
||||
},
|
||||
{
|
||||
command: 'py',
|
||||
prefixArgs: ['-3'],
|
||||
locator: { command: 'where', args: ['py'] },
|
||||
},
|
||||
{
|
||||
command: 'py',
|
||||
prefixArgs: [],
|
||||
locator: { command: 'where', args: ['py'] },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
command: 'python3',
|
||||
prefixArgs: [],
|
||||
locator: { command: 'which', args: ['python3'] },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function extractPythonVersion(output: string): string | null {
|
||||
const match = output.match(/Python\s+([0-9][^\s]*)/i)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function firstOutputLine(output: string): string | null {
|
||||
const line = output
|
||||
.split(/\r?\n/)
|
||||
.map(value => value.trim())
|
||||
.find(Boolean)
|
||||
return line ?? null
|
||||
}
|
||||
|
||||
async function locateCandidatePath(
|
||||
candidate: PythonCandidate,
|
||||
runCommand: CommandRunner,
|
||||
): Promise<string | null> {
|
||||
if (!candidate.locator) return null
|
||||
const locateResult = await runCommand(candidate.locator.command, candidate.locator.args)
|
||||
if (!locateResult.ok) return null
|
||||
return firstOutputLine(locateResult.stdout)
|
||||
}
|
||||
|
||||
export async function detectPythonRuntime(
|
||||
platform: NodeJS.Platform,
|
||||
runCommand: CommandRunner,
|
||||
venvPythonPath?: string,
|
||||
): Promise<PythonRuntimeResolution> {
|
||||
for (const candidate of getPythonCandidates(platform)) {
|
||||
const versionResult = await runCommand(candidate.command, [...candidate.prefixArgs, '--version'])
|
||||
if (!versionResult.ok) continue
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
version: extractPythonVersion(`${versionResult.stdout}\n${versionResult.stderr}`),
|
||||
path: await locateCandidatePath(candidate, runCommand),
|
||||
command: candidate.command,
|
||||
prefixArgs: candidate.prefixArgs,
|
||||
source: 'system',
|
||||
}
|
||||
}
|
||||
|
||||
if (venvPythonPath) {
|
||||
const venvResult = await runCommand(venvPythonPath, ['--version'])
|
||||
if (venvResult.ok) {
|
||||
return {
|
||||
installed: true,
|
||||
version: extractPythonVersion(`${venvResult.stdout}\n${venvResult.stderr}`),
|
||||
path: venvPythonPath,
|
||||
command: venvPythonPath,
|
||||
prefixArgs: [],
|
||||
source: 'venv',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
command: null,
|
||||
prefixArgs: [],
|
||||
source: null,
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,8 @@ import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import type { CuPermissionRequest } from '../../vendor/computer-use-mcp/types.js'
|
||||
import { computerUseApprovalService } from '../services/computerUseApprovalService.js'
|
||||
import { detectPythonRuntime } from './computer-use-python.js'
|
||||
import { DEFAULT_DESKTOP_GRANT_FLAGS } from '../../utils/computerUse/preauthorizedConfig.js'
|
||||
// Embed helper scripts at compile time so they're available in bundled mode
|
||||
// @ts-ignore — Bun text import
|
||||
import MAC_HELPER_CONTENT from '../../../runtime/mac_helper.py' with { type: 'text' }
|
||||
@ -49,6 +51,15 @@ screeninfo>=0.8.1
|
||||
const isWindows = process.platform === 'win32'
|
||||
const REQUIREMENTS_CONTENT = isWindows ? REQUIREMENTS_WIN32 : REQUIREMENTS_DARWIN
|
||||
|
||||
function getPythonCommandEnv(): Record<string, string> | undefined {
|
||||
if (!isWindows) return undefined
|
||||
return {
|
||||
...process.env,
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1',
|
||||
} as Record<string, string>
|
||||
}
|
||||
|
||||
// 清华大学 PyPI 镜像,国内安装速度更快
|
||||
const PIP_INDEX_URL = 'https://pypi.tuna.tsinghua.edu.cn/simple/'
|
||||
const PIP_TRUSTED_HOST = 'pypi.tuna.tsinghua.edu.cn'
|
||||
@ -83,6 +94,7 @@ async function runCommand(
|
||||
const proc = Bun.spawn([cmd, ...args], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: getPythonCommandEnv(),
|
||||
})
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
@ -139,27 +151,14 @@ async function checkStatus(): Promise<EnvStatus> {
|
||||
const platform = process.platform
|
||||
const supported = platform === 'darwin' || platform === 'win32'
|
||||
|
||||
// Check Python 3 — Windows may only have `python`, not `python3`
|
||||
const pythonCmd = isWindows ? 'python' : 'python3'
|
||||
const pythonResult = await runCommand(pythonCmd, ['--version'])
|
||||
const pythonInstalled = pythonResult.ok
|
||||
const pythonVersion = pythonInstalled
|
||||
? pythonResult.stdout.replace('Python ', '')
|
||||
: null
|
||||
|
||||
let pythonPath: string | null = null
|
||||
if (pythonInstalled) {
|
||||
const whichCmd = isWindows ? 'where' : 'which'
|
||||
const whichResult = await runCommand(whichCmd, [pythonCmd])
|
||||
pythonPath = whichResult.ok ? whichResult.stdout.split('\n')[0] : null
|
||||
}
|
||||
|
||||
// Check venv — different paths on Windows vs Unix
|
||||
const venvPython = isWindows
|
||||
? join(venvRoot, 'Scripts', 'python.exe')
|
||||
: join(venvRoot, 'bin', 'python3')
|
||||
const venvCreated = await pathExists(venvPython)
|
||||
|
||||
const pythonRuntime = await detectPythonRuntime(platform, runCommand, venvCreated ? venvPython : undefined)
|
||||
|
||||
// Check dependencies — use the state dir copy
|
||||
const reqPath = getRequirementsPath()
|
||||
const requirementsFound = await pathExists(reqPath)
|
||||
@ -200,7 +199,11 @@ async function checkStatus(): Promise<EnvStatus> {
|
||||
return {
|
||||
platform,
|
||||
supported,
|
||||
python: { installed: pythonInstalled, version: pythonVersion, path: pythonPath },
|
||||
python: {
|
||||
installed: pythonRuntime.installed,
|
||||
version: pythonRuntime.version,
|
||||
path: pythonRuntime.path,
|
||||
},
|
||||
venv: { created: venvCreated, path: venvRoot },
|
||||
dependencies: { installed: depsInstalled, requirementsFound: requirementsFound || true },
|
||||
permissions: { accessibility, screenRecording },
|
||||
@ -215,10 +218,18 @@ type SetupResult = {
|
||||
async function runSetup(): Promise<SetupResult> {
|
||||
const steps: SetupResult['steps'] = []
|
||||
|
||||
const venvPython = isWindows
|
||||
? join(venvRoot, 'Scripts', 'python.exe')
|
||||
: join(venvRoot, 'bin', 'python3')
|
||||
const venvExists = await pathExists(venvPython)
|
||||
|
||||
// Step 1: Check python
|
||||
const pythonCmd = isWindows ? 'python' : 'python3'
|
||||
const pythonCheck = await runCommand(pythonCmd, ['--version'])
|
||||
if (!pythonCheck.ok) {
|
||||
const pythonRuntime = await detectPythonRuntime(
|
||||
process.platform,
|
||||
runCommand,
|
||||
venvExists ? venvPython : undefined,
|
||||
)
|
||||
if (!pythonRuntime.installed) {
|
||||
steps.push({
|
||||
name: 'python_check',
|
||||
ok: false,
|
||||
@ -229,7 +240,9 @@ async function runSetup(): Promise<SetupResult> {
|
||||
steps.push({
|
||||
name: 'python_check',
|
||||
ok: true,
|
||||
message: `Python ${pythonCheck.stdout.replace('Python ', '')}`,
|
||||
message: pythonRuntime.source === 'venv'
|
||||
? `Python ${pythonRuntime.version}(使用现有虚拟环境)`
|
||||
: `Python ${pythonRuntime.version}`,
|
||||
})
|
||||
|
||||
// Step 2: Extract runtime files to ~/.claude/.runtime/
|
||||
@ -246,12 +259,21 @@ async function runSetup(): Promise<SetupResult> {
|
||||
}
|
||||
|
||||
// Step 3: Create venv
|
||||
const venvPython = isWindows
|
||||
? join(venvRoot, 'Scripts', 'python.exe')
|
||||
: join(venvRoot, 'bin', 'python3')
|
||||
const venvExists = await pathExists(venvPython)
|
||||
if (!venvExists) {
|
||||
const venvResult = await runCommand(pythonCmd, ['-m', 'venv', venvRoot])
|
||||
if (!pythonRuntime.command) {
|
||||
steps.push({
|
||||
name: 'venv',
|
||||
ok: false,
|
||||
message: '未找到可用于创建虚拟环境的 Python 命令',
|
||||
})
|
||||
return { success: false, steps }
|
||||
}
|
||||
const venvResult = await runCommand(pythonRuntime.command, [
|
||||
...pythonRuntime.prefixArgs,
|
||||
'-m',
|
||||
'venv',
|
||||
venvRoot,
|
||||
])
|
||||
if (!venvResult.ok) {
|
||||
steps.push({
|
||||
name: 'venv',
|
||||
@ -354,7 +376,7 @@ type RequestAccessBody = {
|
||||
|
||||
const DEFAULT_CONFIG: ComputerUseConfig = {
|
||||
authorizedApps: [],
|
||||
grantFlags: { clipboardRead: true, clipboardWrite: true, systemKeyCombos: true },
|
||||
grantFlags: DEFAULT_DESKTOP_GRANT_FLAGS,
|
||||
}
|
||||
|
||||
async function loadConfig(): Promise<ComputerUseConfig> {
|
||||
|
||||
28
src/utils/computerUse/common.test.ts
Normal file
28
src/utils/computerUse/common.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
|
||||
import {
|
||||
getCliComputerUseCapabilities,
|
||||
isComputerUseSupportedPlatform,
|
||||
} from './common.js'
|
||||
|
||||
describe('computer use platform helpers', () => {
|
||||
it('recognizes supported platforms', () => {
|
||||
expect(isComputerUseSupportedPlatform('darwin')).toBe(true)
|
||||
expect(isComputerUseSupportedPlatform('win32')).toBe(true)
|
||||
expect(isComputerUseSupportedPlatform('linux')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns macOS capabilities with native screenshot filtering', () => {
|
||||
expect(getCliComputerUseCapabilities('darwin')).toEqual({
|
||||
screenshotFiltering: 'native',
|
||||
platform: 'darwin',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns Windows capabilities with unfiltered screenshots', () => {
|
||||
expect(getCliComputerUseCapabilities('win32')).toEqual({
|
||||
screenshotFiltering: 'none',
|
||||
platform: 'win32',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -2,6 +2,13 @@ import { normalizeNameForMCP } from '../../services/mcp/normalization.js'
|
||||
import { env } from '../env.js'
|
||||
|
||||
export const COMPUTER_USE_MCP_SERVER_NAME = 'computer-use'
|
||||
export const CLI_HOST_PLATFORM_BUNDLE_ID = 'com.anthropic.claude-code.cli-no-window'
|
||||
|
||||
export function isComputerUseSupportedPlatform(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): platform is 'darwin' | 'win32' {
|
||||
return platform === 'darwin' || platform === 'win32'
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel bundle ID for the frontmost gate. Claude Code is a terminal — it has
|
||||
@ -10,13 +17,12 @@ export const COMPUTER_USE_MCP_SERVER_NAME = 'computer-use'
|
||||
* keyboard safety-net) is dead code for us. `prepareForAction`'s "exempt our
|
||||
* own window" is likewise a no-op — there is no window to exempt.
|
||||
*/
|
||||
export const CLI_HOST_BUNDLE_ID = 'com.anthropic.claude-code.cli-no-window'
|
||||
export const CLI_HOST_BUNDLE_ID = CLI_HOST_PLATFORM_BUNDLE_ID
|
||||
|
||||
/**
|
||||
* Fallback `env.terminal` → bundleId map for when `__CFBundleIdentifier` is
|
||||
* unset. Covers the macOS terminals we can distinguish — Linux entries
|
||||
* (konsole, gnome-terminal, xterm) are deliberately absent since
|
||||
* `createCliExecutor` is darwin-guarded.
|
||||
* unset. Covers the macOS terminals we can distinguish. On Windows the host is
|
||||
* always the CLI sentinel above, so this table remains macOS-specific.
|
||||
*/
|
||||
const TERMINAL_BUNDLE_ID_FALLBACK: Readonly<Record<string, string>> = {
|
||||
'iTerm.app': 'com.googlecode.iterm2',
|
||||
@ -47,13 +53,32 @@ export function getTerminalBundleId(): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Static capabilities for macOS CLI. `hostBundleId` is not here — it's added
|
||||
* by `executor.ts` per `ComputerExecutor.capabilities`. `buildComputerUseTools`
|
||||
* takes this shape (no `hostBundleId`, no `teachMode`).
|
||||
* CLI computer-use capabilities by platform. `hostBundleId` is not here —
|
||||
* it's added by `executor.ts` per `ComputerExecutor.capabilities`.
|
||||
*/
|
||||
export const CLI_CU_CAPABILITIES = {
|
||||
screenshotFiltering: 'native' as const,
|
||||
platform: 'darwin' as const,
|
||||
export function getCliComputerUseCapabilities(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): {
|
||||
screenshotFiltering: 'native' | 'none'
|
||||
platform: 'darwin' | 'win32'
|
||||
} {
|
||||
if (platform === 'darwin') {
|
||||
return {
|
||||
screenshotFiltering: 'native',
|
||||
platform: 'darwin',
|
||||
}
|
||||
}
|
||||
|
||||
if (platform !== 'win32') {
|
||||
throw new Error(
|
||||
`Computer Use is only supported on macOS and Windows (received ${platform}).`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
screenshotFiltering: 'none',
|
||||
platform: 'win32',
|
||||
}
|
||||
}
|
||||
|
||||
export function isComputerUseMCPServer(name: string): boolean {
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
* CLI `ComputerExecutor` implementation — Python bridge variant.
|
||||
*
|
||||
* Replaces the native Swift/Rust modules with a Python subprocess bridge
|
||||
* (pyautogui + mss + pyobjc). See `pythonBridge.ts` and `runtime/mac_helper.py`.
|
||||
* (pyautogui + mss + platform helpers). See `pythonBridge.ts` and
|
||||
* `runtime/{mac,win}_helper.py`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
@ -16,7 +17,11 @@ import type {
|
||||
} from '../../vendor/computer-use-mcp/index.js'
|
||||
import { API_RESIZE_PARAMS, targetImageSize } from '../../vendor/computer-use-mcp/index.js'
|
||||
import { sleep } from '../sleep.js'
|
||||
import { CLI_CU_CAPABILITIES, CLI_HOST_BUNDLE_ID } from './common.js'
|
||||
import {
|
||||
CLI_HOST_BUNDLE_ID,
|
||||
getCliComputerUseCapabilities,
|
||||
isComputerUseSupportedPlatform,
|
||||
} from './common.js'
|
||||
import { callPythonHelper } from './pythonBridge.js'
|
||||
|
||||
const SCREENSHOT_JPEG_QUALITY = 0.75
|
||||
@ -56,23 +61,48 @@ async function writeClipboardViaPbcopy(text: string): Promise<void> {
|
||||
await callPythonHelper('write_clipboard', { text })
|
||||
}
|
||||
|
||||
async function readClipboard(): Promise<string> {
|
||||
if (process.platform === 'win32') {
|
||||
return callPythonHelper<string>('read_clipboard', {})
|
||||
}
|
||||
|
||||
return readClipboardViaPbpaste()
|
||||
}
|
||||
|
||||
async function writeClipboard(text: string): Promise<void> {
|
||||
if (process.platform === 'win32') {
|
||||
await callPythonHelper('write_clipboard', { text })
|
||||
return
|
||||
}
|
||||
|
||||
await writeClipboardViaPbcopy(text)
|
||||
}
|
||||
|
||||
async function typeViaClipboard(text: string): Promise<void> {
|
||||
let saved: string | undefined
|
||||
try {
|
||||
saved = await readClipboardViaPbpaste()
|
||||
saved = await readClipboard()
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await writeClipboardViaPbcopy(text)
|
||||
// Give NSPasteboard a beat before paste, then keep the new contents
|
||||
// resident long enough for Electron/WebView fields to consume them.
|
||||
await sleep(40)
|
||||
await callPythonHelper('paste_clipboard', {})
|
||||
await sleep(180)
|
||||
await writeClipboard(text)
|
||||
if (process.platform === 'darwin') {
|
||||
// Give NSPasteboard a beat before paste, then keep the new contents
|
||||
// resident long enough for Electron/WebView fields to consume them.
|
||||
await sleep(40)
|
||||
await callPythonHelper('paste_clipboard', {})
|
||||
await sleep(180)
|
||||
} else {
|
||||
await callPythonHelper('key', {
|
||||
keySequence: 'ctrl+v',
|
||||
repeat: 1,
|
||||
})
|
||||
await sleep(100)
|
||||
}
|
||||
} finally {
|
||||
if (typeof saved === 'string') {
|
||||
try {
|
||||
await writeClipboardViaPbcopy(saved)
|
||||
await writeClipboard(saved)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@ -82,21 +112,23 @@ export function createCliExecutor(_opts: {
|
||||
getMouseAnimationEnabled: () => boolean
|
||||
getHideBeforeActionEnabled: () => boolean
|
||||
}): ComputerExecutor {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error(`createCliExecutor called on ${process.platform}. Computer control is macOS-only.`)
|
||||
if (!isComputerUseSupportedPlatform()) {
|
||||
throw new Error(
|
||||
`createCliExecutor called on ${process.platform}. Computer control is only supported on macOS and Windows.`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
capabilities: {
|
||||
...CLI_CU_CAPABILITIES,
|
||||
...getCliComputerUseCapabilities(),
|
||||
hostBundleId,
|
||||
},
|
||||
|
||||
async prepareForAction(): Promise<string[]> {
|
||||
async prepareForAction(_allowlistBundleIds, _displayId): Promise<string[]> {
|
||||
return callPythonHelper('prepare_for_action', {})
|
||||
},
|
||||
|
||||
async previewHideSet() {
|
||||
async previewHideSet(_allowlistBundleIds, _displayId) {
|
||||
return callPythonHelper('preview_hide_set', {})
|
||||
},
|
||||
|
||||
@ -170,8 +202,8 @@ export function createCliExecutor(_opts: {
|
||||
await callPythonHelper('type', { text })
|
||||
},
|
||||
|
||||
readClipboard: readClipboardViaPbpaste,
|
||||
writeClipboard: writeClipboardViaPbcopy,
|
||||
readClipboard,
|
||||
writeClipboard,
|
||||
|
||||
async click(x, y, button, count, modifiers): Promise<void> {
|
||||
await callPythonHelper('click', { x, y, button, count, modifiers })
|
||||
|
||||
32
src/utils/computerUse/preauthorizedConfig.test.ts
Normal file
32
src/utils/computerUse/preauthorizedConfig.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
DEFAULT_DESKTOP_GRANT_FLAGS,
|
||||
resolveStoredComputerUseConfig,
|
||||
} from './preauthorizedConfig.js'
|
||||
|
||||
describe('resolveStoredComputerUseConfig', () => {
|
||||
test('keeps desktop grant flags enabled by default even without authorized apps', () => {
|
||||
expect(resolveStoredComputerUseConfig()).toEqual({
|
||||
authorizedApps: [],
|
||||
grantFlags: DEFAULT_DESKTOP_GRANT_FLAGS,
|
||||
})
|
||||
})
|
||||
|
||||
test('merges stored grant flags without discarding unspecified defaults', () => {
|
||||
expect(
|
||||
resolveStoredComputerUseConfig({
|
||||
grantFlags: {
|
||||
clipboardRead: false,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
authorizedApps: [],
|
||||
grantFlags: {
|
||||
clipboardRead: false,
|
||||
clipboardWrite: true,
|
||||
systemKeyCombos: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
33
src/utils/computerUse/preauthorizedConfig.ts
Normal file
33
src/utils/computerUse/preauthorizedConfig.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { CuGrantFlags } from '../../vendor/computer-use-mcp/types.js'
|
||||
|
||||
export type StoredAuthorizedApp = {
|
||||
bundleId: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export type StoredComputerUseConfig = {
|
||||
authorizedApps?: StoredAuthorizedApp[]
|
||||
grantFlags?: Partial<CuGrantFlags>
|
||||
}
|
||||
|
||||
export const DEFAULT_DESKTOP_GRANT_FLAGS: CuGrantFlags = {
|
||||
clipboardRead: true,
|
||||
clipboardWrite: true,
|
||||
systemKeyCombos: true,
|
||||
}
|
||||
|
||||
export function resolveStoredComputerUseConfig(
|
||||
config?: StoredComputerUseConfig,
|
||||
): {
|
||||
authorizedApps: StoredAuthorizedApp[]
|
||||
grantFlags: CuGrantFlags
|
||||
} {
|
||||
return {
|
||||
authorizedApps: config?.authorizedApps ?? [],
|
||||
grantFlags: {
|
||||
...DEFAULT_DESKTOP_GRANT_FLAGS,
|
||||
...(config?.grantFlags ?? {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +28,15 @@ const helperPath = path.join(runtimeStateRoot, helperFileName)
|
||||
|
||||
let bootstrapPromise: Promise<void> | undefined
|
||||
|
||||
function getPythonCommandEnv(): NodeJS.ProcessEnv | undefined {
|
||||
if (!isWindows) return undefined
|
||||
return {
|
||||
...process.env,
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1',
|
||||
}
|
||||
}
|
||||
|
||||
function pythonBinPath(): string {
|
||||
return isWindows
|
||||
? path.join(venvRoot, 'Scripts', 'python.exe')
|
||||
@ -131,7 +140,7 @@ export async function callPythonHelper<T>(command: string, payload: Record<strin
|
||||
const { code, stdout, stderr } = await execFileNoThrow(
|
||||
pythonBinPath(),
|
||||
[helperPath, command, '--payload', JSON.stringify(payload)],
|
||||
{ useCwd: false },
|
||||
{ useCwd: false, env: getPythonCommandEnv() },
|
||||
)
|
||||
|
||||
if (code !== 0 && !stdout.trim()) {
|
||||
|
||||
@ -5,7 +5,10 @@ import { buildMcpToolName } from '../../services/mcp/mcpStringUtils.js'
|
||||
import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'
|
||||
|
||||
import { isInBundledMode } from '../bundledMode.js'
|
||||
import { CLI_CU_CAPABILITIES, COMPUTER_USE_MCP_SERVER_NAME } from './common.js'
|
||||
import {
|
||||
COMPUTER_USE_MCP_SERVER_NAME,
|
||||
getCliComputerUseCapabilities,
|
||||
} from './common.js'
|
||||
import { getChicagoCoordinateMode } from './gates.js'
|
||||
|
||||
/**
|
||||
@ -25,7 +28,7 @@ export function setupComputerUseMCP(): {
|
||||
allowedTools: string[]
|
||||
} {
|
||||
const allowedTools = buildComputerUseTools(
|
||||
CLI_CU_CAPABILITIES,
|
||||
getCliComputerUseCapabilities(),
|
||||
getChicagoCoordinateMode(),
|
||||
).map(t => buildMcpToolName(COMPUTER_USE_MCP_SERVER_NAME, t.name))
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ import { registerEscHotkey } from './escHotkey.js';
|
||||
import { getChicagoCoordinateMode } from './gates.js';
|
||||
import { getComputerUseHostAdapter } from './hostAdapter.js';
|
||||
import { getComputerUseMCPRenderingOverrides } from './toolRendering.js';
|
||||
import { resolveStoredComputerUseConfig } from './preauthorizedConfig.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
@ -269,6 +270,13 @@ async function runDesktopPermissionDialog(
|
||||
* immediately — no runtime permission dialog needed.
|
||||
*/
|
||||
async function loadPreAuthorizedApps(): Promise<void> {
|
||||
let config:
|
||||
| {
|
||||
authorizedApps?: { bundleId: string; displayName: string }[]
|
||||
grantFlags?: { clipboardRead?: boolean; clipboardWrite?: boolean; systemKeyCombos?: boolean }
|
||||
}
|
||||
| undefined
|
||||
|
||||
try {
|
||||
const configPath = join(
|
||||
process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'),
|
||||
@ -276,46 +284,57 @@ async function loadPreAuthorizedApps(): Promise<void> {
|
||||
'computer-use-config.json',
|
||||
)
|
||||
const raw = await readFile(configPath, 'utf8')
|
||||
const config = JSON.parse(raw) as {
|
||||
authorizedApps?: { bundleId: string; displayName: string }[]
|
||||
grantFlags?: { clipboardRead?: boolean; clipboardWrite?: boolean; systemKeyCombos?: boolean }
|
||||
}
|
||||
|
||||
if (!config.authorizedApps?.length) return
|
||||
|
||||
const apps = config.authorizedApps.map(a => ({
|
||||
bundleId: a.bundleId,
|
||||
displayName: a.displayName,
|
||||
grantedAt: Date.now(),
|
||||
tier: 'full' as const,
|
||||
}))
|
||||
const flags = {
|
||||
...DEFAULT_GRANT_FLAGS,
|
||||
...(config.grantFlags ?? {}),
|
||||
}
|
||||
|
||||
// Inject into appState so getAllowedApps() returns them.
|
||||
// Merge with existing allowedApps (from permission dialog) instead of replacing.
|
||||
if (currentToolUseContext) {
|
||||
currentToolUseContext.setAppState(prev => {
|
||||
const existing = prev.computerUseMcpState?.allowedApps ?? []
|
||||
const existingIds = new Set(existing.map(a => a.bundleId))
|
||||
const merged = [...existing, ...apps.filter(a => !existingIds.has(a.bundleId))]
|
||||
return {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...prev.computerUseMcpState,
|
||||
allowedApps: merged,
|
||||
grantFlags: flags,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logForDebugging(`[Computer Use] Loaded ${apps.length} pre-authorized apps from config`)
|
||||
config = JSON.parse(raw) as typeof config
|
||||
} catch {
|
||||
// Config doesn't exist or is invalid — no pre-authorized apps
|
||||
// Config doesn't exist yet — still honor desktop defaults for grant flags.
|
||||
}
|
||||
|
||||
if (!currentToolUseContext) {
|
||||
return
|
||||
}
|
||||
|
||||
const resolved = resolveStoredComputerUseConfig(config)
|
||||
const apps = resolved.authorizedApps.map(a => ({
|
||||
bundleId: a.bundleId,
|
||||
displayName: a.displayName,
|
||||
grantedAt: Date.now(),
|
||||
tier: 'full' as const,
|
||||
}))
|
||||
const flags = {
|
||||
...DEFAULT_GRANT_FLAGS,
|
||||
...resolved.grantFlags,
|
||||
}
|
||||
|
||||
// Inject into appState so getAllowedApps()/getGrantFlags() return persisted
|
||||
// desktop settings immediately, even when no apps are pre-authorized yet.
|
||||
currentToolUseContext.setAppState(prev => {
|
||||
const existing = prev.computerUseMcpState?.allowedApps ?? []
|
||||
const existingIds = new Set(existing.map(a => a.bundleId))
|
||||
const merged = [...existing, ...apps.filter(a => !existingIds.has(a.bundleId))]
|
||||
const currentFlags = prev.computerUseMcpState?.grantFlags
|
||||
const sameFlags =
|
||||
currentFlags?.clipboardRead === flags.clipboardRead &&
|
||||
currentFlags?.clipboardWrite === flags.clipboardWrite &&
|
||||
currentFlags?.systemKeyCombos === flags.systemKeyCombos
|
||||
const sameApps = existing.length === merged.length
|
||||
|
||||
if (sameFlags && sameApps) {
|
||||
return prev
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...prev.computerUseMcpState,
|
||||
allowedApps: merged,
|
||||
grantFlags: flags,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
logForDebugging(
|
||||
`[Computer Use] Loaded ${apps.length} pre-authorized apps and grant flags from config`,
|
||||
)
|
||||
}
|
||||
|
||||
let preAuthLoaded = false
|
||||
|
||||
11
src/vendor/computer-use-mcp/toolCalls.ts
vendored
11
src/vendor/computer-use-mcp/toolCalls.ts
vendored
@ -2431,13 +2431,18 @@ async function handleType(
|
||||
// §6 item 3 — clipboard-paste fast path. On macOS we also prefer the
|
||||
// clipboard path for ordinary text when clipboardWrite is granted, because
|
||||
// IME/input-source state can corrupt keystroke-by-keystroke typing even for
|
||||
// plain ASCII. The save/restore + read-back-verify lives in the EXECUTOR,
|
||||
// not here. Here we just route.
|
||||
// plain ASCII. On Windows, non-ASCII text (Chinese, Japanese, emoji, etc.)
|
||||
// is much more reliable through paste than through synthetic key events,
|
||||
// which otherwise go through the active IME and can land in the wrong field
|
||||
// or composition state. The save/restore lives in the EXECUTOR, not here.
|
||||
const hasNonControlText = /[^\r\n\t]/u.test(text);
|
||||
const hasNonAsciiText = /[^\u0000-\u007f]/u.test(text);
|
||||
const viaClipboard =
|
||||
(text.includes("\n") ||
|
||||
(adapter.executor.capabilities.platform === "darwin" &&
|
||||
hasNonControlText)) &&
|
||||
hasNonControlText) ||
|
||||
(adapter.executor.capabilities.platform === "win32" &&
|
||||
hasNonAsciiText)) &&
|
||||
overrides.grantFlags.clipboardWrite &&
|
||||
subGates.clipboardPasteMultiline;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user