diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd6f4b..dfb0f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ ### Fixed - 新增 `.gitattributes` 统一文本文件为 `LF`,修复 Windows CI 中 `Prettier --check` 因 `CRLF` 检出导致的格式校验失败。 +- 修复终态任务仍可再次触发 `Approve and run` 的问题,主进程会将其视为 no-op,渲染层也会在任务进入终态后禁用执行按钮。 +- 修复 `Claude Code CLI` 适配器在同一 session 重复执行时复用事件 ID 的问题,避免时间线渲染出现重复 key。 +- 为桌面主窗口补充受信导航策略,仅允许本地 `file://` 或开发服务器地址留在应用内,其它链接统一改走系统浏览器。 ## [0.1.0] - 2026-03-25 diff --git a/apps/desktop/src/main/app/bootstrapDesktopApp.ts b/apps/desktop/src/main/app/bootstrapDesktopApp.ts index eab864b..a69e5db 100644 --- a/apps/desktop/src/main/app/bootstrapDesktopApp.ts +++ b/apps/desktop/src/main/app/bootstrapDesktopApp.ts @@ -8,6 +8,7 @@ import { registerDesktopIpcHandlers } from '../ipc/registerDesktopIpcHandlers' import { FileDesktopStore } from '../persistence/fileDesktopStore' import { SQLiteDesktopStore } from '../persistence/sqliteDesktopStore' import { DesktopController } from '../services/desktopController' +import { isTrustedDesktopUrl } from './navigationPolicy' declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined declare const MAIN_WINDOW_VITE_NAME: string @@ -29,6 +30,31 @@ const createMainWindow = () => { } }) + const handleExternalNavigation = (url: string) => { + if (isTrustedDesktopUrl(url, MAIN_WINDOW_VITE_DEV_SERVER_URL)) { + return + } + + void shell.openExternal(url) + } + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + handleExternalNavigation(url) + + return { + action: isTrustedDesktopUrl(url, MAIN_WINDOW_VITE_DEV_SERVER_URL) ? 'allow' : 'deny' + } + }) + + mainWindow.webContents.on('will-navigate', (event, url) => { + if (isTrustedDesktopUrl(url, MAIN_WINDOW_VITE_DEV_SERVER_URL)) { + return + } + + event.preventDefault() + handleExternalNavigation(url) + }) + if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { void mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL) mainWindow.webContents.openDevTools({ mode: 'detach' }) diff --git a/apps/desktop/src/main/app/navigationPolicy.test.ts b/apps/desktop/src/main/app/navigationPolicy.test.ts new file mode 100644 index 0000000..246ca6f --- /dev/null +++ b/apps/desktop/src/main/app/navigationPolicy.test.ts @@ -0,0 +1,19 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { isTrustedDesktopUrl } from './navigationPolicy' + +describe('isTrustedDesktopUrl', () => { + it('allows only the configured dev server during local development', () => { + expect(isTrustedDesktopUrl('http://127.0.0.1:5173/', 'http://127.0.0.1:5173')).toBe(true) + expect(isTrustedDesktopUrl('https://github.com/FruitsAI/Mango', 'http://127.0.0.1:5173')).toBe( + false + ) + }) + + it('allows packaged file urls in production builds', () => { + expect(isTrustedDesktopUrl('file:///C:/Program%20Files/Mango/index.html')).toBe(true) + expect(isTrustedDesktopUrl('https://example.com')).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/app/navigationPolicy.ts b/apps/desktop/src/main/app/navigationPolicy.ts new file mode 100644 index 0000000..3525f36 --- /dev/null +++ b/apps/desktop/src/main/app/navigationPolicy.ts @@ -0,0 +1,7 @@ +export const isTrustedDesktopUrl = (url: string, devServerUrl?: string): boolean => { + if (devServerUrl) { + return url.startsWith(devServerUrl) + } + + return url.startsWith('file://') +} diff --git a/apps/desktop/src/main/services/desktopController.test.ts b/apps/desktop/src/main/services/desktopController.test.ts index fb97006..2b58c4e 100644 --- a/apps/desktop/src/main/services/desktopController.test.ts +++ b/apps/desktop/src/main/services/desktopController.test.ts @@ -150,4 +150,30 @@ describe('DesktopController', () => { level: 'error' }) }) + + it('treats approve and run as a no-op once the active task has already finished', async () => { + const store = new MemoryDesktopStore(createPersistedState()) + const controller = new DesktopController(store, [ + new FakeAdapter('claude-code', 'Claude Code CLI'), + new FakeAdapter('mock-claude', 'Mock Claude CLI') + ]) + + await controller.generatePlan({ + prompt: 'Implement adapter registry', + workspaceId: workspace.id, + adapterId: 'claude-code' + }) + + const completedState = await controller.approveAndRun() + const repeatedState = await controller.approveAndRun() + + expect(completedState.activeTask?.status).toBe('succeeded') + expect(repeatedState.activeTask?.status).toBe('succeeded') + expect(repeatedState.activeTask?.events).toHaveLength( + completedState.activeTask?.events.length ?? 0 + ) + expect(repeatedState.activeTask?.executionSummary).toBe( + completedState.activeTask?.executionSummary + ) + }) }) diff --git a/apps/desktop/src/main/services/desktopController.ts b/apps/desktop/src/main/services/desktopController.ts index 0271442..729410c 100644 --- a/apps/desktop/src/main/services/desktopController.ts +++ b/apps/desktop/src/main/services/desktopController.ts @@ -152,12 +152,13 @@ export class DesktopController { public async approveAndRun(): Promise { const persisted = await this.ensureState() + const activeTask = persisted.activeTask - if (!persisted.activeTask?.plan) { + if (!activeTask?.plan || !['planned', 'approved'].includes(activeTask.status)) { return this.buildDesktopState(persisted) } - let session = persisted.activeTask + let session = activeTask if (session.status === 'planned') { session = applyTaskTransition(session, { diff --git a/apps/desktop/src/renderer/src/features/task-workbench/TaskWorkbench.tsx b/apps/desktop/src/renderer/src/features/task-workbench/TaskWorkbench.tsx index 3ddd99e..62b8a5d 100644 --- a/apps/desktop/src/renderer/src/features/task-workbench/TaskWorkbench.tsx +++ b/apps/desktop/src/renderer/src/features/task-workbench/TaskWorkbench.tsx @@ -51,6 +51,9 @@ export const TaskWorkbench = ({ selectedAdapterId ?? selectedWorkspace?.providerConfig?.primaryAdapterId ) const activePlan = state.activeTask?.plan + const canApproveAndRun = + Boolean(activePlan) && + (state.activeTask?.status === 'planned' || state.activeTask?.status === 'approved') return (
@@ -178,7 +181,7 @@ export const TaskWorkbench = ({