diff --git a/docs/issues/startup-warning-cleanup/plan.md b/docs/issues/startup-warning-cleanup/plan.md new file mode 100644 index 000000000..351b1796b --- /dev/null +++ b/docs/issues/startup-warning-cleanup/plan.md @@ -0,0 +1,17 @@ +# Startup Warning Cleanup Plan + +## Implementation + +- Add a lifecycle delay normalization helper and use it before the development-only hook delay timer. +- Add `EventBus.sendToRendererIfAvailable(...)`, sharing the existing renderer dispatch behavior while + returning `false` silently when `WindowPresenter` is unavailable. +- Update `EventBus.send(...)` to emit to main listeners first and then use the optional renderer send. +- Route ACP/session-list refresh notifications through the optional renderer send when no renderer may + exist yet. + +## Tests + +- Unit-test lifecycle delay normalization for missing, empty, invalid, negative, fractional, and valid + values. +- Extend EventBus tests to cover optional renderer delivery, direct renderer warnings, and main-process + delivery without a renderer. diff --git a/docs/issues/startup-warning-cleanup/spec.md b/docs/issues/startup-warning-cleanup/spec.md new file mode 100644 index 000000000..62e5abe93 --- /dev/null +++ b/docs/issues/startup-warning-cleanup/spec.md @@ -0,0 +1,20 @@ +# Startup Warning Cleanup Spec + +## Goal + +Startup should not print misleading warning noise when development lifecycle settings are omitted +or when expected early main-process events fire before any renderer window exists. + +## Acceptance Criteria + +- Missing, empty, invalid, non-finite, and negative `VITE_APP_LIFECYCLE_HOOK_DELAY` values produce a + `0` millisecond hook delay and never trigger `TimeoutNaNWarning`. +- `EventBus.send(...)` still emits to main-process listeners when no `WindowPresenter` is registered. +- `EventBus.send(...)` does not warn when renderer delivery is unavailable during startup. +- Direct `EventBus.sendToRenderer(...)` keeps warning when called without a `WindowPresenter`. +- Startup ACP/session-list notifications use the quiet renderer path when renderer delivery is optional. + +## Non-Goals + +- No IPC, preload, renderer UI, database, or migration changes. +- No changes to event payload shapes. diff --git a/docs/issues/startup-warning-cleanup/tasks.md b/docs/issues/startup-warning-cleanup/tasks.md new file mode 100644 index 000000000..51a97daa9 --- /dev/null +++ b/docs/issues/startup-warning-cleanup/tasks.md @@ -0,0 +1,7 @@ +# Startup Warning Cleanup Tasks + +- [x] Add lifecycle hook delay normalization. +- [x] Add optional EventBus renderer send. +- [x] Route startup-safe config notifications through optional renderer delivery. +- [x] Add focused unit tests. +- [x] Run targeted tests and required formatting/i18n/lint checks. diff --git a/src/main/eventbus.ts b/src/main/eventbus.ts index d0b22ce65..2a7a47e77 100644 --- a/src/main/eventbus.ts +++ b/src/main/eventbus.ts @@ -43,20 +43,44 @@ export class EventBus extends EventEmitter { return } + this.dispatchToRenderer(this.windowPresenter, eventName, target, ...args) + } + + /** + * 向渲染进程发送事件(如果窗口展示器已可用) + * @returns 是否已发送到渲染进程 + */ + sendToRendererIfAvailable( + eventName: string, + target: SendTarget = SendTarget.ALL_WINDOWS, + ...args: unknown[] + ): boolean { + if (!this.windowPresenter) { + return false + } + + this.dispatchToRenderer(this.windowPresenter, eventName, target, ...args) + return true + } + + private dispatchToRenderer( + windowPresenter: IWindowPresenter, + eventName: string, + target: SendTarget = SendTarget.ALL_WINDOWS, + ...args: unknown[] + ) { switch (target) { case SendTarget.ALL_WINDOWS: - this.windowPresenter.sendToAllWindows(eventName, ...args) + windowPresenter.sendToAllWindows(eventName, ...args) break case SendTarget.DEFAULT_WINDOW: + windowPresenter.sendToDefaultWindow(eventName, true, ...args) + break case SendTarget.DEFAULT_TAB: - if (typeof this.windowPresenter.sendToDefaultWindow === 'function') { - this.windowPresenter.sendToDefaultWindow(eventName, true, ...args) - } else { - this.windowPresenter.sendToDefaultTab(eventName, true, ...args) - } + windowPresenter.sendToDefaultTab(eventName, true, ...args) break default: - this.windowPresenter.sendToAllWindows(eventName, ...args) + windowPresenter.sendToAllWindows(eventName, ...args) } } @@ -70,8 +94,8 @@ export class EventBus extends EventEmitter { // 发送到主进程 this.sendToMain(eventName, ...args) - // 发送到渲染进程 - this.sendToRenderer(eventName, target, ...args) + // 发送到渲染进程(启动早期没有窗口时静默跳过) + this.sendToRendererIfAvailable(eventName, target, ...args) } /** diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index fc79b50df..f58df2de9 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -2603,7 +2603,7 @@ export class ConfigPresenter implements IConfigPresenter { console.log('[ACP] notifyAcpAgentsChanged: sending MODEL_LIST_CHANGED event for provider "acp"') eventBus.send(CONFIG_EVENTS.MODEL_LIST_CHANGED, SendTarget.ALL_WINDOWS, 'acp') eventBus.send(CONFIG_EVENTS.AGENTS_CHANGED, SendTarget.ALL_WINDOWS, { agentIds }) - eventBus.sendToRenderer(SESSION_EVENTS.LIST_UPDATED, SendTarget.ALL_WINDOWS) + eventBus.sendToRendererIfAvailable(SESSION_EVENTS.LIST_UPDATED, SendTarget.ALL_WINDOWS) } // Provide getMcpConfHelper method to get MCP configuration helper diff --git a/src/main/presenter/lifecyclePresenter/index.ts b/src/main/presenter/lifecyclePresenter/index.ts index c985c61fc..705cc9981 100644 --- a/src/main/presenter/lifecyclePresenter/index.ts +++ b/src/main/presenter/lifecyclePresenter/index.ts @@ -26,6 +26,7 @@ import { } from './types' import { is } from '@electron-toolkit/utils' import { presenter } from '@/presenter' +import { normalizeLifecycleHookDelayMs } from './lifecycleDelay' export { registerCoreHooks } from './coreHooks' @@ -379,7 +380,9 @@ export class LifecycleManager implements ILifecycleManager { const result = await hook.execute(context) if (is.dev) { - const hookDelay = Number(import.meta.env.VITE_APP_LIFECYCLE_HOOK_DELAY) + const hookDelay = normalizeLifecycleHookDelayMs( + import.meta.env.VITE_APP_LIFECYCLE_HOOK_DELAY + ) await new Promise((resolve) => setTimeout(resolve, hookDelay)) } diff --git a/src/main/presenter/lifecyclePresenter/lifecycleDelay.ts b/src/main/presenter/lifecyclePresenter/lifecycleDelay.ts new file mode 100644 index 000000000..f297c8676 --- /dev/null +++ b/src/main/presenter/lifecyclePresenter/lifecycleDelay.ts @@ -0,0 +1,12 @@ +export function normalizeLifecycleHookDelayMs(value: unknown): number { + if (value === undefined || value === null || value === '') { + return 0 + } + + const delayMs = Number(value) + if (!Number.isFinite(delayMs) || delayMs < 0) { + return 0 + } + + return delayMs +} diff --git a/test/main/eventbus/eventbus.test.ts b/test/main/eventbus/eventbus.test.ts index dcf95c8fc..86afb59a1 100644 --- a/test/main/eventbus/eventbus.test.ts +++ b/test/main/eventbus/eventbus.test.ts @@ -124,6 +124,16 @@ describe('EventBus 事件总线', () => { ) }) + it('应该能够发送事件到默认标签页', () => { + const eventName = 'renderer:default-tab' + const testData = { message: 'default tab' } + + eventBus.sendToRenderer(eventName, SendTarget.DEFAULT_TAB, testData) + + expect(mockWindowPresenter.sendToDefaultTab).toHaveBeenCalledWith(eventName, true, testData) + expect(mockWindowPresenter.sendToDefaultWindow).not.toHaveBeenCalled() + }) + it('当WindowPresenter未设置时应该显示警告', () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const newEventBus = new EventBus() @@ -136,6 +146,32 @@ describe('EventBus 事件总线', () => { consoleSpy.mockRestore() }) + + it('当WindowPresenter未设置时应该静默跳过可选渲染发送', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const newEventBus = new EventBus() + + const sent = newEventBus.sendToRendererIfAvailable( + 'test:event', + SendTarget.ALL_WINDOWS, + 'data' + ) + + expect(sent).toBe(false) + expect(consoleSpy).not.toHaveBeenCalled() + + consoleSpy.mockRestore() + }) + + it('应该能够通过可选渲染发送路径发送到所有窗口', () => { + const eventName = 'renderer:optional' + const testData = { message: 'optional renderer' } + + const sent = eventBus.sendToRendererIfAvailable(eventName, SendTarget.ALL_WINDOWS, testData) + + expect(sent).toBe(true) + expect(mockWindowPresenter.sendToAllWindows).toHaveBeenCalledWith(eventName, testData) + }) }) describe('同时发送到主进程和渲染进程', () => { @@ -167,6 +203,22 @@ describe('EventBus 事件总线', () => { expect(mockWindowPresenter.sendToAllWindows).toHaveBeenCalledWith(eventName, testData) }) + + it('当WindowPresenter未设置时仍应发送到主进程且不警告', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const newEventBus = new EventBus() + const eventName = 'both:no-renderer' + const testData = { message: 'main only' } + const mockListener = vi.fn() + newEventBus.on(eventName, mockListener) + + newEventBus.send(eventName, SendTarget.ALL_WINDOWS, testData) + + expect(mockListener).toHaveBeenCalledWith(testData) + expect(consoleSpy).not.toHaveBeenCalled() + + consoleSpy.mockRestore() + }) }) describe('webContents 路由相关功能', () => { diff --git a/test/main/presenter/lifecyclePresenter/lifecycleDelay.test.ts b/test/main/presenter/lifecyclePresenter/lifecycleDelay.test.ts new file mode 100644 index 000000000..8c92c771d --- /dev/null +++ b/test/main/presenter/lifecyclePresenter/lifecycleDelay.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { normalizeLifecycleHookDelayMs } from '@/presenter/lifecyclePresenter/lifecycleDelay' + +describe('normalizeLifecycleHookDelayMs', () => { + it('defaults missing and empty values to zero', () => { + expect(normalizeLifecycleHookDelayMs(undefined)).toBe(0) + expect(normalizeLifecycleHookDelayMs(null)).toBe(0) + expect(normalizeLifecycleHookDelayMs('')).toBe(0) + }) + + it('defaults invalid, non-finite, and negative values to zero', () => { + expect(normalizeLifecycleHookDelayMs('invalid')).toBe(0) + expect(normalizeLifecycleHookDelayMs(Number.NaN)).toBe(0) + expect(normalizeLifecycleHookDelayMs(Number.POSITIVE_INFINITY)).toBe(0) + expect(normalizeLifecycleHookDelayMs('-1')).toBe(0) + }) + + it('preserves fractional and valid delay values', () => { + expect(normalizeLifecycleHookDelayMs('1.5')).toBe(1.5) + expect(normalizeLifecycleHookDelayMs(25)).toBe(25) + }) +})