From 18b955c72e36b0a69d317c828cb2bf90b4ff8652 Mon Sep 17 00:00:00 2001 From: lin onetwo Date: Fri, 2 Jan 2026 14:54:29 +0800 Subject: [PATCH] Refactor and enhance Tidgi mini window initialization and sync Refactors Tidgi mini window startup to use a new initializeTidgiMiniWindow method, improving workspace selection logic and view management. Adds concurrency locks to prevent race conditions during open/close operations. Enhances workspace sync/fixed mode handling, view cleanup, and error logging. Updates interfaces and utilities to support new behaviors and improves robustness of tray icon creation and view realignment. --- src/main.ts | 8 +- src/services/view/index.ts | 76 ++++++++-- .../windows/handleAttachToTidgiMiniWindow.ts | 58 +++++--- src/services/windows/index.ts | 134 +++++++++++++++++- src/services/windows/interface.ts | 14 ++ src/services/workspacesView/index.ts | 19 ++- src/services/workspacesView/utilities.ts | 4 +- 7 files changed, 270 insertions(+), 43 deletions(-) diff --git a/src/main.ts b/src/main.ts index d0bf0afd..24a5b845 100755 --- a/src/main.ts +++ b/src/main.ts @@ -150,10 +150,8 @@ const commonInit = async (): Promise => { // Process any pending deep link after workspaces are initialized await deepLinkService.processPendingDeepLink(); - const tidgiMiniWindow = await preferenceService.get('tidgiMiniWindow'); - if (tidgiMiniWindow) { - await windowService.openTidgiMiniWindow(true, false); - } + // Initialize tidgi mini window if enabled + await windowService.initializeTidgiMiniWindow(); ipcMain.emit('request-update-pause-notifications-info'); // Fix webview is not resized automatically @@ -222,6 +220,8 @@ app.on( 'before-quit', async (): Promise => { logger.info('App before-quit'); + // Clean up tidgi mini window before quit to ensure tray is destroyed + await windowService.closeTidgiMiniWindow(true); destroyLogger(); await Promise.all([ databaseService.immediatelyStoreSettingsToFile(), diff --git a/src/services/view/index.ts b/src/services/view/index.ts index 28259c72..1c4a9b0c 100644 --- a/src/services/view/index.ts +++ b/src/services/view/index.ts @@ -392,17 +392,40 @@ export class View implements IViewService { // For tidgi mini window, decide which workspace to show based on preferences let tidgiMiniWindowTask = Promise.resolve(); if (tidgiMiniWindow) { - // Default to sync (undefined or true), otherwise use fixed workspace ID (fallback to main if not set) - const { targetWorkspaceId } = await getTidgiMiniWindowTargetWorkspace(workspaceID); - const tidgiMiniWindowWorkspaceId = targetWorkspaceId || workspaceID; + // Get preference settings to determine behavior + const { shouldSync, targetWorkspaceId } = await getTidgiMiniWindowTargetWorkspace(workspaceID); - logger.debug('setActiveViewForAllBrowserViews tidgi mini window decision', { - function: 'setActiveViewForAllBrowserViews', - tidgiMiniWindowWorkspaceId, - willSetActiveView: true, - }); - - tidgiMiniWindowTask = this.setActiveView(tidgiMiniWindowWorkspaceId, WindowNames.tidgiMiniWindow); + if (shouldSync) { + // Sync with main window - use the same workspace as main window + logger.debug('setActiveViewForAllBrowserViews tidgi mini window syncing with main window', { + function: 'setActiveViewForAllBrowserViews', + workspaceID, + }); + tidgiMiniWindowTask = this.setActiveView(workspaceID, WindowNames.tidgiMiniWindow); + } else if (targetWorkspaceId) { + // Fixed workspace mode - only update if the main window is switching TO the fixed workspace + // Otherwise, keep showing the fixed workspace (don't update) + if (workspaceID === targetWorkspaceId) { + logger.debug('setActiveViewForAllBrowserViews main window switching to fixed workspace', { + function: 'setActiveViewForAllBrowserViews', + targetWorkspaceId, + }); + tidgiMiniWindowTask = this.setActiveView(targetWorkspaceId, WindowNames.tidgiMiniWindow); + } else { + // Main window is switching to a different workspace, but tidgi mini window stays on fixed workspace + logger.debug('setActiveViewForAllBrowserViews tidgi mini window staying on fixed workspace', { + function: 'setActiveViewForAllBrowserViews', + targetWorkspaceId, + mainWindowWorkspaceId: workspaceID, + }); + // Don't change tidgi mini window view - it should keep showing the fixed workspace + } + } else { + // Not syncing but no fixed workspace selected - do nothing for tidgi mini window + logger.debug('setActiveViewForAllBrowserViews no fixed workspace selected', { + function: 'setActiveViewForAllBrowserViews', + }); + } } else { logger.info('setActiveViewForAllBrowserViews tidgi mini window not enabled', { function: 'setActiveViewForAllBrowserViews', @@ -487,13 +510,40 @@ export class View implements IViewService { } } - public removeAllViewOfWorkspace(workspaceID: string): void { + /** + * Remove all views for a workspace. + * @param workspaceID The workspace ID + * @param permanent If true, views will be fully destroyed. If false, views are just removed from window but kept in memory for quick restore. + */ + public removeAllViewOfWorkspace(workspaceID: string, permanent = false): void { const views = this.views.get(workspaceID); if (views !== undefined) { [...views.keys()].forEach((windowName) => { - this.removeView(workspaceID, windowName); + const view = views.get(windowName); + if (view) { + this.removeView(workspaceID, windowName); + if (permanent) { + // Fully destroy the webContents to free resources + try { + if (!view.webContents.isDestroyed()) { + view.webContents.close(); + } + } catch (error) { + logger.warn('Failed to close webContents during permanent removal', { + workspaceID, + windowName, + error, + function: 'removeAllViewOfWorkspace', + }); + } + } + } }); - views.clear(); + if (permanent) { + this.views.delete(workspaceID); + } else { + views.clear(); + } } } diff --git a/src/services/windows/handleAttachToTidgiMiniWindow.ts b/src/services/windows/handleAttachToTidgiMiniWindow.ts index abd03e8f..a8d62620 100644 --- a/src/services/windows/handleAttachToTidgiMiniWindow.ts +++ b/src/services/windows/handleAttachToTidgiMiniWindow.ts @@ -32,9 +32,25 @@ export async function handleAttachToTidgiMiniWindow( // "Segmentation fault (core dumped)" bug on Linux // https://github.com/electron/electron/issues/22137#issuecomment-586105622 // https://github.com/atomery/translatium/issues/164 - const tray = new Tray(nativeImage.createEmpty()); - // icon template is not supported on Windows & Linux - tray.setImage(nativeImage.createFromPath(TIDGI_MINI_WINDOW_ICON_PATH)); + let tray: Tray; + try { + tray = new Tray(nativeImage.createEmpty()); + // icon template is not supported on Windows & Linux + const iconImage = nativeImage.createFromPath(TIDGI_MINI_WINDOW_ICON_PATH); + if (iconImage.isEmpty()) { + logger.warn('TidGi mini window icon not found or empty', { + function: 'handleAttachToTidgiMiniWindow', + iconPath: TIDGI_MINI_WINDOW_ICON_PATH, + }); + } + tray.setImage(iconImage); + } catch (error) { + logger.error('Failed to create tray for tidgi mini window', { + function: 'handleAttachToTidgiMiniWindow', + error, + }); + throw error; + } // Create tidgi mini window-specific window configuration // Override titleBar settings from windowConfig with tidgi mini window-specific preference @@ -70,25 +86,35 @@ export async function handleAttachToTidgiMiniWindow( tidgiMiniWindow.on('after-create-window', () => { if (tidgiMiniWindow.window !== undefined) { tidgiMiniWindow.window.on('focus', async () => { + // Re-check window existence after async boundary + if (tidgiMiniWindow.window === undefined || tidgiMiniWindow.window.isDestroyed()) { + logger.debug('tidgiMiniWindow.window is undefined or destroyed in focus handler', { function: 'handleAttachToTidgiMiniWindow' }); + return; + } + logger.debug('restore window position', { function: 'handleAttachToTidgiMiniWindow' }); if (windowWithBrowserViewState === undefined) { logger.debug('windowWithBrowserViewState is undefined for tidgiMiniWindow', { function: 'handleAttachToTidgiMiniWindow' }); } else { - if (tidgiMiniWindow.window === undefined) { - logger.debug('tidgiMiniWindow.window is undefined', { function: 'handleAttachToTidgiMiniWindow' }); - } else { - const haveXYValue = [windowWithBrowserViewState.x, windowWithBrowserViewState.y].every((value) => Number.isFinite(value)); - const haveWHValue = [windowWithBrowserViewState.width, windowWithBrowserViewState.height].every((value) => Number.isFinite(value)); - if (haveXYValue) { - tidgiMiniWindow.window.setPosition(windowWithBrowserViewState.x, windowWithBrowserViewState.y, false); - } - if (haveWHValue) { - tidgiMiniWindow.window.setSize(windowWithBrowserViewState.width, windowWithBrowserViewState.height, false); - } + const haveXYValue = [windowWithBrowserViewState.x, windowWithBrowserViewState.y].every((value) => Number.isFinite(value)); + const haveWHValue = [windowWithBrowserViewState.width, windowWithBrowserViewState.height].every((value) => Number.isFinite(value)); + if (haveXYValue) { + tidgiMiniWindow.window.setPosition(windowWithBrowserViewState.x, windowWithBrowserViewState.y, false); + } + if (haveWHValue) { + tidgiMiniWindow.window.setSize(windowWithBrowserViewState.width, windowWithBrowserViewState.height, false); } } - const view = await viewService.getActiveBrowserView(); - view?.webContents.focus(); + + try { + const view = await viewService.getActiveBrowserView(); + // Check again after async call + if (view && !view.webContents.isDestroyed()) { + view.webContents.focus(); + } + } catch (error) { + logger.warn('Failed to focus view in tidgi mini window', { function: 'handleAttachToTidgiMiniWindow', error }); + } }); tidgiMiniWindow.window.removeAllListeners('close'); tidgiMiniWindow.window.on('close', (event) => { diff --git a/src/services/windows/index.ts b/src/services/windows/index.ts index 0adac234..128628ad 100644 --- a/src/services/windows/index.ts +++ b/src/services/windows/index.ts @@ -35,6 +35,8 @@ export class Window implements IWindowService { private windowMeta = {} as Partial; /** tidgi mini window version of main window, if user set attachToTidgiMiniWindow to true in preferences */ private tidgiMiniWindowMenubar?: Menubar; + /** Lock to prevent concurrent tidgi mini window operations */ + private tidgiMiniWindowOperationLock = false; constructor( @inject(serviceIdentifier.Preference) private readonly preferenceService: IPreferenceService, @@ -126,10 +128,20 @@ export class Window implements IWindowService { this.windows.clear(); } + /** + * Check if tidgi mini window is visible (open and showing on screen) + */ public async isTidgiMiniWindowOpen(): Promise { return this.tidgiMiniWindowMenubar?.window?.isVisible() ?? false; } + /** + * Check if tidgi mini window exists (created but may be hidden) + */ + public isTidgiMiniWindowExists(): boolean { + return this.tidgiMiniWindowMenubar?.window !== undefined; + } + public async open(windowName: N, meta?: WindowMeta[N], config?: IWindowOpenConfig): Promise; public async open(windowName: N, meta: WindowMeta[N] | undefined, config: IWindowOpenConfig | undefined, returnWindow: true): Promise; @@ -371,6 +383,13 @@ export class Window implements IWindowService { } public async openTidgiMiniWindow(enableIt = true, showWindow = true): Promise { + // Prevent concurrent operations on tidgi mini window + if (this.tidgiMiniWindowOperationLock) { + logger.warn('TidGi mini window operation already in progress, skipping', { function: 'openTidgiMiniWindow' }); + return; + } + this.tidgiMiniWindowOperationLock = true; + try { // Check if tidgi mini window is already enabled if (this.tidgiMiniWindowMenubar?.window !== undefined) { @@ -432,10 +451,19 @@ export class Window implements IWindowService { } catch (error) { logger.error('Failed to open tidgi mini window', { error, function: 'openTidgiMiniWindow' }); throw error; + } finally { + this.tidgiMiniWindowOperationLock = false; } } public async closeTidgiMiniWindow(disableIt = false): Promise { + // Prevent concurrent operations on tidgi mini window + if (this.tidgiMiniWindowOperationLock) { + logger.warn('TidGi mini window operation already in progress, skipping', { function: 'closeTidgiMiniWindow' }); + return; + } + this.tidgiMiniWindowOperationLock = true; + try { // Check if tidgi mini window exists if (this.tidgiMiniWindowMenubar === undefined) { @@ -466,9 +494,68 @@ export class Window implements IWindowService { } catch (error) { logger.error('Failed to close tidgi mini window', { error }); throw error; + } finally { + this.tidgiMiniWindowOperationLock = false; } } + /** + * Initialize tidgi mini window on app startup. + * Creates window, determines target workspace based on preferences, and sets up view. + */ + public async initializeTidgiMiniWindow(): Promise { + const tidgiMiniWindowEnabled = await this.preferenceService.get('tidgiMiniWindow'); + if (!tidgiMiniWindowEnabled) { + logger.debug('TidGi mini window is disabled, skipping initialization', { function: 'initializeTidgiMiniWindow' }); + return; + } + + // Create the window but don't show it yet + await this.openTidgiMiniWindow(true, false); + + // Determine which workspace to show based on preferences (sync vs fixed) + const { shouldSync, targetWorkspaceId } = await getTidgiMiniWindowTargetWorkspace(); + + if (!targetWorkspaceId) { + logger.info('No target workspace for tidgi mini window (sync disabled and no fixed workspace selected)', { function: 'initializeTidgiMiniWindow' }); + return; + } + + const workspaceService = container.get(serviceIdentifier.Workspace); + const targetWorkspace = await workspaceService.get(targetWorkspaceId); + + if (!targetWorkspace || targetWorkspace.pageType) { + // Skip page workspaces (like Agent) - they don't have browser views + logger.debug('Target workspace is a page type or not found, skipping view creation', { + function: 'initializeTidgiMiniWindow', + targetWorkspaceId, + isPageType: targetWorkspace?.pageType, + }); + return; + } + + // Create view for the target workspace + const viewService = container.get(serviceIdentifier.View); + const existingView = viewService.getView(targetWorkspace.id, WindowNames.tidgiMiniWindow); + + if (!existingView) { + logger.info('Creating tidgi mini window view for target workspace', { + function: 'initializeTidgiMiniWindow', + workspaceId: targetWorkspace.id, + shouldSync, + }); + await viewService.addView(targetWorkspace, WindowNames.tidgiMiniWindow); + } + + // Realign to ensure view is properly positioned + logger.info('Realigning workspace view for tidgi mini window after initialization', { + function: 'initializeTidgiMiniWindow', + workspaceId: targetWorkspace.id, + shouldSync, + }); + await container.get(serviceIdentifier.WorkspaceView).realignActiveWorkspace(targetWorkspace.id); + } + public async updateWindowProperties(windowName: WindowNames, properties: { alwaysOnTop?: boolean }): Promise { try { const window = this.get(windowName); @@ -527,15 +614,17 @@ export class Window implements IWindowService { await this.preferenceService.set('tidgiMiniWindowShowSidebar', false); } - // When tidgi mini window workspace settings change, hide all views and let the next window show trigger realignment + // When tidgi mini window workspace settings change, update the displayed workspace const tidgiMiniWindow = this.get(WindowNames.tidgiMiniWindow); if (tidgiMiniWindow) { const workspaceService = container.get(serviceIdentifier.Workspace); const viewService = container.get(serviceIdentifier.View); const allWorkspaces = await workspaceService.getWorkspacesAsList(); - logger.debug(`Hiding all tidgi mini window views (${allWorkspaces.length} workspaces)`, { function: 'reactWhenPreferencesChanged', key }); - // Hide all views - the correct view will be shown when window is next opened - await Promise.all( + + logger.debug(`Updating tidgi mini window workspace (${allWorkspaces.length} total workspaces)`, { function: 'reactWhenPreferencesChanged', key }); + + // Hide all current views (use allSettled to ensure all operations complete even if some fail) + const hideResults = await Promise.allSettled( allWorkspaces.map(async (workspace) => { const view = viewService.getView(workspace.id, WindowNames.tidgiMiniWindow); if (view) { @@ -543,7 +632,42 @@ export class Window implements IWindowService { } }), ); - // View creation is handled by openTidgiMiniWindow when the window is shown + + // Log any failures + const failures = hideResults.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); + if (failures.length > 0) { + logger.warn('Some views failed to hide', { + function: 'reactWhenPreferencesChanged', + failureCount: failures.length, + errors: failures.map(f => String(f.reason)), + }); + } + + // Determine which workspace should be shown now + const { targetWorkspaceId } = await getTidgiMiniWindowTargetWorkspace(); + + if (targetWorkspaceId) { + const targetWorkspace = await workspaceService.get(targetWorkspaceId); + + if (targetWorkspace && !targetWorkspace.pageType) { + // This is a wiki workspace - ensure it has a view + const existingView = viewService.getView(targetWorkspace.id, WindowNames.tidgiMiniWindow); + if (!existingView) { + logger.info('Creating view for new target workspace', { + function: 'reactWhenPreferencesChanged', + targetWorkspaceId, + }); + await viewService.addView(targetWorkspace, WindowNames.tidgiMiniWindow); + } + + // Show the target workspace view and realign + logger.info('Showing and realigning target workspace', { + function: 'reactWhenPreferencesChanged', + targetWorkspaceId, + }); + await container.get(serviceIdentifier.WorkspaceView).realignActiveWorkspace(targetWorkspaceId); + } + } } else { logger.warn('tidgiMiniWindow not found, skipping view management', { function: 'reactWhenPreferencesChanged', key }); } diff --git a/src/services/windows/interface.ts b/src/services/windows/interface.ts index 2ccbba9a..c5ba44fa 100644 --- a/src/services/windows/interface.ts +++ b/src/services/windows/interface.ts @@ -37,7 +37,14 @@ export interface IWindowService { */ hide(windowName: WindowNames): Promise; isFullScreen(windowName?: WindowNames): Promise; + /** + * Check if tidgi mini window is visible (open and showing on screen) + */ isTidgiMiniWindowOpen(): Promise; + /** + * Check if tidgi mini window exists (created but may be hidden) + */ + isTidgiMiniWindowExists(): boolean; loadURL(windowName: WindowNames, newUrl?: string): Promise; maximize(): Promise; /** @@ -57,6 +64,11 @@ export interface IWindowService { stopFindInPage(close?: boolean, windowName?: WindowNames): Promise; toggleTidgiMiniWindow(): Promise; updateWindowMeta(windowName: N, meta?: WindowMeta[N]): Promise; + /** + * Initialize tidgi mini window on app startup. + * Creates window, determines target workspace based on preferences, and sets up view. + */ + initializeTidgiMiniWindow(): Promise; /** Open tidgi mini window without restart - hot reload. enableIt=true means fully enable and open. */ openTidgiMiniWindow(enableIt?: boolean, showWindow?: boolean): Promise; /** Close tidgi mini window. disableIt=true means fully disable and cleanup tray. */ @@ -78,6 +90,8 @@ export const WindowServiceIPCDescriptor = { goForward: ProxyPropertyType.Function, goHome: ProxyPropertyType.Function, isFullScreen: ProxyPropertyType.Function, + initializeTidgiMiniWindow: ProxyPropertyType.Function, + isTidgiMiniWindowExists: ProxyPropertyType.Function, isTidgiMiniWindowOpen: ProxyPropertyType.Function, loadURL: ProxyPropertyType.Function, maximize: ProxyPropertyType.Function, diff --git a/src/services/workspacesView/index.ts b/src/services/workspacesView/index.ts index f29ce09f..f16153ab 100644 --- a/src/services/workspacesView/index.ts +++ b/src/services/workspacesView/index.ts @@ -625,9 +625,22 @@ export class WorkspaceView implements IWorkspaceViewService { logger.info(`realignActiveWorkspaceView: no tidgiMiniWindowBrowserViewWebContent, skip tidgi mini window for ${workspaceToRealign.id}.`); } else { // For tidgi mini window, decide which workspace to show based on preferences - const { targetWorkspaceId } = await getTidgiMiniWindowTargetWorkspace(workspaceToRealign.id); - const tidgiMiniWindowWorkspaceId = targetWorkspaceId || workspaceToRealign.id; - tasks.push(container.get(serviceIdentifier.View).realignActiveView(tidgiMiniWindow, tidgiMiniWindowWorkspaceId, WindowNames.tidgiMiniWindow)); + const { shouldSync, targetWorkspaceId } = await getTidgiMiniWindowTargetWorkspace(workspaceToRealign.id); + + if (shouldSync) { + // Sync mode - realign with the same workspace as main window + tasks.push(container.get(serviceIdentifier.View).realignActiveView(tidgiMiniWindow, workspaceToRealign.id, WindowNames.tidgiMiniWindow)); + } else if (targetWorkspaceId) { + // Fixed workspace mode - only realign if the workspace being realigned is the fixed workspace + if (workspaceToRealign.id === targetWorkspaceId) { + tasks.push(container.get(serviceIdentifier.View).realignActiveView(tidgiMiniWindow, targetWorkspaceId, WindowNames.tidgiMiniWindow)); + } else { + // Main window is realigning a different workspace, but tidgi mini window stays on fixed workspace + // Still need to realign the fixed workspace to ensure it's displayed correctly + tasks.push(container.get(serviceIdentifier.View).realignActiveView(tidgiMiniWindow, targetWorkspaceId, WindowNames.tidgiMiniWindow)); + } + } + // If not syncing and no fixed workspace, don't realign tidgi mini window } await Promise.all(tasks); } diff --git a/src/services/workspacesView/utilities.ts b/src/services/workspacesView/utilities.ts index fae9e6c5..e5258b14 100644 --- a/src/services/workspacesView/utilities.ts +++ b/src/services/workspacesView/utilities.ts @@ -26,8 +26,8 @@ export async function getTidgiMiniWindowTargetWorkspace(fallbackWorkspaceId?: st // Sync with main window - use fallback or active workspace targetWorkspaceId = fallbackWorkspaceId ?? (await container.get(serviceIdentifier.Workspace).getActiveWorkspace())?.id; } else { - // Use fixed workspace - targetWorkspaceId = tidgiMiniWindowFixedWorkspaceId; + // Use fixed workspace - convert empty string to undefined for consistent handling + targetWorkspaceId = tidgiMiniWindowFixedWorkspaceId || undefined; } return { shouldSync, targetWorkspaceId };