From 9cb600fe55519a4757cc9c37c3ed7493b64835bc Mon Sep 17 00:00:00 2001 From: BitFun Dev Date: Fri, 10 Jul 2026 14:41:40 +0800 Subject: [PATCH 1/3] fix(desktop): prevent duplicate tray icons when multiple workspace instances are running on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a named kernel mutex (CreateMutexW) to detect whether another BitFun process is already running. Only the primary instance creates the system tray icon; secondary instances skip tray creation but otherwise run normally. The existing OnceLock guard inside setup_tray() is kept as an in-process safety net. The mutex handle is held by PRIMARY_MUTEX (OnceLock) for the lifetime of the primary process so the kernel object is never destroyed prematurely. Non-primary instances intentionally leak their duplicate handle — harmless; reclaimed on process exit. --- src/apps/desktop/src/api/app_state.rs | 3 + src/apps/desktop/src/api/system_api.rs | 8 ++- src/apps/desktop/src/lib.rs | 4 +- src/apps/desktop/src/single_instance.rs | 90 +++++++++++++++++++++++++ src/apps/desktop/src/tray.rs | 26 +++++++ 5 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 src/apps/desktop/src/single_instance.rs diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 55aac1fe20..aaaabb223b 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -87,11 +87,13 @@ pub struct AppState { /// Cancellation flags for active file transfers (download/upload), keyed by transfer_id. pub active_transfers: Arc>>>, pub announcement_scheduler: Arc, + pub is_primary_instance: bool, } impl AppState { pub async fn new_async( token_usage_service: Arc, + is_primary_instance: bool, ) -> BitFunResult { let start_time = std::time::Instant::now(); @@ -311,6 +313,7 @@ impl AppState { active_searches: Arc::new(Mutex::new(HashMap::new())), active_transfers: Arc::new(Mutex::new(HashMap::new())), announcement_scheduler, + is_primary_instance, }; if let Some(workspace_info) = initial_workspace { diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index 43cfad3805..4afb017a8c 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -445,8 +445,9 @@ pub async fn quit_app(app: tauri::AppHandle) -> Result<(), String> { pub async fn minimize_to_tray( app: tauri::AppHandle, startup_trace: State<'_, DesktopStartupTrace>, + state: State<'_, AppState>, ) -> Result<(), String> { - if let Err(error) = crate::tray::setup_tray(&app, &startup_trace) { + if let Err(error) = crate::tray::setup_tray(&app, &startup_trace, state.is_primary_instance) { log::warn!("Failed to initialize tray before minimizing: {}", error); } if let Some(window) = app.get_webview_window("main") { @@ -461,8 +462,9 @@ pub async fn minimize_to_tray( pub async fn initialize_tray_after_startup( app: tauri::AppHandle, startup_trace: State<'_, DesktopStartupTrace>, + state: State<'_, AppState>, ) -> Result<(), String> { - crate::tray::setup_tray(&app, &startup_trace).map_err(|e| e.to_string()) + crate::tray::setup_tray(&app, &startup_trace, state.is_primary_instance).map_err(|e| e.to_string()) } /// Minimal startup-window controls used by the static pre-React splash. @@ -508,7 +510,7 @@ pub async fn startup_window_control( crate::perform_process_exit_cleanup(); app.exit(0); } else { - if let Err(error) = crate::tray::setup_tray(&app, &startup_trace) { + if let Err(error) = crate::tray::setup_tray(&app, &startup_trace, state.is_primary_instance) { log::warn!("Failed to initialize tray before startup close: {}", error); } window.hide().map_err(|error| { diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 36cba2610a..4c1855ae8a 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -6,6 +6,7 @@ pub mod computer_use; pub mod crash_diagnostics; pub mod logging; pub mod macos_menubar; +pub mod single_instance; pub mod startup_trace; pub mod theme; pub mod tray; @@ -209,6 +210,7 @@ fn get_startup_native_trace( /// Tauri application entry point #[cfg_attr(mobile, tauri::mobile_entry_point)] pub async fn run() { + let is_primary = single_instance::is_primary_instance(); let startup_started = Instant::now(); let startup_trace_id = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -320,7 +322,7 @@ pub async fn run() { ); let step_started = Instant::now(); - let app_state = match AppState::new_async(token_usage_service).await { + let app_state = match AppState::new_async(token_usage_service, is_primary).await { Ok(state) => state, Err(e) => { log::error!("Failed to initialize AppState: {}", e); diff --git a/src/apps/desktop/src/single_instance.rs b/src/apps/desktop/src/single_instance.rs new file mode 100644 index 0000000000..811c0fd985 --- /dev/null +++ b/src/apps/desktop/src/single_instance.rs @@ -0,0 +1,90 @@ +//! Per-process single-instance detection via OS primitives. +//! +//! On Windows a named kernel mutex ensures at most one process holds the +//! "primary" role. On other platforms the function always returns `true` +//! because the system tray icon duplication issue is Windows-specific. + +#[cfg(target_os = "windows")] +mod imp { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use std::sync::OnceLock; + + extern "system" { + fn CreateMutexW( + lp_mutex_attributes: *const std::ffi::c_void, + b_initial_owner: i32, + lp_name: *const u16, + ) -> isize; + fn GetLastError() -> u32; + } + + const ERROR_ALREADY_EXISTS: u32 = 183; + + /// The handle is kept alive for the lifetime of the primary process so the + /// kernel mutex is not destroyed prematurely. Non-primary instances + /// intentionally leak their handle — it is harmless and avoids a separate + /// `CloseHandle` extern declaration (which would clash with the existing + /// `windows`‑crate declaration in the same build graph). + static PRIMARY_MUTEX: OnceLock = OnceLock::new(); + + pub(crate) fn is_primary_instance_impl() -> bool { + #[cfg(not(test))] + let name: Vec = OsStr::new("Global\\BitFun_Desktop_Instance_Mutex") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + #[cfg(test)] + let name: Vec = OsStr::new("Global\\BitFun_Desktop_Instance_Mutex_Test") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let handle = unsafe { CreateMutexW(std::ptr::null(), 0, name.as_ptr()) }; + if handle == 0 { + // If we cannot create the mutex, conservatively claim we're the + // primary instance so the tray icon is not silently lost. + return true; + } + + let is_primary = unsafe { GetLastError() } != ERROR_ALREADY_EXISTS; + + if is_primary { + PRIMARY_MUTEX.set(handle).ok(); + } + // Non-primary: intentionally leak the duplicate handle — the kernel + // object is still owned by the primary process and the leaked handle + // is reclaimed by the OS when this process exits. + + is_primary + } +} + +#[cfg(not(target_os = "windows"))] +mod imp { + pub(crate) fn is_primary_instance_impl() -> bool { + true + } +} + +pub(crate) fn is_primary_instance() -> bool { + imp::is_primary_instance_impl() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_call_primary_second_not() { + // The first call in a process should always report primary, and a + // second call must not because the mutex is already held. Verifying + // both assertions in the same test avoids the ordering dependency + // that would exist between two separate tests sharing the mutex. + assert!(is_primary_instance(), "first call must report primary"); + assert!( + !is_primary_instance(), + "second call must not report primary" + ); + } +} diff --git a/src/apps/desktop/src/tray.rs b/src/apps/desktop/src/tray.rs index 1ed4b2be9e..ee0de98c2f 100644 --- a/src/apps/desktop/src/tray.rs +++ b/src/apps/desktop/src/tray.rs @@ -31,6 +31,10 @@ static TRAY_ICON: OnceLock = OnceLock::new(); static TRAY_SETUP_LOCK: Mutex<()> = Mutex::new(()); const TRAY_TRACE_CATEGORY: &str = "native_background"; +pub(crate) fn should_create_tray(is_primary: bool) -> bool { + is_primary && TRAY_ICON.get().is_none() +} + struct TrayStrings { show_app: &'static str, quit_app: &'static str, @@ -171,7 +175,12 @@ async fn tray_toggle_desktop_pet(app: &AppHandle) -> Result<(), String> { pub fn setup_tray( app: &tauri::AppHandle, startup_trace: &DesktopStartupTrace, + is_primary: bool, ) -> Result<(), Box> { + if !should_create_tray(is_primary) { + return Ok(()); + } + if TRAY_ICON.get().is_some() { return Ok(()); } @@ -299,3 +308,20 @@ fn toggle_main_window(app: &tauri::AppHandle) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_create_tray_for_primary_without_existing_icon() { + // Before any tray is created, a primary instance should create one. + assert!(should_create_tray(true)); + } + + #[test] + fn should_not_create_tray_for_non_primary() { + // A non-primary instance should never create a tray icon. + assert!(!should_create_tray(false)); + } +} From bb218aa7e730f9ba881bff6b3451ee1f82f96886 Mon Sep 17 00:00:00 2001 From: ArrogHie Date: Mon, 13 Jul 2026 10:44:30 +0800 Subject: [PATCH 2/3] fix(flow-chat): prevent duplicate plan display and ACP permission options - Use deterministic planFilePath-based IDs instead of random IDs in appendPlanDisplayItemsIfNeeded so that addModelRoundItem dedup works. - Add handledPlanDisplayTurns guard to ensure plan display items are injected at most once per (sessionId, turnId). - Make finalizePendingTurnCompletionNow return boolean so the fallback finalizeTurnCompletionState call in handleSessionStateChanged skips when the pending was already processed. - Add content-based dedup in addModelRoundItem for CreatePlan items that share the same plan_file_path as a defensive layer. - Add idempotency checks in handleConfirmationNeeded and applyAcpPermissionRequest to skip duplicate confirmation events. - Add debug logs at all dedup and idempotency guard points. --- scripts/ensure-openssl-windows.mjs | 2 +- .../src/flow_chat/services/FlowChatManager.ts | 1 + .../AcpPermissionToolCardModule.ts | 16 ++++++++++-- .../EventHandlerModule.test.ts | 1 + .../flow-chat-manager/EventHandlerModule.ts | 19 ++++++++++---- .../flow-chat-manager/MessageModule.test.ts | 1 + .../flow-chat-manager/ToolEventModule.ts | 11 ++++++++ .../services/flow-chat-manager/types.ts | 7 +++++ .../src/flow_chat/store/FlowChatStore.ts | 26 +++++++++++++++++++ 9 files changed, 76 insertions(+), 8 deletions(-) diff --git a/scripts/ensure-openssl-windows.mjs b/scripts/ensure-openssl-windows.mjs index 0dc8919f44..53b44c38d2 100644 --- a/scripts/ensure-openssl-windows.mjs +++ b/scripts/ensure-openssl-windows.mjs @@ -42,7 +42,7 @@ async function downloadToFile(url, filePath) { function extractZipWindows(zipPath, destDir) { const esc = (p) => p.replace(/'/g, "''"); const ps = `Expand-Archive -LiteralPath '${esc(zipPath)}' -DestinationPath '${esc(destDir)}' -Force`; - const r = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], { + const r = spawnSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], { stdio: 'inherit', cwd: ROOT, }); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 666692c31d..2f94a1ede6 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -82,6 +82,7 @@ export class FlowChatManager { runtimeStatusTimers: new Map(), userCancelledSessionIds: new Set(), handledTerminalTurnEvents: new Set(), + handledPlanDisplayTurns: new Set(), currentWorkspacePath: null }; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts index b4f658d20f..a33f0422f5 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts @@ -5,6 +5,9 @@ import { FlowChatStore } from '../../store/FlowChatStore'; import type { FlowToolItem } from '../../types/flow-chat'; import type { AcpPermissionRequestEvent } from '@/infrastructure/api/service-api/ACPClientAPI'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('AcpPermissionToolCardModule'); const pendingAcpPermissionRequests = new Map(); @@ -18,7 +21,7 @@ function acpPermissionToolId(event: AcpPermissionRequestEvent): string | null { function findToolContextById( store: FlowChatStore, toolId: string -): { sessionId: string; turnId: string; itemId: string } | null { +): { sessionId: string; turnId: string; itemId: string; item: FlowToolItem } | null { const state = store.getState(); for (const [sessionId, session] of state.sessions) { for (const turn of session.dialogTurns) { @@ -29,7 +32,7 @@ function findToolContextById( )) as FlowToolItem | undefined; if (item) { - return { sessionId, turnId: turn.id, itemId: item.id }; + return { sessionId, turnId: turn.id, itemId: item.id, item }; } } } @@ -47,6 +50,15 @@ function applyAcpPermissionRequest( return false; } + // Idempotency: skip if the same permission was already applied to this tool + if (toolContext.item.acpPermission?.permissionId === event.permissionId) { + log.debug('Skipping duplicate ACP permission request', { + toolId, + permissionId: event.permissionId, + }); + return true; + } + store.updateModelRoundItem(toolContext.sessionId, toolContext.turnId, toolContext.itemId, { requiresConfirmation: true, userConfirmed: false, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index b0e98dce35..1e341de8e5 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -518,6 +518,7 @@ function createFlowChatContext(): FlowChatContext { runtimeStatusTimers: new Map(), userCancelledSessionIds: new Set(), handledTerminalTurnEvents: new Set(), + handledPlanDisplayTurns: new Set(), currentWorkspacePath: null, }; } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 1430d321fc..030c62ddb3 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -1160,18 +1160,20 @@ function finalizePendingTurnCompletion( finalizeTurnCompletionState(context, sessionId, turnId); } -function finalizePendingTurnCompletionNow(context: FlowChatContext, sessionId: string): void { +function finalizePendingTurnCompletionNow(context: FlowChatContext, sessionId: string): boolean { const pending = context.pendingTurnCompletions.get(sessionId); if (!pending) { - return; + return false; } if (pending.timer) { clearTimeout(pending.timer); } + context.pendingTurnCompletions.delete(sessionId); flushPendingBatchedEvents(context); finalizeTurnCompletionState(context, sessionId, pending.turnId); + return true; } function findFinishingTurnForBackendIdle( @@ -1367,8 +1369,8 @@ export function handleSessionStateChanged(context: FlowChatContext, event: any): machineContext.backendSyncedAt = Date.now(); if (isExpectedFinishingDrift) { - finalizePendingTurnCompletionNow(context, sessionId); - if (stateMachineManager.getCurrentState(sessionId) === SessionExecutionState.FINISHING) { + const didFinalize = finalizePendingTurnCompletionNow(context, sessionId); + if (!didFinalize && stateMachineManager.getCurrentState(sessionId) === SessionExecutionState.FINISHING) { const finishingTurnId = findFinishingTurnForBackendIdle( context, sessionId, @@ -2679,6 +2681,13 @@ function appendPlanDisplayItemsIfNeeded( turnId: string, dialogTurn: DialogTurn ): void { + const planDisplayKey = `${sessionId}:${turnId}`; + if (context.handledPlanDisplayTurns.has(planDisplayKey)) { + log.debug('Skipping duplicate plan display injection', { sessionId, turnId }); + return; + } + context.handledPlanDisplayTurns.add(planDisplayKey); + const modifiedPlanFiles = detectModifiedPlanFiles(dialogTurn); if (modifiedPlanFiles.length === 0) return; @@ -2687,7 +2696,7 @@ function appendPlanDisplayItemsIfNeeded( for (const planFilePath of modifiedPlanFiles) { const planToolItem: FlowToolItem = { - id: `plan-display-${Date.now()}-${Math.random().toString(36).slice(2)}`, + id: `plan-display-${planFilePath}`, type: 'tool', toolName: 'CreatePlan', toolCall: { input: {}, id: '' }, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts index 1ee02510d7..cc7adfce3c 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts @@ -81,6 +81,7 @@ describe('MessageModule cancellation', () => { pendingTurnCompletions: new Map(), runtimeStatusTimers: new Map(), handledTerminalTurnEvents: new Set(), + handledPlanDisplayTurns: new Set(), contentBuffers, activeTextItems, }; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts index f5458afa7e..915c120a0e 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts @@ -595,6 +595,17 @@ function handleConfirmationNeeded( turnId: string, toolEvent: ConfirmationNeededToolEvent ): void { + const existingItem = store.findToolItem(sessionId, turnId, toolEvent.tool_id) as FlowToolItem | null; + if (existingItem?.status === 'pending_confirmation') { + log.debug('Skipping duplicate tool confirmation needed event', { + sessionId, + turnId, + toolId: toolEvent.tool_id, + toolName: toolEvent.tool_name, + }); + return; + } + store.updateModelRoundItem(sessionId, turnId, toolEvent.tool_id, { requiresConfirmation: true, status: 'pending_confirmation', diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts index 2ee4734cdf..ed97fd42e1 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts @@ -51,6 +51,13 @@ export interface FlowChatContext { * this set is used to make handlers idempotent. Key format: `sessionId:turnId`. */ handledTerminalTurnEvents: Set; + /** + * Turn IDs whose plan-display items have already been appended. + * Guards against duplicate plan display injection when finalizeTurnCompletionState + * is reached via multiple code paths for the same turn. + * Key format: `sessionId:turnId`. + */ + handledPlanDisplayTurns: Set; currentWorkspacePath: string | null; } diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index eee87d50ea..9eae7bc322 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -2541,6 +2541,32 @@ export class FlowChatStore { return turn; } + // Content-based dedup: skip duplicate plan-display items that reference the same plan file. + // This guards against any code path that might inject a plan-display item more than once. + if ( + item.type === 'tool' && + (item as FlowToolItem).toolName === 'CreatePlan' && + (item as FlowToolItem).toolResult?.result?.plan_file_path + ) { + const incomingPlanPath = (item as FlowToolItem).toolResult!.result!.plan_file_path; + const duplicatePlanItem = targetModelRound.items.find(existingItem => { + if (existingItem.type !== 'tool') return false; + const et = existingItem as FlowToolItem; + return ( + et.toolName === 'CreatePlan' && + et.toolResult?.result?.plan_file_path === incomingPlanPath + ); + }); + if (duplicatePlanItem) { + log.debug('Skipping duplicate plan-display item (same planFilePath)', { + sessionId, + dialogTurnId, + planFilePath: incomingPlanPath, + }); + return turn; + } + } + const updatedModelRounds = [...turn.modelRounds]; const activeAttempts = targetModelRound.attempts ?? deriveRoundAttemptsFromItems(targetModelRound.items); const incomingAttemptId = typeof item.attemptId === 'string' && item.attemptId.length > 0 From d284947139de1b88f9b0d1db680067308743482a Mon Sep 17 00:00:00 2001 From: ArrogHie Date: Mon, 13 Jul 2026 10:47:02 +0800 Subject: [PATCH 3/3] fix build in powershell --- scripts/ensure-openssl-windows.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ensure-openssl-windows.mjs b/scripts/ensure-openssl-windows.mjs index 53b44c38d2..0dc8919f44 100644 --- a/scripts/ensure-openssl-windows.mjs +++ b/scripts/ensure-openssl-windows.mjs @@ -42,7 +42,7 @@ async function downloadToFile(url, filePath) { function extractZipWindows(zipPath, destDir) { const esc = (p) => p.replace(/'/g, "''"); const ps = `Expand-Archive -LiteralPath '${esc(zipPath)}' -DestinationPath '${esc(destDir)}' -Force`; - const r = spawnSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], { + const r = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps], { stdio: 'inherit', cwd: ROOT, });