Compare commits

..

1 Commits

Author SHA1 Message Date
rdmclin2 48aa0ad245 chore: update neon webSocketConstructor ws 2026-04-21 18:29:07 +08:00
595 changed files with 3071 additions and 26232 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ LobeChat agents can answer inside external chat platforms. Inbound messages flow
`supportsMarkdown=false` ⇒ outbound markdown is stripped to plain text via `stripMarkdown` and the AI is told not to use markdown. `supportsMessageEdit=false` ⇒ no progress edits — only the final reply is sent.
**Multi-mode connection** — Slack/Feishu/Lark/QQ ship as websocket but support `webhook` per-provider via `settings.connectionMode`. The runtime always merges schema defaults into stored settings before resolving the mode (`resolveBotProviderConfig` / `resolveConnectionMode` in `platforms/utils.ts`), so the schema's `field.default` is the source of truth — set it correctly when adding a new multi-mode platform.
**Multi-mode connection** — Slack/Feishu/Lark/QQ shipped as websocket but support `webhook` per-provider via `settings.connectionMode`. Legacy rows without that field stay on `webhook` (see `LEGACY_WEBHOOK_PLATFORMS` in `platforms/utils.ts`) — **never add new platforms to that list**.
## Inbound Flow (one webhook → reply)
+1 -1
View File
@@ -5,7 +5,7 @@ description: Internationalization guide using react-i18next. Use when adding tra
# LobeHub Internationalization Guide
- Default language: English (en-US)
- Default language: Chinese (zh-CN)
- Framework: react-i18next
- **Only edit files in `src/locales/default/`** - Never edit JSON files in `locales/`
- Run `pnpm i18n` to generate translations (or manually translate zh-CN/en-US for dev preview)
-57
View File
@@ -30,63 +30,6 @@ This is NON-NEGOTIABLE. Skipping Linear comments is a workflow violation.
When creating issues with `mcp__linear-server__create_issue`, **MUST add the `claude code` label**.
## Creating Sub-issue Trees
When breaking a parent issue into a tree of sub-issues (e.g., task decomposition for LOBE-xxx), follow these rules — they work around real limitations of the Linear MCP tools.
### 1. ALWAYS prefix titles with an ordering index
The Linear Sub-issues panel displays children by `sortOrder`, which **defaults to newest-first** (most recently created appears on top). Neither parallel nor serial creation will produce the intended top-to-bottom reading order, and the MCP `save_issue` tool does **not expose a `sortOrder` parameter** — you cannot set order at create time.
**Workaround**: encode execution order in the title itself:
```plaintext
[1] [db] add schema fields
[2] [db] new table + repository
[3] [service] business logic layer
[4] [api] REST endpoints
[4.1] [sdk] client SDK wrapper
[4.1.1] [app] consumer integration
[4.1.2] [app] UI surface
[4.2] [ui] dashboard page
```
Even when the panel shuffles, the reader can mentally reconstruct the dependency graph at a glance. Dotted numbering `[n.m.k]` should mirror the parent-child nesting so the index and the tree agree.
### 2. Nest sub-issues by logical parent-child, not flat under the root
Linear supports **unlimited sub-issue depth**. A flat list of 8+ siblings under one root is hard to scan. Group by main-subordinate logic:
- Core service → its SDK → SDK consumers
- Don't create a sibling when a child is more accurate
Use `parentId: "LOBE-xxxx"` at creation (or `save_issue` to move). Moving an issue's parent does not disturb its `blockedBy` relations.
### 3. Sub-issue creation order is dictated by `blockedBy`
`blockedBy` requires the blocker to exist first (you need its LOBE-id). So:
1. **Topologically sort** the DAG — leaves (no deps) first, roots last
2. Create issues with zero deps in the first wave
3. Create dependent issues only after collecting the blocker IDs from prior responses
4. `blockedBy` is **append-only**; passing it again does not overwrite — safe to re-run
### 4. Don't waste rounds trying to parallelize
MCP tool calls in a single message look parallel but execute sequentially on the server, and you still need blocker IDs from earlier responses. Just issue calls in dependency order; optimizing for parallelism gains nothing here.
### 5. Keep each sub-issue description self-contained
Each sub-issue should state:
- Goal (12 lines)
- Key files to touch
- Concrete changes / acceptance criteria
- Dependencies (link to blocker issues by `LOBE-xxxx`)
- Validation steps
The implementer may open only the sub-issue, not the parent — don't rely on context that lives only in the parent description.
## Completion Comment Format
Every completed issue MUST have a comment summarizing work done:
+1 -1
View File
@@ -56,7 +56,7 @@ export function registerTaskCommand(program: Command) {
const client = await getTrpcClient();
const input: Record<string, any> = {};
if (options.status) input.statuses = [options.status];
if (options.status) input.status = options.status;
if (options.root) input.parentTaskId = null;
if (options.parent) input.parentTaskId = options.parent;
if (options.agent) input.assigneeAgentId = options.agent;
-4
View File
@@ -1,7 +1,3 @@
## 专题文档
- [桌面端全屏 Overlay 截图方案设计与集成说明](./WindowOverlayCapture.md)
## 核心框架组件目录架构
### 主进程核心组件
-502
View File
@@ -1,502 +0,0 @@
# 桌面端全屏 Overlay 截图方案设计与集成说明
| 字段 | 内容 |
| ------------ | ----------------------------------------------------- |
| 状态 | 已完成技术预研与 demo 验证 |
| 最后更新 | 2026-04-14 |
| 适用范围 | Electron 桌面端全屏遮罩、窗口高亮、点击截窗、区域截图 |
| 当前验证载体 | `tmp/electron-window-overlay-demo` |
| 目标读者 | 后续将该能力接入 LobeHub Desktop 主业务的开发者 |
## 1. 文档目标
本文档用于沉淀以下内容:
| 目标 | 说明 |
| -------------------- | ------------------------------------------------------------- |
| 记录方案演进 | 保存从纯 Electron、native、自研、开源库到最终 demo 的决策过程 |
| 固化关键技术结论 | 明确哪些能力 Electron 可做,哪些能力必须借助额外库 |
| 提供业务接入蓝图 | 指出应修改的真实仓库文件、模块边界、IPC 设计与 UI 接入点 |
| 降低后续重复调研成本 | 使后续实现可以直接沿用本文档,不必重新验证底层假设 |
## 2. 需求回顾
| 需求项 | 结论 |
| ----------------------------------- | --------------------------------------------------- |
| 新增一个 “全屏” 入口 | 需要,但本质上是一个覆盖整块屏幕的透明 overlay 窗口 |
| 覆盖用户整个 screen | 需要,且在 macOS 上要覆盖菜单栏与 Dock 所在区域 |
| 获取系统窗口几何信息 | 需要,至少需要 `appName + bounds + windowId` |
| 在 overlay 上高亮窗口边框并显示 Tag | 需要 |
| 点击高亮窗口即截图该窗口 | 需要 |
| 拖拽任意区域截图 | 需要 |
| 输出先写入剪贴板 | 需要,作为 MVP |
| 避免自研 native addon | 明确要求避免 |
| 跨平台预留 | 需要,至少不能被 macOS-only 自研方案锁死 |
## 3. 关键术语澄清
### 3.1 “压住 macOS 菜单栏与 Dock” 的准确含义
这里的含义不是 “调用系统 fullscreen API”,而是:
| 项目 | 含义 |
| -------- | ------------------------------------------------------------ |
| 覆盖范围 | 窗口尺寸必须基于 `display.bounds`,而不是 `display.workArea` |
| Z 轴层级 | 窗口需要位于普通应用窗口之上,并且进入菜单栏所在区域 |
| 视觉效果 | 用户看到的是整块屏幕都被半透明遮罩覆盖 |
必须区分以下两件事:
| 易混概念 | 实际含义 |
| ----------------------------------- | ---------------------------------------------------- |
| `app.dock.hide()` | 仅隐藏应用在 Dock 中的图标,不会隐藏系统 Dock 栏本身 |
| `BrowserWindow.setFullScreen(true)` | 更接近原生全屏行为,未必适合作为截图 overlay |
## 4. 预研结论总览
### 4.1 方案对比
| 方案 | 能否覆盖菜单栏 / Dock | 能否拿到系统窗口 bounds | 能否按窗口截图 | 跨平台性 | 结论 |
| ------------------------------------- | --------------------: | ----------------------: | -----------------: | -------: | -------------------------- |
| 纯 Electron `desktopCapturer` | 是 | 否 | 部分可做,但不精确 | 高 | 不足以满足需求 |
| 自研 native addon | 是 | 是 | 是 | 中 | 能做,但被明确拒绝 |
| 参考 Claude.app 的 native quick entry | 是 | 是 | 是 | 低到中 | 可借鉴思路,不适合直接照搬 |
| `node-screenshots` 单库 | 是 | 是 | 是 | 中到高 | 核心方案成立 |
| `node-screenshots + get-windows` | 是 | 是 | 是 | 中到高 | 当前最终方案 |
### 4.2 最终选型
| 能力 | 最终实现 |
| --------------------- | -------------------------- |
| 全屏 overlay 窗口 | Electron `BrowserWindow` |
| 系统窗口枚举 | `node-screenshots` |
| 指定窗口截图 | `node-screenshots` |
| 隐藏 / 伪关闭窗口过滤 | `get-windows` 作为白名单 |
| 区域截图 | Electron `desktopCapturer` |
| 输出介质 | `clipboard.writeImage()` |
## 5. 对 Claude.app 的观察结论
本轮曾直接检查过本机解包后的 Claude.app 产物,结论如下:
| 观察对象 | 结论 |
| ------------------ | ------------------------------------------------------------------------------------------------ |
| `quick_window` | 不是全屏 overlay;它是小尺寸 `panel` 弹窗 |
| `nativeQuickEntry` | Claude.app 存在原生 quick entry 能力,说明其真实覆盖式入口并不完全依赖纯 Electron |
| `cu-glow` | 这是最接近本需求的 Electron overlay 实现:使用 `display.bounds`、透明窗、`screen-saver` 置顶层级 |
据此可以得出两个重要判断:
| 判断 | 含义 |
| -------------------------------------------- | ---- |
| Electron 可以做 “整屏遮罩” | 成立 |
| Claude 的 “整屏入口” 并不等于 `quick_window` | 成立 |
## 6. 当前 demo 的最终方案
### 6.1 架构图
```text
┌──────────────────────────────┐
│ Tray / Menu / Future Action │
└──────────────┬───────────────┘
│ startOverlaySession
┌────────────────────────────────────────────┐
│ Main Process │
│ │
│ 1. 选定当前光标所在 display │
│ 2. 枚举窗口:node-screenshots │
│ 3. 过滤隐藏窗口:get-windows 白名单 │
│ 4. 创建整屏 overlay BrowserWindow │
└──────────────┬─────────────────────────────┘
│ preload / IPC
┌────────────────────────────────────────────┐
│ Overlay Renderer │
│ │
│ 1. 渲染窗口高亮框与左上角 tag │
│ 2. 点击窗口 => captureWindow(windowId) │
│ 3. 拖拽区域 => captureRect(rect) │
└──────────────┬─────────────────────────────┘
│ IPC
┌────────────────────────────────────────────┐
│ Main Process Capture Path │
│ │
│ Window: node-screenshots.captureImage() │
│ Region: desktopCapturer + crop │
│ Output: clipboard.writeImage() │
└────────────────────────────────────────────┘
```
### 6.2 demo 文件职责
| 文件 | 作用 |
| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| [`tmp/electron-window-overlay-demo/main.mjs`](../../tmp/electron-window-overlay-demo/main.mjs) | 主进程入口;创建 overlay,枚举窗口,执行截图 |
| [`tmp/electron-window-overlay-demo/preload.cjs`](../../tmp/electron-window-overlay-demo/preload.cjs) | 为 overlay renderer 暴露 IPC bridge |
| [`tmp/electron-window-overlay-demo/renderer/index.html`](../../tmp/electron-window-overlay-demo/renderer/index.html) | overlay 渲染宿主页 |
| [`tmp/electron-window-overlay-demo/renderer/app.js`](../../tmp/electron-window-overlay-demo/renderer/app.js) | 窗口高亮、点击截窗、拖拽截区交互 |
| [`tmp/electron-window-overlay-demo/renderer/styles.css`](../../tmp/electron-window-overlay-demo/renderer/styles.css) | 遮罩视觉样式 |
| [`tmp/electron-window-overlay-demo/README.md`](../../tmp/electron-window-overlay-demo/README.md) | demo 的运行说明 |
## 7. 全屏 overlay 的关键实现参数
### 7.1 必要窗口参数
| 参数 / 调用 | 用途 | 必要性 |
| ----------------------------------- | ---------------------------------- | ------ |
| `x/y/width/height = display.bounds` | 覆盖整块屏幕,包括菜单栏区域 | 必需 |
| `transparent: true` | 允许渲染半透明遮罩 | 必需 |
| `frame: false` | 去除系统边框 | 必需 |
| `skipTaskbar: true` | 避免出现在任务栏 / Dock 窗口列表中 | 建议 |
| `hasShadow: false` | 避免覆盖层产生自身投影 | 建议 |
| `focusable: true` | 允许接收鼠标交互 | 必需 |
| `fullscreenable: false` | 避免进入原生 fullscreen 流程 | 建议 |
| `enableLargerThanScreen: true` | 提升跨平台稳健性 | 建议 |
| `type: 'panel'`macOS) | 更接近工具层窗口行为 | 建议 |
### 7.2 必要层级调用
| 调用 | 作用 |
| ---------------------------------------------------------------- | --------------------------------- |
| `setAlwaysOnTop(true, 'screen-saver')` | 让窗口位于更高层级 |
| `setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })` | 避免 Space / 全屏窗口场景下不可见 |
| `setHiddenInMissionControl(true)` | 降低该窗口对系统窗口管理的干扰 |
### 7.3 重要结论
| 结论 | 说明 |
| ------------------------- | ------------------------------------------- |
| `display.workArea` 不可用 | 它会排除菜单栏 / Dock 区域 |
| `display.bounds` 必须使用 | 只有它能覆盖整个 display |
| `screen-saver` 层级有效 | 这是当前 macOS 上最接近需求的 Electron 方案 |
## 8. 系统窗口枚举与过滤策略
### 8.1 为什么不能只用 Electron
| Electron 能力 | 缺口 |
| --------------------------------------------------- | --------------------------------------------------------- |
| `desktopCapturer.getSources({ types: ['window'] })` | 能列出可捕获源,但没有稳定的窗口 bounds 用于 overlay 画框 |
| `DesktopCapturerSource.thumbnail` | 可截图缩略图,但不适合 “按原窗口精确高亮 + 点击即截” |
因此,纯 Electron 不足以完成 “系统窗口高亮 + 点击截窗”。
### 8.2 `node-screenshots` 的职责
| API | 用途 |
| --------------------------------- | -------------- |
| `Window.all()` | 枚举系统窗口 |
| `window.id()` | 稳定识别窗口 |
| `window.appName()` | 获取应用名 |
| `window.title()` | 获取标题 |
| `window.x()/y()/width()/height()` | 获取几何信息 |
| `window.captureImage()` | 截取该窗口图像 |
### 8.3 `get-windows` 的职责
`get-windows` 在当前方案中不负责截图,而只负责 “第二层白名单过滤”。
| 问题 | 处理方式 |
| ------------------------------------------ | ------------------------------------------------------------- |
| 某些应用逻辑上已隐藏,但底层枚举仍可能残留 | 只保留同时出现在 `get-windows``node-screenshots` 中的窗口 |
| Electron 自身的假关闭 /hide 行为 | 该白名单对这类情况更稳 |
### 8.4 当前过滤规则
| 规则 | 目的 |
| ------------------------------------------------ | ---------------------------- |
| `isMinimized() === false` | 排除最小化窗口 |
| 最小尺寸阈值:`80x60` | 排除菜单栏控件、过小悬浮面板 |
| 排除 `Dock` / `Window Server` / `Control Centre` | 排除系统 UI |
| 排除 demo 自身窗口 | 避免 overlay 自我高亮 |
| 必须与目标 display 相交 | 只画当前屏幕可见窗口 |
| 必须出现在 `get-windows` 白名单中 | 排除隐藏 / 伪关闭残留窗口 |
## 9. 截图路径设计
### 9.1 点击窗口截图
```text
点击高亮框
└───> renderer 发送 windowId
└───> main 查找对应 node-screenshots Window
└───> overlay.hide()
└───> captureImage()
└───> PNG Buffer
└───> nativeImage
└───> clipboard.writeImage()
```
### 9.2 拖拽区域截图
```text
拖拽区域
└───> renderer 发送全局 rect
└───> main 隐藏 overlay
└───> desktopCapturer 获取目标 display 图像
└───> 按 scaleFactor 计算 cropRect
└───> clipboard.writeImage()
```
### 9.3 为什么两条路径采用不同技术
| 路径 | 技术 | 原因 |
| ---------- | ------------------ | --------------------------------- |
| 按窗口截图 | `node-screenshots` | 它天然理解 “窗口” 这一对象 |
| 按区域截图 | `desktopCapturer` | 区域本质上是 display 上的矩形裁剪 |
## 10. 权限与平台边界
### 10.1 macOS 权限
| 权限 | 是否需要 | 用途 |
| ---------------- | ---------------- | ----------------------------------------------------- |
| Screen Recording | 需要 | 窗口截图、区域截图 |
| Accessibility | 当前方案不强依赖 | `get-windows` 已使用 `accessibilityPermission: false` |
### 10.2 当前已知平台边界
| 平台 / 场景 | 状态 | 说明 |
| ------------- | -------- | --------------------------------------------------------------------- |
| macOS | 已验证 | 当前主要验证平台 |
| Windows | 理论可行 | `node-screenshots` / `get-windows` 均支持,但尚未在本仓库内做实机验证 |
| Linux X11 | 理论可行 | 需要单独验证打包与权限 |
| Linux Wayland | 风险较高 | 上游库虽宣称支持,但必须做专项验证 |
### 10.3 特殊窗口风险
| 风险类型 | 当前处理 |
| ---------------------- | -------------------------------------------------------------- |
| 菜单栏状态窗 / 面板 | 通过尺寸阈值与排除名单降低噪音 |
| 系统 UI | 通过应用名黑名单排除 |
| 某些应用截图结果为黑图 | 已观察到个别状态面板存在此现象,应在业务层继续限制候选窗口类别 |
## 11. 已完成验证
| 验证项 | 结果 | 产物 |
| ----------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlay 覆盖整屏 | 通过 | [`tmp/electron-window-overlay-demo/.cache/window-overlay-visual.png`](../../tmp/electron-window-overlay-demo/.cache/window-overlay-visual.png) |
| `node-screenshots` 直接截图普通窗口 | 通过 | [`tmp/electron-window-overlay-demo/.cache/cursor-direct.png`](../../tmp/electron-window-overlay-demo/.cache/cursor-direct.png) |
| 点击高亮窗口后写入剪贴板 | 通过 | [`tmp/electron-window-overlay-demo/.cache/window-capture-probe-final.png`](../../tmp/electron-window-overlay-demo/.cache/window-capture-probe-final.png) |
| 拖拽区域截图 | 通过 | [`tmp/electron-window-overlay-demo/.cache/self-test-clipboard-final.png`](../../tmp/electron-window-overlay-demo/.cache/self-test-clipboard-final.png) |
## 12. 推荐的业务接入方式
### 12.1 总体建议
| 维度 | 建议 |
| -------------------- | ---------------------------------------------------------------------------------- |
| overlay 窗口生命周期 | 不建议直接挂进现有 `BrowserManager` 的常规窗口体系 |
| 原因 | overlay 是瞬态、全屏、平台特化、不可持久化的工具窗口,与主业务窗口生命周期明显不同 |
| 推荐做法 | 新增独立主进程模块管理 overlay;渲染内容仍建议走现有 SPA 路由体系 |
### 12.2 为什么不直接复用 `BrowserManager`
| 观察 | 影响 |
| ----------------------------------------- | ------------------------------- |
| `Browser` 默认承担普通业务窗口职责 | overlay 并非普通业务窗口 |
| `WindowStateManager` 倾向保存窗口状态 | overlay 不应持久化位置与大小 |
| `BrowserManager` 以 “可复用业务窗口” 建模 | overlay 更接近 “一次性工具会话” |
因此,更合理的做法是:
```text
┌────────────────────────────┐
│ BrowserManager │ 负责常规业务窗口
└────────────────────────────┘
┌────────────────────────────┐
│ CaptureOverlayManager │ 负责全屏截图 overlay 会话
└────────────────────────────┘
```
## 13. 建议的生产代码落点
### 13.1 主进程
| 建议文件 | 作用 |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `apps/desktop/src/main/modules/screenCapture/CaptureOverlayManager.ts` | 创建 / 销毁 overlay 窗口;管理一次截图会话 |
| `apps/desktop/src/main/modules/screenCapture/WindowSourceService.ts` | 封装 `node-screenshots + get-windows` 的窗口枚举与过滤 |
| `apps/desktop/src/main/modules/screenCapture/CaptureService.ts` | 封装窗口截图、区域截图、剪贴板输出 |
| `apps/desktop/src/main/modules/screenCapture/permission.ts` | 封装 macOS 屏幕录制权限检查 |
| `apps/desktop/src/main/controllers/ScreenCaptureCtr.ts` | 对 renderer 暴露 `start / captureRect / captureWindow / close` IPC |
| `apps/desktop/src/main/controllers/registry.ts` | 注册 `ScreenCaptureCtr` |
### 13.2 IPC 类型
| 建议文件 | 作用 |
| --------------------------------------------------------- | ------------------------------------------------- |
| `packages/electron-client-ipc/src/types/screenCapture.ts` | 定义 overlay 会话、窗口元数据、截图参数与返回结果 |
| `packages/electron-client-ipc/src/types/index.ts` | 导出新类型 |
建议定义的核心类型:
| 类型名 | 用途 |
| -------------------------- | --------------------------------------------------- |
| `ScreenCaptureDisplayInfo` | display id / bounds / scaleFactor |
| `ScreenCaptureWindowInfo` | `windowId/appName/title/bounds/overlayBounds/order` |
| `ScreenCaptureSession` | `display + windows` |
| `CaptureRectParams` | 全局屏幕坐标的矩形 |
| `ScreenCaptureStartResult` | 权限状态、会话状态、错误信息 |
| `ScreenCaptureOutput` | `clipboard`、后续可扩展 `file``attachment` |
### 13.3 Preload 与 renderer service
| 建议文件 | 作用 |
| ----------------------------------------- | -------------------------------------------------- |
| `apps/desktop/src/preload/electronApi.ts` | 通常无需特殊改造;沿用统一 `invoke` 即可 |
| `src/services/electron/screenCapture.ts` | 前端统一调用 `ensureElectronIpc().screenCapture.*` |
### 13.4 Renderer 路由
生产环境存在两种可选实现:
| 方案 | 优点 | 缺点 | 建议 |
| ------------------ | -------------------------------- | -------------------------------- | ---------------- |
| 独立静态 HTML 页面 | 轻量、与业务隔离、最接近 demo | 与现有 React/i18n / 业务状态脱节 | 仅适合 spike |
| 独立桌面 SPA 路由 | 可复用现有构建、i18n、业务事件链 | 需要维护 desktop router 双配置 | **推荐生产使用** |
若采用 SPA 路由,建议新增:
| 建议文件 | 作用 |
| ------------------------------------------------------- | ------------------------------------ |
| `src/routes/(desktop)/screen-capture-overlay/index.tsx` | overlay 页面入口;仅负责挂载 UI 组件 |
| `src/features/DesktopScreenCaptureOverlay/*` | 业务组件、hooks、样式 |
| `src/spa/router/desktopRouter.config.tsx` | 动态路由配置 |
| `src/spa/router/desktopRouter.config.desktop.tsx` | 同步路由配置 |
必须注意:
| 规则 | 说明 |
| -------------------------------- | ------------------------------------ |
| 两份 desktop router 必须同时更新 | 否则 Electron 本地构建可能出现空白页 |
| overlay route 应保持极薄 | 不在 route 文件中堆叠业务逻辑 |
## 14. 托盘入口的真实接入点
若要从托盘启动 overlay,会涉及以下文件:
| 文件 | 作用 |
| ----------------------------------------------- | -------------------- |
| `apps/desktop/src/main/menus/impls/macOS.ts` | macOS 托盘菜单模板 |
| `apps/desktop/src/main/menus/impls/windows.ts` | Windows 托盘菜单模板 |
| `apps/desktop/src/main/menus/impls/linux.ts` | Linux 托盘菜单模板 |
| `apps/desktop/src/main/locales/default/menu.ts` | 托盘菜单文案 |
推荐新增文案键:
| Key | 语义 |
| -------------------------- | ------------------------ |
| `tray.captureScreen` | 启动截图 overlay |
| `tray.captureScreenWindow` | 启动窗口截图模式(可选) |
## 15. 业务接入分阶段计划
### 阶段一:桌面主进程能力落地
| 步骤 | 目标 |
| ---- | ---------------------------------------------------------------------------------- |
| 1 | 将 `node-screenshots``get-windows` 加入 `apps/desktop/package.json#dependencies` |
| 2 | 新建 `screenCapture` 主进程模块与 controller |
| 3 | 跑通托盘菜单触发 overlay |
| 4 | 继续以剪贴板为唯一输出 |
### 阶段二:接回现有业务 UI
| 步骤 | 目标 |
| ---- | -------------------------------------------------- |
| 1 | 新增桌面专用 overlay route /feature |
| 2 | 将截图结果从 “仅写剪贴板” 升级为 “回传 attachment” |
| 3 | 支持从 chat 输入区触发 |
| 4 | 支持截图后自动插入当前会话 |
### 阶段三:体验完善
| 步骤 | 目标 |
| ---- | ------------------------------------ |
| 1 | 多 display 支持 |
| 2 | Hover 高亮 / 文案优化 |
| 3 | 保存文件、编辑器标注、OCR 等增强能力 |
| 4 | 平台差异补齐(尤其 Windows / Linux |
## 16. 依赖落点与版本建议
### 16.1 应加入的位置
| 文件 | 说明 |
| --------------------------- | --------------------------------- |
| `apps/desktop/package.json` | Electron 桌面运行时的真实依赖落点 |
### 16.2 建议依赖
| 包名 | 用途 | 当前 demo 使用版本 |
| ------------------ | --------------------------- | ------------------ |
| `node-screenshots` | 枚举窗口 + 窗口截图 | `^0.2.8` |
| `get-windows` | 白名单过滤隐藏 / 伪关闭窗口 | `^9.3.0` |
说明:
| 项目 | 结论 |
| ---------------------------- | ---- |
| 这不是 “纯 Electron” 方案 | 成立 |
| 这也不是 “自研 native addon” | 成立 |
| 当前依赖的是开源原生库 | 成立 |
## 17. 测试建议
建议避免写 “窗口列表快照” 这类低信号测试,优先做行为测试。
| 测试层级 | 建议内容 |
| -------------- | ---------------------------------------------------------- |
| 单元测试 | 过滤逻辑:尺寸阈值、系统应用排除、自身窗口排除、白名单交集 |
| 主进程集成测试 | 权限失败、overlay 会话生命周期、错误分支 |
| 手工验证 | 菜单栏覆盖、点击截窗、拖拽截区、隐藏窗口过滤 |
建议手工验证清单:
| 检查项 | 期望 |
| ------------------------ | ------------------------ |
| 当前活动屏幕启动 overlay | 只覆盖当前目标 display |
| 已隐藏的 Electron 子窗口 | 不再出现边框 |
| 点击普通应用窗口 | 剪贴板中得到该窗口图像 |
| 拖拽区域截图 | 剪贴板中得到对应裁剪区域 |
| 取消操作 | `Esc` 可关闭 overlay |
## 18. 当前已确认的非目标
| 非目标 | 说明 |
| ----------------------------------- | ----------------------------------------------------------------------- |
| 当前阶段支持全平台一致体验 | 尚未完成 |
| 当前阶段支持窗口标题绝对准确 | `get-windows` 在无额外权限时标题可为空;当前主要依赖 `node-screenshots` |
| 当前阶段支持多 display 同时 overlay | 尚未实现 |
| 当前阶段支持标注编辑器 | 未实现 |
## 19. 后续实现时的推荐决策
| 决策点 | 推荐 |
| ----------------------------------------------- | ------------------------ |
| overlay 窗口是否复用 `BrowserManager` | 不推荐 |
| renderer 是否走 SPA route | 推荐 |
| 主进程是否继续保留 “剪贴板优先” 输出 | 推荐,先保持最小可用闭环 |
| 是否继续保留 `desktopCapturer` 作为区域截图路径 | 推荐 |
| 是否用 `get-windows` 继续做白名单过滤 | 推荐 |
## 20. 实施摘要
```text
┌──────────────────────────────────────────────┐
│ 已验证的技术事实 │
├──────────────────────────────────────────────┤
│ 1. Electron 可以创建覆盖整块 display 的窗体 │
│ 2. 纯 Electron 无法独立完成系统窗口高亮 │
│ 3. node-screenshots 可完成窗口枚举与截窗 │
│ 4. get-windows 可帮助过滤隐藏 / 残留窗口 │
│ 5. 最终可形成“点击窗口即截图 + 拖拽截区”闭环 │
└──────────────────────────────────────────────┘
```
本文档可视为后续将该能力正式接入 `apps/desktop` 主业务的实施基线。
-1
View File
@@ -255,7 +255,6 @@ const config = {
generateUpdatesFilesForAllChannels: true,
linux: {
category: 'Utility',
icon: 'build/icon.png',
maintainer: 'electronjs.org',
target: ['AppImage', 'snap', 'deb', 'rpm', 'tar.gz'],
},
+3 -21
View File
@@ -1,7 +1,6 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
import dotenv from 'dotenv';
import { defineConfig } from 'electron-vite';
import type { PluginOption, ViteDevServer } from 'vite';
@@ -53,11 +52,6 @@ function electronDesktopHtmlPlugin(): PluginOption {
next();
return;
}
if (pathname === '/overlay' || pathname === '/overlay.html') {
req.url = '/apps/desktop/overlay.html';
next();
return;
}
if (pathname === '/popup.html') {
req.url = '/apps/desktop/popup.html';
next();
@@ -98,8 +92,6 @@ const updateChannel = process.env.UPDATE_CHANNEL;
const desktopPackageJson = JSON.parse(
readFileSync(path.resolve(__dirname, 'package.json'), 'utf8'),
) as { version: string };
const electronRuntimeExternals = ['electron'];
const mainProcessRuntimeExternals = [...electronRuntimeExternals, 'node-mac-permissions'];
console.info(`[electron-vite.config.ts] Detected UPDATE_CHANNEL: ${updateChannel}`);
@@ -108,15 +100,10 @@ export default defineConfig({
build: {
minify: !isDev,
outDir: 'dist/main',
rolldownOptions: {
rollupOptions: {
// Native modules must be externalized to work correctly.
// bufferutil and utf-8-validate are optional peer deps of ws that may not be installed.
external: [
...mainProcessRuntimeExternals,
...getExternalDependencies(),
'bufferutil',
'utf-8-validate',
],
external: [...getExternalDependencies(), 'bufferutil', 'utf-8-validate'],
output: {
// Prevent debug package from being bundled into index.js to avoid side-effect pollution
manualChunks(id) {
@@ -150,9 +137,6 @@ export default defineConfig({
build: {
minify: !isDev,
outDir: 'dist/preload',
rolldownOptions: {
external: electronRuntimeExternals,
},
sourcemap: isDev ? 'inline' : false,
},
resolve: {
@@ -166,10 +150,9 @@ export default defineConfig({
root: ROOT_DIR,
build: {
outDir: path.resolve(__dirname, 'dist/renderer'),
rolldownOptions: {
rollupOptions: {
input: {
main: path.resolve(__dirname, 'index.html'),
overlay: path.resolve(__dirname, 'overlay.html'),
popup: path.resolve(__dirname, 'popup.html'),
},
output: sharedRollupOutput,
@@ -183,7 +166,6 @@ export default defineConfig({
plugins: [
forceAbsoluteBasePlugin(),
electronDesktopHtmlPlugin(),
vanillaExtractPlugin(),
...(sharedRendererPlugins({ platform: 'desktop' }) as PluginOption[]),
],
resolve: {
+1 -2
View File
@@ -36,8 +36,7 @@ export const nativeModules = [
// macOS-only native modules
...(isDarwin ? ['node-mac-permissions'] : []),
'@napi-rs/canvas',
'get-windows',
'node-screenshots',
// Add more native modules here as needed
];
/**
-15
View File
@@ -1,15 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root { width: 100%; height: 100%; overflow: hidden; background: transparent; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/apps/desktop/src/overlay/entry.tsx"></script>
</body>
</html>
+3 -9
View File
@@ -42,10 +42,7 @@
"update-server": "sh scripts/update-test/run-test.sh"
},
"dependencies": {
"@lobehub/fluent-emoji": "^4.1.0",
"@napi-rs/canvas": "^0.1.70",
"get-windows": "^9.3.0",
"node-screenshots": "^0.2.8"
"@napi-rs/canvas": "^0.1.70"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
@@ -67,8 +64,6 @@
"@types/semver": "^7.7.1",
"@types/set-cookie-parser": "^2.4.10",
"@typescript/native-preview": "7.0.0-dev.20251210.1",
"@vanilla-extract/css": "^1.17.4",
"@vanilla-extract/vite-plugin": "^5.1.0",
"async-retry": "^1.3.3",
"consola": "^3.4.2",
"cookie": "^1.1.1",
@@ -81,7 +76,7 @@
"electron-log": "^5.4.3",
"electron-store": "^8.2.0",
"electron-updater": "^6.6.2",
"electron-vite": "6.0.0-beta.1",
"electron-vite": "^5.0.0",
"electron-window-state": "^5.0.3",
"es-toolkit": "^1.43.0",
"eslint": "10.0.0",
@@ -101,14 +96,13 @@
"resolve": "^1.22.11",
"semver": "^7.7.3",
"set-cookie-parser": "^2.7.2",
"strip-ansi": "6.0.1",
"stylelint": "^15.11.0",
"superjson": "^2.2.6",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"undici": "^7.16.0",
"uuid": "^13.0.0",
"vite": "^8.0.9",
"vite": "^7.3.1",
"vitest": "^3.2.4",
"zod": "^3.25.76"
},
-2
View File
@@ -1,6 +1,4 @@
packages:
- '../cli'
- '../../packages/agent-gateway-client'
- '../../packages/const'
- '../../packages/electron-server-ipc'
- '../../packages/electron-client-ipc'
+1 -2
View File
@@ -86,6 +86,5 @@
"window.minimize": "تصغير",
"window.title": "نافذة",
"window.toggleFullscreen": "تبديل وضع ملء الشاشة",
"window.zoom": "تكبير",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "تكبير"
}
@@ -86,6 +86,5 @@
"window.minimize": "Минимизирай",
"window.title": "Прозорец",
"window.toggleFullscreen": "Превключи на цял екран",
"window.zoom": "Мащаб",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "Мащаб"
}
@@ -86,6 +86,5 @@
"window.minimize": "Minimieren",
"window.title": "Fenster",
"window.toggleFullscreen": "Vollbild umschalten",
"window.zoom": "Zoom",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "Zoom"
}
@@ -71,8 +71,6 @@
"macOS.services": "Services",
"macOS.unhide": "Show All",
"tray.open": "Open {{appName}}",
"tray.openMiniToolbar": "Quick Composer",
"tray.quickChat": "Quick Chat",
"tray.quit": "Quit",
"tray.show": "Show {{appName}}",
"view.forceReload": "Force Reload",
@@ -86,6 +86,5 @@
"window.minimize": "Minimizar",
"window.title": "Ventana",
"window.toggleFullscreen": "Alternar pantalla completa",
"window.zoom": "Zoom",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "Zoom"
}
@@ -86,6 +86,5 @@
"window.minimize": "کوچک کردن",
"window.title": "پنجره",
"window.toggleFullscreen": "تغییر به حالت تمام صفحه",
"window.zoom": "زوم",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "زوم"
}
@@ -86,6 +86,5 @@
"window.minimize": "Réduire",
"window.title": "Fenêtre",
"window.toggleFullscreen": "Basculer en plein écran",
"window.zoom": "Zoom",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "Zoom"
}
@@ -86,6 +86,5 @@
"window.minimize": "Minimizza",
"window.title": "Finestra",
"window.toggleFullscreen": "Attiva/disattiva schermo intero",
"window.zoom": "Zoom",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "Zoom"
}
@@ -86,6 +86,5 @@
"window.minimize": "最小化",
"window.title": "ウィンドウ",
"window.toggleFullscreen": "フルスクリーン切替",
"window.zoom": "ズーム",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "ズーム"
}
@@ -86,6 +86,5 @@
"window.minimize": "최소화",
"window.title": "창",
"window.toggleFullscreen": "전체 화면 전환",
"window.zoom": "줌",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "줌"
}
@@ -86,6 +86,5 @@
"window.minimize": "Minimaliseren",
"window.title": "Venster",
"window.toggleFullscreen": "Schakel volledig scherm in/uit",
"window.zoom": "Inzoomen",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "Inzoomen"
}
@@ -86,6 +86,5 @@
"window.minimize": "Zminimalizuj",
"window.title": "Okno",
"window.toggleFullscreen": "Przełącz tryb pełnoekranowy",
"window.zoom": "Powiększenie",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "Powiększenie"
}
@@ -86,6 +86,5 @@
"window.minimize": "Minimizar",
"window.title": "Janela",
"window.toggleFullscreen": "Alternar Tela Cheia",
"window.zoom": "Zoom",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "Zoom"
}
@@ -86,6 +86,5 @@
"window.minimize": "Свернуть",
"window.title": "Окно",
"window.toggleFullscreen": "Переключить полноэкранный режим",
"window.zoom": "Масштаб",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "Масштаб"
}
@@ -86,6 +86,5 @@
"window.minimize": "Küçült",
"window.title": "Pencere",
"window.toggleFullscreen": "Tam Ekrana Geç",
"window.zoom": "Yakınlaştır",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "Yakınlaştır"
}
@@ -86,6 +86,5 @@
"window.minimize": "Thu nhỏ",
"window.title": "Cửa sổ",
"window.toggleFullscreen": "Chuyển đổi toàn màn hình",
"window.zoom": "Thu phóng",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "Thu phóng"
}
@@ -72,8 +72,6 @@
"macOS.services": "服务",
"macOS.unhide": "全部显示",
"tray.open": "打开 {{appName}}",
"tray.openMiniToolbar": "Quick Composer",
"tray.quickChat": "快捷聊天",
"tray.quit": "退出",
"tray.show": "显示 {{appName}}",
"view.forceReload": "强制重新加载",
@@ -86,6 +86,5 @@
"window.minimize": "最小化",
"window.title": "視窗",
"window.toggleFullscreen": "切換全螢幕",
"window.zoom": "縮放",
"tray.openMiniToolbar": "Quick Composer"
"window.zoom": "縮放"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 807 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

@@ -1,51 +0,0 @@
#!/usr/bin/env node
/**
* Generate the macOS tray template icon set (black + alpha).
*
* Template images must contain only black pixels and an alpha channel;
* macOS then recolors them automatically based on the menu bar theme.
*
* Renders two files in apps/desktop/resources:
* - trayTemplate.png (@1x, 18x18)
* - trayTemplate@2x.png (@2x, 36x36)
*
* Run: bun run apps/desktop/scripts/generate-tray-template.mjs
*/
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.resolve(__dirname, '..', 'resources');
// Silhouette derived from the LobeHub logo. Eyes and mouth are cut as
// transparent holes via fill-rule=evenodd so they remain visible when
// macOS tints the entire shape in a single color.
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320">
<path fill="#000" d="M172.997 19.016c-14.027 0-19.5-11.5-41-11-23.394 0-34 13-45.5 23-1.958 1.702-11.5 7-16 9-19.683 8.748-34.5 21.5-34.5 40.5 0 20.711 17.461 37.5 39 37.5 3.536 0 6.963-.453 10.22-1.301 8.7 10.539 22.179 16.658 37.28 17.301 23.5 1 31-15.25 44.5-8.5 9.259 4.629 13.83 8.5 28.5 8.5 17.108 0 25.057-5.233 30-11 9-10.5 22.879-4 31.5-4 18.778 0 34-14.551 34-32.5 0-17.95-15.222-32.5-34-32.5-5.15 0-14.856 1.27-17-7-3.5-13.5-20.148-29-44-29-9.318 0-17.691 1-23 1z"/>
<path fill="#000" fill-rule="evenodd" d="M294 172.519c0 75.655-59.442 128.5-134 128.5-74.558 0-134-53.845-134-129.5 0-22.5 5-32.141 31.5-35.671 47.5-6.329 72.542-3.829 102.5-3.829 29.959 0 72.556-1.27 102.5 3.829 24.5 4.171 30 8.671 31.5 36.671zM101 221.012c15.464 0 28-12.536 28-28s-12.536-28-28-28-28 12.536-28 28 12.536 28 28 28zM219 221.012c15.464 0 28-12.536 28-28s-12.536-28-28-28-28 12.536-28 28 12.536 28 28 28zM159.75 242.51c-28.25 0-35.75 3.5-35.75 3.5s3.5 27 35.75 27 35.75-27 35.75-27-7.5-3.5-35.75-3.5z"/>
</svg>
`;
async function render(size, outFile) {
const buf = Buffer.from(svg);
await sharp(buf, { density: Math.max(72, size * 12) })
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toFile(outFile);
console.log(`wrote ${path.relative(process.cwd(), outFile)} (${size}x${size})`);
}
async function main() {
await mkdir(outDir, { recursive: true });
await render(18, path.join(outDir, 'trayTemplate.png'));
await render(36, path.join(outDir, 'trayTemplate@2x.png'));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+10 -10
View File
@@ -1,29 +1,29 @@
import path from 'node:path';
import { join } from 'node:path';
import { app } from 'electron';
export const mainDir = path.join(__dirname);
export const mainDir = join(__dirname);
export const preloadDir = path.join(mainDir, '../preload');
export const preloadDir = join(mainDir, '../preload');
export const resourcesDir = path.join(mainDir, '../../resources');
export const resourcesDir = join(mainDir, '../../resources');
export const buildDir = path.join(mainDir, '../../build');
export const buildDir = join(mainDir, '../../build');
export const binDir = app.isPackaged
? path.join(process.resourcesPath, 'bin')
: path.join(resourcesDir, 'bin');
? join(process.resourcesPath, 'bin')
: join(resourcesDir, 'bin');
const appPath = app.getAppPath();
export const rendererDir = path.join(appPath, 'dist', 'renderer');
export const rendererDir = join(appPath, 'dist', 'renderer');
export const userDataDir = app.getPath('userData');
export const appStorageDir = path.join(userDataDir, 'lobehub-storage');
export const appStorageDir = join(userDataDir, 'lobehub-storage');
// Legacy local database directory used in older desktop versions
export const legacyLocalDbDir = path.join(appStorageDir, 'lobehub-local-db');
export const legacyLocalDbDir = join(appStorageDir, 'lobehub-local-db');
// ------ Application storage directory ---- //
+5 -5
View File
@@ -1,16 +1,16 @@
import os from 'node:os';
import * as electronIs from 'electron-is';
import { dev, linux, macOS, windows } from 'electron-is';
import { getDesktopEnv } from '@/env';
export const isDev = electronIs.dev();
export const isDev = dev();
export const OFFICIAL_CLOUD_SERVER = getDesktopEnv().OFFICIAL_CLOUD_SERVER;
export const isMac = electronIs.macOS();
export const isWindows = electronIs.windows();
export const isLinux = electronIs.linux();
export const isMac = macOS();
export const isWindows = windows();
export const isLinux = linux();
function getIsMacTahoe(): boolean {
if (!isMac) return false;
@@ -16,21 +16,11 @@ export default class BrowserWindowsCtr extends ControllerModule {
static override readonly groupName = 'windows';
@shortcut('showApp')
toggleMainWindow() {
async toggleMainWindow() {
const mainWindow = this.app.browserManager.getMainWindow();
mainWindow.toggleVisible();
}
@shortcut('quickComposer')
async openQuickComposer() {
await this.app.screenCaptureManager.startSession();
}
@shortcut('quickChat')
openQuickChat() {
this.app.browserManager.openQuickChatPopup();
}
@IpcMethod()
async openSettingsWindow(options?: string | OpenSettingsWindowOptions) {
const normalizedOptions: OpenSettingsWindowOptions =
+1 -96
View File
@@ -9,8 +9,6 @@ import type {
GitBranchListItem,
GitCheckoutResult,
GitLinkedPullRequestResult,
GitPullResult,
GitPushResult,
GitWorkingTreeFiles,
GitWorkingTreeStatus,
} from '@lobechat/electron-client-ipc';
@@ -265,24 +263,10 @@ export default class GitController extends ControllerModule {
* Count commits HEAD is ahead/behind its upstream tracking ref.
* Returns `hasUpstream: false` when the branch has no upstream configured
* (e.g. local-only branches, or after the remote branch is deleted).
*
* Does a best-effort `git fetch` first so the result reflects what's
* actually on the remote — the renderer calls this via SWR with
* `revalidateOnFocus`, so the fetch piggybacks on window re-focus. Fetch
* failures (offline, no credentials, no `origin` remote) are swallowed so
* we still return whatever can be computed against the cached refs.
*/
@IpcMethod()
async getGitAheadBehind(dirPath: string): Promise<GitAheadBehind> {
const execFileAsync = promisify(execFile);
try {
await execFileAsync('git', ['fetch', '--no-tags', '--quiet', 'origin'], {
cwd: dirPath,
timeout: 10_000,
});
} catch {
// swallow — fall through to compute against cached refs
}
try {
const { stdout: upstreamOut } = await execFileAsync(
'git',
@@ -300,36 +284,7 @@ export default class GitController extends ControllerModule {
const [behindStr, aheadStr] = stdout.trim().split(/\s+/);
const behind = Number.parseInt(behindStr ?? '0', 10) || 0;
const ahead = Number.parseInt(aheadStr ?? '0', 10) || 0;
// `git push -u origin HEAD` always targets origin/<current-branch-name>,
// which may differ from upstream (the branched-off-canary case).
let pushTarget: string | undefined;
let pushTargetExists = false;
try {
const { stdout: branchOut } = await execFileAsync(
'git',
['symbolic-ref', '--short', 'HEAD'],
{ cwd: dirPath, timeout: 5000 },
);
const branch = branchOut.trim();
if (branch) {
pushTarget = `origin/${branch}`;
try {
await execFileAsync(
'git',
['rev-parse', '--verify', '--quiet', `refs/remotes/${pushTarget}`],
{ cwd: dirPath, timeout: 5000 },
);
pushTargetExists = true;
} catch {
pushTargetExists = false;
}
}
} catch {
// detached HEAD — leave pushTarget undefined
}
return { ahead, behind, hasUpstream: true, pushTarget, pushTargetExists, upstream };
return { ahead, behind, hasUpstream: true, upstream };
} catch {
// No upstream configured, detached HEAD, or git error — all treated as "no upstream"
return { ahead: 0, behind: 0, hasUpstream: false };
@@ -367,54 +322,4 @@ export default class GitController extends ControllerModule {
return { error: stderr || 'git checkout failed', success: false };
}
}
/**
* Pull the current branch's upstream via fast-forward only.
*
* `--ff-only` avoids creating accidental merge commits when the local branch
* has diverged — in that case the user should resolve merge/rebase in their
* own terminal. For the common "just behind" case this is a safe one-click.
*/
@IpcMethod()
async pullGitBranch(payload: { path: string }): Promise<GitPullResult> {
const { path: dirPath } = payload;
const execFileAsync = promisify(execFile);
try {
const { stdout } = await execFileAsync('git', ['pull', '--ff-only'], {
cwd: dirPath,
timeout: 60_000,
});
const noop = /Already up to date/i.test(stdout);
return { noop, success: true };
} catch (error: any) {
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
logger.debug('[pullGitBranch] failed', { stderr });
return { error: stderr || 'git pull failed', success: false };
}
}
/**
* Push the current branch to its same-named remote on `origin`.
*
* Uses `git push -u origin HEAD` instead of plain `git push` so the action
* works even when local branch name differs from the configured upstream
*/
@IpcMethod()
async pushGitBranch(payload: { path: string }): Promise<GitPushResult> {
const { path: dirPath } = payload;
const execFileAsync = promisify(execFile);
try {
const { stderr } = await execFileAsync('git', ['push', '-u', 'origin', 'HEAD'], {
cwd: dirPath,
timeout: 60_000,
});
// git push writes progress/status to stderr even on success
const noop = /Everything up-to-date/i.test(stderr);
return { noop, success: true };
} catch (error: any) {
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
logger.debug('[pushGitBranch] failed', { stderr });
return { error: stderr || 'git push failed', success: false };
}
}
}
@@ -4,11 +4,12 @@ import { isEqual, merge } from 'es-toolkit/compat';
import { defaultProxySettings } from '@/const/store';
import { createLogger } from '@/utils/logger';
import type { ProxyTestResult } from '../modules/networkProxy';
import type {
ProxyTestResult} from '../modules/networkProxy';
import {
ProxyConfigValidator,
ProxyConnectionTester,
ProxyDispatcherManager,
ProxyDispatcherManager
} from '../modules/networkProxy';
import { ControllerModule, IpcMethod } from './index';
@@ -103,7 +104,7 @@ export default class NetworkProxyCtr extends ControllerModule {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Proxy connection test failed:', errorMessage);
throw new Error(`Connection failed: ${errorMessage}`, { cause: error });
throw new Error(`Connection failed: ${errorMessage}`);
}
}
@@ -3,7 +3,7 @@ import type {
ShowDesktopNotificationParams,
} from '@lobechat/electron-client-ipc';
import { app, Notification } from 'electron';
import * as electronIs from 'electron-is';
import { linux, macOS, windows } from 'electron-is';
import { getIpcContext } from '@/utils/ipc';
import { createLogger } from '@/utils/logger';
@@ -20,7 +20,7 @@ export default class NotificationCtr extends ControllerModule {
if (!Notification.isSupported()) return 'denied';
// Keep a stable status string for renderer-side UI mapping.
// Screen3 expects macOS to return 'authorized' when granted.
if (!electronIs.macOS()) return 'authorized';
if (!macOS()) return 'authorized';
// Electron 38 no longer exposes `systemPreferences.getNotificationSettings()` in types,
// and some runtimes don't provide it at all. Use the renderer's Notification.permission
@@ -43,7 +43,7 @@ export default class NotificationCtr extends ControllerModule {
// On macOS, ask permission via Web Notification API first when possible.
// This helps keep `Notification.permission` in sync for subsequent status checks.
if (electronIs.macOS()) {
if (macOS()) {
try {
const mainWindow = this.app.browserManager.getMainWindow().browserWindow;
await mainWindow.webContents.executeJavaScript('Notification.requestPermission()', true);
@@ -83,12 +83,12 @@ export default class NotificationCtr extends ControllerModule {
}
// On macOS, we may need to explicitly request notification permissions
if (electronIs.macOS()) {
if (macOS()) {
logger.debug('macOS detected, notification permissions should be handled by system');
}
// Set app user model ID on Windows
if (electronIs.windows()) {
if (windows()) {
app.setAppUserModelId('com.lobehub.chat');
logger.debug('Set Windows App User Model ID for notifications');
}
@@ -136,7 +136,7 @@ export default class NotificationCtr extends ControllerModule {
// due to heavy gnome-shell processing. Using 'low' urgency routes notifications to the
// message tray instead, preventing the banner's X button from being shown.
// The urgency option is ignored on macOS and Windows.
urgency: electronIs.linux() ? 'low' : 'normal',
urgency: linux() ? 'low' : 'normal',
});
// Add more event listeners for debugging
@@ -192,7 +192,7 @@ export default class NotificationCtr extends ControllerModule {
try {
const next = Math.max(0, Math.floor(count));
app.setBadgeCount(next);
if (electronIs.macOS() && app.dock) {
if (macOS() && app.dock) {
app.dock.setBadge(next > 0 ? String(next) : '');
}
} catch (error) {
@@ -1,72 +0,0 @@
import type {
CapturePreviewResult,
CaptureRectParams,
OverlayCaptureUploadStatusPayload,
ScreenCaptureSubmitParams,
} from '@lobechat/electron-client-ipc';
import type { OverlaySnapshotPayload } from '@/modules/screenCapture/ScreenCaptureManager';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:ScreenCaptureCtr');
export default class ScreenCaptureCtr extends ControllerModule {
static override readonly groupName = 'screenCapture';
@IpcMethod()
async traceOverlayEvent(payload: { data?: unknown; event: string }): Promise<void> {
console.info('[screenCapture:overlay]', payload.event, payload.data ?? '');
}
@IpcMethod()
async previewWindow(windowId: number): Promise<CapturePreviewResult> {
logger.debug(`previewWindow request: ${windowId}`);
return this.app.screenCaptureManager.handlePreviewWindow(windowId);
}
@IpcMethod()
async previewRect(params: CaptureRectParams): Promise<CapturePreviewResult> {
logger.debug(`previewRect request: ${JSON.stringify(params)}`);
return this.app.screenCaptureManager.handlePreviewRect(params);
}
@IpcMethod()
async submit(params: ScreenCaptureSubmitParams): Promise<void> {
logger.debug(`submit request: prompt-len=${params.prompt.length}`);
await this.app.screenCaptureManager.handleSubmit(params);
}
/**
* Status update reported by the main renderer after it finishes (or fails)
* uploading a capture's bytes. Forwarded to the overlay to drive the send
* button's enabled state.
*/
@IpcMethod()
async reportUploadStatus(payload: OverlayCaptureUploadStatusPayload): Promise<void> {
logger.debug(
`reportUploadStatus captureId=${payload.captureId} status=${payload.status} fileId=${payload.fileId ?? '-'}`,
);
this.app.screenCaptureManager.reportUploadStatus(payload);
}
@IpcMethod()
async close(): Promise<void> {
logger.debug('close overlay request');
this.app.screenCaptureManager.close();
}
/**
* Renderer-driven snapshot of agents/models for the overlay selector. The
* main renderer pushes this whenever its data layer (TRPC stores) reports
* a change; main process only caches and forwards — it does not fetch.
*/
@IpcMethod()
async publishOverlaySnapshot(payload: OverlaySnapshotPayload): Promise<void> {
logger.debug(
`publishOverlaySnapshot — agents=${payload.agents?.length ?? 0} models=${payload.models?.length ?? 0}`,
);
this.app.screenCaptureManager.publishOverlaySnapshot(payload);
}
}
@@ -2,7 +2,7 @@ import process from 'node:process';
import type { ElectronAppState, ThemeMode } from '@lobechat/electron-client-ipc';
import { app, dialog, nativeTheme, shell } from 'electron';
import * as electronIs from 'electron-is';
import { macOS } from 'electron-is';
import { pathExists, readdir } from 'fs-extra';
import { legacyLocalDbDir } from '@/const/dir';
@@ -103,7 +103,7 @@ export default class SystemController extends ControllerModule {
return 'granted';
}
if (!electronIs.macOS()) {
if (!macOS()) {
logger.info('[FullDiskAccess] Not macOS, returning granted');
return 'granted';
}
@@ -64,7 +64,7 @@ vi.mock('@/const/env', () => ({
let randomBytesCounter = 0;
vi.mock('node:crypto', () => ({
default: {
randomBytes: vi.fn((_size: number) => {
randomBytes: vi.fn((size: number) => {
randomBytesCounter++;
return {
toString: vi.fn(() => `mock-random-${randomBytesCounter}`),
@@ -30,7 +30,6 @@ const mockMinimizeWindow = vi.fn();
const mockMaximizeWindow = vi.fn();
const mockIsWindowMaximized = vi.fn();
const mockRetrieveByIdentifier = vi.fn();
const mockStartSession = vi.fn();
const testSenderIdentifierString: string = 'test-window-event-id';
const mockGetIdentifierByWebContents = vi.fn(() => testSenderIdentifierString);
@@ -67,9 +66,6 @@ const mockApp = {
},
),
},
screenCaptureManager: {
startSession: mockStartSession,
},
} as unknown as App;
describe('BrowserWindowsCtr', () => {
@@ -82,21 +78,10 @@ describe('BrowserWindowsCtr', () => {
});
describe('toggleMainWindow', () => {
it('should toggle the main window visibility', () => {
browserWindowsCtr.toggleMainWindow();
it('should get the main window and toggle its visibility', async () => {
await browserWindowsCtr.toggleMainWindow();
expect(mockGetMainWindow).toHaveBeenCalled();
expect(mockToggleVisible).toHaveBeenCalled();
expect(mockStartSession).not.toHaveBeenCalled();
});
});
describe('openQuickComposer', () => {
it('should start the quick composer session', async () => {
await browserWindowsCtr.openQuickComposer();
expect(mockStartSession).toHaveBeenCalled();
expect(mockGetMainWindow).not.toHaveBeenCalled();
expect(mockToggleVisible).not.toHaveBeenCalled();
});
});
@@ -19,7 +19,7 @@ const mockGetShortcutsConfig = vi.fn().mockReturnValue({
toggleMainWindow: 'CommandOrControl+Shift+L',
openSettings: 'CommandOrControl+,',
});
const mockUpdateShortcutConfig = vi.fn().mockImplementation((_id, _accelerator) => {
const mockUpdateShortcutConfig = vi.fn().mockImplementation((id, accelerator) => {
// Simply mock a successful update
return true;
});
@@ -43,14 +43,14 @@ const mockApp = {
} as unknown as App;
describe('UploadFileCtr', () => {
let _controller: UploadFileCtr;
let controller: UploadFileCtr;
beforeEach(() => {
vi.clearAllMocks();
ipcHandlers.clear();
ipcMainHandleMock.mockClear();
(IpcHandler.getInstance() as any).registeredChannels?.clear();
_controller = new UploadFileCtr(mockApp);
controller = new UploadFileCtr(mockApp);
});
describe('uploadFile', () => {
+1 -3
View File
@@ -16,9 +16,7 @@ const shortcutDecorator = (name: string) => (target: any, methodName: string, de
/**
* shortcut inject decorator
*/
type DesktopHotkeyIdCompatible = DesktopHotkeyId | 'quickComposer';
export const shortcut = (method: DesktopHotkeyIdCompatible) => shortcutDecorator(method);
export const shortcut = (method: DesktopHotkeyId) => shortcutDecorator(method);
const protocolDecorator =
(urlType: string, action: string) => (target: any, methodName: string, descriptor?: any) => {
@@ -15,7 +15,6 @@ import NetworkProxyCtr from './NetworkProxyCtr';
import NotificationCtr from './NotificationCtr';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
import RemoteServerSyncCtr from './RemoteServerSyncCtr';
import ScreenCaptureCtr from './ScreenCaptureCtr';
import ShellCommandCtr from './ShellCommandCtr';
import ShortcutController from './ShortcutCtr';
import SystemController from './SystemCtr';
@@ -40,7 +39,6 @@ export const controllerIpcConstructors = [
NotificationCtr,
RemoteServerConfigCtr,
RemoteServerSyncCtr,
ScreenCaptureCtr,
ShellCommandCtr,
ShortcutController,
SystemController,
+9 -10
View File
@@ -1,11 +1,11 @@
import os from 'node:os';
import path from 'node:path';
import { join } from 'node:path';
import type { ElectronIPCEventHandler } from '@lobechat/electron-server-ipc';
import { ElectronIPCServer } from '@lobechat/electron-server-ipc';
import { app, nativeTheme, protocol } from 'electron';
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
import * as electronIs from 'electron-is';
import { macOS, windows } from 'electron-is';
import { name } from '@/../../package.json';
import { binDir, buildDir } from '@/const/dir';
@@ -14,7 +14,6 @@ import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
import type { IControlModule } from '@/controllers';
import AuthCtr from '@/controllers/AuthCtr';
import { generateCliWrapper, getCliWrapperDir } from '@/modules/cliEmbedding';
import { ScreenCaptureManager } from '@/modules/screenCapture/ScreenCaptureManager';
import {
astSearchDetectors,
browserAutomationDetectors,
@@ -63,7 +62,6 @@ export class App {
protocolManager: ProtocolManager;
rendererUrlManager: RendererUrlManager;
toolDetectorManager: ToolDetectorManager;
screenCaptureManager: ScreenCaptureManager;
chromeFlags: string[] = ['OverlayScrollbar', 'FluentOverlayScrollbar', 'FluentScrollbar'];
/**
@@ -143,7 +141,6 @@ export class App {
this.staticFileServerManager = new StaticFileServerManager(this);
this.protocolManager = new ProtocolManager(this);
this.toolDetectorManager = new ToolDetectorManager(this);
this.screenCaptureManager = new ScreenCaptureManager(this);
// Register built-in tool detectors
this.registerBuiltinToolDetectors();
@@ -249,8 +246,10 @@ export class App {
await this.browserManager.initializeBrowsers();
// Initialize tray manager on all platforms (macOS menu bar, Windows / Linux tray).
this.trayManager.initializeTrays();
// Initialize tray manager
if (process.platform === 'win32') {
this.trayManager.initializeTrays();
}
// Initialize updater manager
await this.updaterManager.initialize();
@@ -259,7 +258,7 @@ export class App {
this.isQuiting = false;
app.on('window-all-closed', () => {
if (electronIs.windows() || process.platform === 'linux') {
if (windows() || process.platform === 'linux') {
logger.info(`All windows closed, quitting application (${process.platform})`);
app.quit();
}
@@ -421,8 +420,8 @@ export class App {
logger.debug('Setting up dev branding');
app.setName('lobehub-desktop-dev');
if (electronIs.macOS()) {
app.dock!.setIcon(path.join(buildDir, 'icon-dev.png'));
if (macOS()) {
app.dock!.setIcon(join(buildDir, 'icon-dev.png'));
}
};
+7 -19
View File
@@ -1,5 +1,5 @@
import console from 'node:console';
import path from 'node:path';
import { join } from 'node:path';
import { APP_WINDOW_MIN_SIZE } from '@lobechat/desktop-bridge';
import type { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
@@ -139,7 +139,7 @@ export default class Browser {
webPreferences: {
backgroundThrottling: false,
contextIsolation: true,
preload: path.join(preloadDir, 'index.js'),
preload: join(preloadDir, 'index.js'),
sandbox: false,
webviewTag: true,
},
@@ -238,7 +238,7 @@ export default class Browser {
logger.debug(`[${this.identifier}] Window 'ready-to-show' event fired.`);
if (this.options.showOnInit) {
logger.debug(`Showing window ${this.identifier} because showOnInit is true.`);
this.show();
browserWindow.show();
} else {
logger.debug(`Window ${this.identifier} not shown because showOnInit is false.`);
}
@@ -296,7 +296,6 @@ export default class Browser {
show(): void {
logger.debug(`Showing window: ${this.identifier}`);
this.ensureForegroundAppOnMac();
if (!this._browserWindow?.isDestroyed()) {
this.determineWindowPosition();
}
@@ -329,7 +328,7 @@ export default class Browser {
if (this._browserWindow?.isVisible() && this._browserWindow.isFocused()) {
this.hide();
} else {
this.show();
this._browserWindow?.show();
this._browserWindow?.focus();
}
}
@@ -388,22 +387,11 @@ export default class Browser {
this._browserWindow!.setPosition(newX, newY, false);
}
private ensureForegroundAppOnMac(): void {
if (!isMac || this.identifier !== 'app') return;
try {
app.setActivationPolicy('regular');
app.dock?.show();
} catch (error) {
logger.warn(`[${this.identifier}] Failed to restore regular activation policy:`, error);
}
}
// ==================== Content Loading ====================
loadPlaceholder = async (): Promise<void> => {
logger.debug(`[${this.identifier}] Loading splash screen placeholder`);
await this._browserWindow!.loadFile(path.join(resourcesDir, 'splash.html'));
await this._browserWindow!.loadFile(join(resourcesDir, 'splash.html'));
logger.debug(`[${this.identifier}] Splash screen placeholder loaded.`);
};
@@ -434,7 +422,7 @@ export default class Browser {
private async handleLoadError(urlWithLocale: string): Promise<void> {
try {
logger.info(`[${this.identifier}] Attempting to load error page...`);
await this._browserWindow!.loadFile(path.join(resourcesDir, 'error.html'));
await this._browserWindow!.loadFile(join(resourcesDir, 'error.html'));
logger.info(`[${this.identifier}] Error page loaded successfully.`);
this.setupRetryHandler(urlWithLocale);
@@ -457,7 +445,7 @@ export default class Browser {
} catch (err: any) {
logger.error(`[${this.identifier}] Retry connection failed:`, err);
try {
await this._browserWindow?.loadFile(path.join(resourcesDir, 'error.html'));
await this._browserWindow?.loadFile(join(resourcesDir, 'error.html'));
} catch (loadErr) {
logger.error(`[${this.identifier}] Failed to reload error page:`, loadErr);
}
@@ -39,15 +39,8 @@ export class BrowserManager {
showMainWindow() {
logger.debug('Showing main window');
const browser = this.getMainWindow();
const window = browser.browserWindow;
if (window.isMinimized()) {
window.restore();
}
browser.show();
window.focus();
const window = this.getMainWindow();
window.show();
}
broadcastToAllWindows = <T extends MainBroadcastEventKey>(
@@ -211,23 +204,6 @@ export class BrowserManager {
return true;
}
/**
* Open (or focus) the single-instance Quick Chat popup.
*
* The window is backed by the `topicPopup` template and the route
* `/popup/agent/inbox`, so it mounts a fresh Inbox conversation with no
* active topic. The first message creates a topic via the normal agent
* flow. The `uniqueId` is fixed — repeated invocations focus the existing
* window rather than spawning additional ones.
*/
openQuickChatPopup() {
const uniqueId = 'topicPopup_quick_inbox';
const result = this.createMultiInstanceWindow('topicPopup', '/popup/agent/inbox', uniqueId);
result.browser.show();
result.browser.browserWindow.focus();
return result;
}
private emitTopicPopupsChanged(): void {
this.broadcastToAllWindows('topicPopupsChanged', { popups: this.listTopicPopups() });
}
@@ -1,4 +1,4 @@
import path from 'node:path';
import { join } from 'node:path';
import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
import { type BrowserWindow, type BrowserWindowConstructorOptions, nativeTheme } from 'electron';
@@ -118,7 +118,7 @@ export class WindowThemeManager {
private getWindowsConfig(isDarkMode: boolean): WindowsThemeConfig {
return {
backgroundColor: isDarkMode ? BACKGROUND_DARK : BACKGROUND_LIGHT,
icon: isDev ? path.join(buildDir, 'icon-dev.ico') : undefined,
icon: isDev ? join(buildDir, 'icon-dev.ico') : undefined,
titleBarOverlay: this.getWindowsTitleBarOverlay(isDarkMode),
titleBarStyle: 'hidden',
};
@@ -5,13 +5,12 @@ import Browser, { type BrowserWindowOpts } from '../Browser';
// Use vi.hoisted to define mocks before hoisting
const {
mockAppModule,
mockElectronApp,
mockBrowserWindow,
mockNativeTheme,
mockIpcMain,
mockScreen,
MockBrowserWindow,
mockEnv,
} = vi.hoisted(() => {
const mockBrowserWindow = {
center: vi.fn(),
@@ -52,24 +51,15 @@ const {
},
};
const mockElectronApp = {
dock: { setBadge: vi.fn() },
setBadgeCount: vi.fn(),
};
return {
mockAppModule: {
dock: {
setBadge: vi.fn(),
show: vi.fn(),
},
setActivationPolicy: vi.fn(),
setBadgeCount: vi.fn(),
},
MockBrowserWindow: vi.fn().mockImplementation(() => mockBrowserWindow),
mockElectronApp,
mockBrowserWindow,
mockEnv: {
isDev: false,
isLinux: false,
isMac: false,
isMacTahoe: false,
isWindows: true,
},
mockIpcMain: {
handle: vi.fn(),
removeHandler: vi.fn(),
@@ -96,7 +86,7 @@ const {
// Mock electron
vi.mock('electron', () => ({
app: mockAppModule,
app: mockElectronApp,
BrowserWindow: MockBrowserWindow,
ipcMain: mockIpcMain,
nativeTheme: mockNativeTheme,
@@ -121,21 +111,11 @@ vi.mock('@/const/dir', () => ({
}));
vi.mock('@/const/env', () => ({
get isDev() {
return mockEnv.isDev;
},
get isLinux() {
return mockEnv.isLinux;
},
get isMac() {
return mockEnv.isMac;
},
get isMacTahoe() {
return mockEnv.isMacTahoe;
},
get isWindows() {
return mockEnv.isWindows;
},
isDev: false,
isLinux: false,
isMac: false,
isMacTahoe: false,
isWindows: true,
}));
vi.mock('../../../const/theme', () => ({
@@ -178,10 +158,6 @@ describe('Browser', () => {
mockBrowserWindow.loadURL.mockResolvedValue(undefined);
mockBrowserWindow.loadFile.mockResolvedValue(undefined);
mockNativeTheme.shouldUseDarkColors = false;
mockEnv.isLinux = false;
mockEnv.isMac = false;
mockEnv.isMacTahoe = false;
mockEnv.isWindows = true;
// Create mock App
mockStoreManagerGet = vi.fn().mockReturnValue(undefined);
@@ -506,19 +482,6 @@ describe('Browser', () => {
expect(mockBrowserWindow.show).toHaveBeenCalled();
});
it('should restore regular activation policy when showing the main window on macOS', () => {
mockEnv.isMac = true;
mockEnv.isWindows = false;
const mainBrowser = new Browser({ ...defaultOptions, identifier: 'app' }, mockApp);
vi.spyOn(mainBrowser, 'loadUrl').mockResolvedValue(undefined as any);
mainBrowser.show();
expect(mockAppModule.setActivationPolicy).toHaveBeenCalledWith('regular');
expect(mockAppModule.dock.show).toHaveBeenCalled();
});
});
describe('hide', () => {
@@ -6,13 +6,10 @@ import { BrowserManager } from '../BrowserManager';
// Use vi.hoisted to define mocks before hoisting
const { MockBrowser, mockAppBrowsers, mockWindowTemplates } = vi.hoisted(() => {
const createMockBrowserWindow = () => ({
focus: vi.fn(),
isMaximized: vi.fn().mockReturnValue(false),
isMinimized: vi.fn().mockReturnValue(false),
maximize: vi.fn(),
minimize: vi.fn(),
on: vi.fn(),
restore: vi.fn(),
unmaximize: vi.fn(),
webContents: { id: Math.random() },
});
@@ -139,16 +136,6 @@ describe('BrowserManager', () => {
const appBrowser = manager.browsers.get('app');
expect(appBrowser?.show).toHaveBeenCalled();
expect(appBrowser?.browserWindow.focus).toHaveBeenCalled();
});
it('should restore a minimized main window before showing it', () => {
const appBrowser = manager.getMainWindow();
vi.mocked(appBrowser.browserWindow.isMinimized).mockReturnValue(true);
manager.showMainWindow();
expect(appBrowser.browserWindow.restore).toHaveBeenCalled();
});
});
@@ -1,5 +1,5 @@
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { basename, extname } from 'node:path';
import { app, protocol } from 'electron';
import { pathExistsSync } from 'fs-extra';
@@ -234,7 +234,7 @@ export class RendererProtocolManager {
private isAssetRequest(pathname: string) {
const normalizedPathname = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
const ext = path.extname(normalizedPathname);
const ext = extname(normalizedPathname);
return (
pathname.startsWith('/assets/') ||
@@ -246,6 +246,6 @@ export class RendererProtocolManager {
}
private is404Html(filePath: string) {
return path.basename(filePath) === '404.html';
return basename(filePath) === '404.html';
}
}
@@ -1,4 +1,4 @@
import path from 'node:path';
import { extname, join } from 'node:path';
import { pathExistsSync } from 'fs-extra';
@@ -12,10 +12,9 @@ import { RendererProtocolManager } from './RendererProtocolManager';
const logger = createLogger('core:RendererUrlManager');
// Vite build with root=monorepo preserves input path structure,
// so index.html / overlay.html / popup.html end up under apps/desktop/ in outDir.
const SPA_ENTRY_HTML = path.join(rendererDir, 'apps', 'desktop', 'index.html');
const OVERLAY_ENTRY_HTML = path.join(rendererDir, 'apps', 'desktop', 'overlay.html');
const POPUP_ENTRY_HTML = path.join(rendererDir, 'apps', 'desktop', 'popup.html');
// so index.html / popup.html end up under apps/desktop/ in outDir.
const SPA_ENTRY_HTML = join(rendererDir, 'apps', 'desktop', 'index.html');
const POPUP_ENTRY_HTML = join(rendererDir, 'apps', 'desktop', 'popup.html');
export class RendererUrlManager {
private readonly rendererProtocolManager: RendererProtocolManager;
@@ -63,30 +62,23 @@ export class RendererUrlManager {
*/
buildRendererUrl(path: string): string {
const cleanPath = path.startsWith('/') ? path : `/${path}`;
const normalizedBase = this.rendererLoadedUrl.replace(/\/+$/, '');
return `${normalizedBase}${cleanPath}`;
return `${this.rendererLoadedUrl}${cleanPath}`;
}
/**
* Resolve renderer file path in production.
* Static assets map directly; /overlay routes fall back to overlay.html;
* popup routes go to popup.html; all other routes fall back to index.html (SPA).
* Static assets map directly; popup routes go to popup.html, all other
* routes fall back to index.html (SPA).
*/
resolveRendererFilePath = async (url: URL): Promise<string | null> => {
const pathname = url.pathname;
// Static assets: direct file mapping
if (pathname.startsWith('/assets/') || path.extname(pathname)) {
const filePath = path.join(rendererDir, pathname);
if (pathname.startsWith('/assets/') || extname(pathname)) {
const filePath = join(rendererDir, pathname);
return pathExistsSync(filePath) ? filePath : null;
}
// Overlay entry (separate MPA page)
if (pathname === '/overlay' || pathname === '/overlay.html') {
return OVERLAY_ENTRY_HTML;
}
// Topic popup window has its own SPA bundle.
if (pathname === '/popup' || pathname.startsWith('/popup/')) {
return POPUP_ENTRY_HTML;
@@ -92,18 +92,6 @@ describe('RendererUrlManager', () => {
expect(manager.buildRendererUrl('/settings')).toBe('http://localhost:5173/settings');
});
it('should normalize trailing slashes from ELECTRON_RENDERER_URL', async () => {
mockIsDev = true;
process.env['ELECTRON_RENDERER_URL'] = 'http://localhost:5173/';
const { RendererUrlManager } = await import('../RendererUrlManager');
const manager = new RendererUrlManager();
manager.configureRendererLoader();
expect(manager.buildRendererUrl('/')).toBe('http://localhost:5173/');
expect(manager.buildRendererUrl('/overlay')).toBe('http://localhost:5173/overlay');
});
it('should fall back to protocol handler when ELECTRON_RENDERER_URL is not set', async () => {
mockIsDev = true;
+26 -58
View File
@@ -1,12 +1,15 @@
import path from 'node:path';
import { join } from 'node:path';
import type { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
import type {
DisplayBalloonOptions,
Menu as ElectronMenu,
MenuItemConstructorOptions,
MenuItemConstructorOptions} from 'electron';
import {
app,
Menu,
nativeImage,
Tray as ElectronTray,
} from 'electron';
import { app, Menu, nativeImage, Tray as ElectronTray } from 'electron';
import { resourcesDir } from '@/const/dir';
import { createLogger } from '@/utils/logger';
@@ -27,12 +30,6 @@ export interface TrayOptions {
*/
identifier: string;
/**
* Mark the icon as a macOS template image (black + alpha). macOS will
* then tint it to match the menu bar appearance automatically.
*/
isTemplateImage?: boolean;
/**
* Tray tooltip text
*/
@@ -47,13 +44,6 @@ export class Tray {
*/
private _tray?: ElectronTray;
/**
* Current context menu. We keep this in-house and pop it up manually on
* right-click so that macOS does not swallow the left-click (which would
* happen automatically if we called `_tray.setContextMenu(menu)`).
*/
private _contextMenu?: ElectronMenu;
/**
* Identifier
*/
@@ -97,16 +87,15 @@ export class Tray {
return this._tray;
}
const { iconPath, isTemplateImage, tooltip } = this.options;
const { iconPath, tooltip } = this.options;
// Load tray icon
logger.info(`Creating new tray instance: ${this.identifier}`);
const iconFile = path.join(resourcesDir, iconPath);
const iconFile = join(resourcesDir, iconPath);
logger.debug(`[${this.identifier}] Loading icon: ${iconFile}`);
try {
const icon = nativeImage.createFromPath(iconFile);
if (isTemplateImage) icon.setTemplateImage(true);
this._tray = new ElectronTray(icon);
// Set tooltip
@@ -118,22 +107,12 @@ export class Tray {
// Set default context menu
this.setContextMenu();
// Left-click: open Quick Composer.
// Set click event
this._tray.on('click', () => {
logger.debug(`[${this.identifier}] Tray clicked`);
this.onClick();
});
// Right-click: pop the stored context menu manually so left-click stays
// free (macOS would auto-open the menu on either button if we called
// `_tray.setContextMenu`).
this._tray.on('right-click', () => {
logger.debug(`[${this.identifier}] Tray right-clicked`);
if (this._contextMenu && this._tray) {
this._tray.popUpContextMenu(this._contextMenu);
}
});
logger.debug(`[${this.identifier}] Tray instance created successfully`);
return this._tray;
} catch (error) {
@@ -169,51 +148,40 @@ export class Tray {
];
const contextMenu = Menu.buildFromTemplate(defaultTemplate);
// Store the menu instead of calling `_tray.setContextMenu`. The latter
// makes macOS intercept left-clicks to show the menu, which conflicts
// with our Quick Composer trigger on click.
this._contextMenu = contextMenu;
this._tray?.setContextMenu(contextMenu);
logger.debug(`[${this.identifier}] Tray context menu has been set`);
}
/**
* Handle tray click event — opens the Quick Composer overlay.
* Right-click opens the context menu (handled by Electron automatically).
* Handle tray click event
*/
onClick() {
logger.debug(`[${this.identifier}] Tray click → startSession`);
try {
void this.app.screenCaptureManager.startSession();
} catch (error) {
logger.error(`[${this.identifier}] Failed to start capture session:`, error);
}
}
logger.debug(`[${this.identifier}] Handling tray click event`);
const mainWindow = this.app.browserManager.getMainWindow();
/**
* Replace the tray context menu with a pre-built Electron Menu instance.
* Stored in-house and popped up manually on right-click to preserve
* left-click for the Quick Composer trigger.
*/
setMenu(menu: ElectronMenu) {
logger.debug(`[${this.identifier}] Attaching prebuilt context menu`);
this._contextMenu = menu;
if (mainWindow) {
if (mainWindow.browserWindow.isVisible() && mainWindow.browserWindow.isFocused()) {
logger.debug(`[${this.identifier}] Main window is visible and focused, hiding it now`);
mainWindow.hide();
} else {
logger.debug(`[${this.identifier}] Showing and focusing main window`);
mainWindow.show();
mainWindow.browserWindow.focus();
}
}
}
/**
* Update tray icon
* @param iconPath New icon path (relative to resource directory)
* @param isTemplateImage Whether to mark the new icon as a macOS template image
*/
updateIcon(iconPath: string, isTemplateImage?: boolean) {
updateIcon(iconPath: string) {
logger.debug(`[${this.identifier}] Updating icon: ${iconPath}`);
try {
const iconFile = path.join(resourcesDir, iconPath);
const iconFile = join(resourcesDir, iconPath);
const icon = nativeImage.createFromPath(iconFile);
const nextIsTemplate = isTemplateImage ?? this.options.isTemplateImage;
if (nextIsTemplate) icon.setTemplateImage(true);
this._tray?.setImage(icon);
this.options.iconPath = iconPath;
if (isTemplateImage !== undefined) this.options.isTemplateImage = isTemplateImage;
logger.debug(`[${this.identifier}] Icon updated successfully`);
} catch (error) {
logger.error(`[${this.identifier}] Failed to update icon:`, error);
+10 -15
View File
@@ -1,4 +1,5 @@
import type { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
import { nativeTheme } from 'electron';
import { name } from '@/../../package.json';
import { isMac } from '@/const/env';
@@ -40,15 +41,7 @@ export class TrayManager {
logger.debug('Initialize application tray');
// Initialize main tray
const mainTray = this.initializeMainTray();
// Attach the platform-specific context menu built by MenuManager so the
// tray right-click entries stay in sync with the app menu i18n.
try {
mainTray.setMenu(this.app.menuManager.buildTrayMenu());
} catch (error) {
logger.error('Failed to attach tray context menu:', error);
}
this.initializeMainTray();
}
/**
@@ -59,16 +52,18 @@ export class TrayManager {
}
/**
* Initialize main tray. On macOS we ship a template image (black + alpha)
* so the system recolors it automatically for light / dark menu bars.
* Initialize main tray
*/
initializeMainTray() {
logger.debug('Initialize main tray');
return this.retrieveOrInitialize({
iconPath: isMac ? 'trayTemplate.png' : 'tray.png',
identifier: 'main',
isTemplateImage: isMac,
tooltip: name,
iconPath: isMac
? nativeTheme.shouldUseDarkColorsForSystemIntegratedUI
? 'tray-dark.png'
: 'tray-light.png'
: 'tray.png',
identifier: 'main', // Use app icon, ensure this file exists in resources directory
tooltip: name, // Can use app.getName() or localized string
});
}
@@ -28,7 +28,6 @@ vi.mock('@/utils/logger', () => ({
// Mock desktop global shortcut defaults
vi.mock('@lobechat/const/desktopGlobalShortcuts', () => ({
DEFAULT_ELECTRON_DESKTOP_SHORTCUTS: {
quickComposer: 'Alt+Shift+Space',
showApp: '',
openSettings: 'CommandOrControl+,',
},
@@ -57,10 +56,8 @@ describe('ShortcutManager', () => {
// Mock shortcut method map
mockShortcutMethodMap = new Map();
const quickComposerMethod = vi.fn();
const showAppMethod = vi.fn();
const openSettingsMethod = vi.fn();
mockShortcutMethodMap.set('quickComposer', quickComposerMethod);
mockShortcutMethodMap.set('showApp', showAppMethod);
mockShortcutMethodMap.set('openSettings', openSettingsMethod);
@@ -80,8 +77,7 @@ describe('ShortcutManager', () => {
});
it('should populate shortcuts map from app shortcut method map', () => {
expect(shortcutManager['shortcuts'].size).toBe(3);
expect(shortcutManager['shortcuts'].has('quickComposer')).toBe(true);
expect(shortcutManager['shortcuts'].size).toBe(2);
expect(shortcutManager['shortcuts'].has('showApp')).toBe(true);
expect(shortcutManager['shortcuts'].has('openSettings')).toBe(true);
});
@@ -118,17 +114,15 @@ describe('ShortcutManager', () => {
expect(mockStoreManager.get).toHaveBeenCalledWith('shortcuts');
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+Shift+Space', expect.any(Function));
expect(globalShortcut.register).not.toHaveBeenCalledWith('Control+E', expect.any(Function));
expect(globalShortcut.register).toHaveBeenCalledWith(
'CommandOrControl+,',
expect.any(Function),
);
expect(globalShortcut.register).not.toHaveBeenCalledWith('', expect.any(Function));
});
it('should handle stored config with filtering', () => {
const storedConfig = {
quickComposer: 'Alt+Shift+Q',
showApp: 'Alt+E',
openSettings: 'Ctrl+Shift+P',
invalidKey: 'Ctrl+I', // Should be filtered out
@@ -138,7 +132,6 @@ describe('ShortcutManager', () => {
shortcutManager.initialize();
const config = shortcutManager.getShortcutsConfig();
expect(config.quickComposer).toBe('Alt+Shift+Q');
expect(config.showApp).toBe('Alt+E');
expect(config.openSettings).toBe('Ctrl+Shift+P');
expect(config.invalidKey).toBeUndefined();
@@ -340,13 +333,6 @@ describe('ShortcutManager', () => {
describe('unregisterAll', () => {
it('should unregister all shortcuts', () => {
shortcutManager['shortcutsConfig'] = {
quickComposer: 'Alt+Shift+Space',
showApp: 'Alt+E',
openSettings: 'Ctrl+P',
};
shortcutManager['registerConfiguredShortcuts']();
shortcutManager.unregisterAll();
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
@@ -376,7 +362,6 @@ describe('ShortcutManager', () => {
it('should filter invalid keys from stored config', () => {
const storedConfig = {
quickComposer: 'Alt+Shift+Q',
showApp: 'Alt+E',
openSettings: 'Ctrl+P',
invalidKey1: 'Ctrl+I',
@@ -387,7 +372,6 @@ describe('ShortcutManager', () => {
shortcutManager['loadShortcutsConfig']();
const config = shortcutManager['shortcutsConfig'];
expect(config.quickComposer).toBe('Alt+Shift+Q');
expect(config.showApp).toBe('Alt+E');
expect(config.openSettings).toBe('Ctrl+P');
expect(config.invalidKey1).toBeUndefined();
@@ -400,21 +384,19 @@ describe('ShortcutManager', () => {
it('should add missing default shortcuts', () => {
const incompleteConfig = {
showApp: 'Alt+E',
// Missing quickComposer and openSettings
// Missing openSettings
};
mockStoreManager.get.mockReturnValue(incompleteConfig);
shortcutManager['loadShortcutsConfig']();
const config = shortcutManager['shortcutsConfig'];
expect(config.quickComposer).toBe('Alt+Shift+Space');
expect(config.showApp).toBe('Alt+E');
expect(config.openSettings).toBe('CommandOrControl+,'); // Default value
});
it('should not save config if no invalid keys were found', () => {
const validConfig = {
quickComposer: 'Alt+Shift+Q',
showApp: 'Alt+E',
openSettings: 'Ctrl+P',
};
@@ -443,16 +425,11 @@ describe('ShortcutManager', () => {
describe('saveShortcutsConfig', () => {
it('should save shortcuts config to store', () => {
shortcutManager['shortcutsConfig'] = {
quickComposer: 'Alt+Shift+Q',
showApp: 'Alt+E',
openSettings: 'Ctrl+P',
};
shortcutManager['shortcutsConfig'] = { showApp: 'Alt+E', openSettings: 'Ctrl+P' };
shortcutManager['saveShortcutsConfig']();
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', {
quickComposer: 'Alt+Shift+Q',
showApp: 'Alt+E',
openSettings: 'Ctrl+P',
});
@@ -471,7 +448,6 @@ describe('ShortcutManager', () => {
describe('registerConfiguredShortcuts', () => {
beforeEach(() => {
shortcutManager['shortcutsConfig'] = {
quickComposer: 'Alt+Shift+Q',
showApp: 'Alt+E',
openSettings: 'Ctrl+P',
};
@@ -483,28 +459,24 @@ describe('ShortcutManager', () => {
shortcutManager['registerConfiguredShortcuts']();
expect(globalShortcut.unregisterAll).toHaveBeenCalled();
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+Shift+Q', expect.any(Function));
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+E', expect.any(Function));
expect(globalShortcut.register).toHaveBeenCalledWith('Ctrl+P', expect.any(Function));
});
it('should skip shortcuts not defined in default electron desktop shortcuts', () => {
shortcutManager['shortcutsConfig'] = {
quickComposer: 'Alt+Shift+Q',
showApp: 'Alt+E',
invalidKey: 'Ctrl+I',
};
shortcutManager['registerConfiguredShortcuts']();
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+Shift+Q', expect.any(Function));
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+E', expect.any(Function));
expect(globalShortcut.register).not.toHaveBeenCalledWith('Ctrl+I', expect.any(Function));
});
it('should skip shortcuts with empty accelerator', () => {
shortcutManager['shortcutsConfig'] = {
quickComposer: '',
showApp: '',
openSettings: 'Ctrl+P',
};
@@ -520,14 +492,12 @@ describe('ShortcutManager', () => {
mockShortcutMethodMap.delete('openSettings');
shortcutManager = new ShortcutManager(mockApp);
shortcutManager['shortcutsConfig'] = {
quickComposer: 'Alt+Shift+Q',
showApp: 'Alt+E',
openSettings: 'Ctrl+P',
};
shortcutManager['registerConfiguredShortcuts']();
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+Shift+Q', expect.any(Function));
expect(globalShortcut.register).toHaveBeenCalledWith('Alt+E', expect.any(Function));
expect(globalShortcut.register).not.toHaveBeenCalledWith('Ctrl+P', expect.any(Function));
});
@@ -536,7 +506,6 @@ describe('ShortcutManager', () => {
describe('integration tests', () => {
it('should complete full initialization flow', () => {
const storedConfig = {
quickComposer: 'Alt+Shift+Q',
showApp: 'Alt+E',
openSettings: 'Ctrl+Shift+P',
invalidKey: 'Ctrl+I',
@@ -548,12 +517,11 @@ describe('ShortcutManager', () => {
// Should filter config and register valid shortcuts
const config = shortcutManager.getShortcutsConfig();
expect(config.quickComposer).toBe('Alt+Shift+Q');
expect(config.showApp).toBe('Alt+E');
expect(config.openSettings).toBe('Ctrl+Shift+P');
expect(config.invalidKey).toBeUndefined();
expect(globalShortcut.register).toHaveBeenCalledTimes(3);
expect(globalShortcut.register).toHaveBeenCalledTimes(2);
expect(mockStoreManager.set).toHaveBeenCalledWith('shortcuts', config);
});
@@ -1,4 +1,4 @@
import { app, Menu, nativeImage, Tray as ElectronTray } from 'electron';
import { app, Menu, nativeImage,Tray as ElectronTray } from 'electron';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '../../App';
@@ -47,7 +47,6 @@ describe('Tray', () => {
mockElectronTray = {
setToolTip: vi.fn(),
setContextMenu: vi.fn(),
popUpContextMenu: vi.fn(),
setImage: vi.fn(),
on: vi.fn(),
destroy: vi.fn(),
@@ -75,16 +74,11 @@ describe('Tray', () => {
showMainWindow: vi.fn(),
getMainWindow: vi.fn(() => mockMainWindow),
},
screenCaptureManager: {
startSession: vi.fn(),
},
} as unknown as App;
// Mock electron constructors
vi.mocked(ElectronTray).mockImplementation(() => mockElectronTray);
vi.mocked(nativeImage.createFromPath).mockReturnValue({
setTemplateImage: vi.fn(),
} as any);
vi.mocked(nativeImage.createFromPath).mockReturnValue({} as any);
vi.mocked(Menu.buildFromTemplate).mockReturnValue({} as any);
});
@@ -174,7 +168,7 @@ describe('Tray', () => {
expect(mockElectronTray.on).toHaveBeenCalledWith('click', expect.any(Function));
});
it('should build the default context menu and store it in-house', () => {
it('should set default context menu', () => {
tray = new Tray(
{
iconPath: 'tray.png',
@@ -184,23 +178,7 @@ describe('Tray', () => {
);
expect(Menu.buildFromTemplate).toHaveBeenCalled();
// We no longer hand the menu to Electron directly; macOS would hijack
// left-click if we did. The menu is popped up manually on right-click.
expect(mockElectronTray.setContextMenu).not.toHaveBeenCalled();
});
it('should register both click and right-click listeners', () => {
tray = new Tray(
{
iconPath: 'tray.png',
identifier: 'test-tray',
},
mockApp,
);
const events = mockElectronTray.on.mock.calls.map((c: any[]) => c[0]);
expect(events).toContain('click');
expect(events).toContain('right-click');
expect(mockElectronTray.setContextMenu).toHaveBeenCalled();
});
it('should handle errors when creating tray', () => {
@@ -243,9 +221,7 @@ describe('Tray', () => {
expect.objectContaining({ label: 'Quit' }),
]),
);
// Menu is stored for manual popup on right-click — never handed to
// `_tray.setContextMenu`, which would steal left-click on macOS.
expect(mockElectronTray.setContextMenu).not.toHaveBeenCalled();
expect(mockElectronTray.setContextMenu).toHaveBeenCalled();
});
it('should set custom context menu when template provided', () => {
@@ -257,37 +233,7 @@ describe('Tray', () => {
tray.setContextMenu(customTemplate);
expect(Menu.buildFromTemplate).toHaveBeenCalledWith(customTemplate);
expect(mockElectronTray.setContextMenu).not.toHaveBeenCalled();
});
it('should pop up the stored menu on right-click', () => {
// beforeEach cleared mocks after constructing the tray, so capture the
// right-click handler from a fresh instance.
const mockTrayForRightClick = {
setToolTip: vi.fn(),
setContextMenu: vi.fn(),
popUpContextMenu: vi.fn(),
setImage: vi.fn(),
on: vi.fn(),
destroy: vi.fn(),
displayBalloon: vi.fn(),
};
vi.mocked(ElectronTray).mockImplementationOnce(() => mockTrayForRightClick as any);
const builtMenu = { _mockMenu: true } as any;
vi.mocked(Menu.buildFromTemplate).mockReturnValue(builtMenu);
const freshTray = new Tray({ iconPath: 'tray.png', identifier: 'rc-tray' }, mockApp);
freshTray.setContextMenu();
const rightClickHandler = mockTrayForRightClick.on.mock.calls.find(
(c: any[]) => c[0] === 'right-click',
)?.[1];
expect(rightClickHandler).toBeDefined();
rightClickHandler?.();
expect(mockTrayForRightClick.popUpContextMenu).toHaveBeenCalledWith(builtMenu);
expect(mockElectronTray.setContextMenu).toHaveBeenCalled();
});
it('should call showMainWindow when Show Main Window is clicked', () => {
@@ -324,23 +270,40 @@ describe('Tray', () => {
);
});
it('should start the Quick Composer capture session', () => {
it('should hide window when it is visible and focused', () => {
mockBrowserWindow.isVisible.mockReturnValue(true);
mockBrowserWindow.isFocused.mockReturnValue(true);
tray.onClick();
expect(mockApp.screenCaptureManager.startSession).toHaveBeenCalled();
});
it('should not touch main window visibility', () => {
tray.onClick();
expect(mockMainWindow.hide).not.toHaveBeenCalled();
expect(mockMainWindow.hide).toHaveBeenCalled();
expect(mockMainWindow.show).not.toHaveBeenCalled();
});
it('should not throw when startSession rejects', () => {
vi.mocked(mockApp.screenCaptureManager.startSession).mockImplementationOnce(() => {
throw new Error('capture failed');
});
it('should show and focus window when it is not visible', () => {
mockBrowserWindow.isVisible.mockReturnValue(false);
mockBrowserWindow.isFocused.mockReturnValue(false);
tray.onClick();
expect(mockMainWindow.show).toHaveBeenCalled();
expect(mockBrowserWindow.focus).toHaveBeenCalled();
expect(mockMainWindow.hide).not.toHaveBeenCalled();
});
it('should show and focus window when it is visible but not focused', () => {
mockBrowserWindow.isVisible.mockReturnValue(true);
mockBrowserWindow.isFocused.mockReturnValue(false);
tray.onClick();
expect(mockMainWindow.show).toHaveBeenCalled();
expect(mockBrowserWindow.focus).toHaveBeenCalled();
expect(mockMainWindow.hide).not.toHaveBeenCalled();
});
it('should handle case when main window is null', () => {
vi.mocked(mockApp.browserManager.getMainWindow).mockReturnValue(null);
expect(() => tray.onClick()).not.toThrow();
});
@@ -541,9 +504,11 @@ describe('Tray', () => {
tray.updateTooltip('New Tooltip');
expect(mockElectronTray.setToolTip).toHaveBeenCalledWith('New Tooltip');
// Test click behavior — now opens the Quick Composer session
// Test click behavior
mockBrowserWindow.isVisible.mockReturnValue(true);
mockBrowserWindow.isFocused.mockReturnValue(true);
tray.onClick();
expect(mockApp.screenCaptureManager.startSession).toHaveBeenCalled();
expect(mockMainWindow.hide).toHaveBeenCalled();
// Destroy
tray.destroy();
@@ -1,11 +1,16 @@
import { nativeTheme } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '../../App';
import { Tray } from '../Tray';
import { TrayManager } from '../TrayManager';
// Mock electron modules (empty shim — TrayManager no longer reads nativeTheme)
vi.mock('electron', () => ({}));
// Mock electron modules
vi.mock('electron', () => ({
nativeTheme: {
shouldUseDarkColorsForSystemIntegratedUI: false,
},
}));
// Mock logger
vi.mock('@/utils/logger', () => ({
@@ -45,17 +50,12 @@ describe('TrayManager', () => {
identifier: 'main',
broadcast: vi.fn(),
destroy: vi.fn(),
setMenu: vi.fn(),
updateIcon: vi.fn(),
updateTooltip: vi.fn(),
};
// Mock App — initializeTrays now pulls a prebuilt menu from MenuManager.
mockApp = {
menuManager: {
buildTrayMenu: vi.fn(() => ({ _mockMenu: true }) as any),
},
} as unknown as App;
// Mock App
mockApp = {} as unknown as App;
// Mock Tray constructor
vi.mocked(Tray).mockImplementation(() => mockTray);
@@ -86,24 +86,22 @@ describe('TrayManager', () => {
expect(spy).toHaveBeenCalled();
});
it('should attach the platform tray menu to the main tray', () => {
trayManager.initializeTrays();
expect(mockApp.menuManager.buildTrayMenu).toHaveBeenCalled();
expect(mockTray.setMenu).toHaveBeenCalledWith({ _mockMenu: true });
});
});
describe('initializeMainTray', () => {
it('should create main tray with a template image on macOS', () => {
it('should create main tray with dark icon on macOS when dark mode is enabled', () => {
Object.defineProperty(nativeTheme, 'shouldUseDarkColorsForSystemIntegratedUI', {
value: true,
writable: true,
configurable: true,
});
const result = trayManager.initializeMainTray();
expect(Tray).toHaveBeenCalledWith(
expect.objectContaining({
iconPath: 'trayTemplate.png',
iconPath: 'tray-dark.png',
identifier: 'main',
isTemplateImage: true,
tooltip: 'test-app',
}),
mockApp,
@@ -111,6 +109,25 @@ describe('TrayManager', () => {
expect(result).toBe(mockTray);
});
it('should create main tray with light icon on macOS when light mode is enabled', () => {
Object.defineProperty(nativeTheme, 'shouldUseDarkColorsForSystemIntegratedUI', {
value: false,
writable: true,
configurable: true,
});
trayManager.initializeMainTray();
expect(Tray).toHaveBeenCalledWith(
expect.objectContaining({
iconPath: 'tray-light.png',
identifier: 'main',
tooltip: 'test-app',
}),
mockApp,
);
});
it('should add created tray to trays map', () => {
trayManager.initializeMainTray();
+3 -1
View File
@@ -1,4 +1,4 @@
/// <reference types="vite/client" />
import 'vite/client';
/**
* `node-mac-permissions` is a macOS-only native module.
@@ -30,3 +30,5 @@ declare module 'node-mac-permissions' {
export function askForScreenCaptureAccess(openPreferences?: boolean): void;
export function askForFullDiskAccess(): void;
}
export {};
@@ -71,9 +71,7 @@ const menu = {
'macOS.preferences': 'Preferences...',
'macOS.services': 'Services',
'macOS.unhide': 'Show All',
'tray.openMiniToolbar': 'Quick Composer',
'tray.open': 'Open {{appName}}',
'tray.quickChat': 'Quick Chat',
'tray.quit': 'Quit',
'tray.show': 'Show {{appName}}',
'view.forceReload': 'Force Reload',
@@ -61,7 +61,6 @@ const createMockApp = () => {
'dev.forceReload': 'Force Reload',
'dev.devTools': 'Developer Tools',
'dev.devPanel': 'Dev Panel',
'tray.openMiniToolbar': 'Quick Composer',
'tray.open': `Open ${params?.appName || 'App'}`,
'tray.quit': 'Quit',
};
@@ -455,14 +455,6 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
click: () => this.app.browserManager.showMainWindow(),
label: t('tray.open', { appName }),
},
{
click: () => this.app.screenCaptureManager.startSession(),
label: t('tray.openMiniToolbar'),
},
{
click: () => this.app.browserManager.openQuickChatPopup(),
label: t('tray.quickChat'),
},
{ type: 'separator' },
{
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
@@ -13,9 +13,6 @@ vi.mock('electron', () => ({
setApplicationMenu: vi.fn(),
},
app: {
dock: {
setMenu: vi.fn(),
},
getAppPath: vi.fn(() => '/mock/app/path'),
getName: vi.fn(() => 'LobeChat'),
getPath: vi.fn((type: string) => {
@@ -66,9 +63,6 @@ const createMockApp = () => {
show: vi.fn(),
})),
},
screenCaptureManager: {
startSession: vi.fn(),
},
updaterManager: {
checkForUpdates: vi.fn(),
getUpdaterState: vi.fn(() => ({ stage: 'idle' })),
@@ -102,7 +96,6 @@ describe('MacOSMenu', () => {
expect(Menu.buildFromTemplate).toHaveBeenCalled();
expect(Menu.setApplicationMenu).toHaveBeenCalled();
expect(app.dock.setMenu).toHaveBeenCalled();
expect(menu).toBeDefined();
});
@@ -179,13 +172,6 @@ describe('MacOSMenu', () => {
expect(template.some((item: any) => item.label?.includes('Show'))).toBe(true);
expect(template.some((item: any) => item.label === 'Quit')).toBe(true);
});
it('should include the mini toolbar action in the dock menu', () => {
macOSMenu.buildAndSetAppMenu();
const dockMenu = (app.dock.setMenu as any).mock.calls[0][0];
expect(dockMenu.template.some((item: any) => item.label === 'Quick Composer')).toBe(true);
});
});
describe('refresh', () => {
@@ -290,19 +276,6 @@ describe('MacOSMenu', () => {
expect(preferencesItem.accelerator).toBe('Command+,');
});
it('should not show a fixed accelerator for Quick Composer', () => {
macOSMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const quickComposerItem = fileMenu.submenu.find(
(item: any) => item.label === 'Quick Composer',
);
expect(quickComposerItem).toBeDefined();
expect(quickComposerItem.accelerator).toBeUndefined();
});
it('should use role for quit (accelerator handled by Electron)', () => {
macOSMenu.buildAndSetAppMenu();
+1 -39
View File
@@ -1,4 +1,4 @@
import path from 'node:path';
import * as path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
@@ -12,7 +12,6 @@ import { BaseMenuPlatform } from './BaseMenuPlatform';
export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
private appMenu: Menu | null = null;
private dockMenu: Menu | null = null;
private trayMenu: Menu | null = null;
buildAndSetAppMenu(options?: MenuOptions): Menu {
@@ -21,7 +20,6 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
this.appMenu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(this.appMenu);
this.buildAndSetDockMenu();
return this.appMenu;
}
@@ -156,11 +154,6 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
label: t('file.newPage'),
},
{ type: 'separator' },
{
click: () => this.app.screenCaptureManager.startSession(),
label: t('tray.openMiniToolbar'),
},
{ type: 'separator' },
{
accelerator: 'CmdOrCtrl+W',
click: () => {
@@ -680,14 +673,6 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
click: () => this.app.browserManager.showMainWindow(),
label: t('tray.show', { appName }),
},
{
click: () => this.app.screenCaptureManager.startSession(),
label: t('tray.openMiniToolbar'),
},
{
click: () => this.app.browserManager.openQuickChatPopup(),
label: t('tray.quickChat'),
},
{
click: async () => {
const mainWindow = this.app.browserManager.getMainWindow();
@@ -700,27 +685,4 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
{ label: t('tray.quit'), role: 'quit' },
];
}
private buildAndSetDockMenu() {
if (!app.dock?.setMenu) return;
this.dockMenu = Menu.buildFromTemplate(this.getDockMenuTemplate());
app.dock.setMenu(this.dockMenu);
}
private getDockMenuTemplate(): MenuItemConstructorOptions[] {
const t = this.app.i18n.ns('menu');
const appName = app.getName();
return [
{
click: () => this.app.browserManager.showMainWindow(),
label: t('tray.show', { appName }),
},
{
click: () => this.app.screenCaptureManager.startSession(),
label: t('tray.openMiniToolbar'),
},
];
}
}
@@ -56,7 +56,6 @@ const createMockApp = () => {
'dev.forceReload': 'Force Reload',
'dev.devTools': 'Developer Tools',
'dev.devPanel': 'Dev Panel',
'tray.openMiniToolbar': 'Quick Composer',
'tray.open': `Open ${params?.appName || 'App'}`,
'tray.quit': 'Quit',
};
@@ -462,14 +462,6 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
click: () => this.app.browserManager.showMainWindow(),
label: t('tray.open', { appName }),
},
{
click: () => this.app.screenCaptureManager.startSession(),
label: t('tray.openMiniToolbar'),
},
{
click: () => this.app.browserManager.openQuickChatPopup(),
label: t('tray.quickChat'),
},
{ type: 'separator' },
{
click: () => this.app.browserManager.retrieveByIdentifier('settings').show(),
@@ -152,7 +152,7 @@ export abstract class BaseContentSearch {
const regex = new RegExp(pattern, flags);
// Determine files to search
let filesToSearch: string[];
let filesToSearch: string[] = [];
const stats = await stat(searchPath);
if (stats.isFile()) {
@@ -1,5 +1,5 @@
import { stat } from 'node:fs/promises';
import path from 'node:path';
import * as path from 'node:path';
import type { GlobFilesParams, GlobFilesResult } from '@lobechat/electron-client-ipc';
@@ -1,6 +1,6 @@
import { stat } from 'node:fs/promises';
import * as os from 'node:os';
import path from 'node:path';
import * as path from 'node:path';
import { execa } from 'execa';
@@ -369,10 +369,10 @@ export class MacOSSearchServiceImpl extends UnixFileSearch {
continue;
}
const keyValue = line.split(/\s+=\s+/, 2);
if (keyValue.length === 2 && /^\w+$/.test(keyValue[0])) {
currentKey = keyValue[0];
const value = keyValue[1].trim();
const match = line.match(/^(\w+)\s+=\s+(.*)$/);
if (match) {
currentKey = match[1];
const value = match[2].trim();
if (value.includes('(') && !value.includes(')')) {
isMultilineValue = true;
@@ -403,7 +403,8 @@ export class MacOSSearchServiceImpl extends UnixFileSearch {
if (value === 'Yes' || value === 'true') return true;
if (value === 'No' || value === 'false') return false;
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{4}$/.test(value)) {
const dateMatch = value.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{4})$/);
if (dateMatch) {
try {
return new Date(value);
} catch {
@@ -411,7 +412,7 @@ export class MacOSSearchServiceImpl extends UnixFileSearch {
}
}
if (/^-?\d+(?:\.\d+)?$/.test(value)) {
if (/^-?\d+(\.\d+)?$/.test(value)) {
return Number(value);
}
@@ -1,3 +1,4 @@
import { type Stats } from 'node:fs';
import { stat } from 'node:fs/promises';
import * as os from 'node:os';
@@ -190,8 +191,7 @@ export abstract class UnixFileSearch extends BaseFileSearch {
logger.debug('Performing find search', { keywords: options.keywords, searchDir });
try {
const args: string[] = [
searchDir,
const args: string[] = [searchDir,
'-maxdepth',
'10',
'-type',
@@ -207,8 +207,7 @@ export abstract class UnixFileSearch extends BaseFileSearch {
'*/*cache*/*',
')',
'-prune',
'-o',
];
'-o'];
// Limit depth and exclude common directories
@@ -281,7 +280,7 @@ export abstract class UnixFileSearch extends BaseFileSearch {
return this.processFilePaths(limitedFiles, options, 'fast-glob');
} catch (error) {
logger.error('fast-glob search failed:', error);
throw new Error(`File search failed: ${(error as Error).message}`, { cause: error });
throw new Error(`File search failed: ${(error as Error).message}`);
}
}
@@ -1,3 +1,4 @@
import { type Stats } from 'node:fs';
import { stat } from 'node:fs/promises';
import * as os from 'node:os';
@@ -289,7 +290,7 @@ export class WindowsSearchServiceImpl extends BaseFileSearch {
return this.processFilePaths(limitedFiles, options, 'fast-glob');
} catch (error) {
logger.error('fast-glob search failed:', error);
throw new Error(`File search failed: ${(error as Error).message}`, { cause: error });
throw new Error(`File search failed: ${(error as Error).message}`);
}
}
@@ -1,5 +1,5 @@
import type { NetworkProxySettings } from '@lobechat/electron-client-ipc';
import type { SocksProxies } from 'fetch-socks';
import type {SocksProxies } from 'fetch-socks';
import { socksDispatcher } from 'fetch-socks';
import { Agent, getGlobalDispatcher, ProxyAgent, setGlobalDispatcher } from 'undici';
@@ -120,7 +120,6 @@ export class ProxyDispatcherManager {
logger.error(`Failed to create proxy agent for ${proxyType}:`, error);
throw new Error(
`Failed to create proxy agent: ${error instanceof Error ? error.message : 'Unknown error'}`,
{ cause: error },
);
}
}
@@ -71,8 +71,9 @@ export class ProxyConfigValidator {
*/
private static isValidHost(host: string): boolean {
// Simple host validation (IP address or domain name)
const ipRegex = /^(?:\d{1,3}\.){3}\d{1,3}$/;
const domainRegex = /^[\dA-Z](?:[\dA-Z-]*[\dA-Z])?(?:\.[\dA-Z](?:[\dA-Z-]*[\dA-Z])?)*$/i;
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
const domainRegex =
/^[\dA-Z]([\dA-Z-]*[\dA-Z])?(\.[\dA-Z]([\dA-Z-]*[\dA-Z])?)*$/i;
return ipRegex.test(host) || domainRegex.test(host);
}
@@ -1,167 +0,0 @@
import type { CaptureRectParams } from '@lobechat/electron-client-ipc';
import { Monitor } from 'node-screenshots';
import { createLogger } from '@/utils/logger';
import { findWindowById } from './WindowSourceService';
const logger = createLogger('screenCapture:CaptureService');
const CAPTURE_RETRY_DELAY_MS = 120;
const CAPTURE_RETRY_TIMES = 2;
interface DisplayBounds {
height: number;
width: number;
x: number;
y: number;
}
/**
* Capture a specific window by its native window id.
*/
export async function captureWindow(windowId: number): Promise<Buffer | null> {
try {
const win = findWindowById(windowId);
if (!win) {
logger.warn(`Window ${windowId} not found`);
return null;
}
const image = await win.captureImage();
const pngBuffer = Buffer.from(await image.toPng());
return pngBuffer;
} catch (error) {
logger.error('Failed to capture window:', error);
return null;
}
}
/**
* Capture a rect region from the monitor that contains the rect.
* `absoluteRect` is in absolute DIP coordinates.
*/
export async function captureRect(
absoluteRect: CaptureRectParams,
scaleFactor: number,
displayBounds?: DisplayBounds,
): Promise<Buffer | null> {
try {
const centerX = Math.round((absoluteRect.x + absoluteRect.width / 2) * scaleFactor);
const centerY = Math.round((absoluteRect.y + absoluteRect.height / 2) * scaleFactor);
const monitor = resolveMonitor({
centerX,
centerY,
displayBounds,
scaleFactor,
});
if (!monitor) {
logger.warn(`No monitor found at point (${centerX}, ${centerY})`);
return null;
}
const image = await captureMonitorImageWithRetry(monitor);
if (!image) {
return null;
}
const physX = Math.round(absoluteRect.x * scaleFactor) - monitor.x();
const physY = Math.round(absoluteRect.y * scaleFactor) - monitor.y();
const physW = Math.round(absoluteRect.width * scaleFactor);
const physH = Math.round(absoluteRect.height * scaleFactor);
const cropX = Math.max(0, physX);
const cropY = Math.max(0, physY);
const cropW = Math.min(physW, image.width - cropX);
const cropH = Math.min(physH, image.height - cropY);
if (cropW <= 0 || cropH <= 0) {
logger.warn(`Crop rect out of monitor bounds: ${cropX},${cropY} ${cropW}x${cropH}`);
return null;
}
const cropped = await image.crop(cropX, cropY, cropW, cropH);
const pngBuffer = Buffer.from(await cropped.toPng());
return pngBuffer;
} catch (error) {
logger.error('Failed to capture rect:', error);
return null;
}
}
async function captureMonitorImageWithRetry(
monitor: Monitor,
): Promise<Awaited<ReturnType<Monitor['captureImage']>> | null> {
for (let attempt = 1; attempt <= CAPTURE_RETRY_TIMES; attempt += 1) {
try {
const image = await monitor.captureImage();
return image;
} catch (error) {
logger.error(`captureImage failed on attempt ${attempt} for monitor ${monitor.id()}:`, error);
if (attempt < CAPTURE_RETRY_TIMES) {
await delay(CAPTURE_RETRY_DELAY_MS);
continue;
}
}
}
return null;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function resolveMonitor({
centerX,
centerY,
displayBounds,
scaleFactor,
}: {
centerX: number;
centerY: number;
displayBounds?: DisplayBounds;
scaleFactor: number;
}): Monitor | null {
const monitors = Monitor.all();
const displayMonitor = displayBounds
? findMonitorByDisplayBounds(monitors, displayBounds, scaleFactor)
: null;
if (displayMonitor) {
return displayMonitor;
}
return Monitor.fromPoint(centerX, centerY);
}
function findMonitorByDisplayBounds(
monitors: Monitor[],
displayBounds: DisplayBounds,
scaleFactor: number,
): Monitor | null {
const expected = {
height: Math.round(displayBounds.height * scaleFactor),
width: Math.round(displayBounds.width * scaleFactor),
x: Math.round(displayBounds.x * scaleFactor),
y: Math.round(displayBounds.y * scaleFactor),
};
let bestMonitor: Monitor | null = null;
let bestScore = Number.POSITIVE_INFINITY;
for (const monitor of monitors) {
const score =
Math.abs(monitor.x() - expected.x) +
Math.abs(monitor.y() - expected.y) +
Math.abs(monitor.width() - expected.width) +
Math.abs(monitor.height() - expected.height);
if (score < bestScore) {
bestMonitor = monitor;
bestScore = score;
}
}
return bestMonitor;
}
@@ -1,262 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ScreenCaptureManager } from './ScreenCaptureManager';
const {
mockBrowserWindow,
MockBrowserWindow,
mockScreen,
mockEnumerateWindows,
mockIsMac,
mockCaptureWindow,
mockCaptureRect,
} = vi.hoisted(() => {
const mockBrowserWindow = {
destroy: vi.fn(),
focus: vi.fn(),
hide: vi.fn(),
isDestroyed: vi.fn().mockReturnValue(false),
loadURL: vi.fn().mockResolvedValue(undefined),
moveTop: vi.fn(),
setAlwaysOnTop: vi.fn(),
setHiddenInMissionControl: vi.fn(),
setOpacity: vi.fn(),
setVisibleOnAllWorkspaces: vi.fn(),
show: vi.fn(),
webContents: {
on: vi.fn(),
once: vi.fn((_event, listener) => {
listener();
}),
send: vi.fn(),
},
};
return {
mockBrowserWindow,
MockBrowserWindow: vi.fn(() => mockBrowserWindow),
mockCaptureRect: vi.fn(),
mockCaptureWindow: vi.fn(),
mockEnumerateWindows: vi.fn().mockResolvedValue([]),
mockIsMac: { value: true },
mockScreen: {
getCursorScreenPoint: vi.fn(() => ({ x: 10, y: 10 })),
getDisplayNearestPoint: vi.fn(() => ({
bounds: { height: 900, width: 1440, x: 0, y: 0 },
id: 1,
scaleFactor: 2,
})),
},
};
});
vi.mock('electron', () => ({
BrowserWindow: MockBrowserWindow,
screen: mockScreen,
}));
vi.mock('@/const/dir', () => ({
preloadDir: '/mock/preload',
}));
vi.mock('@/const/env', () => ({
get isMac() {
return mockIsMac.value;
},
}));
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock('./WindowSourceService', () => ({
enumerateWindows: mockEnumerateWindows,
}));
vi.mock('./CaptureService', () => ({
captureRect: (...args: unknown[]) => mockCaptureRect(...args),
captureWindow: (...args: unknown[]) => mockCaptureWindow(...args),
}));
describe('ScreenCaptureManager', () => {
const createApp = () =>
({
browserManager: {
broadcastToAllWindows: vi.fn(),
broadcastToWindow: vi.fn(),
showMainWindow: vi.fn(),
},
buildRendererUrl: vi.fn().mockResolvedValue('http://localhost:5173/overlay'),
}) as any;
beforeEach(() => {
vi.clearAllMocks();
mockBrowserWindow.isDestroyed.mockReturnValue(false);
mockEnumerateWindows.mockResolvedValue([]);
mockIsMac.value = true;
});
it('keeps the app in regular mode when showing overlay on macOS', async () => {
const manager = new ScreenCaptureManager(createApp());
await manager.startSession();
expect(mockBrowserWindow.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, {
skipTransformProcessType: true,
visibleOnFullScreen: true,
});
});
it('focuses the overlay after showing it', async () => {
const manager = new ScreenCaptureManager(createApp());
await manager.startSession();
expect(mockBrowserWindow.show).toHaveBeenCalled();
expect(mockBrowserWindow.focus).toHaveBeenCalled();
expect(mockBrowserWindow.moveTop).toHaveBeenCalled();
});
describe('preview handlers', () => {
it('hides overlay via opacity while capturing rect and restores after', async () => {
const app = createApp();
const manager = new ScreenCaptureManager(app);
await manager.startSession();
const pngBuffer = Buffer.from([1, 2, 3, 4]);
mockCaptureRect.mockResolvedValue(pngBuffer);
const result = await manager.handlePreviewRect({ height: 50, width: 100, x: 10, y: 20 });
expect(result.success).toBe(true);
expect(result.captureId).toEqual(expect.any(String));
expect(result.dataUrl).toBe(`data:image/png;base64,${pngBuffer.toString('base64')}`);
expect(mockBrowserWindow.setOpacity).toHaveBeenCalledWith(0);
expect(mockBrowserWindow.setOpacity).toHaveBeenLastCalledWith(1);
expect(mockCaptureRect).toHaveBeenCalledWith({ height: 50, width: 100, x: 10, y: 20 }, 2, {
height: 900,
width: 1440,
x: 0,
y: 0,
});
expect(app.browserManager.broadcastToWindow).toHaveBeenCalledWith(
'app',
'overlayUploadRequest',
expect.objectContaining({
captureId: result.captureId,
filename: `screen-capture-${result.captureId}.png`,
mimeType: 'image/png',
}),
);
});
it('returns failure when previewRect has no session', async () => {
const manager = new ScreenCaptureManager(createApp());
const result = await manager.handlePreviewRect({ height: 50, width: 100, x: 10, y: 20 });
expect(result.success).toBe(false);
expect(mockCaptureRect).not.toHaveBeenCalled();
});
it('returns dataUrl after previewWindow and attaches window bounds', async () => {
mockEnumerateWindows.mockResolvedValue([
{
appName: 'Safari',
bounds: { height: 200, width: 300, x: 5, y: 6 },
order: 0,
overlayBounds: { height: 200, width: 300, x: 5, y: 6 },
title: 'Docs',
windowId: 42,
},
]);
const app = createApp();
const manager = new ScreenCaptureManager(app);
await manager.startSession();
const pngBuffer = Buffer.from([9, 9, 9]);
mockCaptureWindow.mockResolvedValue(pngBuffer);
const result = await manager.handlePreviewWindow(42);
expect(result.success).toBe(true);
expect(result.captureId).toEqual(expect.any(String));
expect(result.dataUrl).toBe(`data:image/png;base64,${pngBuffer.toString('base64')}`);
expect(result.rect).toEqual({ height: 200, width: 300, x: 5, y: 6 });
expect(mockCaptureWindow).toHaveBeenCalledWith(42);
expect(app.browserManager.broadcastToWindow).toHaveBeenCalledWith(
'app',
'overlayUploadRequest',
expect.objectContaining({ captureId: result.captureId }),
);
});
it('restores opacity even when capture fails', async () => {
const manager = new ScreenCaptureManager(createApp());
await manager.startSession();
mockCaptureRect.mockResolvedValue(null);
const result = await manager.handlePreviewRect({ height: 50, width: 100, x: 10, y: 20 });
expect(result.success).toBe(false);
expect(mockBrowserWindow.setOpacity).toHaveBeenLastCalledWith(1);
});
});
describe('submit', () => {
it('closes overlay on submit', async () => {
const manager = new ScreenCaptureManager(createApp());
await manager.startSession();
await manager.handleSubmit({
captureIds: ['capture-1'],
prompt: 'hello',
});
expect(mockBrowserWindow.destroy).toHaveBeenCalled();
});
});
describe('reportUploadStatus', () => {
it('forwards status updates to the overlay after a preview', async () => {
const manager = new ScreenCaptureManager(createApp());
await manager.startSession();
const pngBuffer = Buffer.from([1, 2, 3]);
mockCaptureRect.mockResolvedValue(pngBuffer);
const result = await manager.handlePreviewRect({ height: 50, width: 100, x: 0, y: 0 });
expect(result.captureId).toBeTruthy();
mockBrowserWindow.webContents.send.mockClear();
manager.reportUploadStatus({
captureId: result.captureId!,
fileId: 'file-1',
status: 'ready',
});
expect(mockBrowserWindow.webContents.send).toHaveBeenCalledWith(
'overlayCaptureUploadStatus',
{ captureId: result.captureId, fileId: 'file-1', status: 'ready' },
);
});
it('ignores status updates for unknown captureIds', async () => {
const manager = new ScreenCaptureManager(createApp());
await manager.startSession();
mockBrowserWindow.webContents.send.mockClear();
manager.reportUploadStatus({ captureId: 'unknown', status: 'ready' });
expect(mockBrowserWindow.webContents.send).not.toHaveBeenCalledWith(
'overlayCaptureUploadStatus',
expect.anything(),
);
});
});
});
@@ -1,332 +0,0 @@
import { randomUUID } from 'node:crypto';
import type {
CapturePreviewResult,
CaptureRectParams,
OverlayCaptureUploadStatus,
OverlayCaptureUploadStatusPayload,
ScreenCaptureAgentOption,
ScreenCaptureModelOption,
ScreenCaptureOverlayTheme,
ScreenCaptureSession,
ScreenCaptureSubmitParams,
} from '@lobechat/electron-client-ipc';
import { BrowserWindow, screen } from 'electron';
import { BrowsersIdentifiers } from '@/appBrowsers';
import { preloadDir } from '@/const/dir';
import { isMac } from '@/const/env';
import type { App } from '@/core/App';
import { createLogger } from '@/utils/logger';
import { captureRect, captureWindow } from './CaptureService';
import { enumerateWindows } from './WindowSourceService';
const logger = createLogger('screenCapture:ScreenCaptureManager');
const HIDE_SETTLE_MS = 40;
export interface OverlaySnapshotPayload {
agents?: ScreenCaptureAgentOption[];
defaultAgentId?: string;
defaultModelId?: string;
defaultProvider?: string;
models?: ScreenCaptureModelOption[];
theme?: ScreenCaptureOverlayTheme;
}
interface CaptureUploadEntry {
fileId?: string;
filename: string;
status: OverlayCaptureUploadStatus;
}
export class ScreenCaptureManager {
private overlayWindow: BrowserWindow | null = null;
private session: ScreenCaptureSession | null = null;
/**
* Most recent agent/model snapshot published by the main renderer via
* `screenCapture.publishOverlaySnapshot`. Populated asynchronously; the
* overlay still opens with an empty selector list if the renderer has not
* pushed yet.
*/
private snapshot: OverlaySnapshotPayload = {};
/**
* Per-capture upload state used to drive the overlay send button and to
* resolve captureIds back to uploaded fileIds on submit. Cleared when the
* session closes.
*/
private captureUploads = new Map<string, CaptureUploadEntry>();
constructor(private readonly app: App) {}
publishOverlaySnapshot(payload: OverlaySnapshotPayload): void {
this.snapshot = payload;
// If a session is already on screen, push the updated lists so the user
// sees the current agents without reopening the overlay.
if (this.session) {
this.session = { ...this.session, ...this.snapshot };
if (this.overlayWindow && !this.overlayWindow.isDestroyed()) {
this.overlayWindow.webContents.send('screenCaptureSession', this.session);
}
}
}
get isActive(): boolean {
return this.overlayWindow !== null && !this.overlayWindow.isDestroyed();
}
async startSession(): Promise<void> {
if (this.isActive) {
logger.warn('Capture session already active');
this.close();
}
const cursor = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursor);
const { bounds, scaleFactor } = display;
logger.info(
`Starting capture session on display ${display.id} (${bounds.width}x${bounds.height} @${scaleFactor}x)`,
);
const windows = await enumerateWindows(bounds, scaleFactor);
this.session = {
displayBounds: bounds,
scaleFactor,
windows,
...this.snapshot,
};
await this.createOverlayWindow(bounds);
}
async handlePreviewWindow(windowId: number): Promise<CapturePreviewResult> {
if (!this.session) {
return { error: 'no active session', success: false };
}
const winInfo = this.session.windows.find((w) => w.windowId === windowId);
if (!winInfo) {
return { error: `window ${windowId} not found`, success: false };
}
logger.info(`Previewing window ${windowId} (${winInfo.appName})`);
const pngBuffer = await this.withOverlayHidden(() => captureWindow(windowId));
if (!pngBuffer) {
return { error: 'capture failed', success: false };
}
const captureId = randomUUID();
const filename = `screen-capture-${captureId}.png`;
this.dispatchUpload(captureId, filename, pngBuffer);
return {
captureId,
dataUrl: `data:image/png;base64,${pngBuffer.toString('base64')}`,
rect: {
height: winInfo.overlayBounds.height,
width: winInfo.overlayBounds.width,
x: winInfo.overlayBounds.x,
y: winInfo.overlayBounds.y,
},
success: true,
};
}
/**
* Preview a rect from the overlay. `params` is in overlay-local DIP
* (relative to the current display); main translates to absolute before
* handing to the capture pipeline.
*/
async handlePreviewRect(params: CaptureRectParams): Promise<CapturePreviewResult> {
if (!this.session) {
return { error: 'no active session', success: false };
}
const { displayBounds, scaleFactor } = this.session;
const absolute = {
height: params.height,
width: params.width,
x: params.x + displayBounds.x,
y: params.y + displayBounds.y,
};
logger.info(`Previewing rect (${params.x},${params.y} ${params.width}x${params.height})`);
const pngBuffer = await this.withOverlayHidden(() =>
captureRect(absolute, scaleFactor, displayBounds),
);
if (!pngBuffer) {
return { error: 'capture failed', success: false };
}
const captureId = randomUUID();
const filename = `screen-capture-${captureId}.png`;
this.dispatchUpload(captureId, filename, pngBuffer);
return {
captureId,
dataUrl: `data:image/png;base64,${pngBuffer.toString('base64')}`,
rect: params,
success: true,
};
}
/**
* Record an upload status update from the main renderer and forward it to
* the overlay so the send button can reflect live progress.
*/
reportUploadStatus(payload: OverlayCaptureUploadStatusPayload): void {
const entry = this.captureUploads.get(payload.captureId);
if (!entry) {
logger.warn(`reportUploadStatus for unknown captureId=${payload.captureId}`);
return;
}
entry.status = payload.status;
if (payload.fileId) entry.fileId = payload.fileId;
logger.debug(
`upload status captureId=${payload.captureId} status=${payload.status} fileId=${payload.fileId ?? '-'}`,
);
if (this.overlayWindow && !this.overlayWindow.isDestroyed()) {
this.overlayWindow.webContents.send('overlayCaptureUploadStatus', payload);
}
}
async handleSubmit(params: ScreenCaptureSubmitParams): Promise<void> {
logger.info(
`Submit capture — promptLen=${params.prompt.length} captureIds=${params.captureIds.length} agentId=${params.agentId ?? '-'} modelId=${params.modelId ?? '-'}`,
);
// Close the overlay first so focus transfers cleanly to the main window.
this.close();
try {
this.app.browserManager.showMainWindow();
} catch (error) {
logger.error('Failed to show main window on submit:', error);
}
this.app.browserManager.broadcastToAllWindows('overlayDispatchMessage', params);
}
close(): void {
if (this.overlayWindow && !this.overlayWindow.isDestroyed()) {
this.overlayWindow.destroy();
}
this.overlayWindow = null;
this.session = null;
this.captureUploads.clear();
logger.info('Capture session closed');
}
/**
* Fade overlay out via opacity so the capture pipeline sees clean pixels
* underneath, then restore opacity. Keeping the window alive (as opposed to
* hide/show) avoids focus/z-order glitches.
*/
private async withOverlayHidden<T>(task: () => Promise<T>): Promise<T> {
const win = this.overlayWindow;
if (!win || win.isDestroyed()) {
return task();
}
win.setOpacity(0);
await delay(HIDE_SETTLE_MS);
try {
return await task();
} finally {
if (!win.isDestroyed()) {
win.setOpacity(1);
}
}
}
/**
* Hand the PNG buffer to the main renderer so the upload pipeline (TRPC +
* hash dedup + S3) runs there; keep a local entry so the overlay can
* observe status transitions via reportUploadStatus.
*
* The main renderer receives an `ArrayBuffer` via Electron's structured
* clone, avoiding the ~33% base64 overhead of a dataUrl round-trip.
*/
private dispatchUpload(captureId: string, filename: string, pngBuffer: Buffer): void {
this.captureUploads.set(captureId, { filename, status: 'uploading' });
// Copy into a fresh ArrayBuffer so the IPC structured-clone layer owns
// the memory outright (Node's Buffer pool can otherwise alias bytes).
const bytes = new ArrayBuffer(pngBuffer.byteLength);
new Uint8Array(bytes).set(pngBuffer);
this.app.browserManager.broadcastToWindow(BrowsersIdentifiers.app, 'overlayUploadRequest', {
bytes,
captureId,
filename,
mimeType: 'image/png',
});
}
private async createOverlayWindow(bounds: Electron.Rectangle): Promise<void> {
const win = new BrowserWindow({
...(isMac ? { type: 'panel' } : {}),
enableLargerThanScreen: true,
focusable: true,
frame: false,
fullscreenable: false,
hasShadow: false,
height: bounds.height,
resizable: false,
skipTaskbar: true,
transparent: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: true,
preload: `${preloadDir}/index.js`,
sandbox: false,
},
width: bounds.width,
x: bounds.x,
y: bounds.y,
});
win.setAlwaysOnTop(true, 'screen-saver');
win.setVisibleOnAllWorkspaces(true, {
...(isMac ? { skipTransformProcessType: true } : {}),
visibleOnFullScreen: true,
});
if (isMac) {
win.setHiddenInMissionControl(true);
}
this.overlayWindow = win;
win.webContents.on('did-fail-load', (_event, code, description) => {
logger.error(`Overlay did-fail-load code=${code} description=${description}`);
});
const url = await this.app.buildRendererUrl('/overlay');
logger.info(`Loading overlay URL: ${url}`);
win.webContents.once('did-finish-load', () => {
logger.info('Overlay did-finish-load');
if (this.session && !win.isDestroyed()) {
logger.info(`Sending overlay session with ${this.session.windows.length} windows`);
win.webContents.send('screenCaptureSession', this.session);
}
});
await win.loadURL(url);
win.show();
win.focus();
win.moveTop();
logger.info('Overlay window created and shown');
}
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -1,130 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockWindows = vi.fn();
const mockOpenWindowsSync = vi.fn();
const originalPlatform = process.platform;
vi.mock('electron', () => ({
app: {
getName: vi.fn(() => 'LobeHub'),
},
}));
vi.mock('node-screenshots', () => ({
Window: {
all: mockWindows,
},
}));
vi.mock('get-windows', () => ({
openWindowsSync: mockOpenWindowsSync,
}));
describe('WindowSourceService', () => {
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('normalizes window geometry to display DIPs on Windows high-DPI displays', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
mockOpenWindowsSync.mockReturnValue([{ owner: { processId: 42 } }]);
mockWindows.mockReturnValue([
{
appName: () => 'Finder',
height: () => 1200,
id: () => 1001,
isMinimized: () => false,
pid: () => 42,
title: () => 'Example',
width: () => 1600,
x: () => 400,
y: () => 200,
z: () => 10,
},
]);
const { enumerateWindows } = await import('./WindowSourceService');
const windows = await enumerateWindows(
{
height: 1080,
width: 1920,
x: 0,
y: 0,
},
1.5,
);
expect(windows).toEqual([
{
appName: 'Finder',
bounds: {
height: 800,
width: 1066.6666666666667,
x: 266.6666666666667,
y: 133.33333333333334,
},
order: 0,
overlayBounds: {
height: 800,
width: 1066.6666666666667,
x: 266.6666666666667,
y: 133.33333333333334,
},
title: 'Example',
windowId: 1001,
},
]);
});
it('preserves window geometry on retina displays without dividing by scale factor', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
mockOpenWindowsSync.mockReturnValue([{ owner: { processId: 42 } }]);
mockWindows.mockReturnValue([
{
appName: () => 'Finder',
height: () => 900,
id: () => 1001,
isMinimized: () => false,
pid: () => 42,
scaleFactor: () => 2,
title: () => 'Example',
width: () => 1440,
x: () => 200,
y: () => 100,
z: () => 10,
},
]);
const { enumerateWindows } = await import('./WindowSourceService');
const windows = await enumerateWindows({
height: 1620,
width: 2880,
x: 0,
y: 0,
});
expect(windows).toEqual([
{
appName: 'Finder',
bounds: {
height: 900,
width: 1440,
x: 200,
y: 100,
},
order: 0,
overlayBounds: {
height: 900,
width: 1440,
x: 200,
y: 100,
},
title: 'Example',
windowId: 1001,
},
]);
});
});
@@ -1,140 +0,0 @@
import type { ScreenCaptureWindowInfo } from '@lobechat/electron-client-ipc';
import { app } from 'electron';
import { openWindowsSync } from 'get-windows';
import { Window } from 'node-screenshots';
import { createLogger } from '@/utils/logger';
const logger = createLogger('screenCapture:WindowSourceService');
const MIN_WIDTH = 80;
const MIN_HEIGHT = 60;
const SYSTEM_APP_BLACKLIST = new Set([
'Dock',
'Window Server',
'WindowServer',
'Control Centre',
'Control Center',
'SystemUIServer',
'Notification Centre',
'Notification Center',
]);
interface DisplayBounds {
height: number;
width: number;
x: number;
y: number;
}
interface PreparedWindow {
appName: string;
bounds: DisplayBounds;
title: string;
windowId: number;
z: number;
}
interface WindowWithOptionalScaleFactor {
scaleFactor?: () => number;
}
function intersects(a: DisplayBounds, b: DisplayBounds): boolean {
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
}
function normalizeWindowBounds(
bounds: DisplayBounds,
scaleFactor: number | undefined,
): DisplayBounds {
if (process.platform !== 'win32') return bounds;
const normalizedScaleFactor =
typeof scaleFactor === 'number' && Number.isFinite(scaleFactor) && scaleFactor > 0
? scaleFactor
: 1;
if (normalizedScaleFactor === 1) return bounds;
return {
height: bounds.height / normalizedScaleFactor,
width: bounds.width / normalizedScaleFactor,
x: bounds.x / normalizedScaleFactor,
y: bounds.y / normalizedScaleFactor,
};
}
export async function enumerateWindows(
displayBounds: DisplayBounds,
displayScaleFactor?: number,
): Promise<ScreenCaptureWindowInfo[]> {
const selfName = app.getName();
let visiblePids: Set<number> | undefined;
try {
const visible = openWindowsSync({
accessibilityPermission: false,
screenRecordingPermission: false,
});
visiblePids = new Set(visible.map((w) => w.owner.processId));
} catch (error) {
logger.warn('get-windows unavailable, skipping whitelist filter:', error);
}
const preparedWindows = Window.all()
.map((win): PreparedWindow | null => {
if (visiblePids && !visiblePids.has(win.pid())) return null;
const appName = win.appName();
if (SYSTEM_APP_BLACKLIST.has(appName) || appName === selfName) return null;
if (win.isMinimized()) return null;
const width = win.width();
const height = win.height();
if (width < MIN_WIDTH || height < MIN_HEIGHT) return null;
const bounds = {
height,
width,
x: win.x(),
y: win.y(),
};
const normalizedBounds = normalizeWindowBounds(
bounds,
displayScaleFactor ?? (win as WindowWithOptionalScaleFactor).scaleFactor?.(),
);
if (!intersects(normalizedBounds, displayBounds)) return null;
return {
appName,
bounds: normalizedBounds,
title: win.title(),
windowId: win.id(),
z: win.z(),
};
})
.filter((win): win is PreparedWindow => win !== null)
.sort((left, right) => right.z - left.z);
const results = preparedWindows.map((win, index) => ({
appName: win.appName,
bounds: win.bounds,
order: index,
overlayBounds: {
height: win.bounds.height,
width: win.bounds.width,
x: win.bounds.x - displayBounds.x,
y: win.bounds.y - displayBounds.y,
},
title: win.title,
windowId: win.windowId,
}));
logger.info(`Enumerated ${results.length} windows for display`);
return results;
}
export function findWindowById(windowId: number): Window | undefined {
return Window.all().find((w) => w.id() === windowId);
}
@@ -432,9 +432,9 @@ describe('FileService', () => {
});
it('should handle partial failures in batch deletion', async () => {
let _callCount = 0;
let callCount = 0;
mockFsUnlink.mockImplementation((path: any, callback: any) => {
_callCount++;
callCount++;
// Fail on a specific file
if (path.includes('file2.txt') && !path.includes('.meta')) {
callback(new Error('Permission denied'));
+17 -25
View File
@@ -1,6 +1,6 @@
import * as fs from 'node:fs';
import { writeFile } from 'node:fs/promises';
import nodePath from 'node:path';
import path, { join } from 'node:path';
import { promisify } from 'node:util';
import type { DeleteFilesResponse } from '@lobechat/electron-server-ipc';
@@ -51,7 +51,7 @@ export default class FileService extends ServiceModule {
* @deprecated Only for backward compatibility with legacy file access, new files should be stored under custom paths in FILE_STORAGE_DIR
*/
get UPLOADS_DIR() {
return nodePath.join(this.app.appStoragePath, FILE_STORAGE_DIR, 'uploads');
return join(this.app.appStoragePath, FILE_STORAGE_DIR, 'uploads');
}
constructor(app) {
@@ -75,11 +75,11 @@ export default class FileService extends ServiceModule {
const date = (now / 1000 / 60 / 60).toFixed(0);
// Use provided filePath as the file storage path
const fullStoragePath = nodePath.join(this.app.appStoragePath, FILE_STORAGE_DIR, filePath);
const fullStoragePath = join(this.app.appStoragePath, FILE_STORAGE_DIR, filePath);
logger.debug(`Target file storage path: ${fullStoragePath}`);
// Ensure target directory exists
const targetDir = nodePath.dirname(fullStoragePath);
const targetDir = path.dirname(fullStoragePath);
logger.debug(`Ensuring target directory exists: ${targetDir}`);
makeSureDirExist(targetDir);
@@ -116,7 +116,7 @@ export default class FileService extends ServiceModule {
logger.info(`File upload successful: ${desktopPath}`);
// Extract filename and directory information from path
const parsedPath = nodePath.parse(filePath);
const parsedPath = path.parse(filePath);
const dirname = parsedPath.dir || '';
const savedFilename = parsedPath.base;
@@ -131,7 +131,7 @@ export default class FileService extends ServiceModule {
};
} catch (error) {
logger.error(`File upload failed:`, error);
throw new Error(`File upload failed: ${(error as Error).message}`, { cause: error });
throw new Error(`File upload failed: ${(error as Error).message}`);
}
}
@@ -179,12 +179,12 @@ export default class FileService extends ServiceModule {
if (this.isLegacyPath(relativePath)) {
// Legacy path: read from uploads directory (backward compatibility)
filePath = nodePath.join(this.UPLOADS_DIR, relativePath);
filePath = join(this.UPLOADS_DIR, relativePath);
isLegacyAttempt = true;
logger.debug(`Legacy path detected, reading from uploads directory: ${filePath}`);
} else {
// New path: read from FILE_STORAGE_DIR root directory
filePath = nodePath.join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
filePath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(`New path format, reading from storage root: ${filePath}`);
}
@@ -197,11 +197,7 @@ export default class FileService extends ServiceModule {
} catch (firstError) {
if (isLegacyAttempt) {
// If legacy path read fails, try reading from new path
const fallbackPath = nodePath.join(
this.app.appStoragePath,
FILE_STORAGE_DIR,
relativePath,
);
const fallbackPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(
`Legacy path read failed, attempting fallback to storage root: ${fallbackPath}`,
);
@@ -281,7 +277,7 @@ export default class FileService extends ServiceModule {
throw new FileNotFoundError(`File not found: ${path}`, path);
}
throw new Error(`File retrieval failed: ${(error as Error).message}`, { cause: error });
throw new Error(`File retrieval failed: ${(error as Error).message}`);
}
}
@@ -309,12 +305,12 @@ export default class FileService extends ServiceModule {
if (this.isLegacyPath(relativePath)) {
// Legacy path: delete from uploads directory (backward compatibility)
filePath = nodePath.join(this.UPLOADS_DIR, relativePath);
filePath = join(this.UPLOADS_DIR, relativePath);
isLegacyAttempt = true;
logger.debug(`Legacy path detected, deleting from uploads directory: ${filePath}`);
} else {
// New path: delete from FILE_STORAGE_DIR root directory
filePath = nodePath.join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
filePath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(`New path format, deleting from storage root: ${filePath}`);
}
@@ -326,11 +322,7 @@ export default class FileService extends ServiceModule {
} catch (firstError) {
if (isLegacyAttempt) {
// If legacy path deletion fails, try deleting from new path
const fallbackPath = nodePath.join(
this.app.appStoragePath,
FILE_STORAGE_DIR,
relativePath,
);
const fallbackPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(
`Legacy path deletion failed, attempting fallback to storage root: ${fallbackPath}`,
);
@@ -362,7 +354,7 @@ export default class FileService extends ServiceModule {
return { success: true };
} catch (error) {
logger.error(`File deletion failed:`, error);
throw new Error(`File deletion failed: ${(error as Error).message}`, { cause: error });
throw new Error(`File deletion failed: ${(error as Error).message}`);
}
}
@@ -436,7 +428,7 @@ export default class FileService extends ServiceModule {
let fullPath: string;
if (this.isLegacyPath(relativePath)) {
// Legacy path: get from uploads directory (backward compatibility)
fullPath = nodePath.join(this.UPLOADS_DIR, relativePath);
fullPath = join(this.UPLOADS_DIR, relativePath);
logger.debug(`Legacy path detected, resolved to uploads directory: ${fullPath}`);
// Check if file exists, if not try new path
@@ -445,7 +437,7 @@ export default class FileService extends ServiceModule {
logger.debug(`Legacy path file exists: ${fullPath}`);
} catch {
// If legacy path file doesn't exist, try new path
const fallbackPath = nodePath.join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
const fallbackPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(`Legacy path file not found, trying fallback path: ${fallbackPath}`);
try {
await fs.promises.access(fallbackPath, fs.constants.F_OK);
@@ -460,7 +452,7 @@ export default class FileService extends ServiceModule {
}
} else {
// New path: get from FILE_STORAGE_DIR root directory
fullPath = nodePath.join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
fullPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath);
logger.debug(`New path format, resolved to storage root: ${fullPath}`);
}
@@ -307,7 +307,7 @@ export default class GatewayConnectionService extends ServiceModule {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
return payload.sub || null;
} catch {
logger.warn('Failed to extract userId from JWT token');
+2 -5
View File
@@ -7,12 +7,9 @@ export const makeSureDirExist = (dir: string) => {
// Use recursive: true, no effect if directory exists, create if it doesn't
try {
mkdirSync(dir, { recursive: true });
} catch (mkdirError: unknown) {
} catch (mkdirError: any) {
// Throw error if directory creation fails (e.g., permission issues)
const message = mkdirError instanceof Error ? mkdirError.message : String(mkdirError);
throw new Error(`Could not create target directory: ${dir}. Error: ${message}`, {
cause: mkdirError,
});
throw new Error(`Could not create target directory: ${dir}. Error: ${mkdirError.message}`);
}
}
};
+2 -2
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import { extname } from 'node:path';
export const getExportMimeType = (filePath: string) => {
const ext = path.extname(filePath).toLowerCase();
const ext = extname(filePath).toLowerCase();
const map: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
+16 -12
View File
@@ -6,8 +6,7 @@
* It must be dynamically imported to prevent loading errors on Windows/Linux.
*/
import { shell } from 'electron';
import * as electronIs from 'electron-is';
import type * as MacPermissions from 'node-mac-permissions';
import { macOS } from 'electron-is';
import { createLogger } from './logger';
@@ -28,31 +27,33 @@ type AuthType =
type PermissionType = 'authorized' | 'denied' | 'not determined' | 'restricted';
type MacPermissionsModule = typeof MacPermissions;
// Lazy-loaded module cache
let macPermissionsModule: MacPermissionsModule | null = null;
// @ts-ignore - node-mac-permissions is optional and only available on macOS
let macPermissionsModule: typeof import('node-mac-permissions') | null = null;
// Test injection override (set via __setMacPermissionsModule for testing)
let testModuleOverride: MacPermissionsModule | null = null;
// @ts-ignore - node-mac-permissions is optional and only available on macOS
let testModuleOverride: typeof import('node-mac-permissions') | null = null;
/**
* Lazily load the node-mac-permissions module (macOS only)
* Returns null on non-macOS platforms
*/
function getMacPermissionsModule(): MacPermissionsModule | null {
// @ts-ignore - node-mac-permissions is optional and only available on macOS
function getMacPermissionsModule(): typeof import('node-mac-permissions') | null {
// Allow test injection to override the module
if (testModuleOverride) {
return testModuleOverride;
}
if (!electronIs.macOS()) {
if (!macOS()) {
return null;
}
if (!macPermissionsModule) {
// Dynamic require to prevent module loading on non-macOS platforms
macPermissionsModule = require('node-mac-permissions') as MacPermissionsModule;
macPermissionsModule = require('node-mac-permissions');
}
return macPermissionsModule;
@@ -71,7 +72,10 @@ export function __resetMacPermissionsModuleCache(): void {
* Set the mac permissions module (for testing purposes)
* @internal
*/
export function __setMacPermissionsModule(module: MacPermissionsModule | null): void {
export function __setMacPermissionsModule(
// @ts-ignore - node-mac-permissions is optional and only available on macOS
module: typeof import('node-mac-permissions') | null,
): void {
testModuleOverride = module;
}
@@ -296,7 +300,7 @@ export function requestFullDiskAccess(): void {
* Alternative method using shell.openExternal
*/
export async function openFullDiskAccessSettings(): Promise<void> {
if (!electronIs.macOS()) {
if (!macOS()) {
logger.info('[FullDiskAccess] Not macOS, skipping');
return;
}
@@ -346,7 +350,7 @@ export function getInputMonitoringStatus(): PermissionStatus {
* Maps 'microphone' and 'screen' to corresponding permission checks
*/
export function getMediaAccessStatus(mediaType: 'microphone' | 'screen'): string {
if (!electronIs.macOS()) return 'granted';
if (!macOS()) return 'granted';
const status = getPermissionStatus(mediaType === 'microphone' ? 'microphone' : 'screen');
-64
View File
@@ -1,64 +0,0 @@
import { FluentEmoji, getEmoji } from '@lobehub/fluent-emoji';
import { memo, useMemo } from 'react';
import * as styles from './avatar.css.ts';
export interface OverlayAvatarProps {
avatar?: string | null;
background?: string | null;
size?: number;
title?: string | null;
}
const URL_PATTERN = /^(?:blob:|data:|file:|https?:|\/|\.\.?\/)/;
const isUrl = (value: string) => URL_PATTERN.test(value);
const firstGlyph = (value?: string | null) => {
if (!value) return '?';
const trimmed = value.trim();
return trimmed ? Array.from(trimmed)[0] ?? '?' : '?';
};
const OverlayAvatar = memo<OverlayAvatarProps>(({ avatar, background, size = 18, title }) => {
const emoji = useMemo(
() => (avatar && typeof avatar === 'string' ? getEmoji(avatar) : undefined),
[avatar],
);
const boxStyle = {
background: background ?? undefined,
height: size,
width: size,
};
if (emoji) {
return (
<span className={styles.emojiBox} style={boxStyle}>
<FluentEmoji emoji={emoji} size={Math.round(size * 0.82)} type="3d" />
</span>
);
}
if (avatar && isUrl(avatar)) {
return (
<img
alt={title ?? 'avatar'}
className={styles.image}
draggable={false}
src={avatar}
style={boxStyle}
/>
);
}
return (
<span className={styles.textBox} style={boxStyle}>
{firstGlyph(title ?? avatar)}
</span>
);
});
OverlayAvatar.displayName = 'OverlayAvatar';
export default OverlayAvatar;
@@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolvePanelPlacement } from './panelPlacement';
describe('resolvePanelPlacement', () => {
it('keeps the last selection placement while a reselection is in progress', () => {
expect(
resolvePanelPlacement({
dockedPlacement: null,
initialPlacement: { left: 480, top: 720, width: 420 },
lastSelectionPlacement: { left: 812, top: 168, width: 360 },
}),
).toEqual({
left: 812,
top: 168,
width: 360,
});
});
it('falls back to the initial placement after the remembered position is cleared', () => {
expect(
resolvePanelPlacement({
dockedPlacement: null,
initialPlacement: { left: 480, top: 720, width: 420 },
lastSelectionPlacement: null,
}),
).toEqual({
left: 480,
top: 720,
width: 420,
});
});
});
-528
View File
@@ -1,528 +0,0 @@
import type {
OverlayCaptureUploadStatus,
ScreenCaptureAgentOption,
ScreenCaptureModelOption,
ScreenCaptureOverlayTheme,
} from '@lobechat/electron-client-ipc';
import { ModelIcon } from '@lobehub/icons';
import { AlertCircleIcon, ChevronDownIcon, Loader2Icon, XIcon } from 'lucide-react';
import type {
ChangeEvent as ReactChangeEvent,
CSSProperties,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
} from 'react';
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import OverlayAvatar from './Avatar';
import * as styles from './chatPanel.css.ts';
import { cn } from './cn';
import { OVERLAY_COPY, OVERLAY_LAYOUT, OVERLAY_SHORTCUTS } from './constants';
import {
createDockedPanelPlacement,
createInitialPanelPlacement,
type PanelPlacement,
resolvePanelPlacement,
} from './panelPlacement';
import { computeDockPosition, connectorPoint, type DockResult, type Rect } from './useDockPosition';
export interface ChatPanelSelection {
captureId: string;
dataUrl: string;
label: string;
rect: Rect;
uploadStatus: OverlayCaptureUploadStatus;
}
export interface ChatPanelSubmitPayload {
agentId?: string;
captureIds: string[];
modelId?: string;
prompt: string;
provider?: string;
}
export interface ChatPanelProps {
agentId?: string;
agents?: ScreenCaptureAgentOption[];
hidden?: boolean;
modelId?: string;
models?: ScreenCaptureModelOption[];
onRemoveSelection: (selectionId: string) => void;
onSubmit: (payload: ChatPanelSubmitPayload) => void;
placementResetKey?: number;
selections: ChatPanelSelection[];
theme?: ScreenCaptureOverlayTheme;
viewportHeight: number;
viewportWidth: number;
}
const formatBytes = (rect: Rect): string =>
`${Math.round(rect.width)} × ${Math.round(rect.height)} · ${OVERLAY_COPY.selectionFormatLabel}`;
const SendIcon = () => (
<svg
aria-hidden="true"
fill="currentColor"
fillRule="evenodd"
focusable="false"
height={14}
style={{ flex: 'none', lineHeight: 1 }}
viewBox="0 0 14 14"
width={14}
xmlns="http://www.w3.org/2000/svg"
>
<path d="M.743 3.773c-.818-.555-.422-1.834.567-1.828l11.496.074a1 1 0 01.837 1.538l-6.189 9.689c-.532.833-1.822.47-1.842-.518L5.525 8.51a1 1 0 01.522-.9l1.263-.686a.808.808 0 00-.772-1.42l-1.263.686a1 1 0 01-1.039-.051L.743 3.773z" />
</svg>
);
const UploadStatusIndicator = ({
iconSize = 16,
status,
}: {
iconSize?: number;
status: OverlayCaptureUploadStatus;
}) => {
if (status === 'uploading') {
return (
<div
aria-label={OVERLAY_COPY.uploadingLabel}
className={cn(styles.uploadOverlay, styles.uploadOverlayUploading)}
>
<Loader2Icon className={styles.uploadSpinnerIcon} size={iconSize} strokeWidth={2.2} />
</div>
);
}
if (status === 'failed') {
return (
<div
aria-label={OVERLAY_COPY.uploadFailedLabel}
className={cn(styles.uploadOverlay, styles.uploadOverlayFailed)}
>
<AlertCircleIcon size={iconSize} strokeWidth={2.2} />
</div>
);
}
return null;
};
const ChatPanel = memo<ChatPanelProps>(
({
agentId: initialAgentId,
agents,
hidden = false,
modelId: initialModelId,
models,
onRemoveSelection,
onSubmit,
placementResetKey = 0,
selections,
theme,
viewportHeight,
viewportWidth,
}) => {
const [prompt, setPrompt] = useState('');
const [agentId, setAgentId] = useState<string | undefined>(initialAgentId);
const [modelId, setModelId] = useState<string | undefined>(initialModelId);
const lastSelectionPlacementRef = useRef<PanelPlacement | null>(null);
const lastPlacementResetKeyRef = useRef(placementResetKey);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const selectionCount = selections.length;
const activeSelection = selectionCount > 0 ? selections[selectionCount - 1]! : null;
const selected = selectionCount > 0;
const currentAgent = useMemo(
() => agents?.find((item) => item.id === agentId),
[agents, agentId],
);
const currentModel = useMemo(
() => models?.find((item) => item.id === modelId),
[models, modelId],
);
useEffect(() => {
if (!initialAgentId) return;
setAgentId(initialAgentId);
}, [initialAgentId]);
useEffect(() => {
if (!initialModelId) return;
setModelId(initialModelId);
}, [initialModelId]);
useEffect(() => {
if (!agents?.length) return;
if (agentId && agents.some((item) => item.id === agentId)) return;
const nextAgentId =
(initialAgentId && agents.some((item) => item.id === initialAgentId)
? initialAgentId
: undefined) ?? agents[0]?.id;
if (nextAgentId !== agentId) {
setAgentId(nextAgentId);
}
}, [agents, agentId, initialAgentId]);
useEffect(() => {
if (!models?.length) return;
if (modelId && models.some((item) => item.id === modelId)) return;
const nextModelId =
(initialModelId && models.some((item) => item.id === initialModelId)
? initialModelId
: undefined) ?? models[0]?.id;
if (nextModelId !== modelId) {
setModelId(nextModelId);
}
}, [initialModelId, modelId, models]);
const initialPlacement = useMemo(
() => createInitialPanelPlacement(viewportWidth, viewportHeight),
[viewportWidth, viewportHeight],
);
const dockPanelHeight =
selectionCount > 1
? OVERLAY_LAYOUT.panelHeightEstimateExpanded
: OVERLAY_LAYOUT.panelHeightEstimate;
const dock: DockResult | null = useMemo(() => {
if (!activeSelection) return null;
return computeDockPosition({
gap: OVERLAY_LAYOUT.dockGap,
panelHeight: dockPanelHeight,
panelWidth: OVERLAY_LAYOUT.panelWidthDocked,
rect: activeSelection.rect,
viewportHeight,
viewportWidth,
});
}, [activeSelection, dockPanelHeight, viewportWidth, viewportHeight]);
const dockedPlacement: PanelPlacement | null = dock ? createDockedPanelPlacement(dock) : null;
useEffect(() => {
if (dockedPlacement) {
lastSelectionPlacementRef.current = dockedPlacement;
}
}, [dockedPlacement]);
if (lastPlacementResetKeyRef.current !== placementResetKey) {
lastSelectionPlacementRef.current = null;
lastPlacementResetKeyRef.current = placementResetKey;
}
const placement = resolvePanelPlacement({
dockedPlacement,
initialPlacement,
lastSelectionPlacement: lastSelectionPlacementRef.current,
});
const connector = useMemo(() => {
if (!activeSelection || !dock || dock.side === 'edge') return null;
const pt = connectorPoint(activeSelection.rect, dock.side);
return {
left: pt.x - OVERLAY_LAYOUT.connectorSize / 2,
top: pt.y - OVERLAY_LAYOUT.connectorSize / 2,
};
}, [activeSelection, dock]);
const themeStyle = useMemo<CSSProperties | undefined>(() => {
if (!theme) return undefined;
return {
'--lobe-overlay-bg-elevated': theme.colorBgElevated,
'--lobe-overlay-border-secondary': theme.colorBorderSecondary,
'--lobe-overlay-fill': theme.colorFill,
'--lobe-overlay-fill-quaternary': theme.colorFillQuaternary,
'--lobe-overlay-fill-secondary': theme.colorFillSecondary,
'--lobe-overlay-fill-tertiary': theme.colorFillTertiary,
'--lobe-overlay-panel-border': theme.panelBorder,
'--lobe-overlay-primary': theme.colorPrimary,
'--lobe-overlay-primary-active': theme.colorPrimaryActive,
'--lobe-overlay-primary-hover': theme.colorPrimaryHover,
'--lobe-overlay-shadow': theme.panelShadow,
'--lobe-overlay-text': theme.colorText,
'--lobe-overlay-text-light-solid': theme.colorTextLightSolid,
'--lobe-overlay-text-quaternary': theme.colorTextQuaternary,
'--lobe-overlay-text-secondary': theme.colorTextSecondary,
'--lobe-overlay-text-tertiary': theme.colorTextTertiary,
} as CSSProperties;
}, [theme]);
useLayoutEffect(() => {
if (selected && !hidden && textareaRef.current) {
textareaRef.current.focus();
}
}, [hidden, selected]);
useEffect(() => {
if (!selected) setPrompt('');
}, [selected]);
const allUploadsReady = useMemo(
() => selections.every((item) => item.uploadStatus === 'ready'),
[selections],
);
const hasUploading = useMemo(
() => selections.some((item) => item.uploadStatus === 'uploading'),
[selections],
);
const hasFailed = useMemo(
() => selections.some((item) => item.uploadStatus === 'failed'),
[selections],
);
const submit = useCallback(() => {
if (selections.length === 0 || !prompt.trim() || !allUploadsReady) return;
onSubmit({
agentId,
captureIds: selections.map((item) => item.captureId),
modelId,
prompt: prompt.trim(),
provider: currentModel?.provider,
});
}, [selections, prompt, agentId, modelId, currentModel, onSubmit, allUploadsReady]);
const handleKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submit();
}
},
[submit],
);
const canSend = selected && prompt.trim().length > 0 && allUploadsReady;
const handleAgentChange = useCallback((e: ReactChangeEvent<HTMLSelectElement>) => {
setAgentId(e.target.value || undefined);
}, []);
const handleModelChange = useCallback((e: ReactChangeEvent<HTMLSelectElement>) => {
setModelId(e.target.value || undefined);
}, []);
const hasAgents = !!agents && agents.length > 0;
const hasModels = !!models && models.length > 0;
return (
<>
{connector && (
<div
style={{ ...themeStyle, left: connector.left, top: connector.top }}
className={cn(
styles.connector,
selected && styles.connectorVisible,
hidden && styles.connectorHidden,
)}
/>
)}
<div
aria-hidden={hidden}
className={cn(
styles.panel,
!selected && styles.initialEnter,
hidden && styles.panelHidden,
)}
style={{
...themeStyle,
cursor: 'default',
left: placement.left,
top: placement.top,
width: placement.width,
}}
onMouseDown={(e: ReactMouseEvent<HTMLDivElement>) => e.stopPropagation()}
onMouseMove={(e: ReactMouseEvent<HTMLDivElement>) => e.stopPropagation()}
onMouseUp={(e: ReactMouseEvent<HTMLDivElement>) => e.stopPropagation()}
>
{selectionCount === 1 && activeSelection && (
<div className={styles.selectionSummary}>
<div
aria-label="screenshot thumbnail"
className={styles.thumb}
style={{ backgroundImage: `url(${activeSelection.dataUrl})` }}
>
<UploadStatusIndicator iconSize={16} status={activeSelection.uploadStatus} />
</div>
<div className={styles.summaryText}>
<div className={styles.summaryTitle}>
{OVERLAY_COPY.screenshotLabel} · {activeSelection.label}
</div>
<div className={styles.summaryMeta}>{formatBytes(activeSelection.rect)}</div>
</div>
<button
aria-label={OVERLAY_COPY.removeSelectionLabel}
className={styles.iconBtn}
type="button"
onClick={() => onRemoveSelection(activeSelection.captureId)}
>
<XIcon size={14} strokeWidth={2} />
</button>
</div>
)}
{selectionCount > 1 && activeSelection && (
<div className={styles.multiSelectionSummary}>
<div className={styles.multiSelectionHeader}>
<div className={styles.multiSelectionTitle}>
{selectionCount} {OVERLAY_COPY.screenshotsLabel}
</div>
<div className={styles.multiSelectionMeta}>
{OVERLAY_COPY.latestSelectionLabel} · {activeSelection.label}
</div>
</div>
<div className={styles.multiSelectionRail}>
{selections.map((item) => {
const isActive = item.captureId === activeSelection.captureId;
return (
<div
key={item.captureId}
className={cn(
styles.multiSelectionItem,
isActive && styles.multiSelectionItemActive,
)}
>
<div className={styles.multiSelectionThumbFrame}>
<div
aria-label="screenshot thumbnail"
className={styles.multiSelectionThumb}
style={{ backgroundImage: `url(${item.dataUrl})` }}
/>
<UploadStatusIndicator iconSize={18} status={item.uploadStatus} />
<button
aria-label={OVERLAY_COPY.removeSelectionLabel}
className={styles.multiSelectionRemoveBtn}
type="button"
onClick={() => onRemoveSelection(item.captureId)}
>
<XIcon size={12} strokeWidth={2} />
</button>
</div>
<div className={styles.multiSelectionItemLabel}>{item.label}</div>
</div>
);
})}
</div>
</div>
)}
<div className={styles.inputRow}>
<textarea
className={styles.textarea}
ref={textareaRef}
rows={2}
spellCheck={false}
value={prompt}
placeholder={
selected
? selectionCount > 1
? OVERLAY_COPY.multipleSelectedPlaceholder
: OVERLAY_COPY.selectedPlaceholder
: OVERLAY_COPY.idlePlaceholder
}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<div className={styles.actionBar}>
<div className={styles.actionBarLeft}>
<label
aria-label={OVERLAY_COPY.agentSelectLabel}
className={cn(styles.selectChip, !hasAgents && styles.selectChipDisabled)}
>
<OverlayAvatar
avatar={currentAgent?.avatar}
background={currentAgent?.backgroundColor}
size={18}
title={currentAgent?.title}
/>
<span className={styles.chipLabel}>
{currentAgent?.title ?? OVERLAY_COPY.agentSelectPlaceholder}
</span>
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
<select
aria-label={OVERLAY_COPY.agentSelectLabel}
className={styles.nativeSelect}
disabled={!hasAgents}
value={agentId ?? ''}
onChange={handleAgentChange}
>
{!hasAgents && <option value="">{OVERLAY_COPY.agentSelectPlaceholder}</option>}
{agents?.map((item) => (
<option key={item.id} value={item.id}>
{item.avatar && typeof item.avatar === 'string' && item.avatar.length <= 4
? `${item.avatar} ${item.title}`
: item.title}
</option>
))}
</select>
</label>
<label
aria-label={OVERLAY_COPY.modelSelectLabel}
className={cn(styles.selectChip, !hasModels && styles.selectChipDisabled)}
>
{currentModel ? (
<span className={styles.modelIconBox}>
<ModelIcon model={currentModel.id} size={16} />
</span>
) : (
<span className={styles.modelIconBoxFallback} />
)}
<span className={styles.chipLabel}>
{currentModel?.displayName ??
currentModel?.id ??
OVERLAY_COPY.modelSelectPlaceholder}
</span>
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
<select
aria-label={OVERLAY_COPY.modelSelectLabel}
className={styles.nativeSelect}
disabled={!hasModels}
value={modelId ?? ''}
onChange={handleModelChange}
>
{!hasModels && <option value="">{OVERLAY_COPY.modelSelectPlaceholder}</option>}
{models?.map((item) => (
<option key={item.id} value={item.id}>
{item.displayName ?? item.id}
</option>
))}
</select>
</label>
</div>
<div className={styles.actionBarRight}>
<button
aria-label={OVERLAY_COPY.sendAriaLabel}
className={styles.sendBtn}
disabled={!canSend}
type="button"
title={
hasUploading
? OVERLAY_COPY.uploadingLabel
: hasFailed
? OVERLAY_COPY.uploadFailedLabel
: `${OVERLAY_COPY.sendAriaLabel} · ${OVERLAY_SHORTCUTS.send}\n${OVERLAY_COPY.newlineHint} · ${OVERLAY_SHORTCUTS.newline}\n${OVERLAY_COPY.closeLabel} · ${OVERLAY_SHORTCUTS.close}`
}
onClick={submit}
>
<SendIcon />
</button>
</div>
</div>
</div>
</>
);
},
);
ChatPanel.displayName = 'ChatPanel';
export default ChatPanel;
@@ -1,455 +0,0 @@
import type {
CapturePreviewResult,
OverlayCaptureUploadStatusPayload,
ScreenCaptureSession,
} from '@lobechat/electron-client-ipc';
import type { MouseEvent as ReactMouseEvent } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ChatPanel, { type ChatPanelSelection, type ChatPanelSubmitPayload } from './ChatPanel';
import { OVERLAY_COPY, OVERLAY_LAYOUT } from './constants';
import * as styles from './overlay.css.ts';
import { resolveCommittedSelectionRect, shouldHideChatPanel } from './overlaySelectionState';
import { useDragSelection } from './useDragSelection';
import { getTopmostWindowAtPoint, useWindowHighlight } from './useWindowHighlight';
import WindowTag from './WindowTag';
const clipLabel = (text: string, max = OVERLAY_LAYOUT.labelClipLength): string =>
text.length > max ? `${text.slice(0, max)}` : text;
const ScreenCaptureOverlay = memo(() => {
const [isPanelHidden, setIsPanelHidden] = useState(false);
const [pendingSelectionRect, setPendingSelectionRect] = useState<
ChatPanelSelection['rect'] | null
>(null);
const [placementResetKey, setPlacementResetKey] = useState(0);
const [session, setSession] = useState<ScreenCaptureSession | null>(null);
const [selections, setSelections] = useState<ChatPanelSelection[]>([]);
const capturingRef = useRef(false);
const pendingWindowRef = useRef<ScreenCaptureSession['windows'][number] | null>(null);
const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
const unsubscribe = window.electronAPI?.onScreenCaptureSession?.((data) => {
setSession(data);
});
if (!unsubscribe) {
console.error('[overlay] screenCapture session bridge missing');
return;
}
return () => {
unsubscribe();
};
}, []);
const windows = useMemo(() => session?.windows ?? [], [session?.windows]);
const activeSelection = useMemo(
() => (selections.length > 0 ? selections.at(-1)! : null),
[selections],
);
const hasSelections = selections.length > 0;
const { hoveredWindow, handleMouseMove: hitTest } = useWindowHighlight(windows);
const {
dragRect,
dragRectRef,
isDragging,
isDraggingRef,
onMouseDown,
onMouseMove,
onMouseUp,
reset,
} = useDragSelection();
const viewportWidth = session?.displayBounds.width ?? window.innerWidth;
const viewportHeight = session?.displayBounds.height ?? window.innerHeight;
const traceOverlayEvent = useCallback((event: string, data?: unknown) => {
void window.electronAPI?.invoke?.('screenCapture.traceOverlayEvent', {
data,
event,
});
}, []);
const handleClose = useCallback(() => {
traceOverlayEvent('overlay.close');
window.electronAPI?.invoke?.('screenCapture.close');
}, [traceOverlayEvent]);
const removeSelection = useCallback(
(captureId: string) => {
const nextSelections = selections.filter((item) => item.captureId !== captureId);
if (nextSelections.length === selections.length) return;
traceOverlayEvent(nextSelections.length === 0 ? 'selection.clear' : 'selection.remove', {
pendingSelectionRect,
remainingSelectionCount: nextSelections.length,
removedCaptureId: captureId,
selectionRect: activeSelection?.rect ?? null,
});
setSelections(nextSelections);
setPendingSelectionRect(null);
setIsPanelHidden(false);
if (nextSelections.length === 0) {
setPlacementResetKey((key) => key + 1);
reset();
}
},
[activeSelection?.rect, pendingSelectionRect, reset, selections, traceOverlayEvent],
);
const handleSubmit = useCallback((payload: ChatPanelSubmitPayload) => {
window.electronAPI?.invoke?.('screenCapture.submit', {
agentId: payload.agentId,
captureIds: payload.captureIds,
modelId: payload.modelId,
prompt: payload.prompt,
provider: payload.provider,
});
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [handleClose]);
/**
* Upload status comes from the main process via
* `overlayCaptureUploadStatus`. We merge it into the matching selection so
* ChatPanel can grey the send button while anything is still uploading.
*/
useEffect(() => {
const listener = (_event: unknown, payload: OverlayCaptureUploadStatusPayload) => {
setSelections((current) =>
current.map((item) =>
item.captureId === payload.captureId ? { ...item, uploadStatus: payload.status } : item,
),
);
};
window.electron?.ipcRenderer?.on?.('overlayCaptureUploadStatus', listener);
return () => {
window.electron?.ipcRenderer?.removeListener?.('overlayCaptureUploadStatus', listener);
};
}, []);
const previewWindow = useCallback(
async (win: ScreenCaptureSession['windows'][number]) => {
if (capturingRef.current) return;
traceOverlayEvent('previewWindow.request', {
overlayBounds: win.overlayBounds,
windowId: win.windowId,
});
capturingRef.current = true;
try {
const result = (await window.electronAPI?.invoke?.(
'screenCapture.previewWindow',
win.windowId,
)) as CapturePreviewResult | undefined;
traceOverlayEvent('previewWindow.result', {
rect: result?.rect ?? null,
success: !!result?.success,
windowId: win.windowId,
});
if (result?.success && result.dataUrl && result.captureId) {
const captureId = result.captureId;
setPendingSelectionRect(null);
setSelections((current) => [
...current,
{
captureId,
dataUrl: result.dataUrl!,
label: clipLabel(`${win.appName}${win.title}`),
rect: result.rect ?? {
height: win.overlayBounds.height,
width: win.overlayBounds.width,
x: win.overlayBounds.x,
y: win.overlayBounds.y,
},
uploadStatus: 'uploading',
},
]);
} else {
setPendingSelectionRect(null);
}
} finally {
capturingRef.current = false;
setIsPanelHidden(false);
}
},
[traceOverlayEvent],
);
const previewRect = useCallback(
async (overlayLocalRect: { height: number; width: number; x: number; y: number }) => {
if (capturingRef.current) return;
traceOverlayEvent('previewRect.request', {
rect: overlayLocalRect,
});
capturingRef.current = true;
try {
const result = (await window.electronAPI?.invoke?.(
'screenCapture.previewRect',
overlayLocalRect,
)) as CapturePreviewResult | undefined;
traceOverlayEvent('previewRect.result', {
rect: overlayLocalRect,
returnedRect: result?.rect ?? null,
success: !!result?.success,
});
if (result?.success && result.dataUrl && result.captureId) {
const captureId = result.captureId;
setPendingSelectionRect(null);
setSelections((current) => [
...current,
{
captureId,
dataUrl: result.dataUrl!,
label: OVERLAY_COPY.customRegionLabel,
rect: overlayLocalRect,
uploadStatus: 'uploading',
},
]);
} else {
setPendingSelectionRect(null);
}
} finally {
capturingRef.current = false;
setIsPanelHidden(false);
}
},
[traceOverlayEvent],
);
const handleMouseDown = useCallback(
(e: ReactMouseEvent) => {
if (e.button === 2) {
handleClose();
return;
}
if (e.button !== 0) return;
// Allow re-selection: any pointer down outside the panel starts a fresh pick.
// Resolve the current window under the pointer synchronously — `hoveredWindow`
// from state is stale in the same handler when hit-testing was paused.
setIsPanelHidden(true);
setPendingSelectionRect(null);
const hitWindow = hasSelections
? getTopmostWindowAtPoint(windows, e.clientX, e.clientY)
: hoveredWindow;
traceOverlayEvent('pointer.down', {
hadSelection: hasSelections,
hitWindowId: hitWindow?.windowId ?? null,
point: { x: e.clientX, y: e.clientY },
selectionCount: selections.length,
});
if (hasSelections) {
reset();
hitTest(e.clientX, e.clientY);
}
pointerStartRef.current = { x: e.clientX, y: e.clientY };
if (hitWindow) {
pendingWindowRef.current = hitWindow;
} else {
pendingWindowRef.current = null;
onMouseDown(e.clientX, e.clientY);
}
},
[
hasSelections,
hoveredWindow,
windows,
selections.length,
onMouseDown,
handleClose,
reset,
hitTest,
traceOverlayEvent,
],
);
const handleMouseMoveEvent = useCallback(
(e: ReactMouseEvent) => {
const pointerStart = pointerStartRef.current;
const dragging = isDraggingRef.current;
if (pointerStart && pendingWindowRef.current && !dragging) {
const deltaX = Math.abs(e.clientX - pointerStart.x);
const deltaY = Math.abs(e.clientY - pointerStart.y);
if (
deltaX >= OVERLAY_LAYOUT.clickToDragThreshold ||
deltaY >= OVERLAY_LAYOUT.clickToDragThreshold
) {
traceOverlayEvent('drag.threshold-crossed', {
point: { x: e.clientX, y: e.clientY },
start: pointerStart,
windowId: pendingWindowRef.current.windowId,
});
pendingWindowRef.current = null;
onMouseDown(pointerStart.x, pointerStart.y);
onMouseMove(e.clientX, e.clientY);
return;
}
}
if (dragging) {
onMouseMove(e.clientX, e.clientY);
} else if (!hasSelections) {
// Only do hit-testing while there is no committed selection; once selected,
// highlighting a window under the overlay is noisy.
hitTest(e.clientX, e.clientY);
}
},
[hasSelections, isDraggingRef, onMouseDown, onMouseMove, hitTest, traceOverlayEvent],
);
const handleMouseUp = useCallback(() => {
const pendingWindow = pendingWindowRef.current;
const committedDragRect = dragRectRef.current;
const dragging = isDraggingRef.current;
traceOverlayEvent('pointer.up', {
committedDragRect,
dragging,
pendingWindowId: pendingWindow?.windowId ?? null,
});
pendingWindowRef.current = null;
pointerStartRef.current = null;
if (pendingWindow && !dragging) {
setPendingSelectionRect({
height: pendingWindow.overlayBounds.height,
width: pendingWindow.overlayBounds.width,
x: pendingWindow.overlayBounds.x,
y: pendingWindow.overlayBounds.y,
});
onMouseUp();
void previewWindow(pendingWindow);
return;
}
if (dragging && committedDragRect) {
if (
committedDragRect.width >= OVERLAY_LAYOUT.minDragSize &&
committedDragRect.height >= OVERLAY_LAYOUT.minDragSize
) {
setPendingSelectionRect(committedDragRect);
reset();
onMouseUp();
void previewRect(committedDragRect);
return;
}
setIsPanelHidden(false);
setPendingSelectionRect(null);
reset();
}
if (!dragging) {
setIsPanelHidden(false);
}
onMouseUp();
}, [dragRectRef, isDraggingRef, reset, onMouseUp, previewWindow, previewRect, traceOverlayEvent]);
const committedSelectionRect = resolveCommittedSelectionRect({
pendingSelectionRect,
selection: isPanelHidden ? null : activeSelection,
});
const panelHidden = shouldHideChatPanel({
isPreviewingSelection: !!pendingSelectionRect,
isSelecting: isPanelHidden,
});
const showHover = hoveredWindow && !hasSelections && !isDragging && !committedSelectionRect;
const showDrag = isDragging && dragRect;
return (
<div
className={styles.overlay}
style={{
cursor: committedSelectionRect
? 'default'
: isDragging
? 'crosshair'
: hoveredWindow
? 'pointer'
: 'crosshair',
}}
onContextMenu={(e) => e.preventDefault()}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMoveEvent}
onMouseUp={handleMouseUp}
>
{showHover && (
<>
<div
className={styles.windowHighlight}
style={{
height: hoveredWindow.overlayBounds.height,
left: hoveredWindow.overlayBounds.x,
top: hoveredWindow.overlayBounds.y,
width: hoveredWindow.overlayBounds.width,
}}
/>
<WindowTag viewportWidth={viewportWidth} window={hoveredWindow} />
</>
)}
{showDrag && dragRect && (
<div
className={styles.selection}
style={{
height: dragRect.height,
left: dragRect.x,
top: dragRect.y,
width: dragRect.width,
}}
/>
)}
{committedSelectionRect && (
<div
className={styles.selection}
style={{
height: committedSelectionRect.height,
left: committedSelectionRect.x,
top: committedSelectionRect.y,
width: committedSelectionRect.width,
}}
/>
)}
<ChatPanel
agentId={session?.defaultAgentId}
agents={session?.agents}
hidden={panelHidden}
modelId={session?.defaultModelId}
models={session?.models}
placementResetKey={placementResetKey}
selections={selections}
theme={session?.theme}
viewportHeight={viewportHeight}
viewportWidth={viewportWidth}
onRemoveSelection={removeSelection}
onSubmit={handleSubmit}
/>
</div>
);
});
ScreenCaptureOverlay.displayName = 'ScreenCaptureOverlay';
export default ScreenCaptureOverlay;
-44
View File
@@ -1,44 +0,0 @@
import type { ScreenCaptureWindowInfo } from '@lobechat/electron-client-ipc';
import { memo } from 'react';
import { OVERLAY_LAYOUT } from './constants';
import * as styles from './overlay.css.ts';
interface WindowTagProps {
viewportWidth: number;
window: ScreenCaptureWindowInfo;
}
const WindowTag = memo<WindowTagProps>(({ viewportWidth, window: win }) => {
const { width, x, y } = win.overlayBounds;
const maxWidth = Math.min(
Math.max(width - OVERLAY_LAYOUT.windowTagHorizontalInset * 2, 0),
OVERLAY_LAYOUT.windowTagMaxWidth,
);
const left = Math.min(
Math.max(x + OVERLAY_LAYOUT.windowTagHorizontalInset, OVERLAY_LAYOUT.windowTagHorizontalInset),
Math.max(
viewportWidth - maxWidth - OVERLAY_LAYOUT.windowTagHorizontalInset,
OVERLAY_LAYOUT.windowTagHorizontalInset,
),
);
return (
<div
className={styles.windowTag}
style={{
left,
maxWidth,
top: Math.max(y + OVERLAY_LAYOUT.windowTagTopOffset, OVERLAY_LAYOUT.windowTagTopOffset),
}}
>
<span className={styles.windowTagApp}>{win.appName}</span>
{win.title && <span className={styles.windowTagDivider}></span>}
{win.title && <span className={styles.windowTagTitle}>{win.title}</span>}
</div>
);
});
WindowTag.displayName = 'WindowTag';
export default WindowTag;
-29
View File
@@ -1,29 +0,0 @@
import { style } from '@vanilla-extract/css';
const box = {
alignItems: 'center',
borderRadius: '6px',
display: 'inline-flex',
flexShrink: 0,
justifyContent: 'center',
overflow: 'hidden',
} as const;
export const emojiBox = style({
...box,
background: 'var(--lobe-overlay-fill-tertiary, rgba(0, 0, 0, 0.04))',
});
export const image = style({
...box,
objectFit: 'cover',
});
export const textBox = style({
...box,
background: 'var(--lobe-overlay-fill-secondary, rgba(0, 0, 0, 0.06))',
color: 'var(--lobe-overlay-text-secondary, rgba(0, 0, 0, 0.65))',
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
});
-536
View File
@@ -1,536 +0,0 @@
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
import { OVERLAY_LAYOUT } from './constants';
const vars = {
colorBgElevated: '--lobe-overlay-bg-elevated',
colorBorderSecondary: '--lobe-overlay-border-secondary',
colorFill: '--lobe-overlay-fill',
colorFillQuaternary: '--lobe-overlay-fill-quaternary',
colorFillSecondary: '--lobe-overlay-fill-secondary',
colorFillTertiary: '--lobe-overlay-fill-tertiary',
colorPrimary: '--lobe-overlay-primary',
colorPrimaryActive: '--lobe-overlay-primary-active',
colorPrimaryHover: '--lobe-overlay-primary-hover',
colorText: '--lobe-overlay-text',
colorTextLightSolid: '--lobe-overlay-text-light-solid',
colorTextQuaternary: '--lobe-overlay-text-quaternary',
colorTextSecondary: '--lobe-overlay-text-secondary',
colorTextTertiary: '--lobe-overlay-text-tertiary',
panelBorder: '--lobe-overlay-panel-border',
panelShadow: '--lobe-overlay-shadow',
} as const;
const v = (name: string) => `var(${name})`;
const font = {
mono: "'SF Mono', ui-monospace, Menlo, monospace",
system:
"'SF Pro Display', 'SF Pro Text', 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
} as const;
const radius = {
button: 8,
chip: 12,
kbd: 4,
panel: 12,
thumb: 6,
} as const;
const motion = {
enter: 'cubic-bezier(0.22, 1, 0.36, 1)',
spring: 'cubic-bezier(0.32, 0.72, 0, 1)',
} as const;
export const panel = style({
'background': v(vars.colorBgElevated),
'backdropFilter': 'blur(14px)',
'WebkitBackdropFilter': 'blur(14px)',
'border': `1px solid ${v(vars.panelBorder)}`,
'borderRadius': radius.panel,
'boxShadow': v(vars.panelShadow),
'color': v(vars.colorText),
'fontFamily': font.system,
'overflow': 'hidden',
'pointerEvents': 'auto',
'position': 'fixed',
'transition': `left 420ms ${motion.spring}, top 420ms ${motion.spring}, width 320ms ${motion.enter}`,
'willChange': 'left, top, width',
'zIndex': 20,
'vars': {
[vars.colorBgElevated]: '#ffffff',
[vars.colorBorderSecondary]: '#eeeeee',
[vars.colorFill]: 'rgba(0, 0, 0, 0.12)',
[vars.colorFillSecondary]: 'rgba(0, 0, 0, 0.06)',
[vars.colorFillTertiary]: 'rgba(0, 0, 0, 0.03)',
[vars.colorFillQuaternary]: 'rgba(0, 0, 0, 0.015)',
[vars.colorPrimary]: '#222222',
[vars.colorPrimaryActive]: '#111111',
[vars.colorPrimaryHover]: '#333333',
[vars.colorText]: '#080808',
[vars.colorTextLightSolid]: '#f8f8f8',
[vars.colorTextSecondary]: '#666666',
[vars.colorTextTertiary]: '#999999',
[vars.colorTextQuaternary]: '#bbbbbb',
[vars.panelBorder]: 'rgba(0, 0, 0, 0.12)',
[vars.panelShadow]: '0 4px 4px color-mix(in srgb, #000 4%, transparent)',
},
'@media': {
'(prefers-color-scheme: dark)': {
vars: {
[vars.colorBgElevated]: '#1a1a1a',
[vars.colorBorderSecondary]: '#1a1a1a',
[vars.colorFill]: 'rgba(255, 255, 255, 0.16)',
[vars.colorFillSecondary]: 'rgba(255, 255, 255, 0.1)',
[vars.colorFillTertiary]: 'rgba(255, 255, 255, 0.06)',
[vars.colorFillQuaternary]: 'rgba(255, 255, 255, 0.02)',
[vars.colorPrimary]: '#eeeeee',
[vars.colorPrimaryActive]: '#cccccc',
[vars.colorPrimaryHover]: '#ffffff',
[vars.colorText]: '#ffffff',
[vars.colorTextLightSolid]: '#000000',
[vars.colorTextSecondary]: '#aaaaaa',
[vars.colorTextTertiary]: '#6f6f6f',
[vars.colorTextQuaternary]: '#555555',
[vars.panelBorder]: 'rgba(255, 255, 255, 0.1)',
[vars.panelShadow]: '0 4px 4px color-mix(in srgb, #000 40%, transparent)',
},
},
},
});
export const selectionSummary = style({
alignItems: 'center',
borderBottom: `1px solid ${v(vars.colorBorderSecondary)}`,
display: 'flex',
gap: 10,
padding: '10px 12px',
});
export const thumb = style({
background: v(vars.colorFillTertiary),
backgroundPosition: 'center',
backgroundSize: 'cover',
border: `1px solid ${v(vars.colorBorderSecondary)}`,
borderRadius: radius.thumb,
flexShrink: 0,
height: 40,
overflow: 'hidden',
position: 'relative',
width: 40,
});
export const summaryText = style({
display: 'flex',
flexDirection: 'column',
flex: 1,
gap: 2,
minWidth: 0,
});
export const summaryTitle = style({
color: v(vars.colorText),
fontSize: 13,
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const summaryMeta = style({
color: v(vars.colorTextQuaternary),
fontFamily: font.mono,
fontSize: 11,
letterSpacing: '0.01em',
});
export const iconBtn = style({
alignItems: 'center',
background: 'transparent',
border: 'none',
borderRadius: radius.button,
color: v(vars.colorTextSecondary),
cursor: 'pointer',
display: 'inline-flex',
height: 28,
justifyContent: 'center',
transition: `background 120ms ease, color 120ms ease`,
width: 28,
selectors: {
'&:hover': {
background: v(vars.colorFillSecondary),
color: v(vars.colorText),
},
'&:active': { background: v(vars.colorFill) },
},
});
export const multiSelectionSummary = style({
borderBottom: `1px solid ${v(vars.colorBorderSecondary)}`,
display: 'flex',
flexDirection: 'column',
gap: 10,
padding: '10px 12px',
});
export const multiSelectionHeader = style({
display: 'flex',
flexDirection: 'column',
gap: 2,
minWidth: 0,
});
export const multiSelectionTitle = style({
color: v(vars.colorText),
fontSize: 13,
fontWeight: 600,
});
export const multiSelectionMeta = style({
color: v(vars.colorTextQuaternary),
fontFamily: font.mono,
fontSize: 11,
letterSpacing: '0.01em',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const multiSelectionRail = style({
display: 'flex',
gap: 8,
overflowX: 'auto',
paddingBottom: 2,
scrollbarWidth: 'none',
});
export const multiSelectionItem = style({
background: v(vars.colorFillQuaternary),
border: `1px solid ${v(vars.colorBorderSecondary)}`,
borderRadius: 10,
display: 'flex',
flex: '0 0 104px',
flexDirection: 'column',
gap: 6,
minWidth: 0,
padding: 6,
});
export const multiSelectionItemActive = style({
background: v(vars.colorFillTertiary),
borderColor: `color-mix(in srgb, ${v(vars.colorText)} 12%, ${v(vars.colorBorderSecondary)} 88%)`,
});
export const multiSelectionThumbFrame = style({
position: 'relative',
});
export const multiSelectionThumb = style({
background: v(vars.colorFillTertiary),
backgroundPosition: 'center',
backgroundSize: 'cover',
border: `1px solid ${v(vars.colorBorderSecondary)}`,
borderRadius: 8,
height: 58,
overflow: 'hidden',
width: '100%',
});
export const multiSelectionRemoveBtn = style({
alignItems: 'center',
background: `color-mix(in srgb, ${v(vars.colorBgElevated)} 82%, transparent)`,
border: `1px solid ${v(vars.colorBorderSecondary)}`,
borderRadius: 999,
color: v(vars.colorTextSecondary),
cursor: 'pointer',
display: 'inline-flex',
height: 22,
justifyContent: 'center',
padding: 0,
position: 'absolute',
right: 6,
top: 6,
transition: `background 120ms ease, color 120ms ease, transform 120ms ease`,
width: 22,
selectors: {
'&:hover': {
background: v(vars.colorBgElevated),
color: v(vars.colorText),
},
'&:active': {
transform: 'scale(0.94)',
},
},
});
export const multiSelectionItemLabel = style({
color: v(vars.colorText),
fontSize: 11,
fontWeight: 600,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const inputRow = style({
display: 'flex',
padding: '10px 12px 4px',
});
export const textarea = style({
background: 'transparent',
border: 'none',
color: v(vars.colorText),
display: 'block',
fontFamily: 'inherit',
flex: 1,
fontSize: 14,
lineHeight: 1.5,
maxHeight: 160,
minHeight: 44,
outline: 'none',
padding: 0,
resize: 'none',
selectors: {
'&::placeholder': { color: v(vars.colorTextTertiary) },
},
});
export const actionBar = style({
alignItems: 'center',
display: 'flex',
gap: 8,
padding: '4px 8px 8px 10px',
});
export const actionBarLeft = style({
alignItems: 'center',
display: 'flex',
flex: 1,
gap: 4,
minWidth: 0,
});
export const actionBarRight = style({
alignItems: 'center',
display: 'flex',
flexShrink: 0,
gap: 8,
});
export const selectChip = style({
alignItems: 'center',
background: v(vars.colorFillTertiary),
border: 'none',
borderRadius: radius.chip,
color: v(vars.colorText),
cursor: 'pointer',
display: 'inline-flex',
fontSize: 12,
fontWeight: 500,
gap: 6,
height: 32,
maxWidth: 180,
minWidth: 0,
padding: '0 10px 0 6px',
position: 'relative',
transition: 'background 120ms ease',
selectors: {
'&:hover': {
background: v(vars.colorFillSecondary),
},
},
});
export const selectChipDisabled = style({
cursor: 'not-allowed',
opacity: 0.55,
selectors: {
'&:hover': {
background: v(vars.colorFillTertiary),
},
},
});
export const chipLabel = style({
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const chevron = style({
color: v(vars.colorTextQuaternary),
flexShrink: 0,
});
export const nativeSelect = style({
appearance: 'none',
background: 'transparent',
border: 'none',
color: 'transparent',
cursor: 'pointer',
fontSize: 'inherit',
inset: 0,
margin: 0,
opacity: 0,
outline: 'none',
padding: 0,
position: 'absolute',
width: '100%',
selectors: {
'&:disabled': { cursor: 'not-allowed' },
},
});
export const modelIconBox = style({
alignItems: 'center',
display: 'inline-flex',
flexShrink: 0,
height: 20,
justifyContent: 'center',
width: 20,
});
export const modelIconBoxFallback = style({
background: v(vars.colorFillSecondary),
borderRadius: 5,
flexShrink: 0,
height: 20,
width: 20,
});
export const shortcutHint = style({
alignItems: 'center',
color: v(vars.colorTextQuaternary),
display: 'inline-flex',
fontSize: 11,
gap: 4,
userSelect: 'none',
});
export const shortcutKbd = style({
alignItems: 'center',
background: v(vars.colorFillQuaternary),
border: `1px solid ${v(vars.colorBorderSecondary)}`,
borderRadius: radius.kbd,
color: v(vars.colorTextSecondary),
display: 'inline-flex',
fontFamily: font.mono,
fontSize: 10,
fontWeight: 500,
height: 16,
justifyContent: 'center',
minWidth: 16,
padding: '0 4px',
});
export const sendBtn = style({
alignItems: 'center',
background: v(vars.colorBgElevated),
border: `1px solid ${v(vars.colorBgElevated)}`,
borderRadius: radius.button,
cursor: 'pointer',
color: v(vars.colorText),
display: 'inline-flex',
flexShrink: 0,
height: 32,
justifyContent: 'center',
padding: 0,
transition: `background-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1), color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1), transform 140ms ${motion.spring}`,
width: 32,
selectors: {
'&:hover:not(:disabled)': {
background: `color-mix(in srgb, ${v(vars.colorBgElevated)} 88%, ${v(vars.colorText)} 12%)`,
borderColor: `color-mix(in srgb, ${v(vars.colorBgElevated)} 88%, ${v(vars.colorText)} 12%)`,
},
'&:active:not(:disabled)': {
background: `color-mix(in srgb, ${v(vars.colorBgElevated)} 92%, #000 8%)`,
borderColor: `color-mix(in srgb, ${v(vars.colorBgElevated)} 92%, #000 8%)`,
transform: 'scale(0.94)',
},
'&:disabled': {
background: 'transparent',
borderColor: v(vars.colorBgElevated),
color: v(vars.colorTextQuaternary),
cursor: 'default',
},
},
});
export const connector = style({
background: v(vars.colorPrimary),
borderRadius: '50%',
boxShadow: `0 0 0 4px ${v(vars.colorFillSecondary)}, 0 0 16px ${v(vars.colorPrimary)}`,
height: OVERLAY_LAYOUT.connectorSize,
opacity: 0,
pointerEvents: 'none',
position: 'fixed',
transition: `opacity 200ms ${motion.enter} 140ms, left 320ms ${motion.spring}, top 320ms ${motion.spring}`,
width: OVERLAY_LAYOUT.connectorSize,
zIndex: 15,
});
export const connectorVisible = style({
opacity: 1,
});
export const connectorHidden = style({
opacity: 0,
transitionDelay: '0ms',
visibility: 'hidden',
});
const fadeIn = keyframes({
from: { opacity: 0, transform: 'translate(-50%, 8px)' },
to: { opacity: 1, transform: 'translate(-50%, 0)' },
});
const spin = keyframes({
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' },
});
export const uploadOverlay = style({
alignItems: 'center',
backdropFilter: 'blur(2px)',
borderRadius: 'inherit',
color: v(vars.colorTextLightSolid),
display: 'flex',
inset: 0,
justifyContent: 'center',
pointerEvents: 'none',
position: 'absolute',
});
export const uploadOverlayUploading = style({
background: 'color-mix(in srgb, #000 36%, transparent)',
});
export const uploadOverlayFailed = style({
background: 'color-mix(in srgb, #e53935 55%, transparent)',
});
export const uploadSpinnerIcon = style({
animation: `${spin} 0.9s linear infinite`,
});
export const initialEnter = style({
animation: `${fadeIn} 280ms ${motion.enter}`,
});
export const panelHidden = style({
opacity: 0,
pointerEvents: 'none',
visibility: 'hidden',
});
globalStyle(`.${multiSelectionRail}::-webkit-scrollbar`, {
display: 'none',
});
globalStyle(`.${textarea}::selection`, {
background: 'color-mix(in srgb, var(--lobe-overlay-primary) 22%, transparent)',
});
-2
View File
@@ -1,2 +0,0 @@
export const cn = (...values: Array<false | null | string | undefined>): string =>
values.filter(Boolean).join(' ');
-45
View File
@@ -1,45 +0,0 @@
export const OVERLAY_COPY = {
agentSelectLabel: 'Agent',
agentSelectPlaceholder: 'Default agent',
clearSelectionLabel: 'Clear selection',
closeLabel: 'Close',
customRegionLabel: 'Custom region',
idlePlaceholder: 'Select a window or drag a region to start asking…',
latestSelectionLabel: 'Latest',
modelSelectLabel: 'Model',
modelSelectPlaceholder: 'Default model',
multipleSelectedPlaceholder: 'Ask about these screenshots…',
newlineHint: 'New line',
removeSelectionLabel: 'Remove selection',
screenshotLabel: 'Screenshot',
screenshotsLabel: 'Screenshots',
selectedPlaceholder: 'Ask about this screenshot…',
selectionFormatLabel: 'PNG',
sendAriaLabel: 'Send',
sendHint: 'Send',
uploadFailedLabel: 'Upload failed',
uploadingLabel: 'Uploading screenshot…',
} as const;
export const OVERLAY_SHORTCUTS = {
close: 'Esc',
newline: 'Shift + Enter',
send: 'Enter',
} as const;
export const OVERLAY_LAYOUT = {
clickToDragThreshold: 5,
connectorSize: 8,
dockGap: 12,
labelClipLength: 60,
minDragSize: 10,
panelBottomGap: 32,
panelHeightEstimate: 208,
panelHeightEstimateExpanded: 300,
panelWidthDocked: 440,
panelWidthInitial: 560,
viewportMargin: 16,
windowTagHorizontalInset: 12,
windowTagMaxWidth: 420,
windowTagTopOffset: 12,
} as const;
-6
View File
@@ -1,6 +0,0 @@
import { createRoot } from 'react-dom/client';
import ScreenCaptureOverlay from './ScreenCaptureOverlay';
const root = createRoot(document.getElementById('root')!);
root.render(<ScreenCaptureOverlay />);
-162
View File
@@ -1,162 +0,0 @@
import { createGlobalTheme, globalStyle, style } from '@vanilla-extract/css';
const overlayTheme = {
color: {
highlightBorder: 'rgba(241, 246, 255, 0.96)',
highlightFill: 'rgba(99, 138, 255, 0.16)',
scrim: 'rgba(4, 9, 20, 0.28)',
scrimGlow: 'rgba(82, 124, 255, 0.12)',
selectionBorder: 'rgba(255, 255, 255, 0.9)',
selectionFill: 'rgba(99, 138, 255, 0.16)',
tagBackground: 'rgba(15, 23, 42, 0.88)',
tagBorder: 'rgba(255, 255, 255, 0.16)',
tagDivider: 'rgba(255, 255, 255, 0.42)',
tagMuted: 'rgba(226, 232, 240, 0.76)',
tagText: 'rgba(248, 250, 252, 0.96)',
},
font: {
system:
"'SF Pro Display', 'SF Pro Text', 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
},
radius: {
highlight: '14px',
selection: '14px',
tag: '999px',
},
shadow: {
highlight:
'0 0 0 1px rgba(99, 138, 255, 0.18), 0 12px 32px rgba(2, 8, 23, 0.24), 0 0 0 6px rgba(99, 138, 255, 0.08)',
selection: '0 0 0 1px rgba(99, 138, 255, 0.24), 0 16px 36px rgba(2, 8, 23, 0.18)',
tag: '0 10px 24px rgba(0, 0, 0, 0.24)',
},
} as const;
const vars = createGlobalTheme(':root', overlayTheme);
globalStyle('*', {
boxSizing: 'border-box',
});
globalStyle('html, body, #root', {
height: '100%',
margin: 0,
overflow: 'hidden',
});
globalStyle('body', {
background: 'transparent',
color: vars.color.tagText,
fontFamily: vars.font.system,
userSelect: 'none',
WebkitFontSmoothing: 'antialiased',
});
const overlayFrame = style({
overflow: 'hidden',
pointerEvents: 'none',
position: 'absolute',
});
const overlayInsetFrame = {
'::after': {
borderRadius: 'inherit',
boxShadow: 'inset 0 0 0 1px rgba(255, 255, 255, 0.1)',
content: '',
inset: 0,
position: 'absolute',
},
} as const;
export const overlay = style({
'background': [
`radial-gradient(circle at top center, ${vars.color.scrimGlow}, transparent 36%)`,
`linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 24%)`,
vars.color.scrim,
].join(', '),
'height': '100vh',
'inset': 0,
'isolation': 'isolate',
'position': 'fixed',
'userSelect': 'none',
'width': '100vw',
'::before': {
background: 'linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 20%)',
content: '',
inset: 0,
pointerEvents: 'none',
position: 'absolute',
},
});
export const windowHighlight = style([
overlayFrame,
overlayInsetFrame,
{
background: `linear-gradient(180deg, rgba(99, 138, 255, 0.16), ${vars.color.highlightFill})`,
border: `1.5px solid ${vars.color.highlightBorder}`,
borderRadius: vars.radius.highlight,
boxShadow: vars.shadow.highlight,
},
]);
export const windowTag = style({
alignItems: 'center',
backdropFilter: 'blur(12px)',
background: vars.color.tagBackground,
border: `1px solid ${vars.color.tagBorder}`,
borderRadius: vars.radius.tag,
boxShadow: vars.shadow.tag,
display: 'flex',
gap: 8,
minHeight: 32,
minWidth: 0,
padding: '6px 12px',
pointerEvents: 'none',
position: 'absolute',
zIndex: 10,
});
const windowTagText = style({
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const windowTagApp = style([
windowTagText,
{
color: vars.color.tagText,
flex: '0 1 auto',
fontSize: 12,
fontWeight: 650,
letterSpacing: '0.01em',
},
]);
export const windowTagDivider = style({
color: vars.color.tagDivider,
flex: 'none',
fontSize: 10,
});
export const windowTagTitle = style([
windowTagText,
{
color: vars.color.tagMuted,
flex: '1 1 auto',
fontSize: 12,
fontWeight: 500,
},
]);
export const selection = style([
overlayFrame,
overlayInsetFrame,
{
background: `linear-gradient(180deg, rgba(99, 138, 255, 0.18), ${vars.color.selectionFill})`,
border: `1px solid ${vars.color.selectionBorder}`,
borderRadius: vars.radius.selection,
boxShadow: vars.shadow.selection,
},
]);

Some files were not shown because too many files have changed in this diff Show More