@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.
- package/.cursor/rules/desktop-feature-implementation.mdc +2 -2
- package/.cursor/rules/desktop-local-tools-implement.mdc +2 -2
- package/.github/workflows/test.yml +13 -5
- package/CHANGELOG.md +52 -0
- package/apps/desktop/Development.md +1 -6
- package/apps/desktop/README.md +2 -17
- package/apps/desktop/README.zh-CN.md +1 -15
- package/apps/desktop/build/Icon-beta.Assets.car +0 -0
- package/apps/desktop/build/Icon-beta.icns +0 -0
- package/apps/desktop/build/icon-beta.ico +0 -0
- package/apps/desktop/build/icon-beta.png +0 -0
- package/apps/desktop/src/main/controllers/index.ts +1 -1
- package/apps/desktop/src/main/controllers/registry.ts +0 -9
- package/apps/desktop/src/main/core/App.ts +1 -11
- package/apps/desktop/src/main/core/browser/Browser.ts +278 -457
- package/apps/desktop/src/main/core/browser/WindowStateManager.ts +180 -0
- package/apps/desktop/src/main/core/browser/WindowThemeManager.ts +167 -0
- package/apps/desktop/src/main/core/browser/__tests__/WindowStateManager.test.ts +237 -0
- package/apps/desktop/src/main/core/browser/__tests__/WindowThemeManager.test.ts +240 -0
- package/apps/desktop/src/main/exports.d.ts +1 -1
- package/apps/desktop/src/main/exports.ts +1 -1
- package/apps/desktop/src/main/utils/__tests__/http-headers.test.ts +131 -0
- package/apps/desktop/src/main/utils/http-headers.ts +61 -0
- package/apps/desktop/src/main/utils/ipc/__tests__/base.test.ts +1 -22
- package/apps/desktop/src/main/utils/ipc/base.ts +0 -20
- package/apps/desktop/src/main/utils/ipc/index.ts +1 -9
- package/changelog/v1.json +10 -0
- package/locales/ar/models.json +48 -7
- package/locales/ar/plugin.json +9 -0
- package/locales/ar/providers.json +1 -0
- package/locales/bg-BG/models.json +35 -7
- package/locales/bg-BG/plugin.json +9 -0
- package/locales/bg-BG/providers.json +1 -0
- package/locales/de-DE/models.json +26 -6
- package/locales/de-DE/plugin.json +9 -0
- package/locales/de-DE/providers.json +1 -0
- package/locales/en-US/models.json +10 -10
- package/locales/en-US/oauth.json +0 -1
- package/locales/en-US/providers.json +1 -0
- package/locales/en-US/subscription.json +2 -2
- package/locales/es-ES/models.json +42 -7
- package/locales/es-ES/plugin.json +9 -0
- package/locales/es-ES/providers.json +1 -0
- package/locales/fa-IR/models.json +52 -10
- package/locales/fa-IR/plugin.json +9 -0
- package/locales/fa-IR/providers.json +1 -0
- package/locales/fr-FR/models.json +36 -7
- package/locales/fr-FR/plugin.json +9 -0
- package/locales/fr-FR/providers.json +1 -0
- package/locales/it-IT/models.json +42 -7
- package/locales/it-IT/plugin.json +9 -0
- package/locales/it-IT/providers.json +1 -0
- package/locales/ja-JP/models.json +35 -6
- package/locales/ja-JP/plugin.json +9 -0
- package/locales/ja-JP/providers.json +1 -0
- package/locales/ko-KR/models.json +28 -7
- package/locales/ko-KR/plugin.json +9 -0
- package/locales/ko-KR/providers.json +1 -0
- package/locales/nl-NL/models.json +35 -6
- package/locales/nl-NL/plugin.json +9 -0
- package/locales/nl-NL/providers.json +1 -0
- package/locales/pl-PL/models.json +36 -7
- package/locales/pl-PL/plugin.json +9 -0
- package/locales/pl-PL/providers.json +1 -0
- package/locales/pt-BR/models.json +35 -6
- package/locales/pt-BR/plugin.json +9 -0
- package/locales/pt-BR/providers.json +1 -0
- package/locales/ru-RU/models.json +35 -7
- package/locales/ru-RU/plugin.json +9 -0
- package/locales/ru-RU/providers.json +1 -0
- package/locales/tr-TR/models.json +5 -7
- package/locales/tr-TR/plugin.json +9 -0
- package/locales/tr-TR/providers.json +1 -0
- package/locales/vi-VN/models.json +5 -5
- package/locales/vi-VN/plugin.json +9 -0
- package/locales/vi-VN/providers.json +1 -0
- package/locales/zh-CN/models.json +48 -6
- package/locales/zh-CN/oauth.json +0 -1
- package/locales/zh-CN/providers.json +1 -0
- package/locales/zh-CN/subscription.json +1 -1
- package/locales/zh-TW/models.json +10 -10
- package/locales/zh-TW/plugin.json +9 -0
- package/locales/zh-TW/providers.json +1 -0
- package/package.json +1 -1
- package/src/features/ChatInput/InputEditor/Placeholder.tsx +4 -1
- package/src/locales/default/subscription.ts +2 -3
- package/src/server/services/memory/userMemory/extract.ts +46 -6
- package/apps/desktop/src/main/controllers/UploadFileServerCtr.ts +0 -33
- package/apps/desktop/src/main/controllers/__tests__/UploadFileServerCtr.test.ts +0 -55
- 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
|
+
});
|