Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 41 additions & 10 deletions apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { InterceptRouteParams } from '@lobechat/electron-client-ipc';
import { InterceptRouteParams, OpenSettingsWindowOptions } from '@lobechat/electron-client-ipc';
import { extractSubPath, findMatchingRoute } from '~common/routes';

import { AppBrowsersIdentifiers, BrowsersIdentifiers, WindowTemplateIdentifiers } from '@/appBrowsers';
import {
AppBrowsersIdentifiers,
BrowsersIdentifiers,
WindowTemplateIdentifiers,
} from '@/appBrowsers';
import { IpcClientEventSender } from '@/types/ipcClientEvent';

import { ControllerModule, ipcClientEvent, shortcut } from './index';
Expand All @@ -14,11 +18,16 @@ export default class BrowserWindowsCtr extends ControllerModule {
}

@ipcClientEvent('openSettingsWindow')
async openSettingsWindow(tab?: string) {
console.log('[BrowserWindowsCtr] Received request to open settings window', tab);
async openSettingsWindow(options?: string | OpenSettingsWindowOptions) {
const normalizedOptions: OpenSettingsWindowOptions =
typeof options === 'string' || options === undefined
? { tab: typeof options === 'string' ? options : undefined }
: options;

console.log('[BrowserWindowsCtr] Received request to open settings window', normalizedOptions);

try {
await this.app.browserManager.showSettingsWindowWithTab(tab);
await this.app.browserManager.showSettingsWindowWithTab(normalizedOptions);

return { success: true };
} catch (error) {
Expand Down Expand Up @@ -68,15 +77,37 @@ export default class BrowserWindowsCtr extends ControllerModule {

try {
if (matchedRoute.targetWindow === BrowsersIdentifiers.settings) {
const subPath = extractSubPath(path, matchedRoute.pathPrefix);

await this.app.browserManager.showSettingsWindowWithTab(subPath);
const extractedSubPath = extractSubPath(path, matchedRoute.pathPrefix);
const sanitizedSubPath =
extractedSubPath && !extractedSubPath.startsWith('?') ? extractedSubPath : undefined;
let searchParams: Record<string, string> | undefined;
try {
const url = new URL(params.url);
const entries = Array.from(url.searchParams.entries());
if (entries.length > 0) {
searchParams = entries.reduce<Record<string, string>>((acc, [key, value]) => {
acc[key] = value;
return acc;
}, {});
}
} catch (error) {
console.warn(
'[BrowserWindowsCtr] Failed to parse URL for settings route interception:',
params.url,
error,
);
}

await this.app.browserManager.showSettingsWindowWithTab({
searchParams,
tab: sanitizedSubPath,
});

return {
intercepted: true,
path,
source,
subPath,
subPath: sanitizedSubPath,
targetWindow: matchedRoute.targetWindow,
};
} else {
Expand Down Expand Up @@ -105,8 +136,8 @@ export default class BrowserWindowsCtr extends ControllerModule {
*/
@ipcClientEvent('createMultiInstanceWindow')
async createMultiInstanceWindow(params: {
templateId: WindowTemplateIdentifiers;
path: string;
templateId: WindowTemplateIdentifiers;
uniqueId?: string;
}) {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describe('BrowserWindowsCtr', () => {
it('should show the settings window with the specified tab', async () => {
const tab = 'appearance';
const result = await browserWindowsCtr.openSettingsWindow(tab);
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith(tab);
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith({ tab });
expect(result).toEqual({ success: true });
});

Expand Down Expand Up @@ -120,19 +120,22 @@ describe('BrowserWindowsCtr', () => {
it('should show settings window if matched route target is settings', async () => {
const params: InterceptRouteParams = {
...baseParams,
path: '/settings?active=common',
url: 'app://host/settings?active=common',
path: '/settings/provider',
url: 'app://host/settings/provider?active=provider&provider=ollama',
};
const matchedRoute = { targetWindow: BrowsersIdentifiers.settings, pathPrefix: '/settings' };
const subPath = 'common';
const subPath = 'provider';
(findMatchingRoute as Mock).mockReturnValue(matchedRoute);
(extractSubPath as Mock).mockReturnValue(subPath);

const result = await browserWindowsCtr.interceptRoute(params);

expect(findMatchingRoute).toHaveBeenCalledWith(params.path);
expect(extractSubPath).toHaveBeenCalledWith(params.path, matchedRoute.pathPrefix);
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith(subPath);
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith({
searchParams: { active: 'provider', provider: 'ollama' },
tab: subPath,
});
expect(result).toEqual({
intercepted: true,
path: params.path,
Expand Down Expand Up @@ -170,18 +173,22 @@ describe('BrowserWindowsCtr', () => {
it('should return error if processing route interception fails for settings', async () => {
const params: InterceptRouteParams = {
...baseParams,
path: '/settings?active=general',
path: '/settings',
url: 'app://host/settings?active=general',
};
const matchedRoute = { targetWindow: BrowsersIdentifiers.settings, pathPrefix: '/settings' };
const subPath = 'general';
const subPath = undefined;
const errorMessage = 'Processing error for settings';
(findMatchingRoute as Mock).mockReturnValue(matchedRoute);
(extractSubPath as Mock).mockReturnValue(subPath);
mockShowSettingsWindowWithTab.mockRejectedValueOnce(new Error(errorMessage));

const result = await browserWindowsCtr.interceptRoute(params);

expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith({
searchParams: { active: 'general' },
tab: subPath,
});
expect(result).toEqual({
error: errorMessage,
intercepted: false,
Expand Down
74 changes: 56 additions & 18 deletions apps/desktop/src/main/core/browser/BrowserManager.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
import {
MainBroadcastEventKey,
MainBroadcastParams,
OpenSettingsWindowOptions,
} from '@lobechat/electron-client-ipc';
import { WebContents } from 'electron';

import { createLogger } from '@/utils/logger';

import { AppBrowsersIdentifiers, appBrowsers, WindowTemplate, WindowTemplateIdentifiers, windowTemplates } from '../../appBrowsers';
import {
AppBrowsersIdentifiers,
WindowTemplateIdentifiers,
appBrowsers,
windowTemplates,
} from '../../appBrowsers';
import type { App } from '../App';
import type { BrowserWindowOpts } from './Browser';
import Browser from './Browser';
Expand Down Expand Up @@ -63,14 +72,35 @@ export class BrowserManager {
* Display the settings window and navigate to a specific tab
* @param tab Settings window sub-path tab
*/
async showSettingsWindowWithTab(tab?: string) {
logger.debug(`Showing settings window with tab: ${tab || 'default'}`);
// common is the main path for settings route
if (tab && tab !== 'common') {
const browser = await this.redirectToPage('settings', tab);
async showSettingsWindowWithTab(options?: OpenSettingsWindowOptions) {
const tab = options?.tab;
const searchParams = options?.searchParams;

const query = new URLSearchParams();
if (searchParams) {
Object.entries(searchParams).forEach(([key, value]) => {
if (value !== undefined) query.set(key, value);
});
}

if (tab && tab !== 'common' && !query.has('active')) {
query.set('active', tab);
}

const queryString = query.toString();
const activeTab = query.get('active') ?? tab;

logger.debug(
`Showing settings window with navigation: active=${activeTab || 'default'}, query=${
queryString || 'none'
}`,
);

if (queryString) {
const browser = await this.redirectToPage('settings', undefined, queryString);

// make provider page more large
if (tab.startsWith('provider/')) {
if (activeTab?.startsWith('provider')) {
logger.debug('Resizing window for provider settings');
browser.setWindowSize({ height: 1000, width: 1400 });
browser.moveToCenter();
Expand All @@ -87,7 +117,7 @@ export class BrowserManager {
* @param identifier Window identifier
* @param subPath Sub-path, such as 'agent', 'about', etc.
*/
async redirectToPage(identifier: string, subPath?: string) {
async redirectToPage(identifier: string, subPath?: string, search?: string) {
try {
// Ensure window is retrieved or created
const browser = this.retrieveByIdentifier(identifier);
Expand All @@ -105,11 +135,14 @@ export class BrowserManager {

// Build complete URL path
const fullPath = subPath ? `${baseRoute}/${subPath}` : baseRoute;
const normalizedSearch =
search && search.length > 0 ? (search.startsWith('?') ? search : `?${search}`) : '';
const fullUrl = `${fullPath}${normalizedSearch}`;

logger.debug(`Redirecting to: ${fullPath}`);
logger.debug(`Redirecting to: ${fullUrl}`);

// Load URL and show window
await browser.loadUrl(fullPath);
await browser.loadUrl(fullUrl);
browser.show();

return browser;
Expand Down Expand Up @@ -143,14 +176,20 @@ export class BrowserManager {
* @param uniqueId Optional unique identifier, will be generated if not provided
* @returns The window identifier and Browser instance
*/
createMultiInstanceWindow(templateId: WindowTemplateIdentifiers, path: string, uniqueId?: string) {
createMultiInstanceWindow(
templateId: WindowTemplateIdentifiers,
path: string,
uniqueId?: string,
) {
const template = windowTemplates[templateId];
if (!template) {
throw new Error(`Window template ${templateId} not found`);
}

// Generate unique identifier
const windowId = uniqueId || `${template.baseIdentifier}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const windowId =
uniqueId ||
`${template.baseIdentifier}_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;

// Create browser options from template
const browserOpts: BrowserWindowOpts = {
Expand All @@ -164,8 +203,8 @@ export class BrowserManager {
const browser = this.retrieveOrInitialize(browserOpts);

return {
identifier: windowId,
browser: browser,
identifier: windowId,
};
}

Expand All @@ -176,7 +215,7 @@ export class BrowserManager {
*/
getWindowsByTemplate(templateId: string): string[] {
const prefix = `${templateId}_`;
return Array.from(this.browsers.keys()).filter(id => id.startsWith(prefix));
return Array.from(this.browsers.keys()).filter((id) => id.startsWith(prefix));
}

/**
Expand All @@ -185,7 +224,7 @@ export class BrowserManager {
*/
closeWindowsByTemplate(templateId: string): void {
const windowIds = this.getWindowsByTemplate(templateId);
windowIds.forEach(id => {
windowIds.forEach((id) => {
const browser = this.browsers.get(id);
if (browser) {
browser.close();
Expand Down Expand Up @@ -235,8 +274,7 @@ export class BrowserManager {
});

browser.browserWindow.on('show', () => {
if (browser.webContents)
this.webContentsMap.set(browser.webContents, browser.identifier);
if (browser.webContents) this.webContentsMap.set(browser.webContents, browser.identifier);
});

return browser;
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,9 @@
"react-layout-kit": "^2.0.0",
"react-lazy-load": "^4.0.1",
"react-pdf": "^9.2.1",
"react-responsive": "^10.0.1",
"react-rnd": "^10.5.2",
"react-router-dom": "^7.9.4",
"react-scan": "^0.4.3",
"react-virtuoso": "^4.14.1",
"react-wrap-balancer": "^1.1.1",
Expand Down
2 changes: 2 additions & 0 deletions packages/electron-client-ipc/src/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,5 @@ export type MainBroadcastEventKey = keyof MainBroadcastEvents;
export type MainBroadcastParams<T extends MainBroadcastEventKey> = Parameters<
MainBroadcastEvents[T]
>[0];

export type { OpenSettingsWindowOptions } from './windows';
51 changes: 32 additions & 19 deletions packages/electron-client-ipc/src/events/windows.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,50 @@
import { InterceptRouteParams, InterceptRouteResponse } from '../types/route';

export interface OpenSettingsWindowOptions {
/**
* Query parameters that should be appended to the settings URL.
*/
searchParams?: Record<string, string | undefined>;
/**
* Settings page tab path or identifier.
*/
tab?: string;
}

export interface CreateMultiInstanceWindowParams {
templateId: string;
path: string;
templateId: string;
uniqueId?: string;
}

export interface CreateMultiInstanceWindowResponse {
error?: string;
success: boolean;
windowId?: string;
error?: string;
}

export interface GetWindowsByTemplateResponse {
error?: string;
success: boolean;
windowIds?: string[];
error?: string;
}

export interface WindowsDispatchEvents {
/**
* 拦截客户端路由导航请求
* @param params 包含路径和来源信息的参数对象
* @returns 路由拦截结果
*/
interceptRoute: (params: InterceptRouteParams) => InterceptRouteResponse;

/**
* open the LobeHub Devtools
* Close all windows by template
* @param templateId Template identifier
* @returns Operation result
*/
openDevtools: () => void;

openSettingsWindow: (tab?: string) => void;
closeWindowsByTemplate: (templateId: string) => { error?: string, success: boolean; };

/**
* Create a new multi-instance window
* @param params Window creation parameters
* @returns Creation result
*/
createMultiInstanceWindow: (params: CreateMultiInstanceWindowParams) => CreateMultiInstanceWindowResponse;
createMultiInstanceWindow: (
params: CreateMultiInstanceWindowParams,
) => CreateMultiInstanceWindowResponse;

/**
* Get all windows by template
Expand All @@ -48,9 +54,16 @@ export interface WindowsDispatchEvents {
getWindowsByTemplate: (templateId: string) => GetWindowsByTemplateResponse;

/**
* Close all windows by template
* @param templateId Template identifier
* @returns Operation result
* 拦截客户端路由导航请求
* @param params 包含路径和来源信息的参数对象
* @returns 路由拦截结果
*/
closeWindowsByTemplate: (templateId: string) => { success: boolean; error?: string };
interceptRoute: (params: InterceptRouteParams) => InterceptRouteResponse;

/**
* open the LobeHub Devtools
*/
openDevtools: () => void;

openSettingsWindow: (options?: OpenSettingsWindowOptions | string) => void;
}
Loading
Loading