mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-17 21:08:36 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48aa0ad245 |
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (1–2 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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
## 专题文档
|
||||
|
||||
- [桌面端全屏 Overlay 截图方案设计与集成说明](./WindowOverlayCapture.md)
|
||||
|
||||
## 核心框架组件目录架构
|
||||
|
||||
### 主进程核心组件
|
||||
|
||||
@@ -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` 主业务的实施基线。
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
packages:
|
||||
- '../cli'
|
||||
- '../../packages/agent-gateway-client'
|
||||
- '../../packages/const'
|
||||
- '../../packages/electron-server-ipc'
|
||||
- '../../packages/electron-client-ipc'
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 ---- //
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Vendored
+3
-1
@@ -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,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'));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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)',
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export const cn = (...values: Array<false | null | string | undefined>): string =>
|
||||
values.filter(Boolean).join(' ');
|
||||
@@ -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;
|
||||
@@ -1,6 +0,0 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import ScreenCaptureOverlay from './ScreenCaptureOverlay';
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(<ScreenCaptureOverlay />);
|
||||
@@ -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
Reference in New Issue
Block a user