mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix(desktop): keep Computer Use toggles stable
Settings refreshes can complete after the user changes Computer Use authorization state. The page now ignores stale config reads once a local mutation has happened, so enablement and grant controls no longer visually roll back and require a second operation. Constraint: Computer Use config is shared between the desktop settings page and runtime MCP exposure. Rejected: Refetch after every save | still allows older in-flight reads to overwrite newer local intent. Confidence: high Scope-risk: narrow Directive: Do not apply async settings snapshots after a newer local authorization mutation without a freshness check. Tested: cd desktop && bun run test -- src/pages/ComputerUseSettings.test.tsx Tested: bun run check:desktop Tested: bun run check:coverage Not-tested: bun run verify is blocked by existing path-aware policy labels for broader dirty scope.
This commit is contained in:
parent
f01ba4eda6
commit
0a4f2ae6b6
@ -50,6 +50,14 @@ const enabledConfig = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>(res => {
|
||||||
|
resolve = res
|
||||||
|
})
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
describe('ComputerUseSettings', () => {
|
describe('ComputerUseSettings', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSettingsStore.setState({ locale: 'en' })
|
useSettingsStore.setState({ locale: 'en' })
|
||||||
@ -95,4 +103,114 @@ describe('ComputerUseSettings', () => {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the user-selected enablement when a stale refresh resolves later', async () => {
|
||||||
|
const staleRefresh = deferred<typeof enabledConfig>()
|
||||||
|
computerUseApiMock.getStatus.mockResolvedValue({
|
||||||
|
...readyStatus,
|
||||||
|
venv: {
|
||||||
|
...readyStatus.venv,
|
||||||
|
created: true,
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
...readyStatus.dependencies,
|
||||||
|
installed: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
computerUseApiMock.getInstalledApps.mockResolvedValue({ apps: [] })
|
||||||
|
computerUseApiMock.getAuthorizedApps
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
...enabledConfig,
|
||||||
|
enabled: false,
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce(staleRefresh.promise)
|
||||||
|
|
||||||
|
render(<ComputerUseSettings />)
|
||||||
|
|
||||||
|
const toggle = await screen.findByLabelText('Enabled')
|
||||||
|
await waitFor(() => expect(toggle).not.toBeChecked())
|
||||||
|
await waitFor(() => expect(computerUseApiMock.getInstalledApps).toHaveBeenCalled())
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(toggle).toBeChecked()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
staleRefresh.resolve({
|
||||||
|
...enabledConfig,
|
||||||
|
enabled: false,
|
||||||
|
})
|
||||||
|
await staleRefresh.promise
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(toggle).toBeChecked()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves app and grant flag changes from the ready environment view', async () => {
|
||||||
|
computerUseApiMock.getStatus.mockResolvedValue({
|
||||||
|
...readyStatus,
|
||||||
|
venv: {
|
||||||
|
...readyStatus.venv,
|
||||||
|
created: true,
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
...readyStatus.dependencies,
|
||||||
|
installed: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
computerUseApiMock.getInstalledApps.mockResolvedValue({
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
bundleId: 'com.example.Preview',
|
||||||
|
displayName: 'Preview',
|
||||||
|
path: '/Applications/Preview.app',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ComputerUseSettings />)
|
||||||
|
|
||||||
|
await screen.findByText('Preview')
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText('Preview'))
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({
|
||||||
|
authorizedApps: [
|
||||||
|
expect.objectContaining({
|
||||||
|
bundleId: 'com.example.Preview',
|
||||||
|
displayName: 'Preview',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
grantFlags: {
|
||||||
|
clipboardRead: true,
|
||||||
|
clipboardWrite: true,
|
||||||
|
systemKeyCombos: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByLabelText('Clipboard Access'))
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(computerUseApiMock.setAuthorizedApps).toHaveBeenCalledWith({
|
||||||
|
authorizedApps: [
|
||||||
|
expect.objectContaining({
|
||||||
|
bundleId: 'com.example.Preview',
|
||||||
|
displayName: 'Preview',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
grantFlags: {
|
||||||
|
clipboardRead: false,
|
||||||
|
clipboardWrite: false,
|
||||||
|
systemKeyCombos: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import { computerUseApi, type ComputerUseStatus, type SetupResult, type InstalledApp, type AuthorizedApp } from '../api/computerUse'
|
import { computerUseApi, type ComputerUseStatus, type SetupResult, type InstalledApp, type AuthorizedApp } from '../api/computerUse'
|
||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
|
|
||||||
@ -61,6 +61,7 @@ export function ComputerUseSettings() {
|
|||||||
const [computerUseEnabled, setComputerUseEnabled] = useState(true)
|
const [computerUseEnabled, setComputerUseEnabled] = useState(true)
|
||||||
const [clipboardAccess, setClipboardAccess] = useState(true)
|
const [clipboardAccess, setClipboardAccess] = useState(true)
|
||||||
const [systemKeys, setSystemKeys] = useState(true)
|
const [systemKeys, setSystemKeys] = useState(true)
|
||||||
|
const configMutationSeqRef = useRef(0)
|
||||||
|
|
||||||
const fetchStatus = useCallback(async () => {
|
const fetchStatus = useCallback(async () => {
|
||||||
setCheckState('loading')
|
setCheckState('loading')
|
||||||
@ -73,7 +74,11 @@ export function ComputerUseSettings() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const applyConfig = useCallback((configResult: Awaited<ReturnType<typeof computerUseApi.getAuthorizedApps>>) => {
|
const applyConfig = useCallback((
|
||||||
|
configResult: Awaited<ReturnType<typeof computerUseApi.getAuthorizedApps>>,
|
||||||
|
requestSeq = configMutationSeqRef.current,
|
||||||
|
) => {
|
||||||
|
if (requestSeq !== configMutationSeqRef.current) return
|
||||||
setComputerUseEnabled(configResult.enabled)
|
setComputerUseEnabled(configResult.enabled)
|
||||||
setAuthorizedApps(configResult.authorizedApps)
|
setAuthorizedApps(configResult.authorizedApps)
|
||||||
setAuthorizedBundleIds(new Set(configResult.authorizedApps.map(a => a.bundleId)))
|
setAuthorizedBundleIds(new Set(configResult.authorizedApps.map(a => a.bundleId)))
|
||||||
@ -82,14 +87,16 @@ export function ComputerUseSettings() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const fetchConfig = useCallback(async () => {
|
const fetchConfig = useCallback(async () => {
|
||||||
|
const requestSeq = configMutationSeqRef.current
|
||||||
try {
|
try {
|
||||||
applyConfig(await computerUseApi.getAuthorizedApps())
|
applyConfig(await computerUseApi.getAuthorizedApps(), requestSeq)
|
||||||
} catch {
|
} catch {
|
||||||
// API not ready
|
// API not ready
|
||||||
}
|
}
|
||||||
}, [applyConfig])
|
}, [applyConfig])
|
||||||
|
|
||||||
const fetchApps = useCallback(async () => {
|
const fetchApps = useCallback(async () => {
|
||||||
|
const requestSeq = configMutationSeqRef.current
|
||||||
setAppsLoading(true)
|
setAppsLoading(true)
|
||||||
try {
|
try {
|
||||||
const [appsResult, configResult] = await Promise.all([
|
const [appsResult, configResult] = await Promise.all([
|
||||||
@ -97,7 +104,7 @@ export function ComputerUseSettings() {
|
|||||||
computerUseApi.getAuthorizedApps(),
|
computerUseApi.getAuthorizedApps(),
|
||||||
])
|
])
|
||||||
setInstalledApps(appsResult.apps)
|
setInstalledApps(appsResult.apps)
|
||||||
applyConfig(configResult)
|
applyConfig(configResult, requestSeq)
|
||||||
} catch {
|
} catch {
|
||||||
// API not ready
|
// API not ready
|
||||||
} finally {
|
} finally {
|
||||||
@ -132,6 +139,7 @@ export function ComputerUseSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleApp = (app: InstalledApp) => {
|
const toggleApp = (app: InstalledApp) => {
|
||||||
|
configMutationSeqRef.current += 1
|
||||||
const newSet = new Set(authorizedBundleIds)
|
const newSet = new Set(authorizedBundleIds)
|
||||||
let newAuthorized = [...authorizedApps]
|
let newAuthorized = [...authorizedApps]
|
||||||
if (newSet.has(app.bundleId)) {
|
if (newSet.has(app.bundleId)) {
|
||||||
@ -159,6 +167,7 @@ export function ComputerUseSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleFlag = (flag: 'clipboard' | 'systemKeys', value: boolean) => {
|
const toggleFlag = (flag: 'clipboard' | 'systemKeys', value: boolean) => {
|
||||||
|
configMutationSeqRef.current += 1
|
||||||
if (flag === 'clipboard') setClipboardAccess(value)
|
if (flag === 'clipboard') setClipboardAccess(value)
|
||||||
else setSystemKeys(value)
|
else setSystemKeys(value)
|
||||||
|
|
||||||
@ -173,6 +182,7 @@ export function ComputerUseSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleComputerUseEnabled = (value: boolean) => {
|
const toggleComputerUseEnabled = (value: boolean) => {
|
||||||
|
configMutationSeqRef.current += 1
|
||||||
setComputerUseEnabled(value)
|
setComputerUseEnabled(value)
|
||||||
computerUseApi.setAuthorizedApps({ enabled: value }).then(() => {
|
computerUseApi.setAuthorizedApps({ enabled: value }).then(() => {
|
||||||
setAppsSaved(true)
|
setAppsSaved(true)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user