diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 6f350ceb9a..79786b377c 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -151,7 +151,6 @@ export enum IpcChannel { HideMiniWindow = 'hide-mini-window', ShowMiniWindow = 'show-mini-window', - MiniWindowReload = 'miniwindow-reload', ReduxStateChange = 'redux-state-change', ReduxStoreReady = 'redux-store-ready', @@ -159,5 +158,11 @@ export enum IpcChannel { // Search Window SearchWindow_Open = 'search-window:open', SearchWindow_Close = 'search-window:close', - SearchWindow_OpenUrl = 'search-window:open-url' + SearchWindow_OpenUrl = 'search-window:open-url', + + //Store Sync + StoreSync_Subscribe = 'store-sync:subscribe', + StoreSync_Unsubscribe = 'store-sync:unsubscribe', + StoreSync_OnUpdate = 'store-sync:on-update', + StoreSync_BroadcastSync = 'store-sync:broadcast-sync' } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e4c9bf3e40..f5fd62b9fa 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -24,6 +24,7 @@ import ObsidianVaultService from './services/ObsidianVaultService' import { ProxyConfig, proxyManager } from './services/ProxyManager' import { searchService } from './services/SearchService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' +import storeSyncService from './services/StoreSyncService' import { TrayService } from './services/TrayService' import { setOpenLinkExternal } from './services/WebviewService' import { windowService } from './services/WindowService' @@ -31,7 +32,6 @@ import { getResourcePath } from './utils' import { decrypt, encrypt } from './utils/aes' import { getConfigDir, getFilesDir } from './utils/file' import { compress, decompress } from './utils/zip' - const fileManager = new FileStorage() const backupManager = new BackupManager() const exportService = new ExportService(fileManager) @@ -338,4 +338,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) => setOpenLinkExternal(webviewId, isExternal) ) + + // store sync + storeSyncService.registerIpcHandler() } diff --git a/src/main/services/StoreSyncService.ts b/src/main/services/StoreSyncService.ts new file mode 100644 index 0000000000..6032d570b7 --- /dev/null +++ b/src/main/services/StoreSyncService.ts @@ -0,0 +1,114 @@ +import { IpcChannel } from '@shared/IpcChannel' +import type { StoreSyncAction } from '@types' +import { BrowserWindow, ipcMain } from 'electron' + +/** + * StoreSyncService class manages Redux store synchronization between multiple windows in the main process + * It uses singleton pattern to ensure only one sync service instance exists in the application + * + * Main features: + * 1. Manages window subscriptions for store sync + * 2. Handles IPC communication for store sync between windows + * 3. Broadcasts Redux actions from one window to all other windows + * 4. Adds metadata to synced actions to prevent infinite sync loops + */ +export class StoreSyncService { + private static instance: StoreSyncService + private windowIds: number[] = [] + private isIpcHandlerRegistered = false + + private constructor() { + return + } + + /** + * Get the singleton instance of StoreSyncService + */ + public static getInstance(): StoreSyncService { + if (!StoreSyncService.instance) { + StoreSyncService.instance = new StoreSyncService() + } + return StoreSyncService.instance + } + + /** + * Subscribe a window to store sync + * @param windowId ID of the window to subscribe + */ + public subscribe(windowId: number): void { + if (!this.windowIds.includes(windowId)) { + this.windowIds.push(windowId) + } + } + + /** + * Unsubscribe a window from store sync + * @param windowId ID of the window to unsubscribe + */ + public unsubscribe(windowId: number): void { + this.windowIds = this.windowIds.filter((id) => id !== windowId) + } + + /** + * Register IPC handlers for store sync communication + * Handles window subscription, unsubscription and action broadcasting + */ + public registerIpcHandler(): void { + if (this.isIpcHandlerRegistered) return + + ipcMain.handle(IpcChannel.StoreSync_Subscribe, (event) => { + const windowId = BrowserWindow.fromWebContents(event.sender)?.id + if (windowId) { + this.subscribe(windowId) + } + }) + + ipcMain.handle(IpcChannel.StoreSync_Unsubscribe, (event) => { + const windowId = BrowserWindow.fromWebContents(event.sender)?.id + if (windowId) { + this.unsubscribe(windowId) + } + }) + + ipcMain.handle(IpcChannel.StoreSync_OnUpdate, (event, action: StoreSyncAction) => { + const sourceWindowId = BrowserWindow.fromWebContents(event.sender)?.id + + if (!sourceWindowId) return + + // Broadcast the action to all other windows + this.broadcastToOtherWindows(sourceWindowId, action) + }) + } + + /** + * Broadcast a Redux action to all other windows except the source + * @param sourceWindowId ID of the window that originated the action + * @param action Redux action to broadcast + */ + private broadcastToOtherWindows(sourceWindowId: number, action: StoreSyncAction): void { + // Add metadata to indicate this action came from sync + const syncAction = { + ...action, + meta: { + ...action.meta, + fromSync: true, + source: `windowId:${sourceWindowId}` + } + } + + // Send to all windows except the source + this.windowIds.forEach((windowId) => { + if (windowId !== sourceWindowId) { + const targetWindow = BrowserWindow.fromId(windowId) + if (targetWindow && !targetWindow.isDestroyed()) { + targetWindow.webContents.send(IpcChannel.StoreSync_BroadcastSync, syncAction) + } else { + this.unsubscribe(windowId) + } + } + }) + } +} + +// Export singleton instance +export default StoreSyncService.getInstance() diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 2e94cdf3b4..94cfbb39d9 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -3,7 +3,7 @@ import { isDev, isLinux, isMac, isWin } from '@main/constant' import { getFilesDir } from '@main/utils/file' import { IpcChannel } from '@shared/IpcChannel' import { ThemeMode } from '@types' -import { app, BrowserWindow, ipcMain, Menu, MenuItem, nativeTheme, shell } from 'electron' +import { app, BrowserWindow, Menu, MenuItem, nativeTheme, shell } from 'electron' import Logger from 'electron-log' import windowStateKeeper from 'electron-window-state' import { join } from 'path' @@ -484,10 +484,6 @@ export class WindowService { this.miniWindow?.webContents.send(IpcChannel.ShowMiniWindow) }) - ipcMain.on(IpcChannel.MiniWindowReload, () => { - this.miniWindow?.reload() - }) - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { this.miniWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/src/windows/mini/index.html') } else { diff --git a/src/preload/index.ts b/src/preload/index.ts index 978417fa3d..674df1867a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -155,7 +155,6 @@ const api = { logout: () => ipcRenderer.invoke(IpcChannel.Copilot_Logout), getUser: (token: string) => ipcRenderer.invoke(IpcChannel.Copilot_GetUser, token) }, - // Binary related APIs isBinaryExist: (name: string) => ipcRenderer.invoke(IpcChannel.App_IsBinaryExist, name), getBinaryPath: (name: string) => ipcRenderer.invoke(IpcChannel.App_GetBinaryPath, name), @@ -186,6 +185,11 @@ const api = { webview: { setOpenLinkExternal: (webviewId: number, isExternal: boolean) => ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal) + }, + storeSync: { + subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe), + unsubscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Unsubscribe), + onUpdate: (action: any) => ipcRenderer.invoke(IpcChannel.StoreSync_OnUpdate, action) } } diff --git a/src/renderer/src/init.ts b/src/renderer/src/init.ts index 71d7a219ff..2fd5ab212d 100644 --- a/src/renderer/src/init.ts +++ b/src/renderer/src/init.ts @@ -2,6 +2,7 @@ import KeyvStorage from '@kangfenmao/keyv-storage' import { startAutoSync } from './services/BackupService' import { startNutstoreAutoSync } from './services/NutstoreService' +import storeSyncService from './services/StoreSyncService' import store from './store' function initSpinner() { @@ -29,6 +30,11 @@ function initAutoSync() { }, 8000) } +function initStoreSync() { + storeSyncService.subscribe() +} + initSpinner() initKeyv() initAutoSync() +initStoreSync() diff --git a/src/renderer/src/services/StoreSyncService.ts b/src/renderer/src/services/StoreSyncService.ts new file mode 100644 index 0000000000..31643d102a --- /dev/null +++ b/src/renderer/src/services/StoreSyncService.ts @@ -0,0 +1,136 @@ +import { Middleware } from '@reduxjs/toolkit' +import { IpcChannel } from '@shared/IpcChannel' +import type { StoreSyncAction } from '@types' + +type SyncOptions = { + syncList: string[] +} + +/** + * StoreSyncService class manages Redux store synchronization between multiple windows + * It uses singleton pattern to ensure only one sync service instance exists in the application + * + * Main features: + * 1. Synchronizes Redux actions between windows via IPC + * 2. Provides Redux middleware to intercept and broadcast actions that need syncing + * 3. Supports whitelist configuration for action types to sync + * 4. Handles window subscription and unsubscription logic + */ +export class StoreSyncService { + private static instance: StoreSyncService + private options: SyncOptions = { + syncList: [] + } + private broadcastSyncRemover: (() => void) | null = null + + private constructor() { + return + } + + /** + * Get the singleton instance of StoreSyncService + */ + public static getInstance(): StoreSyncService { + if (!StoreSyncService.instance) { + StoreSyncService.instance = new StoreSyncService() + } + return StoreSyncService.instance + } + + /** + * Set sync options + * @param options Partial sync options + */ + public setOptions(options: Partial): void { + this.options = { ...this.options, ...options } + } + + /** + * Create Redux middleware to intercept and broadcast actions + * Actions will not be broadcasted if they are not in whitelist or come from sync + */ + public createMiddleware(): Middleware { + return () => (next) => (action) => { + // Process the action normally first + const result = next(action) + + // Check if this action came from sync or is a whitelisted action + const syncAction = action as StoreSyncAction + if (!syncAction.meta?.fromSync && this.shouldSyncAction(syncAction.type)) { + // Send to main process for broadcasting to other windows using the preload API + if (window.api?.storeSync) { + window.api.storeSync.onUpdate(syncAction) + } + } + + return result + } + } + + /** + * Check if action type is in whitelist + * @param actionType Action type to check + * @returns Whether the action should be synced + */ + private shouldSyncAction(actionType: string): boolean { + // If no whitelist is specified, sync nothing + if (!this.options.syncList.length) { + return false + } + + // Check if the action belongs to a store slice we want to sync + return this.options.syncList.some((prefix) => { + return actionType.startsWith(prefix) + }) + } + + /** + * Subscribe to sync service + * Sets up IPC listener and registers cleanup on window close + */ + public subscribe(): void { + if (this.broadcastSyncRemover || !window.api?.storeSync) { + return + } + + this.broadcastSyncRemover = window.electron.ipcRenderer.on( + IpcChannel.StoreSync_BroadcastSync, + (_, action: StoreSyncAction) => { + try { + console.log('StoreSync_BroadcastSync action', action) + + // Dispatch to the store + if (window.store) { + window.store.dispatch(action) + } + } catch (error) { + console.error('Error dispatching synced action:', error) + } + } + ) + + window.api.storeSync.subscribe() + + window.addEventListener('beforeunload', () => { + this.unsubscribe() + }) + } + + /** + * Unsubscribe from sync service + * Cleans up IPC listener and related resources + */ + public unsubscribe(): void { + if (window.api?.storeSync) { + window.api.storeSync.unsubscribe() + } + + if (this.broadcastSyncRemover) { + this.broadcastSyncRemover() + this.broadcastSyncRemover = null + } + } +} + +// Export singleton instance +export default StoreSyncService.getInstance() diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index e7a95a7857..8faad7884c 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -3,6 +3,7 @@ import { useDispatch, useSelector, useStore } from 'react-redux' import { FLUSH, PAUSE, PERSIST, persistReducer, persistStore, PURGE, REGISTER, REHYDRATE } from 'redux-persist' import storage from 'redux-persist/lib/storage' +import storeSyncService from '../services/StoreSyncService' import agents from './agents' import assistants from './assistants' import backup from './backup' @@ -52,6 +53,21 @@ const persistedReducer = persistReducer( rootReducer ) +/** + * Configures the store sync service to synchronize specific state slices across all windows. + * For detailed implementation, see @renderer/services/StoreSyncService.ts + * + * Usage: + * - 'xxxx/' - Synchronizes the entire state slice + * - 'xxxx/sliceName' - Synchronizes a specific slice within the state + * + * To listen for store changes in a window: + * Call storeSyncService.subscribe() in the window's entryPoint.tsx + */ +storeSyncService.setOptions({ + syncList: ['assistants/', 'settings/', 'llm/'] +}) + const store = configureStore({ // @ts-ignore store type is unknown reducer: persistedReducer as typeof rootReducer, @@ -60,7 +76,7 @@ const store = configureStore({ serializableCheck: { ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER] } - }) + }).concat(storeSyncService.createMiddleware()) }, devTools: true }) @@ -72,7 +88,6 @@ export const persistor = persistStore(store) export const useAppDispatch = useDispatch.withTypes() export const useAppSelector = useSelector.withTypes() export const useAppStore = useStore.withTypes() - window.store = store export default store diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index c05446d5b3..09407d535a 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -2,7 +2,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { isLocalAi } from '@renderer/config/env' import { SYSTEM_MODELS } from '@renderer/config/models' import { Model, Provider } from '@renderer/types' -import { IpcChannel } from '@shared/IpcChannel' import { uniqBy } from 'lodash' type LlmSettings = { @@ -583,7 +582,6 @@ const llmSlice = createSlice({ }, setDefaultModel: (state, action: PayloadAction<{ model: Model }>) => { state.defaultModel = action.payload.model - window.electron.ipcRenderer.send(IpcChannel.MiniWindowReload) }, setTopicNamingModel: (state, action: PayloadAction<{ model: Model }>) => { state.topicNamingModel = action.payload.model diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 981e98cb9f..1e6241c5ac 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -1,7 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' import { CodeStyleVarious, LanguageVarious, MathEngine, ThemeMode, TranslateLanguageVarious } from '@renderer/types' -import { IpcChannel } from '@shared/IpcChannel' import { WebDAVSyncState } from './backup' @@ -256,7 +255,6 @@ const settingsSlice = createSlice({ }, setLanguage: (state, action: PayloadAction) => { state.language = action.payload - window.electron.ipcRenderer.send(IpcChannel.MiniWindowReload) }, setTargetLanguage: (state, action: PayloadAction) => { state.targetLanguage = action.payload diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 6d889756a9..77e3dc37cf 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -620,3 +620,12 @@ export interface Citation { } export type MathEngine = 'KaTeX' | 'MathJax' | 'none' + +export interface StoreSyncAction { + type: string + payload: any + meta?: { + fromSync?: boolean + source?: string + } +} diff --git a/src/renderer/src/windows/mini/entryPoint.tsx b/src/renderer/src/windows/mini/entryPoint.tsx index 4f07f4fa6e..b8fe037e1d 100644 --- a/src/renderer/src/windows/mini/entryPoint.tsx +++ b/src/renderer/src/windows/mini/entryPoint.tsx @@ -2,6 +2,7 @@ import '@renderer/assets/styles/index.scss' import '@ant-design/v5-patch-for-react-19' import KeyvStorage from '@kangfenmao/keyv-storage' +import storeSyncService from '@renderer/services/StoreSyncService' import { createRoot } from 'react-dom/client' import MiniWindowApp from './MiniWindowApp' @@ -18,5 +19,8 @@ function initKeyv() { } initKeyv() +//subscribe to store sync +storeSyncService.subscribe() + const root = createRoot(document.getElementById('root') as HTMLElement) root.render() diff --git a/src/renderer/src/windows/mini/home/HomeWindow.tsx b/src/renderer/src/windows/mini/home/HomeWindow.tsx index 71d56be006..b1fd9b18e5 100644 --- a/src/renderer/src/windows/mini/home/HomeWindow.tsx +++ b/src/renderer/src/windows/mini/home/HomeWindow.tsx @@ -216,7 +216,7 @@ const HomeWindow: FC = () => { setIsFirstMessage(false) setText('') // ✅ 清除输入框内容 }, - [content, defaultAssistant] + [content, defaultAssistant, topic] ) const clearClipboard = () => {