@lobehub/lobehub 2.0.0-next.242 → 2.0.0-next.243

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/.cursor/rules/desktop-feature-implementation.mdc +2 -2
  2. package/.cursor/rules/desktop-local-tools-implement.mdc +2 -2
  3. package/CHANGELOG.md +27 -0
  4. package/apps/desktop/Development.md +1 -6
  5. package/apps/desktop/README.md +2 -17
  6. package/apps/desktop/README.zh-CN.md +1 -15
  7. package/apps/desktop/src/main/controllers/index.ts +1 -1
  8. package/apps/desktop/src/main/controllers/registry.ts +0 -9
  9. package/apps/desktop/src/main/core/App.ts +1 -11
  10. package/apps/desktop/src/main/core/browser/Browser.ts +278 -457
  11. package/apps/desktop/src/main/core/browser/WindowStateManager.ts +180 -0
  12. package/apps/desktop/src/main/core/browser/WindowThemeManager.ts +167 -0
  13. package/apps/desktop/src/main/core/browser/__tests__/WindowStateManager.test.ts +237 -0
  14. package/apps/desktop/src/main/core/browser/__tests__/WindowThemeManager.test.ts +240 -0
  15. package/apps/desktop/src/main/exports.d.ts +1 -1
  16. package/apps/desktop/src/main/exports.ts +1 -1
  17. package/apps/desktop/src/main/utils/__tests__/http-headers.test.ts +131 -0
  18. package/apps/desktop/src/main/utils/http-headers.ts +61 -0
  19. package/apps/desktop/src/main/utils/ipc/__tests__/base.test.ts +1 -22
  20. package/apps/desktop/src/main/utils/ipc/base.ts +0 -20
  21. package/apps/desktop/src/main/utils/ipc/index.ts +1 -9
  22. package/changelog/v1.json +5 -0
  23. package/package.json +1 -1
  24. package/src/features/ChatInput/InputEditor/Placeholder.tsx +4 -1
  25. package/apps/desktop/src/main/controllers/UploadFileServerCtr.ts +0 -33
  26. package/apps/desktop/src/main/controllers/__tests__/UploadFileServerCtr.test.ts +0 -55
  27. package/src/server/modules/ElectronIPCClient/index.ts +0 -92
@@ -4,32 +4,27 @@ import {
4
4
  BrowserWindowConstructorOptions,
5
5
  session as electronSession,
6
6
  ipcMain,
7
- nativeTheme,
8
7
  screen,
9
8
  } from 'electron';
10
9
  import console from 'node:console';
11
10
  import { join } from 'node:path';
12
11
 
13
- import { buildDir, preloadDir, resourcesDir } from '@/const/dir';
14
- import { isDev, isMac, isWindows } from '@/const/env';
12
+ import { preloadDir, resourcesDir } from '@/const/dir';
13
+ import { isMac } from '@/const/env';
15
14
  import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
16
- import {
17
- BACKGROUND_DARK,
18
- BACKGROUND_LIGHT,
19
- SYMBOL_COLOR_DARK,
20
- SYMBOL_COLOR_LIGHT,
21
- THEME_CHANGE_DELAY,
22
- TITLE_BAR_HEIGHT,
23
- } from '@/const/theme';
24
15
  import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
25
16
  import { backendProxyProtocolManager } from '@/core/infrastructure/BackendProxyProtocolManager';
17
+ import { setResponseHeader } from '@/utils/http-headers';
26
18
  import { createLogger } from '@/utils/logger';
27
19
 
28
20
  import type { App } from '../App';
21
+ import { WindowStateManager } from './WindowStateManager';
22
+ import { WindowThemeManager } from './WindowThemeManager';
29
23
 
30
- // Create logger
31
24
  const logger = createLogger('core:Browser');
32
25
 
26
+ // ==================== Types ====================
27
+
33
28
  export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
34
29
  devTools?: boolean;
35
30
  height?: number;
@@ -42,297 +37,193 @@ export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
42
37
  width?: number;
43
38
  }
44
39
 
45
- interface WindowState {
46
- height?: number;
47
- width?: number;
48
- x?: number;
49
- y?: number;
50
- }
40
+ // ==================== Browser Class ====================
51
41
 
52
42
  export default class Browser {
53
- private app: App;
43
+ private readonly app: App;
44
+ private readonly stateManager: WindowStateManager;
45
+ private readonly themeManager: WindowThemeManager;
46
+
54
47
  private _browserWindow?: BrowserWindow;
55
- private themeListenerSetup = false;
56
- identifier: string;
57
- options: BrowserWindowOpts;
58
- private readonly windowStateKey: string;
59
48
 
60
- get browserWindow() {
49
+ readonly identifier: string;
50
+ readonly options: BrowserWindowOpts;
51
+
52
+ // ==================== Accessors ====================
53
+
54
+ get browserWindow(): BrowserWindow {
61
55
  return this.retrieveOrInitialize();
62
56
  }
57
+
63
58
  get webContents() {
64
- if (this._browserWindow.isDestroyed()) return null;
65
- return this._browserWindow.webContents;
59
+ if (this._browserWindow?.isDestroyed()) return null;
60
+ return this._browserWindow?.webContents ?? null;
66
61
  }
67
62
 
68
- /**
69
- * Method to construct BrowserWindows object
70
- * @param options
71
- * @param application
72
- */
63
+ // ==================== Constructor ====================
64
+
73
65
  constructor(options: BrowserWindowOpts, application: App) {
74
66
  logger.debug(`Creating Browser instance: ${options.identifier}`);
75
67
  logger.debug(`Browser options: ${JSON.stringify(options)}`);
68
+
76
69
  this.app = application;
77
70
  this.identifier = options.identifier;
78
71
  this.options = options;
79
- this.windowStateKey = `windowSize_${this.identifier}`;
80
72
 
81
- // Initialization
73
+ // Initialize managers
74
+ this.stateManager = new WindowStateManager(application, {
75
+ identifier: options.identifier,
76
+ keepAlive: options.keepAlive,
77
+ });
78
+ this.themeManager = new WindowThemeManager(options.identifier);
79
+
80
+ // Initialize window
82
81
  this.retrieveOrInitialize();
83
82
  }
84
83
 
84
+ // ==================== Window Lifecycle ====================
85
+
85
86
  /**
86
- * Get platform-specific theme configuration for window creation
87
+ * Initialize or retrieve existing browser window
87
88
  */
88
- private getPlatformThemeConfig(): Record<string, any> {
89
- if (isWindows) {
90
- return this.getWindowsThemeConfig(this.isDarkMode);
89
+ retrieveOrInitialize(): BrowserWindow {
90
+ if (this._browserWindow && !this._browserWindow.isDestroyed()) {
91
+ logger.debug(`[${this.identifier}] Returning existing BrowserWindow instance.`);
92
+ return this._browserWindow;
91
93
  }
92
94
 
93
- return {};
94
- }
95
-
96
- /**
97
- * Get Windows-specific theme configuration
98
- */
99
- private getWindowsThemeConfig(isDarkMode: boolean) {
100
- return {
101
- backgroundColor: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
102
- icon: isDev ? join(buildDir, 'icon-dev.ico') : undefined,
103
- titleBarOverlay: {
104
- color: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
105
- height: TITLE_BAR_HEIGHT,
106
- symbolColor: isDarkMode ? SYMBOL_COLOR_DARK : SYMBOL_COLOR_LIGHT,
107
- },
108
- titleBarStyle: 'hidden' as const,
109
- };
110
- }
95
+ const browserWindow = this.createBrowserWindow();
96
+ this._browserWindow = browserWindow;
111
97
 
112
- private setupThemeListener(): void {
113
- if (this.themeListenerSetup) return;
98
+ this.setupWindow(browserWindow);
114
99
 
115
- nativeTheme.on('updated', this.handleThemeChange);
116
- this.themeListenerSetup = true;
100
+ logger.debug(`[${this.identifier}] retrieveOrInitialize completed.`);
101
+ return browserWindow;
117
102
  }
118
103
 
119
- private handleThemeChange = (): void => {
120
- logger.debug(`[${this.identifier}] System theme changed, reapplying visual effects.`);
121
- setTimeout(() => {
122
- this.applyVisualEffects();
123
- }, THEME_CHANGE_DELAY);
124
- };
125
-
126
104
  /**
127
- * Handle application theme mode change (called from BrowserManager)
105
+ * Destroy window instance and cleanup resources
128
106
  */
129
- handleAppThemeChange = (): void => {
130
- logger.debug(`[${this.identifier}] App theme mode changed, reapplying visual effects.`);
131
- setTimeout(() => {
132
- this.applyVisualEffects();
133
- }, THEME_CHANGE_DELAY);
134
- };
135
-
136
- private applyVisualEffects(): void {
137
- if (!this._browserWindow || this._browserWindow.isDestroyed()) return;
138
-
139
- logger.debug(`[${this.identifier}] Applying visual effects for platform`);
140
- const isDarkMode = this.isDarkMode;
141
-
142
- try {
143
- if (isWindows) {
144
- this.applyWindowsVisualEffects(isDarkMode);
145
- }
146
-
147
- logger.debug(
148
- `[${this.identifier}] Visual effects applied successfully (dark mode: ${isDarkMode})`,
149
- );
150
- } catch (error) {
151
- logger.error(`[${this.identifier}] Failed to apply visual effects:`, error);
152
- }
107
+ destroy(): void {
108
+ logger.debug(`Destroying window instance: ${this.identifier}`);
109
+ this.themeManager.cleanup();
110
+ this._browserWindow = undefined;
153
111
  }
154
112
 
155
- private applyWindowsVisualEffects(isDarkMode: boolean): void {
156
- const config = this.getWindowsThemeConfig(isDarkMode);
113
+ // ==================== Window Creation ====================
157
114
 
158
- this._browserWindow.setBackgroundColor(config.backgroundColor);
159
- this._browserWindow.setTitleBarOverlay(config.titleBarOverlay);
160
- }
115
+ private createBrowserWindow(): BrowserWindow {
116
+ const { title, width, height, ...rest } = this.options;
161
117
 
162
- private clampNumber(value: number, min: number, max: number) {
163
- return Math.min(Math.max(value, min), max);
164
- }
118
+ const resolvedState = this.stateManager.resolveState({ height, width });
119
+ logger.info(`Creating new BrowserWindow instance: ${this.identifier}`);
120
+ logger.debug(`[${this.identifier}] Resolved window state: ${JSON.stringify(resolvedState)}`);
165
121
 
166
- private resolveWindowState(
167
- savedState: WindowState | undefined,
168
- fallbackState: { height?: number; width?: number },
169
- ): WindowState {
170
- const width = savedState?.width ?? fallbackState.width;
171
- const height = savedState?.height ?? fallbackState.height;
172
- const resolvedState: WindowState = { height, width };
173
-
174
- const hasPosition = Number.isFinite(savedState?.x) && Number.isFinite(savedState?.y);
175
- if (!hasPosition) return resolvedState;
176
-
177
- const x = savedState?.x as number;
178
- const y = savedState?.y as number;
179
-
180
- const targetDisplay = screen.getDisplayMatching({
181
- height: height ?? 0,
182
- width: width ?? 0,
183
- x,
184
- y,
122
+ return new BrowserWindow({
123
+ ...rest,
124
+ autoHideMenuBar: true,
125
+ backgroundColor: '#00000000',
126
+ darkTheme: this.themeManager.isDarkMode,
127
+ frame: false,
128
+ height: resolvedState.height,
129
+ show: false,
130
+ title,
131
+ vibrancy: 'sidebar',
132
+ visualEffectState: 'active',
133
+ webPreferences: {
134
+ backgroundThrottling: false,
135
+ contextIsolation: true,
136
+ preload: join(preloadDir, 'index.js'),
137
+ sandbox: false,
138
+ },
139
+ width: resolvedState.width,
140
+ x: resolvedState.x,
141
+ y: resolvedState.y,
142
+ ...this.themeManager.getPlatformConfig(),
185
143
  });
144
+ }
186
145
 
187
- const workArea = targetDisplay?.workArea ?? screen.getPrimaryDisplay().workArea;
188
- const resolvedWidth = typeof width === 'number' ? Math.min(width, workArea.width) : width;
189
- const resolvedHeight = typeof height === 'number' ? Math.min(height, workArea.height) : height;
146
+ private setupWindow(browserWindow: BrowserWindow): void {
147
+ logger.debug(`[${this.identifier}] BrowserWindow instance created.`);
190
148
 
191
- const maxX = workArea.x + Math.max(0, workArea.width - (resolvedWidth ?? 0));
192
- const maxY = workArea.y + Math.max(0, workArea.height - (resolvedHeight ?? 0));
149
+ // Setup theme management
150
+ this.themeManager.attach(browserWindow);
193
151
 
194
- return {
195
- height: resolvedHeight,
196
- width: resolvedWidth,
197
- x: this.clampNumber(x, workArea.x, maxX),
198
- y: this.clampNumber(y, workArea.y, maxY),
199
- };
200
- }
152
+ // Setup network interceptors
153
+ this.setupCORSBypass(browserWindow);
154
+ this.setupRemoteServerRequestHook(browserWindow);
201
155
 
202
- private cleanupThemeListener(): void {
203
- if (this.themeListenerSetup) {
204
- // Note: nativeTheme listeners are global, consider using a centralized theme manager
205
- nativeTheme.off('updated', this.handleThemeChange);
206
- // for multiple windows to avoid duplicate listeners
207
- this.themeListenerSetup = false;
156
+ // Load content
157
+ this.initiateContentLoading();
158
+
159
+ // Setup devtools if enabled
160
+ if (this.options.devTools) {
161
+ logger.debug(`[${this.identifier}] Opening DevTools.`);
162
+ browserWindow.webContents.openDevTools();
208
163
  }
209
- }
210
164
 
211
- private get isDarkMode() {
212
- return nativeTheme.shouldUseDarkColors;
165
+ // Setup event listeners
166
+ this.setupEventListeners(browserWindow);
213
167
  }
214
168
 
215
- loadUrl = async (path: string) => {
216
- const initUrl = await this.app.buildRendererUrl(path);
217
-
218
- // Inject locale from store to help renderer boot with the correct language.
219
- // Skip when set to auto to let the renderer detect locale normally.
220
- const storedLocale = this.app.storeManager.get('locale', 'auto');
221
- const urlWithLocale =
222
- storedLocale && storedLocale !== 'auto'
223
- ? `${initUrl}${initUrl.includes('?') ? '&' : '?'}lng=${storedLocale}`
224
- : initUrl;
225
-
226
- console.log('[Browser] initUrl', urlWithLocale);
169
+ private initiateContentLoading(): void {
170
+ logger.debug(`[${this.identifier}] Initiating placeholder and URL loading sequence.`);
171
+ this.loadPlaceholder().then(() => {
172
+ this.loadUrl(this.options.path).catch((e) => {
173
+ logger.error(
174
+ `[${this.identifier}] Initial loadUrl error for path '${this.options.path}':`,
175
+ e,
176
+ );
177
+ });
178
+ });
179
+ }
227
180
 
228
- try {
229
- logger.debug(`[${this.identifier}] Attempting to load URL: ${urlWithLocale}`);
230
- await this._browserWindow.loadURL(urlWithLocale);
181
+ // ==================== Event Listeners ====================
231
182
 
232
- logger.debug(`[${this.identifier}] Successfully loaded URL: ${urlWithLocale}`);
233
- } catch (error) {
234
- logger.error(`[${this.identifier}] Failed to load URL (${urlWithLocale}):`, error);
183
+ private setupEventListeners(browserWindow: BrowserWindow): void {
184
+ this.setupReadyToShowListener(browserWindow);
185
+ this.setupCloseListener(browserWindow);
186
+ }
235
187
 
236
- // Try to load local error page
237
- try {
238
- logger.info(`[${this.identifier}] Attempting to load error page...`);
239
- await this._browserWindow.loadFile(join(resourcesDir, 'error.html'));
240
- logger.info(`[${this.identifier}] Error page loaded successfully.`);
241
-
242
- // Remove previously set retry listeners to avoid duplicates
243
- ipcMain.removeHandler('retry-connection');
244
- logger.debug(`[${this.identifier}] Removed existing retry-connection handler if any.`);
245
-
246
- // Set retry logic
247
- ipcMain.handle('retry-connection', async () => {
248
- logger.info(`[${this.identifier}] Retry connection requested for: ${urlWithLocale}`);
249
- try {
250
- await this._browserWindow?.loadURL(urlWithLocale);
251
- logger.info(`[${this.identifier}] Reconnection successful to ${urlWithLocale}`);
252
- return { success: true };
253
- } catch (err) {
254
- logger.error(`[${this.identifier}] Retry connection failed for ${urlWithLocale}:`, err);
255
- // Reload error page
256
- try {
257
- logger.info(`[${this.identifier}] Reloading error page after failed retry...`);
258
- await this._browserWindow?.loadFile(join(resourcesDir, 'error.html'));
259
- logger.info(`[${this.identifier}] Error page reloaded.`);
260
- } catch (loadErr) {
261
- logger.error('[${this.identifier}] Failed to reload error page:', loadErr);
262
- }
263
- return { error: err.message, success: false };
264
- }
265
- });
266
- logger.debug(`[${this.identifier}] Set up retry-connection handler.`);
267
- } catch (err) {
268
- logger.error(`[${this.identifier}] Failed to load error page:`, err);
269
- // If even the error page can't be loaded, at least show a simple error message
270
- try {
271
- logger.warn(`[${this.identifier}] Attempting to load fallback error HTML string...`);
272
- await this._browserWindow.loadURL(
273
- 'data:text/html,<html><body><h1>Loading Failed</h1><p>Unable to connect to server, please restart the application</p></body></html>',
274
- );
275
- logger.info(`[${this.identifier}] Fallback error HTML string loaded.`);
276
- } catch (finalErr) {
277
- logger.error(`[${this.identifier}] Unable to display any page:`, finalErr);
278
- }
188
+ private setupReadyToShowListener(browserWindow: BrowserWindow): void {
189
+ logger.debug(`[${this.identifier}] Setting up 'ready-to-show' event listener.`);
190
+ browserWindow.once('ready-to-show', () => {
191
+ logger.debug(`[${this.identifier}] Window 'ready-to-show' event fired.`);
192
+ if (this.options.showOnInit) {
193
+ logger.debug(`Showing window ${this.identifier} because showOnInit is true.`);
194
+ browserWindow.show();
195
+ } else {
196
+ logger.debug(`Window ${this.identifier} not shown because showOnInit is false.`);
279
197
  }
280
- }
281
- };
282
-
283
- loadPlaceholder = async () => {
284
- logger.debug(`[${this.identifier}] Loading splash screen placeholder`);
285
- // First load a local HTML loading page
286
- await this._browserWindow.loadFile(join(resourcesDir, 'splash.html'));
287
- logger.debug(`[${this.identifier}] Splash screen placeholder loaded.`);
288
- };
289
-
290
- show() {
291
- logger.debug(`Showing window: ${this.identifier}`);
292
- if (!this._browserWindow.isDestroyed()) this.determineWindowPosition();
198
+ });
199
+ }
293
200
 
294
- this.browserWindow.show();
201
+ private setupCloseListener(browserWindow: BrowserWindow): void {
202
+ logger.debug(`[${this.identifier}] Setting up 'close' event listener.`);
203
+ const closeHandler = this.stateManager.createCloseHandler(browserWindow, {
204
+ onCleanup: () => this.themeManager.cleanup(),
205
+ onHide: () => this.hide(),
206
+ });
207
+ browserWindow.on('close', closeHandler);
295
208
  }
296
209
 
297
- private determineWindowPosition() {
298
- const { parentIdentifier } = this.options;
210
+ // ==================== Window Actions ====================
299
211
 
300
- if (parentIdentifier) {
301
- // todo: fix ts type
302
- const parentWin = this.app.browserManager.retrieveByIdentifier(parentIdentifier as any);
303
- if (parentWin) {
304
- logger.debug(`[${this.identifier}] Found parent window: ${parentIdentifier}`);
305
-
306
- const display = screen.getDisplayNearestPoint(parentWin.browserWindow.getContentBounds());
307
- if (display) {
308
- const {
309
- workArea: { x, y, width: displayWidth, height: displayHeight },
310
- } = display;
311
-
312
- const { width, height } = this._browserWindow.getContentBounds();
313
- logger.debug(
314
- `[${this.identifier}] Display bounds: x=${x}, y=${y}, width=${displayWidth}, height=${displayHeight}`,
315
- );
316
-
317
- // Calculate new position
318
- const newX = Math.floor(Math.max(x + (displayWidth - width) / 2, x));
319
- const newY = Math.floor(Math.max(y + (displayHeight - height) / 2, y));
320
- logger.debug(`[${this.identifier}] Calculated position: x=${newX}, y=${newY}`);
321
- this._browserWindow.setPosition(newX, newY, false);
322
- }
323
- }
212
+ show(): void {
213
+ logger.debug(`Showing window: ${this.identifier}`);
214
+ if (!this._browserWindow?.isDestroyed()) {
215
+ this.determineWindowPosition();
324
216
  }
217
+ this.browserWindow.show();
325
218
  }
326
219
 
327
- hide() {
220
+ hide(): void {
328
221
  logger.debug(`Hiding window: ${this.identifier}`);
329
222
 
330
223
  // Fix for macOS fullscreen black screen issue
331
224
  // See: https://github.com/electron/electron/issues/20263
332
225
  if (isMac && this.browserWindow.isFullScreen()) {
333
- logger.debug(
334
- `[${this.identifier}] Window is in fullscreen mode, exiting fullscreen before hiding.`,
335
- );
226
+ logger.debug(`[${this.identifier}] Exiting fullscreen before hiding.`);
336
227
  this.browserWindow.once('leave-full-screen', () => {
337
228
  this.browserWindow.hide();
338
229
  });
@@ -342,284 +233,215 @@ export default class Browser {
342
233
  }
343
234
  }
344
235
 
345
- close() {
236
+ close(): void {
346
237
  logger.debug(`Attempting to close window: ${this.identifier}`);
347
238
  this.browserWindow.close();
348
239
  }
349
240
 
350
- /**
351
- * Destroy instance
352
- */
353
- destroy() {
354
- logger.debug(`Destroying window instance: ${this.identifier}`);
355
- this.cleanupThemeListener();
356
- this._browserWindow = undefined;
241
+ toggleVisible(): void {
242
+ logger.debug(`Toggling visibility for window: ${this.identifier}`);
243
+ if (this._browserWindow?.isVisible() && this._browserWindow.isFocused()) {
244
+ this.hide();
245
+ } else {
246
+ this._browserWindow?.show();
247
+ this._browserWindow?.focus();
248
+ }
357
249
  }
358
250
 
359
- /**
360
- * Initialize
361
- */
362
- retrieveOrInitialize() {
363
- // When there is this window and it has not been destroyed
364
- if (this._browserWindow && !this._browserWindow.isDestroyed()) {
365
- logger.debug(`[${this.identifier}] Returning existing BrowserWindow instance.`);
366
- return this._browserWindow;
367
- }
251
+ moveToCenter(): void {
252
+ logger.debug(`Centering window: ${this.identifier}`);
253
+ this._browserWindow?.center();
254
+ }
368
255
 
369
- const { path, title, width, height, devTools, showOnInit, ...res } = this.options;
256
+ setWindowSize(boundSize: { height?: number; width?: number }): void {
257
+ logger.debug(`Setting window size for ${this.identifier}: ${JSON.stringify(boundSize)}`);
258
+ const currentBounds = this._browserWindow?.getBounds();
259
+ this._browserWindow?.setBounds({
260
+ height: boundSize.height || currentBounds?.height,
261
+ width: boundSize.width || currentBounds?.width,
262
+ });
263
+ }
370
264
 
371
- // Load window state
372
- const savedState = this.app.storeManager.get(this.windowStateKey as any) as
373
- | WindowState
374
- | undefined;
375
- logger.info(`Creating new BrowserWindow instance: ${this.identifier}`);
376
- logger.debug(`[${this.identifier}] Options for new window: ${JSON.stringify(this.options)}`);
377
- logger.debug(`[${this.identifier}] Saved window state: ${JSON.stringify(savedState)}`);
265
+ setWindowResizable(resizable: boolean): void {
266
+ logger.debug(`[${this.identifier}] Setting window resizable: ${resizable}`);
267
+ this._browserWindow?.setResizable(resizable);
268
+ }
378
269
 
379
- const resolvedState = this.resolveWindowState(savedState, { height, width });
380
- logger.debug(`[${this.identifier}] Resolved window state: ${JSON.stringify(resolvedState)}`);
270
+ // ==================== Window Position ====================
381
271
 
382
- const browserWindow = new BrowserWindow({
383
- ...res,
384
- autoHideMenuBar: true,
385
- backgroundColor: '#00000000',
386
- darkTheme: this.isDarkMode,
387
- frame: false,
388
- height: resolvedState.height,
389
- show: false,
390
- title,
391
- vibrancy: 'sidebar',
392
- visualEffectState: 'active',
393
- webPreferences: {
394
- backgroundThrottling: false,
395
- contextIsolation: true,
396
- preload: join(preloadDir, 'index.js'),
397
- sandbox: false,
398
- },
399
- width: resolvedState.width,
400
- x: resolvedState.x,
401
- y: resolvedState.y,
402
- ...this.getPlatformThemeConfig(),
403
- });
272
+ private determineWindowPosition(): void {
273
+ const { parentIdentifier } = this.options;
274
+ if (!parentIdentifier) return;
404
275
 
405
- this._browserWindow = browserWindow;
406
- logger.debug(`[${this.identifier}] BrowserWindow instance created.`);
276
+ // todo: fix ts type
277
+ const parentWin = this.app.browserManager.retrieveByIdentifier(parentIdentifier as any);
278
+ if (!parentWin) return;
407
279
 
408
- // Initialize theme listener for this window to handle theme changes
409
- this.setupThemeListener();
410
- logger.debug(`[${this.identifier}] Theme listener setup and applying initial visual effects.`);
280
+ logger.debug(`[${this.identifier}] Found parent window: ${parentIdentifier}`);
411
281
 
412
- // Apply initial visual effects
413
- this.applyVisualEffects();
282
+ const display = screen.getDisplayNearestPoint(parentWin.browserWindow.getContentBounds());
283
+ if (!display) return;
414
284
 
415
- // Setup CORS bypass for local file server
416
- this.setupCORSBypass(browserWindow);
417
- // Setup request hook for remote server sync (base URL rewrite + OIDC header)
418
- this.setupRemoteServerRequestHook(browserWindow);
285
+ const { workArea } = display;
286
+ const { width, height } = this._browserWindow!.getContentBounds();
419
287
 
420
- logger.debug(`[${this.identifier}] Initiating placeholder and URL loading sequence.`);
421
- this.loadPlaceholder().then(() => {
422
- this.loadUrl(path).catch((e) => {
423
- logger.error(`[${this.identifier}] Initial loadUrl error for path '${path}':`, e);
424
- });
425
- });
288
+ const newX = Math.floor(Math.max(workArea.x + (workArea.width - width) / 2, workArea.x));
289
+ const newY = Math.floor(Math.max(workArea.y + (workArea.height - height) / 2, workArea.y));
426
290
 
427
- // Show devtools if enabled
428
- if (devTools) {
429
- logger.debug(`[${this.identifier}] Opening DevTools because devTools option is true.`);
430
- browserWindow.webContents.openDevTools();
431
- }
291
+ logger.debug(`[${this.identifier}] Calculated position: x=${newX}, y=${newY}`);
292
+ this._browserWindow!.setPosition(newX, newY, false);
293
+ }
432
294
 
433
- logger.debug(`[${this.identifier}] Setting up 'ready-to-show' event listener.`);
434
- browserWindow.once('ready-to-show', () => {
435
- logger.debug(`[${this.identifier}] Window 'ready-to-show' event fired.`);
436
- if (showOnInit) {
437
- logger.debug(`Showing window ${this.identifier} because showOnInit is true.`);
438
- browserWindow?.show();
439
- } else {
440
- logger.debug(
441
- `Window ${this.identifier} not shown on 'ready-to-show' because showOnInit is false.`,
442
- );
443
- }
444
- });
295
+ // ==================== Content Loading ====================
445
296
 
446
- logger.debug(`[${this.identifier}] Setting up 'close' event listener.`);
447
- browserWindow.on('close', (e) => {
448
- logger.debug(`Window 'close' event triggered for: ${this.identifier}`);
449
- logger.debug(
450
- `[${this.identifier}] State during close event: isQuiting=${this.app.isQuiting}, keepAlive=${this.options.keepAlive}`,
451
- );
297
+ loadPlaceholder = async (): Promise<void> => {
298
+ logger.debug(`[${this.identifier}] Loading splash screen placeholder`);
299
+ await this._browserWindow!.loadFile(join(resourcesDir, 'splash.html'));
300
+ logger.debug(`[${this.identifier}] Splash screen placeholder loaded.`);
301
+ };
452
302
 
453
- // If in application quitting process, allow window to be closed
454
- if (this.app.isQuiting) {
455
- logger.debug(`[${this.identifier}] App is quitting, allowing window to close naturally.`);
456
- // Save state before quitting
457
- try {
458
- const bounds = browserWindow.getBounds();
459
- const sizeState = {
460
- height: bounds.height,
461
- width: bounds.width,
462
- x: bounds.x,
463
- y: bounds.y,
464
- };
465
- logger.debug(
466
- `[${this.identifier}] Saving window state on quit: ${JSON.stringify(sizeState)}`,
467
- );
468
- this.app.storeManager.set(this.windowStateKey as any, sizeState);
469
- } catch (error) {
470
- logger.error(`[${this.identifier}] Failed to save window state on quit:`, error);
471
- }
472
- // Need to clean up theme manager
473
- this.cleanupThemeListener();
474
- return;
475
- }
303
+ loadUrl = async (path: string): Promise<void> => {
304
+ const initUrl = await this.app.buildRendererUrl(path);
305
+ const urlWithLocale = this.buildUrlWithLocale(initUrl);
476
306
 
477
- // Prevent window from being destroyed, just hide it (if marked as keepAlive)
478
- if (this.options.keepAlive) {
479
- logger.debug(
480
- `[${this.identifier}] keepAlive is true, preventing default close and hiding window.`,
481
- );
482
- // Optionally save state when hiding if desired, but primary save is on actual close/quit
483
- // try {
484
- // const bounds = browserWindow.getBounds();
485
- // logger.debug(`[${this.identifier}] Saving window state on hide: ${JSON.stringify(bounds)}`);
486
- // this.app.storeManager.set(this.windowStateKey, bounds);
487
- // } catch (error) {
488
- // logger.error(`[${this.identifier}] Failed to save window state on hide:`, error);
489
- // }
490
- e.preventDefault();
491
- this.hide();
492
- } else {
493
- // Window is actually closing (not keepAlive)
494
- logger.debug(
495
- `[${this.identifier}] keepAlive is false, allowing window to close. Saving state...`,
496
- );
497
- try {
498
- const bounds = browserWindow.getBounds();
499
- const sizeState = {
500
- height: bounds.height,
501
- width: bounds.width,
502
- x: bounds.x,
503
- y: bounds.y,
504
- };
505
- logger.debug(
506
- `[${this.identifier}] Saving window state on close: ${JSON.stringify(sizeState)}`,
507
- );
508
- this.app.storeManager.set(this.windowStateKey as any, sizeState);
509
- } catch (error) {
510
- logger.error(`[${this.identifier}] Failed to save window state on close:`, error);
511
- }
512
- // Need to clean up theme manager
513
- this.cleanupThemeListener();
514
- }
515
- });
307
+ console.log('[Browser] initUrl', urlWithLocale);
516
308
 
517
- logger.debug(`[${this.identifier}] retrieveOrInitialize completed.`);
518
- return browserWindow;
309
+ try {
310
+ logger.debug(`[${this.identifier}] Attempting to load URL: ${urlWithLocale}`);
311
+ await this._browserWindow!.loadURL(urlWithLocale);
312
+ logger.debug(`[${this.identifier}] Successfully loaded URL: ${urlWithLocale}`);
313
+ } catch (error) {
314
+ logger.error(`[${this.identifier}] Failed to load URL (${urlWithLocale}):`, error);
315
+ await this.handleLoadError(urlWithLocale);
316
+ }
317
+ };
318
+
319
+ private buildUrlWithLocale(initUrl: string): string {
320
+ const storedLocale = this.app.storeManager.get('locale', 'auto');
321
+ if (storedLocale && storedLocale !== 'auto') {
322
+ return `${initUrl}${initUrl.includes('?') ? '&' : '?'}lng=${storedLocale}`;
323
+ }
324
+ return initUrl;
519
325
  }
520
326
 
521
- moveToCenter() {
522
- logger.debug(`Centering window: ${this.identifier}`);
523
- this._browserWindow?.center();
327
+ private async handleLoadError(urlWithLocale: string): Promise<void> {
328
+ try {
329
+ logger.info(`[${this.identifier}] Attempting to load error page...`);
330
+ await this._browserWindow!.loadFile(join(resourcesDir, 'error.html'));
331
+ logger.info(`[${this.identifier}] Error page loaded successfully.`);
332
+
333
+ this.setupRetryHandler(urlWithLocale);
334
+ } catch (err) {
335
+ logger.error(`[${this.identifier}] Failed to load error page:`, err);
336
+ await this.loadFallbackError();
337
+ }
524
338
  }
525
339
 
526
- setWindowSize(boundSize: { height?: number; width?: number }) {
527
- logger.debug(
528
- `Setting window size for ${this.identifier}: width=${boundSize.width}, height=${boundSize.height}`,
529
- );
530
- const windowSize = this._browserWindow.getBounds();
531
- this._browserWindow?.setBounds({
532
- height: boundSize.height || windowSize.height,
533
- width: boundSize.width || windowSize.width,
340
+ private setupRetryHandler(urlWithLocale: string): void {
341
+ ipcMain.removeHandler('retry-connection');
342
+ logger.debug(`[${this.identifier}] Removed existing retry-connection handler if any.`);
343
+
344
+ ipcMain.handle('retry-connection', async () => {
345
+ logger.info(`[${this.identifier}] Retry connection requested for: ${urlWithLocale}`);
346
+ try {
347
+ await this._browserWindow?.loadURL(urlWithLocale);
348
+ logger.info(`[${this.identifier}] Reconnection successful to ${urlWithLocale}`);
349
+ return { success: true };
350
+ } catch (err: any) {
351
+ logger.error(`[${this.identifier}] Retry connection failed:`, err);
352
+ try {
353
+ await this._browserWindow?.loadFile(join(resourcesDir, 'error.html'));
354
+ } catch (loadErr) {
355
+ logger.error(`[${this.identifier}] Failed to reload error page:`, loadErr);
356
+ }
357
+ return { error: err.message, success: false };
358
+ }
534
359
  });
360
+ logger.debug(`[${this.identifier}] Set up retry-connection handler.`);
535
361
  }
536
362
 
537
- setWindowResizable(resizable: boolean) {
538
- logger.debug(`[${this.identifier}] Setting window resizable: ${resizable}`);
539
- this._browserWindow?.setResizable(resizable);
363
+ private async loadFallbackError(): Promise<void> {
364
+ try {
365
+ logger.warn(`[${this.identifier}] Attempting to load fallback error HTML string...`);
366
+ await this._browserWindow!.loadURL(
367
+ 'data:text/html,<html><body><h1>Loading Failed</h1><p>Unable to connect to server, please restart the application</p></body></html>',
368
+ );
369
+ logger.info(`[${this.identifier}] Fallback error HTML string loaded.`);
370
+ } catch (finalErr) {
371
+ logger.error(`[${this.identifier}] Unable to display any page:`, finalErr);
372
+ }
540
373
  }
541
374
 
542
- broadcast = <T extends MainBroadcastEventKey>(channel: T, data?: MainBroadcastParams<T>) => {
543
- if (this._browserWindow.isDestroyed()) return;
375
+ // ==================== Communication ====================
544
376
 
377
+ broadcast = <T extends MainBroadcastEventKey>(
378
+ channel: T,
379
+ data?: MainBroadcastParams<T>,
380
+ ): void => {
381
+ if (this._browserWindow?.isDestroyed()) return;
545
382
  logger.debug(`Broadcasting to window ${this.identifier}, channel: ${channel}`);
546
- this._browserWindow.webContents.send(channel, data);
383
+ this._browserWindow!.webContents.send(channel, data);
547
384
  };
548
385
 
549
- toggleVisible() {
550
- logger.debug(`Toggling visibility for window: ${this.identifier}`);
551
- if (this._browserWindow.isVisible() && this._browserWindow.isFocused()) {
552
- this.hide(); // Use the hide() method which handles fullscreen
553
- } else {
554
- this._browserWindow.show();
555
- this._browserWindow.focus();
556
- }
557
- }
386
+ // ==================== Theme (Delegated) ====================
558
387
 
559
388
  /**
560
- * Manually reapply visual effects (useful for fixing lost effects after window state changes)
389
+ * Handle application theme mode change (called from BrowserManager)
390
+ */
391
+ handleAppThemeChange = (): void => {
392
+ this.themeManager.handleAppThemeChange();
393
+ };
394
+
395
+ /**
396
+ * Manually reapply visual effects
561
397
  */
562
398
  reapplyVisualEffects(): void {
563
- logger.debug(`[${this.identifier}] Manually reapplying visual effects via Browser.`);
564
- this.applyVisualEffects();
399
+ this.themeManager.reapplyVisualEffects();
565
400
  }
566
401
 
402
+ // ==================== Network Setup ====================
403
+
567
404
  /**
568
405
  * Setup CORS bypass for ALL requests
569
- * In production, the renderer uses app://next protocol which triggers CORS for all external requests
570
- * This completely bypasses CORS by:
571
- * 1. Removing Origin header from requests (prevents OPTIONS preflight)
572
- * 2. Adding proper CORS response headers using the stored origin value
406
+ * In production, the renderer uses app://next protocol which triggers CORS
573
407
  */
574
408
  private setupCORSBypass(browserWindow: BrowserWindow): void {
575
409
  logger.debug(`[${this.identifier}] Setting up CORS bypass for all requests`);
576
410
 
577
411
  const session = browserWindow.webContents.session;
578
-
579
- // Store origin values for each request ID
580
412
  const originMap = new Map<number, string>();
581
413
 
582
- // Remove Origin header and store it for later use
583
414
  session.webRequest.onBeforeSendHeaders((details, callback) => {
584
415
  const requestHeaders = { ...details.requestHeaders };
585
416
 
586
- // Store and remove Origin header to prevent CORS preflight
587
417
  if (requestHeaders['Origin']) {
588
418
  originMap.set(details.id, requestHeaders['Origin']);
589
419
  delete requestHeaders['Origin'];
590
- logger.debug(
591
- `[${this.identifier}] Removed Origin header for: ${details.url} (stored: ${requestHeaders['Origin']})`,
592
- );
420
+ logger.debug(`[${this.identifier}] Removed Origin header for: ${details.url}`);
593
421
  }
594
422
 
595
423
  callback({ requestHeaders });
596
424
  });
597
425
 
598
- // Add CORS headers to ALL responses using stored origin
599
426
  session.webRequest.onHeadersReceived((details, callback) => {
600
427
  const responseHeaders = details.responseHeaders || {};
601
-
602
- // Get the original origin from our map, fallback to default
603
428
  const origin = originMap.get(details.id) || '*';
604
429
 
605
- // Cannot use '*' when Access-Control-Allow-Credentials is true
606
- responseHeaders['Access-Control-Allow-Origin'] = [origin];
607
- responseHeaders['Access-Control-Allow-Methods'] = ['GET, POST, PUT, DELETE, OPTIONS, PATCH'];
608
- responseHeaders['Access-Control-Allow-Headers'] = ['*'];
609
- responseHeaders['Access-Control-Allow-Credentials'] = ['true'];
430
+ // Force set CORS headers (replace existing to avoid duplicates from case-insensitive keys)
431
+ setResponseHeader(responseHeaders, 'Access-Control-Allow-Origin', origin);
432
+ setResponseHeader(
433
+ responseHeaders,
434
+ 'Access-Control-Allow-Methods',
435
+ 'GET, POST, PUT, DELETE, OPTIONS, PATCH',
436
+ );
437
+ setResponseHeader(responseHeaders, 'Access-Control-Allow-Headers', '*');
438
+ setResponseHeader(responseHeaders, 'Access-Control-Allow-Credentials', 'true');
610
439
 
611
- // Clean up the stored origin after response
612
440
  originMap.delete(details.id);
613
441
 
614
- // For OPTIONS requests, add preflight cache and override status
615
442
  if (details.method === 'OPTIONS') {
616
- responseHeaders['Access-Control-Max-Age'] = ['86400']; // 24 hours
617
- logger.debug(`[${this.identifier}] Adding CORS headers to OPTIONS response`);
618
-
619
- callback({
620
- responseHeaders,
621
- statusLine: 'HTTP/1.1 200 OK',
622
- });
443
+ setResponseHeader(responseHeaders, 'Access-Control-Max-Age', '86400');
444
+ callback({ responseHeaders, statusLine: 'HTTP/1.1 200 OK' });
623
445
  return;
624
446
  }
625
447
 
@@ -630,10 +452,9 @@ export default class Browser {
630
452
  }
631
453
 
632
454
  /**
633
- * Rewrite tRPC requests to remote server and inject OIDC token via webRequest hooks.
634
- * Replaces the previous proxyTRPCRequest IPC forwarding.
455
+ * Rewrite tRPC requests to remote server and inject OIDC token
635
456
  */
636
- private setupRemoteServerRequestHook(browserWindow: BrowserWindow) {
457
+ private setupRemoteServerRequestHook(browserWindow: BrowserWindow): void {
637
458
  const session = browserWindow.webContents.session;
638
459
  const remoteServerConfigCtr = this.app.getController(RemoteServerConfigCtr);
639
460