smartWindow.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import path from 'path';
  2. import { BrowserWindow, screen } from 'electron';
  3. import { getBaseDir, isDev } from 'ee-core/ps';
  4. import { logger } from 'ee-core/log';
  5. import { getMainWindow } from 'ee-core/electron';
  6. import { getSmartWindowOptions, smartWindowSize } from '../../config/windowOptions';
  7. import { localSettingsService } from '../setting/localSettings';
  8. /** 窗口 bounds 在本机设置里的键名 */
  9. const BOUNDS_KEY = 'smartWindowBounds';
  10. /** 智能窗口只承载工作台,非该路径一律改写 */
  11. const WORKBENCH_PATH = '/workbench';
  12. /** 主进程 → 主窗口的窗口开关状态通道 */
  13. export const SMART_WINDOW_STATE_CHANNEL = 'smart-window:state';
  14. export interface OpenSmartWindowOptions {
  15. /** 完整路由(含 query),如 /workbench?newChat=1 */
  16. fullPath?: string;
  17. /** 窗口已存在时是否整窗重载(换案后需要重新读案件上下文) */
  18. reloadIfOpen?: boolean;
  19. /** 窗口已存在且目标带 query 时,是否导航到该地址(?newChat=1 / ?view=agent 这类明确意图) */
  20. navigateIfOpen?: boolean;
  21. }
  22. interface SavedBounds {
  23. x: number;
  24. y: number;
  25. width: number;
  26. height: number;
  27. }
  28. /** 单例:应用内最多一个智能工作台窗口 */
  29. let smartWindow: BrowserWindow | null = null;
  30. export function getSmartWindow(): BrowserWindow | null {
  31. return smartWindow && !smartWindow.isDestroyed() ? smartWindow : null;
  32. }
  33. export function isSmartWindowOpen(): boolean {
  34. return !!getSmartWindow();
  35. }
  36. /** 判断某个窗口是不是智能工作台窗口(IPC 解析窗口角色用) */
  37. export function isSmartWindow(win: BrowserWindow | null | undefined): boolean {
  38. const current = getSmartWindow();
  39. return !!win && !!current && win.id === current.id;
  40. }
  41. /**
  42. * 打开/聚焦智能工作台窗口。
  43. *
  44. * 已存在时只做 restore/show/focus(reloadIfOpen 时再 reload),保证单例。
  45. */
  46. export function openSmartWindow(options: OpenSmartWindowOptions = {}): BrowserWindow {
  47. const fullPath = normalizeFullPath(options.fullPath);
  48. const existing = getSmartWindow();
  49. if (existing) {
  50. focusWindow(existing);
  51. if (options.reloadIfOpen) {
  52. // 刚由本次导航创建、还在加载时不用再刷一次;已加载完成的才需要按新案件重建
  53. if (!existing.webContents.isLoading()) {
  54. existing.webContents.reload();
  55. }
  56. } else if (options.navigateIfOpen && fullPath.includes('?')) {
  57. // 显式带 query(?newChat=1 / ?view=agent)说明是明确的导航意图;
  58. // 普通打开/聚焦不动它当前所在的视图
  59. loadRenderer(existing, fullPath);
  60. }
  61. return existing;
  62. }
  63. const win = new BrowserWindow({
  64. ...getSmartWindowOptions(),
  65. ...restoreBounds(),
  66. });
  67. smartWindow = win;
  68. win.once('ready-to-show', () => {
  69. if (!win.isDestroyed()) {
  70. win.show();
  71. win.focus();
  72. }
  73. });
  74. win.on('close', () => saveBounds(win));
  75. win.on('closed', () => {
  76. if (smartWindow === win) smartWindow = null;
  77. notifyState(false);
  78. });
  79. // 渲染进程崩溃后留一个空壳没有意义:销毁它,下次点「智能」重新创建
  80. win.webContents.on('render-process-gone', (_event, details) => {
  81. logger.error('[smartWindow] render-process-gone:', details?.reason);
  82. if (!win.isDestroyed()) {
  83. saveBounds(win);
  84. win.destroy();
  85. }
  86. });
  87. loadRenderer(win, fullPath);
  88. notifyState(true);
  89. return win;
  90. }
  91. export function closeSmartWindow(): void {
  92. const win = getSmartWindow();
  93. if (!win) return;
  94. win.close();
  95. }
  96. /** 把已打开的智能窗口拉到前台;没打开返回 false */
  97. export function focusSmartWindow(): boolean {
  98. const win = getSmartWindow();
  99. if (!win) return false;
  100. focusWindow(win);
  101. return true;
  102. }
  103. /** 把主窗口拉到前台(智能窗口遇到登录/授权失效时,由主窗口负责提示) */
  104. export function focusMainWindow(): void {
  105. try {
  106. focusWindow(getMainWindow());
  107. } catch (e) {
  108. logger.warn('[smartWindow] focusMainWindow failed:', e instanceof Error ? e.message : String(e));
  109. }
  110. }
  111. function focusWindow(win: BrowserWindow): void {
  112. if (win.isMinimized()) win.restore();
  113. win.show();
  114. win.focus();
  115. }
  116. function normalizeFullPath(fullPath?: string): string {
  117. const raw = typeof fullPath === 'string' ? fullPath.trim() : '';
  118. if (!raw) return WORKBENCH_PATH;
  119. // 只接受工作台地址,避免把业务页塞进智能窗口(如 /workbenchfoo 不算)
  120. return /^\/workbench(\/|\?|$)/.test(raw) ? raw : WORKBENCH_PATH;
  121. }
  122. function loadRenderer(win: BrowserWindow, fullPath: string): void {
  123. const devServerUrl = resolveDevServerUrl();
  124. if (devServerUrl) {
  125. win.loadURL(`${devServerUrl}${fullPath}`).catch((err) => {
  126. logger.error('[smartWindow] loadURL failed:', err);
  127. });
  128. return;
  129. }
  130. // 生产是 file:// + hash 路由:hash 带上完整路径与 query
  131. const indexPath = path.join(getBaseDir(), 'public', 'dist', 'index.html');
  132. win.loadFile(indexPath, { hash: fullPath }).catch((err) => {
  133. logger.error('[smartWindow] loadFile failed:', err);
  134. });
  135. }
  136. /**
  137. * 开发环境的前端地址:与 ee-core 加载主窗口同口径,端口来自 cmd/bin.js 的 dev.frontend.port。
  138. * 生产环境(或读不到配置)返回 null,走 loadFile。
  139. */
  140. function resolveDevServerUrl(): string | null {
  141. if (!isDev()) return null;
  142. try {
  143. // eslint-disable-next-line @typescript-eslint/no-var-requires
  144. const binConfig = require(path.join(getBaseDir(), 'cmd', 'bin.js'));
  145. const frontend = binConfig?.dev?.frontend || {};
  146. const protocol = typeof frontend.protocol === 'string' ? frontend.protocol : 'http://';
  147. if (protocol === 'file://') return null;
  148. const hostname = typeof frontend.hostname === 'string' ? frontend.hostname : 'localhost';
  149. const port = Number(frontend.port) || 3100;
  150. return `${protocol}${hostname}:${port}`;
  151. } catch (e) {
  152. logger.warn('[smartWindow] 读取 cmd/bin.js 失败,回退 http://localhost:3100');
  153. return 'http://localhost:3100';
  154. }
  155. }
  156. function saveBounds(win: BrowserWindow): void {
  157. try {
  158. const { x, y, width, height } = win.getBounds();
  159. localSettingsService.set(BOUNDS_KEY, JSON.stringify({ x, y, width, height }));
  160. } catch (e) {
  161. logger.warn('[smartWindow] 保存窗口位置失败:', e instanceof Error ? e.message : String(e));
  162. }
  163. }
  164. /** 恢复上次的位置与大小;越界或尺寸不合法时交给 Electron 默认放置 */
  165. function restoreBounds(): Partial<SavedBounds> {
  166. try {
  167. const raw = localSettingsService.get(BOUNDS_KEY);
  168. if (!raw) return {};
  169. const parsed = JSON.parse(raw) as Partial<SavedBounds>;
  170. const { x, y, width, height } = parsed || {};
  171. if (![x, y, width, height].every((value) => typeof value === 'number' && Number.isFinite(value))) {
  172. return {};
  173. }
  174. if ((width as number) < smartWindowSize.minWidth || (height as number) < smartWindowSize.minHeight) {
  175. return {};
  176. }
  177. // 显示器拔掉/分辨率变化后,旧坐标可能落在屏幕外
  178. const visible = screen.getAllDisplays().some((display) => {
  179. const area = display.workArea;
  180. return (
  181. (x as number) < area.x + area.width &&
  182. (x as number) + (width as number) > area.x &&
  183. (y as number) < area.y + area.height &&
  184. (y as number) + (height as number) > area.y
  185. );
  186. });
  187. return visible ? { x, y, width, height } : {};
  188. } catch (e) {
  189. logger.warn('[smartWindow] 恢复窗口位置失败:', e instanceof Error ? e.message : String(e));
  190. return {};
  191. }
  192. }
  193. function notifyState(open: boolean): void {
  194. try {
  195. const main = getMainWindow();
  196. if (!main.isDestroyed() && !main.webContents.isDestroyed()) {
  197. main.webContents.send(SMART_WINDOW_STATE_CHANNEL, { open });
  198. }
  199. } catch {
  200. // 主窗口尚未创建或应用正在退出:忽略
  201. }
  202. }