@lobehub/lobehub 2.0.0-next.241 → 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 (90) 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/.github/workflows/test.yml +13 -5
  4. package/CHANGELOG.md +52 -0
  5. package/apps/desktop/Development.md +1 -6
  6. package/apps/desktop/README.md +2 -17
  7. package/apps/desktop/README.zh-CN.md +1 -15
  8. package/apps/desktop/build/Icon-beta.Assets.car +0 -0
  9. package/apps/desktop/build/Icon-beta.icns +0 -0
  10. package/apps/desktop/build/icon-beta.ico +0 -0
  11. package/apps/desktop/build/icon-beta.png +0 -0
  12. package/apps/desktop/src/main/controllers/index.ts +1 -1
  13. package/apps/desktop/src/main/controllers/registry.ts +0 -9
  14. package/apps/desktop/src/main/core/App.ts +1 -11
  15. package/apps/desktop/src/main/core/browser/Browser.ts +278 -457
  16. package/apps/desktop/src/main/core/browser/WindowStateManager.ts +180 -0
  17. package/apps/desktop/src/main/core/browser/WindowThemeManager.ts +167 -0
  18. package/apps/desktop/src/main/core/browser/__tests__/WindowStateManager.test.ts +237 -0
  19. package/apps/desktop/src/main/core/browser/__tests__/WindowThemeManager.test.ts +240 -0
  20. package/apps/desktop/src/main/exports.d.ts +1 -1
  21. package/apps/desktop/src/main/exports.ts +1 -1
  22. package/apps/desktop/src/main/utils/__tests__/http-headers.test.ts +131 -0
  23. package/apps/desktop/src/main/utils/http-headers.ts +61 -0
  24. package/apps/desktop/src/main/utils/ipc/__tests__/base.test.ts +1 -22
  25. package/apps/desktop/src/main/utils/ipc/base.ts +0 -20
  26. package/apps/desktop/src/main/utils/ipc/index.ts +1 -9
  27. package/changelog/v1.json +10 -0
  28. package/locales/ar/models.json +48 -7
  29. package/locales/ar/plugin.json +9 -0
  30. package/locales/ar/providers.json +1 -0
  31. package/locales/bg-BG/models.json +35 -7
  32. package/locales/bg-BG/plugin.json +9 -0
  33. package/locales/bg-BG/providers.json +1 -0
  34. package/locales/de-DE/models.json +26 -6
  35. package/locales/de-DE/plugin.json +9 -0
  36. package/locales/de-DE/providers.json +1 -0
  37. package/locales/en-US/models.json +10 -10
  38. package/locales/en-US/oauth.json +0 -1
  39. package/locales/en-US/providers.json +1 -0
  40. package/locales/en-US/subscription.json +2 -2
  41. package/locales/es-ES/models.json +42 -7
  42. package/locales/es-ES/plugin.json +9 -0
  43. package/locales/es-ES/providers.json +1 -0
  44. package/locales/fa-IR/models.json +52 -10
  45. package/locales/fa-IR/plugin.json +9 -0
  46. package/locales/fa-IR/providers.json +1 -0
  47. package/locales/fr-FR/models.json +36 -7
  48. package/locales/fr-FR/plugin.json +9 -0
  49. package/locales/fr-FR/providers.json +1 -0
  50. package/locales/it-IT/models.json +42 -7
  51. package/locales/it-IT/plugin.json +9 -0
  52. package/locales/it-IT/providers.json +1 -0
  53. package/locales/ja-JP/models.json +35 -6
  54. package/locales/ja-JP/plugin.json +9 -0
  55. package/locales/ja-JP/providers.json +1 -0
  56. package/locales/ko-KR/models.json +28 -7
  57. package/locales/ko-KR/plugin.json +9 -0
  58. package/locales/ko-KR/providers.json +1 -0
  59. package/locales/nl-NL/models.json +35 -6
  60. package/locales/nl-NL/plugin.json +9 -0
  61. package/locales/nl-NL/providers.json +1 -0
  62. package/locales/pl-PL/models.json +36 -7
  63. package/locales/pl-PL/plugin.json +9 -0
  64. package/locales/pl-PL/providers.json +1 -0
  65. package/locales/pt-BR/models.json +35 -6
  66. package/locales/pt-BR/plugin.json +9 -0
  67. package/locales/pt-BR/providers.json +1 -0
  68. package/locales/ru-RU/models.json +35 -7
  69. package/locales/ru-RU/plugin.json +9 -0
  70. package/locales/ru-RU/providers.json +1 -0
  71. package/locales/tr-TR/models.json +5 -7
  72. package/locales/tr-TR/plugin.json +9 -0
  73. package/locales/tr-TR/providers.json +1 -0
  74. package/locales/vi-VN/models.json +5 -5
  75. package/locales/vi-VN/plugin.json +9 -0
  76. package/locales/vi-VN/providers.json +1 -0
  77. package/locales/zh-CN/models.json +48 -6
  78. package/locales/zh-CN/oauth.json +0 -1
  79. package/locales/zh-CN/providers.json +1 -0
  80. package/locales/zh-CN/subscription.json +1 -1
  81. package/locales/zh-TW/models.json +10 -10
  82. package/locales/zh-TW/plugin.json +9 -0
  83. package/locales/zh-TW/providers.json +1 -0
  84. package/package.json +1 -1
  85. package/src/features/ChatInput/InputEditor/Placeholder.tsx +4 -1
  86. package/src/locales/default/subscription.ts +2 -3
  87. package/src/server/services/memory/userMemory/extract.ts +46 -6
  88. package/apps/desktop/src/main/controllers/UploadFileServerCtr.ts +0 -33
  89. package/apps/desktop/src/main/controllers/__tests__/UploadFileServerCtr.test.ts +0 -55
  90. package/src/server/modules/ElectronIPCClient/index.ts +0 -92
@@ -0,0 +1,180 @@
1
+ import Electron, { BrowserWindow, screen } from 'electron';
2
+
3
+ import { createLogger } from '@/utils/logger';
4
+
5
+ import type { App } from '../App';
6
+
7
+ const logger = createLogger('core:WindowStateManager');
8
+
9
+ export interface WindowState {
10
+ height?: number;
11
+ width?: number;
12
+ x?: number;
13
+ y?: number;
14
+ }
15
+
16
+ export interface WindowStateManagerOptions {
17
+ identifier: string;
18
+ keepAlive?: boolean;
19
+ }
20
+
21
+ /**
22
+ * Manages window state persistence and close behavior
23
+ */
24
+ export class WindowStateManager {
25
+ private readonly app: App;
26
+ private readonly identifier: string;
27
+ private readonly stateKey: string;
28
+ private readonly keepAlive: boolean;
29
+
30
+ constructor(app: App, options: WindowStateManagerOptions) {
31
+ this.app = app;
32
+ this.identifier = options.identifier;
33
+ this.stateKey = `windowSize_${options.identifier}`;
34
+ this.keepAlive = options.keepAlive ?? false;
35
+ }
36
+
37
+ // ==================== State Persistence ====================
38
+
39
+ /**
40
+ * Load saved window state from persistent storage
41
+ */
42
+ loadState(): WindowState | undefined {
43
+ return this.app.storeManager.get(this.stateKey as any) as WindowState | undefined;
44
+ }
45
+
46
+ /**
47
+ * Save current window bounds to persistent storage
48
+ */
49
+ saveState(browserWindow: BrowserWindow, context: 'quit' | 'close' | 'hide' = 'close'): void {
50
+ try {
51
+ const bounds = browserWindow.getBounds();
52
+ const state: WindowState = {
53
+ height: bounds.height,
54
+ width: bounds.width,
55
+ x: bounds.x,
56
+ y: bounds.y,
57
+ };
58
+ logger.debug(
59
+ `[${this.identifier}] Saving window state on ${context}: ${JSON.stringify(state)}`,
60
+ );
61
+ this.app.storeManager.set(this.stateKey as any, state);
62
+ } catch (error) {
63
+ logger.error(`[${this.identifier}] Failed to save window state on ${context}:`, error);
64
+ }
65
+ }
66
+
67
+ // ==================== State Resolution ====================
68
+
69
+ /**
70
+ * Resolve window state by merging saved state with fallback,
71
+ * ensuring position is within visible screen bounds
72
+ */
73
+ resolveState(fallback: { height?: number; width?: number }): WindowState {
74
+ const savedState = this.loadState();
75
+ return this.resolveWindowState(savedState, fallback);
76
+ }
77
+
78
+ private resolveWindowState(
79
+ savedState: WindowState | undefined,
80
+ fallbackState: { height?: number; width?: number },
81
+ ): WindowState {
82
+ const width = savedState?.width ?? fallbackState.width;
83
+ const height = savedState?.height ?? fallbackState.height;
84
+ const resolvedState: WindowState = { height, width };
85
+
86
+ const hasPosition = Number.isFinite(savedState?.x) && Number.isFinite(savedState?.y);
87
+ if (!hasPosition) return resolvedState;
88
+
89
+ const x = savedState?.x as number;
90
+ const y = savedState?.y as number;
91
+
92
+ const targetDisplay = screen.getDisplayMatching({
93
+ height: height ?? 0,
94
+ width: width ?? 0,
95
+ x,
96
+ y,
97
+ });
98
+
99
+ const workArea = targetDisplay?.workArea ?? screen.getPrimaryDisplay().workArea;
100
+ const resolvedWidth = typeof width === 'number' ? Math.min(width, workArea.width) : width;
101
+ const resolvedHeight = typeof height === 'number' ? Math.min(height, workArea.height) : height;
102
+
103
+ const maxX = workArea.x + Math.max(0, workArea.width - (resolvedWidth ?? 0));
104
+ const maxY = workArea.y + Math.max(0, workArea.height - (resolvedHeight ?? 0));
105
+
106
+ return {
107
+ height: resolvedHeight,
108
+ width: resolvedWidth,
109
+ x: this.clampNumber(x, workArea.x, maxX),
110
+ y: this.clampNumber(y, workArea.y, maxY),
111
+ };
112
+ }
113
+
114
+ private clampNumber(value: number, min: number, max: number): number {
115
+ return Math.min(Math.max(value, min), max);
116
+ }
117
+
118
+ // ==================== Close Event Handling ====================
119
+
120
+ /**
121
+ * Create a close event handler for the browser window
122
+ * Returns a handler function and a cleanup function
123
+ */
124
+ createCloseHandler(
125
+ browserWindow: BrowserWindow,
126
+ callbacks: {
127
+ onCleanup: () => void;
128
+ onHide: () => void;
129
+ },
130
+ ): (e: Electron.Event) => void {
131
+ return (e: Electron.Event) => {
132
+ logger.debug(`Window 'close' event triggered for: ${this.identifier}`);
133
+ logger.debug(
134
+ `[${this.identifier}] State during close event: isQuiting=${this.app.isQuiting}, keepAlive=${this.keepAlive}`,
135
+ );
136
+
137
+ if (this.app.isQuiting) {
138
+ this.handleCloseOnQuit(browserWindow, callbacks.onCleanup);
139
+ return;
140
+ }
141
+
142
+ if (this.keepAlive) {
143
+ this.handleCloseWithKeepAlive(e, callbacks.onHide);
144
+ } else {
145
+ this.handleCloseNormally(browserWindow, callbacks.onCleanup);
146
+ }
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Handle close when application is quitting - save state and cleanup
152
+ */
153
+ private handleCloseOnQuit(browserWindow: BrowserWindow, onCleanup: () => void): void {
154
+ logger.debug(`[${this.identifier}] App is quitting, allowing window to close naturally.`);
155
+ this.saveState(browserWindow, 'quit');
156
+ onCleanup();
157
+ }
158
+
159
+ /**
160
+ * Handle close when keepAlive is enabled - prevent close and hide instead
161
+ */
162
+ private handleCloseWithKeepAlive(e: Electron.Event, onHide: () => void): void {
163
+ logger.debug(
164
+ `[${this.identifier}] keepAlive is true, preventing default close and hiding window.`,
165
+ );
166
+ e.preventDefault();
167
+ onHide();
168
+ }
169
+
170
+ /**
171
+ * Handle normal close - save state, cleanup, and allow window to close
172
+ */
173
+ private handleCloseNormally(browserWindow: BrowserWindow, onCleanup: () => void): void {
174
+ logger.debug(
175
+ `[${this.identifier}] keepAlive is false, allowing window to close. Saving state...`,
176
+ );
177
+ this.saveState(browserWindow, 'close');
178
+ onCleanup();
179
+ }
180
+ }
@@ -0,0 +1,167 @@
1
+ import { BrowserWindow, nativeTheme } from 'electron';
2
+ import { join } from 'node:path';
3
+
4
+ import { buildDir } from '@/const/dir';
5
+ import { isDev, isWindows } from '@/const/env';
6
+ import {
7
+ BACKGROUND_DARK,
8
+ BACKGROUND_LIGHT,
9
+ SYMBOL_COLOR_DARK,
10
+ SYMBOL_COLOR_LIGHT,
11
+ THEME_CHANGE_DELAY,
12
+ TITLE_BAR_HEIGHT,
13
+ } from '@/const/theme';
14
+ import { createLogger } from '@/utils/logger';
15
+
16
+ const logger = createLogger('core:WindowThemeManager');
17
+
18
+ interface WindowsThemeConfig {
19
+ backgroundColor: string;
20
+ icon?: string;
21
+ titleBarOverlay: {
22
+ color: string;
23
+ height: number;
24
+ symbolColor: string;
25
+ };
26
+ titleBarStyle: 'hidden';
27
+ }
28
+
29
+ /**
30
+ * Manages window theme configuration and visual effects
31
+ */
32
+ export class WindowThemeManager {
33
+ private readonly identifier: string;
34
+ private browserWindow?: BrowserWindow;
35
+ private listenerSetup = false;
36
+ private boundHandleThemeChange: () => void;
37
+
38
+ constructor(identifier: string) {
39
+ this.identifier = identifier;
40
+ this.boundHandleThemeChange = this.handleThemeChange.bind(this);
41
+ }
42
+
43
+ // ==================== Lifecycle ====================
44
+
45
+ /**
46
+ * Attach to a browser window and setup theme handling
47
+ */
48
+ attach(browserWindow: BrowserWindow): void {
49
+ this.browserWindow = browserWindow;
50
+ this.setupThemeListener();
51
+ this.applyVisualEffects();
52
+ }
53
+
54
+ /**
55
+ * Cleanup theme listener when window is destroyed
56
+ */
57
+ cleanup(): void {
58
+ if (this.listenerSetup) {
59
+ nativeTheme.off('updated', this.boundHandleThemeChange);
60
+ this.listenerSetup = false;
61
+ logger.debug(`[${this.identifier}] Theme listener cleaned up.`);
62
+ }
63
+ this.browserWindow = undefined;
64
+ }
65
+
66
+ // ==================== Theme Configuration ====================
67
+
68
+ /**
69
+ * Get current dark mode state
70
+ */
71
+ get isDarkMode(): boolean {
72
+ return nativeTheme.shouldUseDarkColors;
73
+ }
74
+
75
+ /**
76
+ * Get platform-specific theme configuration for window creation
77
+ */
78
+ getPlatformConfig(): Partial<WindowsThemeConfig> {
79
+ if (isWindows) {
80
+ return this.getWindowsConfig(this.isDarkMode);
81
+ }
82
+ return {};
83
+ }
84
+
85
+ /**
86
+ * Get Windows-specific theme configuration
87
+ */
88
+ private getWindowsConfig(isDarkMode: boolean): WindowsThemeConfig {
89
+ return {
90
+ backgroundColor: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
91
+ icon: isDev ? join(buildDir, 'icon-dev.ico') : undefined,
92
+ titleBarOverlay: {
93
+ color: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
94
+ height: TITLE_BAR_HEIGHT,
95
+ symbolColor: isDarkMode ? SYMBOL_COLOR_DARK : SYMBOL_COLOR_LIGHT,
96
+ },
97
+ titleBarStyle: 'hidden',
98
+ };
99
+ }
100
+
101
+ // ==================== Theme Listener ====================
102
+
103
+ private setupThemeListener(): void {
104
+ if (this.listenerSetup) return;
105
+
106
+ nativeTheme.on('updated', this.boundHandleThemeChange);
107
+ this.listenerSetup = true;
108
+ logger.debug(`[${this.identifier}] Theme listener setup.`);
109
+ }
110
+
111
+ private handleThemeChange(): void {
112
+ logger.debug(`[${this.identifier}] System theme changed, reapplying visual effects.`);
113
+ setTimeout(() => {
114
+ this.applyVisualEffects();
115
+ }, THEME_CHANGE_DELAY);
116
+ }
117
+
118
+ /**
119
+ * Handle application theme mode change (called from BrowserManager)
120
+ */
121
+ handleAppThemeChange(): void {
122
+ logger.debug(`[${this.identifier}] App theme mode changed, reapplying visual effects.`);
123
+ setTimeout(() => {
124
+ this.applyVisualEffects();
125
+ }, THEME_CHANGE_DELAY);
126
+ }
127
+
128
+ // ==================== Visual Effects ====================
129
+
130
+ /**
131
+ * Apply visual effects based on current theme
132
+ */
133
+ applyVisualEffects(): void {
134
+ if (!this.browserWindow || this.browserWindow.isDestroyed()) return;
135
+
136
+ logger.debug(`[${this.identifier}] Applying visual effects for platform`);
137
+ const isDarkMode = this.isDarkMode;
138
+
139
+ try {
140
+ if (isWindows) {
141
+ this.applyWindowsVisualEffects(isDarkMode);
142
+ }
143
+
144
+ logger.debug(
145
+ `[${this.identifier}] Visual effects applied successfully (dark mode: ${isDarkMode})`,
146
+ );
147
+ } catch (error) {
148
+ logger.error(`[${this.identifier}] Failed to apply visual effects:`, error);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Manually reapply visual effects
154
+ */
155
+ reapplyVisualEffects(): void {
156
+ logger.debug(`[${this.identifier}] Manually reapplying visual effects.`);
157
+ this.applyVisualEffects();
158
+ }
159
+
160
+ private applyWindowsVisualEffects(isDarkMode: boolean): void {
161
+ if (!this.browserWindow) return;
162
+
163
+ const config = this.getWindowsConfig(isDarkMode);
164
+ this.browserWindow.setBackgroundColor(config.backgroundColor);
165
+ this.browserWindow.setTitleBarOverlay(config.titleBarOverlay);
166
+ }
167
+ }
@@ -0,0 +1,237 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import type { App as AppCore } from '../../App';
4
+ import { WindowStateManager } from '../WindowStateManager';
5
+
6
+ // Use vi.hoisted to define mocks before hoisting
7
+ const { mockScreen } = vi.hoisted(() => ({
8
+ mockScreen: {
9
+ getDisplayMatching: vi.fn().mockReturnValue({
10
+ workArea: { height: 1080, width: 1920, x: 0, y: 0 },
11
+ }),
12
+ getPrimaryDisplay: vi.fn().mockReturnValue({
13
+ workArea: { height: 1080, width: 1920, x: 0, y: 0 },
14
+ }),
15
+ },
16
+ }));
17
+
18
+ vi.mock('electron', () => ({
19
+ screen: mockScreen,
20
+ }));
21
+
22
+ vi.mock('@/utils/logger', () => ({
23
+ createLogger: () => ({
24
+ debug: vi.fn(),
25
+ error: vi.fn(),
26
+ info: vi.fn(),
27
+ warn: vi.fn(),
28
+ }),
29
+ }));
30
+
31
+ describe('WindowStateManager', () => {
32
+ let manager: WindowStateManager;
33
+ let mockApp: AppCore;
34
+ let mockStoreManagerGet: ReturnType<typeof vi.fn>;
35
+ let mockStoreManagerSet: ReturnType<typeof vi.fn>;
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+
40
+ mockStoreManagerGet = vi.fn().mockReturnValue(undefined);
41
+ mockStoreManagerSet = vi.fn();
42
+
43
+ mockApp = {
44
+ isQuiting: false,
45
+ storeManager: {
46
+ get: mockStoreManagerGet,
47
+ set: mockStoreManagerSet,
48
+ },
49
+ } as unknown as AppCore;
50
+
51
+ manager = new WindowStateManager(mockApp, {
52
+ identifier: 'test-window',
53
+ keepAlive: false,
54
+ });
55
+ });
56
+
57
+ describe('loadState', () => {
58
+ it('should load state from store', () => {
59
+ const savedState = { height: 700, width: 900, x: 100, y: 100 };
60
+ mockStoreManagerGet.mockReturnValue(savedState);
61
+
62
+ const state = manager.loadState();
63
+
64
+ expect(mockStoreManagerGet).toHaveBeenCalledWith('windowSize_test-window');
65
+ expect(state).toEqual(savedState);
66
+ });
67
+
68
+ it('should return undefined when no saved state', () => {
69
+ mockStoreManagerGet.mockReturnValue(undefined);
70
+
71
+ const state = manager.loadState();
72
+
73
+ expect(state).toBeUndefined();
74
+ });
75
+ });
76
+
77
+ describe('saveState', () => {
78
+ it('should save window bounds to store', () => {
79
+ const mockBrowserWindow = {
80
+ getBounds: vi.fn().mockReturnValue({ height: 600, width: 800, x: 50, y: 50 }),
81
+ } as any;
82
+
83
+ manager.saveState(mockBrowserWindow, 'close');
84
+
85
+ expect(mockStoreManagerSet).toHaveBeenCalledWith('windowSize_test-window', {
86
+ height: 600,
87
+ width: 800,
88
+ x: 50,
89
+ y: 50,
90
+ });
91
+ });
92
+
93
+ it('should handle errors gracefully', () => {
94
+ const mockBrowserWindow = {
95
+ getBounds: vi.fn().mockImplementation(() => {
96
+ throw new Error('Window destroyed');
97
+ }),
98
+ } as any;
99
+
100
+ // Should not throw
101
+ expect(() => manager.saveState(mockBrowserWindow, 'close')).not.toThrow();
102
+ });
103
+ });
104
+
105
+ describe('resolveState', () => {
106
+ it('should use fallback when no saved state', () => {
107
+ mockStoreManagerGet.mockReturnValue(undefined);
108
+
109
+ const state = manager.resolveState({ height: 600, width: 800 });
110
+
111
+ expect(state).toEqual({ height: 600, width: 800 });
112
+ });
113
+
114
+ it('should use saved size over fallback', () => {
115
+ mockStoreManagerGet.mockReturnValue({ height: 700, width: 900 });
116
+
117
+ const state = manager.resolveState({ height: 600, width: 800 });
118
+
119
+ expect(state).toEqual({ height: 700, width: 900 });
120
+ });
121
+
122
+ it('should restore saved position when valid', () => {
123
+ mockStoreManagerGet.mockReturnValue({ height: 700, width: 900, x: 100, y: 100 });
124
+
125
+ const state = manager.resolveState({ height: 600, width: 800 });
126
+
127
+ expect(state).toEqual({ height: 700, width: 900, x: 100, y: 100 });
128
+ });
129
+
130
+ it('should clamp position to screen bounds', () => {
131
+ mockStoreManagerGet.mockReturnValue({ height: 700, width: 900, x: 2000, y: 1500 });
132
+
133
+ const state = manager.resolveState({ height: 600, width: 800 });
134
+
135
+ // x should be clamped: maxX = 0 + max(0, 1920 - 900) = 1020
136
+ // y should be clamped: maxY = 0 + max(0, 1080 - 700) = 380
137
+ expect(state.x).toBe(1020);
138
+ expect(state.y).toBe(380);
139
+ });
140
+
141
+ it('should clamp size to screen bounds', () => {
142
+ mockScreen.getDisplayMatching.mockReturnValueOnce({
143
+ workArea: { height: 800, width: 1200, x: 0, y: 0 },
144
+ });
145
+ mockStoreManagerGet.mockReturnValue({ height: 1200, width: 2000, x: 0, y: 0 });
146
+
147
+ const state = manager.resolveState({ height: 600, width: 800 });
148
+
149
+ expect(state.width).toBe(1200);
150
+ expect(state.height).toBe(800);
151
+ });
152
+
153
+ it('should use primary display when no matching display found', () => {
154
+ mockScreen.getDisplayMatching.mockReturnValueOnce(null);
155
+ mockStoreManagerGet.mockReturnValue({ height: 700, width: 900, x: 100, y: 100 });
156
+
157
+ const state = manager.resolveState({ height: 600, width: 800 });
158
+
159
+ expect(mockScreen.getPrimaryDisplay).toHaveBeenCalled();
160
+ expect(state).toBeDefined();
161
+ });
162
+ });
163
+
164
+ describe('createCloseHandler', () => {
165
+ let mockBrowserWindow: any;
166
+ let onCleanup: ReturnType<typeof vi.fn>;
167
+ let onHide: ReturnType<typeof vi.fn>;
168
+
169
+ beforeEach(() => {
170
+ mockBrowserWindow = {
171
+ getBounds: vi.fn().mockReturnValue({ height: 600, width: 800, x: 0, y: 0 }),
172
+ };
173
+ onCleanup = vi.fn();
174
+ onHide = vi.fn();
175
+ });
176
+
177
+ describe('when app is quitting', () => {
178
+ beforeEach(() => {
179
+ (mockApp as any).isQuiting = true;
180
+ });
181
+
182
+ it('should save state and call cleanup', () => {
183
+ const handler = manager.createCloseHandler(mockBrowserWindow, { onCleanup, onHide });
184
+ const mockEvent = { preventDefault: vi.fn() };
185
+
186
+ handler(mockEvent as any);
187
+
188
+ expect(mockStoreManagerSet).toHaveBeenCalledWith('windowSize_test-window', {
189
+ height: 600,
190
+ width: 800,
191
+ x: 0,
192
+ y: 0,
193
+ });
194
+ expect(onCleanup).toHaveBeenCalled();
195
+ expect(mockEvent.preventDefault).not.toHaveBeenCalled();
196
+ });
197
+ });
198
+
199
+ describe('when keepAlive is true', () => {
200
+ beforeEach(() => {
201
+ manager = new WindowStateManager(mockApp, {
202
+ identifier: 'test-window',
203
+ keepAlive: true,
204
+ });
205
+ });
206
+
207
+ it('should prevent close and call hide', () => {
208
+ const handler = manager.createCloseHandler(mockBrowserWindow, { onCleanup, onHide });
209
+ const mockEvent = { preventDefault: vi.fn() };
210
+
211
+ handler(mockEvent as any);
212
+
213
+ expect(mockEvent.preventDefault).toHaveBeenCalled();
214
+ expect(onHide).toHaveBeenCalled();
215
+ expect(mockStoreManagerSet).not.toHaveBeenCalled();
216
+ });
217
+ });
218
+
219
+ describe('when keepAlive is false (normal close)', () => {
220
+ it('should save state and call cleanup', () => {
221
+ const handler = manager.createCloseHandler(mockBrowserWindow, { onCleanup, onHide });
222
+ const mockEvent = { preventDefault: vi.fn() };
223
+
224
+ handler(mockEvent as any);
225
+
226
+ expect(mockStoreManagerSet).toHaveBeenCalledWith('windowSize_test-window', {
227
+ height: 600,
228
+ width: 800,
229
+ x: 0,
230
+ y: 0,
231
+ });
232
+ expect(onCleanup).toHaveBeenCalled();
233
+ expect(mockEvent.preventDefault).not.toHaveBeenCalled();
234
+ });
235
+ });
236
+ });
237
+ });