mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Computer Use setup could fail on machines where PATH discovery misses a valid Python installation, especially Windows or conda-style environments. Store an optional interpreter path, prefer it during environment checks and venv creation, and expose a desktop settings control for selecting or clearing it. Constraint: Python discovery is environment-specific and cannot always be inferred from PATH. Rejected: Continue falling back to PATH after an invalid custom path | hides a user-selected broken interpreter and makes diagnosis ambiguous. Confidence: high Scope-risk: moderate Directive: Preserve unknown Computer Use config fields and keep blank interpreter paths normalized to automatic detection. Tested: bun test src/utils/computerUse/preauthorizedConfig.test.ts src/server/__tests__/computer-use-python.test.ts src/server/__tests__/computer-use-api.test.ts Tested: cd desktop && bun run test src/pages/ComputerUseSettings.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: Computer Use browser smoke saved /opt/homebrew/bin/python3 and backend persisted then reset pythonPath to null Not-tested: Full bun run check:server; existing cron-scheduler-launcher test expects CLAUDE_CODE_ENTRYPOINT=sdk-cli but received undefined. Related: https://github.com/NanmiCoder/cc-haha/issues/331
588 lines
26 KiB
TypeScript
588 lines
26 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|
import { computerUseApi, type ComputerUseStatus, type SetupResult, type InstalledApp, type AuthorizedApp } from '../api/computerUse'
|
|
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) {
|
|
return <span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">help</span>
|
|
}
|
|
return ok ? (
|
|
<span className="material-symbols-outlined text-[18px] text-green-500" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
|
) : (
|
|
<span className="material-symbols-outlined text-[18px] text-red-400" style={{ fontVariationSettings: "'FILL' 1" }}>cancel</span>
|
|
)
|
|
}
|
|
|
|
function StatusRow({ label, ok, detail }: { label: string; ok: boolean | null; detail: string }) {
|
|
return (
|
|
<div className="flex items-center gap-3 py-2.5 px-4 rounded-lg bg-[var(--color-surface-container-low)]">
|
|
<StatusIcon ok={ok} />
|
|
<div className="flex-1 min-w-0">
|
|
<span className="text-sm font-medium text-[var(--color-text-primary)]">{label}</span>
|
|
<span className="ml-2 text-xs text-[var(--color-text-tertiary)]">{detail}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
async function openSystemSettings(pane: 'Privacy_ScreenCapture' | 'Privacy_Accessibility') {
|
|
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)
|
|
const [checkState, setCheckState] = useState<CheckState>('loading')
|
|
const [setupRunning, setSetupRunning] = useState(false)
|
|
const [setupResult, setSetupResult] = useState<SetupResult | null>(null)
|
|
|
|
// App authorization state
|
|
const [installedApps, setInstalledApps] = useState<InstalledApp[]>([])
|
|
const [authorizedBundleIds, setAuthorizedBundleIds] = useState<Set<string>>(new Set())
|
|
const [authorizedApps, setAuthorizedApps] = useState<AuthorizedApp[]>([])
|
|
const [appsLoading, setAppsLoading] = useState(false)
|
|
const [appsSaved, setAppsSaved] = useState(false)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [computerUseEnabled, setComputerUseEnabled] = useState(true)
|
|
const [clipboardAccess, setClipboardAccess] = useState(true)
|
|
const [systemKeys, setSystemKeys] = useState(true)
|
|
const [pythonPathDraft, setPythonPathDraft] = useState('')
|
|
const [pythonPathSaved, setPythonPathSaved] = useState('')
|
|
const [pythonPathSaving, setPythonPathSaving] = useState(false)
|
|
const [pythonPathMessage, setPythonPathMessage] = useState<string | null>(null)
|
|
const configMutationSeqRef = useRef(0)
|
|
|
|
const fetchStatus = useCallback(async () => {
|
|
setCheckState('loading')
|
|
try {
|
|
const s = await computerUseApi.getStatus()
|
|
setStatus(s)
|
|
setCheckState('ready')
|
|
} catch {
|
|
setCheckState('error')
|
|
}
|
|
}, [])
|
|
|
|
const applyConfig = useCallback((
|
|
configResult: Awaited<ReturnType<typeof computerUseApi.getAuthorizedApps>>,
|
|
requestSeq = configMutationSeqRef.current,
|
|
) => {
|
|
if (requestSeq !== configMutationSeqRef.current) return
|
|
setComputerUseEnabled(configResult.enabled)
|
|
setAuthorizedApps(configResult.authorizedApps)
|
|
setAuthorizedBundleIds(new Set(configResult.authorizedApps.map(a => a.bundleId)))
|
|
setClipboardAccess(configResult.grantFlags.clipboardRead)
|
|
setSystemKeys(configResult.grantFlags.systemKeyCombos)
|
|
setPythonPathDraft(configResult.pythonPath ?? '')
|
|
setPythonPathSaved(configResult.pythonPath ?? '')
|
|
}, [])
|
|
|
|
const fetchConfig = useCallback(async () => {
|
|
const requestSeq = configMutationSeqRef.current
|
|
try {
|
|
applyConfig(await computerUseApi.getAuthorizedApps(), requestSeq)
|
|
} catch {
|
|
// API not ready
|
|
}
|
|
}, [applyConfig])
|
|
|
|
const fetchApps = useCallback(async () => {
|
|
const requestSeq = configMutationSeqRef.current
|
|
setAppsLoading(true)
|
|
try {
|
|
const [appsResult, configResult] = await Promise.all([
|
|
computerUseApi.getInstalledApps(),
|
|
computerUseApi.getAuthorizedApps(),
|
|
])
|
|
setInstalledApps(appsResult.apps)
|
|
applyConfig(configResult, requestSeq)
|
|
} catch {
|
|
// API not ready
|
|
} finally {
|
|
setAppsLoading(false)
|
|
}
|
|
}, [applyConfig])
|
|
|
|
useEffect(() => {
|
|
fetchStatus()
|
|
fetchConfig()
|
|
}, [fetchStatus, fetchConfig])
|
|
|
|
// Load apps when environment is ready
|
|
const envReady = status?.venv.created && status?.dependencies.installed
|
|
useEffect(() => {
|
|
if (envReady) fetchApps()
|
|
}, [envReady, fetchApps])
|
|
|
|
const handleSetup = async () => {
|
|
setSetupRunning(true)
|
|
setSetupResult(null)
|
|
try {
|
|
const result = await computerUseApi.runSetup()
|
|
setSetupResult(result)
|
|
await fetchStatus()
|
|
if (result.success) await fetchApps()
|
|
} catch {
|
|
setSetupResult({ success: false, steps: [{ name: 'error', ok: false, message: 'Request failed' }] })
|
|
} finally {
|
|
setSetupRunning(false)
|
|
}
|
|
}
|
|
|
|
const toggleApp = (app: InstalledApp) => {
|
|
configMutationSeqRef.current += 1
|
|
const newSet = new Set(authorizedBundleIds)
|
|
let newAuthorized = [...authorizedApps]
|
|
if (newSet.has(app.bundleId)) {
|
|
newSet.delete(app.bundleId)
|
|
newAuthorized = newAuthorized.filter(a => a.bundleId !== app.bundleId)
|
|
} else {
|
|
newSet.add(app.bundleId)
|
|
newAuthorized.push({
|
|
bundleId: app.bundleId,
|
|
displayName: app.displayName,
|
|
authorizedAt: new Date().toISOString(),
|
|
})
|
|
}
|
|
setAuthorizedBundleIds(newSet)
|
|
setAuthorizedApps(newAuthorized)
|
|
|
|
// Auto-save
|
|
computerUseApi.setAuthorizedApps({
|
|
authorizedApps: newAuthorized,
|
|
grantFlags: { clipboardRead: clipboardAccess, clipboardWrite: clipboardAccess, systemKeyCombos: systemKeys },
|
|
}).then(() => {
|
|
setAppsSaved(true)
|
|
setTimeout(() => setAppsSaved(false), 1500)
|
|
})
|
|
}
|
|
|
|
const toggleFlag = (flag: 'clipboard' | 'systemKeys', value: boolean) => {
|
|
configMutationSeqRef.current += 1
|
|
if (flag === 'clipboard') setClipboardAccess(value)
|
|
else setSystemKeys(value)
|
|
|
|
computerUseApi.setAuthorizedApps({
|
|
authorizedApps,
|
|
grantFlags: {
|
|
clipboardRead: flag === 'clipboard' ? value : clipboardAccess,
|
|
clipboardWrite: flag === 'clipboard' ? value : clipboardAccess,
|
|
systemKeyCombos: flag === 'systemKeys' ? value : systemKeys,
|
|
},
|
|
})
|
|
}
|
|
|
|
const toggleComputerUseEnabled = (value: boolean) => {
|
|
configMutationSeqRef.current += 1
|
|
setComputerUseEnabled(value)
|
|
computerUseApi.setAuthorizedApps({ enabled: value }).then(() => {
|
|
setAppsSaved(true)
|
|
setTimeout(() => setAppsSaved(false), 1500)
|
|
})
|
|
}
|
|
|
|
const savePythonPath = async (value = pythonPathDraft) => {
|
|
configMutationSeqRef.current += 1
|
|
const normalized = value.trim()
|
|
setPythonPathSaving(true)
|
|
setPythonPathMessage(null)
|
|
try {
|
|
await computerUseApi.setAuthorizedApps({ pythonPath: normalized || null })
|
|
setPythonPathDraft(normalized)
|
|
setPythonPathSaved(normalized)
|
|
setPythonPathMessage(t('settings.computerUse.pythonPathSaved'))
|
|
await fetchStatus()
|
|
} catch {
|
|
setPythonPathMessage(t('settings.computerUse.pythonPathSaveFailed'))
|
|
} finally {
|
|
setPythonPathSaving(false)
|
|
}
|
|
}
|
|
|
|
const choosePythonPath = async () => {
|
|
try {
|
|
const { open } = await import('@tauri-apps/plugin-dialog')
|
|
const selected = await open({
|
|
multiple: false,
|
|
directory: false,
|
|
title: t('settings.computerUse.pythonPathDialogTitle'),
|
|
})
|
|
const selectedPath = Array.isArray(selected) ? selected[0] : selected
|
|
if (typeof selectedPath === 'string' && selectedPath.trim()) {
|
|
setPythonPathDraft(selectedPath)
|
|
await savePythonPath(selectedPath)
|
|
}
|
|
} catch {
|
|
setPythonPathMessage(t('settings.computerUse.pythonPathDialogFailed'))
|
|
}
|
|
}
|
|
|
|
const allReady =
|
|
status?.supported &&
|
|
status.python.installed &&
|
|
status.venv.created &&
|
|
status.dependencies.installed
|
|
|
|
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/'
|
|
const pythonPathDirty = pythonPathDraft.trim() !== pythonPathSaved
|
|
const pythonDetail = status?.python.installed
|
|
? `${t('settings.computerUse.pythonFound')} — ${status.python.version} (${status.python.path})`
|
|
: status?.python.source === 'custom'
|
|
? `${t('settings.computerUse.pythonCustomInvalid')} — ${status.python.path}${status.python.error ? `: ${status.python.error}` : ''}`
|
|
: t('settings.computerUse.pythonNotFound')
|
|
|
|
// Filter apps by search query
|
|
const filteredApps = useMemo(() => {
|
|
if (!searchQuery) return installedApps
|
|
const q = searchQuery.toLowerCase()
|
|
return installedApps.filter(
|
|
a => a.displayName.toLowerCase().includes(q) || a.bundleId.toLowerCase().includes(q)
|
|
)
|
|
}, [installedApps, searchQuery])
|
|
|
|
// Sort: authorized apps first, then alphabetical
|
|
const sortedApps = useMemo(() => {
|
|
return [...filteredApps].sort((a, b) => {
|
|
const aAuth = authorizedBundleIds.has(a.bundleId) ? 0 : 1
|
|
const bAuth = authorizedBundleIds.has(b.bundleId) ? 0 : 1
|
|
if (aAuth !== bAuth) return aAuth - bAuth
|
|
return a.displayName.localeCompare(b.displayName)
|
|
})
|
|
}, [filteredApps, authorizedBundleIds])
|
|
|
|
return (
|
|
<div className="max-w-2xl space-y-6">
|
|
{/* Title */}
|
|
<div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
|
{t('settings.computerUse.title')}
|
|
</h2>
|
|
<label className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)] cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={computerUseEnabled}
|
|
onChange={e => toggleComputerUseEnabled(e.target.checked)}
|
|
className="rounded border-[var(--color-border)] accent-[var(--color-brand)]"
|
|
/>
|
|
{t('settings.computerUse.enabledToggle')}
|
|
</label>
|
|
</div>
|
|
<p className="mt-1 text-sm text-[var(--color-text-secondary)]">
|
|
{t('settings.computerUse.description')}
|
|
</p>
|
|
</div>
|
|
|
|
{!computerUseEnabled && (
|
|
<div className="px-4 py-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-sm text-yellow-700">
|
|
{t('settings.computerUse.disabledHint')}
|
|
</div>
|
|
)}
|
|
|
|
{checkState === 'loading' ? (
|
|
<div className="py-8 text-center text-sm text-[var(--color-text-tertiary)]">
|
|
{t('common.loading')}
|
|
</div>
|
|
) : checkState === 'error' ? (
|
|
<div className="py-8 text-center text-sm text-red-400">
|
|
Failed to check status.
|
|
<button onClick={fetchStatus} className="ml-2 underline">{t('common.retry')}</button>
|
|
</div>
|
|
) : status ? (
|
|
<>
|
|
{!status.supported && (
|
|
<div className="px-4 py-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-sm text-yellow-600">
|
|
{t('settings.computerUse.notSupported')}
|
|
</div>
|
|
)}
|
|
|
|
{/* Status checks */}
|
|
<div className="space-y-2">
|
|
<StatusRow
|
|
label={t('settings.computerUse.python')}
|
|
ok={status.python.installed}
|
|
detail={pythonDetail}
|
|
/>
|
|
<StatusRow
|
|
label={t('settings.computerUse.venv')}
|
|
ok={status.venv.created}
|
|
detail={status.venv.created ? `${t('settings.computerUse.venvReady')} — ${status.venv.path}` : t('settings.computerUse.venvNotReady')}
|
|
/>
|
|
<StatusRow
|
|
label={t('settings.computerUse.deps')}
|
|
ok={status.dependencies.installed}
|
|
detail={status.dependencies.installed ? t('settings.computerUse.depsReady') : t('settings.computerUse.depsNotReady')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4">
|
|
<label htmlFor="computer-use-python-path" className="block text-sm font-medium text-[var(--color-text-primary)]">
|
|
{t('settings.computerUse.pythonPathLabel')}
|
|
</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
<input
|
|
id="computer-use-python-path"
|
|
type="text"
|
|
value={pythonPathDraft}
|
|
onChange={e => {
|
|
setPythonPathDraft(e.target.value)
|
|
setPythonPathMessage(null)
|
|
}}
|
|
placeholder={t('settings.computerUse.pythonPathPlaceholder')}
|
|
className="min-w-[220px] flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-2 font-mono text-xs text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-brand)] focus:outline-none"
|
|
/>
|
|
<button
|
|
onClick={choosePythonPath}
|
|
disabled={pythonPathSaving}
|
|
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] px-3 py-2 text-xs font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">folder_open</span>
|
|
{t('settings.computerUse.pythonPathBrowse')}
|
|
</button>
|
|
<button
|
|
onClick={() => savePythonPath()}
|
|
disabled={pythonPathSaving || !pythonPathDirty}
|
|
className="flex items-center gap-1.5 rounded-lg bg-[var(--color-brand)] px-3 py-2 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">{pythonPathSaving ? 'hourglass_empty' : 'save'}</span>
|
|
{t('settings.computerUse.pythonPathSave')}
|
|
</button>
|
|
{pythonPathSaved && (
|
|
<button
|
|
onClick={() => savePythonPath('')}
|
|
disabled={pythonPathSaving}
|
|
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] px-3 py-2 text-xs font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
|
|
{t('settings.computerUse.pythonPathAuto')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-[var(--color-text-tertiary)]">
|
|
{pythonPathMessage ?? t('settings.computerUse.pythonPathHint')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* macOS Permissions — only shown on macOS (darwin) */}
|
|
{envReady && status.platform === 'darwin' && (
|
|
<>
|
|
<StatusRow
|
|
label={t('settings.computerUse.accessibility')}
|
|
ok={status.permissions.accessibility}
|
|
detail={
|
|
status.permissions.accessibility === null ? t('settings.computerUse.permUnknown')
|
|
: status.permissions.accessibility ? t('settings.computerUse.permGranted')
|
|
: t('settings.computerUse.permDenied')
|
|
}
|
|
/>
|
|
<StatusRow
|
|
label={t('settings.computerUse.screenRecording')}
|
|
ok={screenRecordingReady}
|
|
detail={
|
|
status.permissions.screenRecording === true ? t('settings.computerUse.permGranted')
|
|
: status.permissions.screenRecording === false ? t('settings.computerUse.permDenied')
|
|
: t('settings.computerUse.permScreenRecordingUnknownSoft')
|
|
}
|
|
/>
|
|
{(accessibilityNeedsAttention || screenRecordingNeedsAttention) && (
|
|
<div className="flex flex-col gap-2 px-4 py-3 rounded-lg bg-yellow-500/5 border border-yellow-500/20">
|
|
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.computerUse.permRestartHint')}</p>
|
|
<div className="flex gap-2">
|
|
{accessibilityNeedsAttention && (
|
|
<button
|
|
onClick={() => openSystemSettings('Privacy_Accessibility')}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-[var(--color-text-accent)] border border-[var(--color-border)] rounded-lg hover:bg-[var(--color-surface-hover)]"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
|
{t('settings.computerUse.openAccessibility')}
|
|
</button>
|
|
)}
|
|
{screenRecordingNeedsAttention && (
|
|
<button
|
|
onClick={() => openSystemSettings('Privacy_ScreenCapture')}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-[var(--color-text-accent)] border border-[var(--color-border)] rounded-lg hover:bg-[var(--color-surface-hover)]"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
|
{t('settings.computerUse.openScreenRecording')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{allReady && (status.platform !== 'darwin' || (status.permissions.accessibility && screenRecordingReady)) && (
|
|
<div className="px-4 py-3 rounded-lg bg-green-500/10 border border-green-500/30 text-sm text-green-600 flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-[18px]" style={{ fontVariationSettings: "'FILL' 1" }}>verified</span>
|
|
{t('settings.computerUse.allReady')}
|
|
</div>
|
|
)}
|
|
|
|
{setupResult && (
|
|
<div className={`rounded-lg border p-4 space-y-2 ${setupResult.success ? 'bg-green-500/5 border-green-500/30' : 'bg-red-500/5 border-red-500/30'}`}>
|
|
<div className={`text-sm font-medium ${setupResult.success ? 'text-green-600' : 'text-red-400'}`}>
|
|
{setupResult.success ? t('settings.computerUse.setupSuccess') : t('settings.computerUse.setupFail')}
|
|
</div>
|
|
{setupResult.steps.map((step, i) => (
|
|
<div key={i} className="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
|
|
<StatusIcon ok={step.ok} />
|
|
<span>{step.message}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 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}
|
|
disabled={setupRunning}
|
|
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 disabled:opacity-50 transition-opacity"
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">{setupRunning ? 'hourglass_empty' : 'download'}</span>
|
|
{setupRunning ? t('settings.computerUse.setupRunning') : t('settings.computerUse.setupBtn')}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={fetchStatus}
|
|
className="flex items-center gap-2 px-4 py-2.5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] border border-[var(--color-border)] rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">refresh</span>
|
|
{t('settings.computerUse.recheckBtn')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* ─── App Authorization Section ─── */}
|
|
{envReady && (
|
|
<div className="space-y-4 pt-4 border-t border-[var(--color-border)]">
|
|
<div>
|
|
<h3 className="text-base font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
|
|
{t('settings.computerUse.appsTitle')}
|
|
{appsSaved && (
|
|
<span className="text-xs font-normal text-green-500 flex items-center gap-1">
|
|
<span className="material-symbols-outlined text-[14px]" style={{ fontVariationSettings: "'FILL' 1" }}>check</span>
|
|
{t('settings.computerUse.appsSaved')}
|
|
</span>
|
|
)}
|
|
</h3>
|
|
<p className="mt-1 text-sm text-[var(--color-text-secondary)]">
|
|
{t('settings.computerUse.appsDescription')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Grant flags */}
|
|
<div className="flex gap-4">
|
|
<label className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)] cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={clipboardAccess}
|
|
onChange={e => toggleFlag('clipboard', e.target.checked)}
|
|
className="rounded border-[var(--color-border)] accent-[var(--color-brand)]"
|
|
/>
|
|
{t('settings.computerUse.flagClipboard')}
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)] cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={systemKeys}
|
|
onChange={e => toggleFlag('systemKeys', e.target.checked)}
|
|
className="rounded border-[var(--color-border)] accent-[var(--color-brand)]"
|
|
/>
|
|
{t('settings.computerUse.flagSystemKeys')}
|
|
</label>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="relative">
|
|
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] absolute left-3 top-1/2 -translate-y-1/2">search</span>
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
placeholder={t('settings.computerUse.appsSearch')}
|
|
className="w-full pl-9 pr-4 py-2 text-sm bg-[var(--color-surface-container-low)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-brand)]"
|
|
/>
|
|
</div>
|
|
|
|
{/* App list */}
|
|
{appsLoading ? (
|
|
<div className="py-6 text-center text-sm text-[var(--color-text-tertiary)]">
|
|
{t('settings.computerUse.appsLoading')}
|
|
</div>
|
|
) : installedApps.length === 0 ? (
|
|
<div className="py-6 text-center text-sm text-[var(--color-text-tertiary)]">
|
|
{t('settings.computerUse.appsEmpty')}
|
|
</div>
|
|
) : (
|
|
<div className="max-h-[400px] overflow-y-auto rounded-lg border border-[var(--color-border)]">
|
|
{sortedApps.map(app => {
|
|
const isAuthorized = authorizedBundleIds.has(app.bundleId)
|
|
return (
|
|
<button
|
|
key={app.bundleId}
|
|
onClick={() => toggleApp(app)}
|
|
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] border-b border-[var(--color-border)] last:border-b-0 ${
|
|
isAuthorized ? 'bg-[var(--color-brand)]/5' : ''
|
|
}`}
|
|
>
|
|
<div className={`w-5 h-5 rounded flex items-center justify-center flex-shrink-0 border ${
|
|
isAuthorized
|
|
? 'bg-[var(--color-brand)] border-[var(--color-brand)]'
|
|
: 'border-[var(--color-border)]'
|
|
}`}>
|
|
{isAuthorized && (
|
|
<span className="material-symbols-outlined text-[14px] text-white" style={{ fontVariationSettings: "'FILL' 1" }}>check</span>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">
|
|
{app.displayName}
|
|
</div>
|
|
<div className="text-[11px] text-[var(--color-text-tertiary)] truncate font-mono">
|
|
{app.bundleId}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|