Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 32 additions & 11 deletions docs/WINDOW_PIN_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@

## 1. 概述与核心目标

在 VCP 客户端的 Windows 桌面场景中,用户常打开多个独立子窗口(如日志中心、备忘录、图片预览、独立终端、BladeGame 插件等)。传统的简单置顶机制存在多窗口层叠遮挡、小窗口被大窗口吞没、拖拽时层级跳动冲突等问题。
在 VCP 客户端的 Windows 桌面场景中,用户常打开多个独立子窗口(如骰子、音乐、协同画布、日志中心、备忘录、图片预览等)。传统的简单置顶机制存在多窗口层叠遮挡、小窗口被大窗口吞没、拖拽时层级跳动冲突等问题。

本设计旨在建立一套**零侵入、跨隔离、手感稳定、兼顾临时查看与自动归纳**的完整置顶与 Z 序调度体系。
本设计建立一套**边界清晰、官方受管窗口平权、手感稳定、兼顾临时查看与自动归纳**的完整置顶与 Z 序调度体系。

---

## 2. 异构窗口双轨装配设计(兼容新旧与插件窗口)
## 2. 窗口范围与角色架构契约(严格收拢与非受管让道)

VCP 内部存在两种完全不同技术栈与生命周期的独立窗口,本设计采用“双轨并行”方案实现无死角自动装配:
为杜绝非受管第三方窗口发生事件死锁,体系在架构边界上确立了**严密的识别与隔离原则**:

| 窗口类型 | 典型代表 | Preload 隔离与环境 | 装配与通信通道 |
| 窗口类别 | 判定标准与环境 | 装配模式与通信机制 | 交互与状态保障 |
| :--- | :--- | :--- | :--- |
| **标准 VCP 子窗口** | 论坛 (Forum)、备忘录 (Memo)、日志中心 (Log)、图片预览 (ImageViewer) | 注入标准 `utility.js` Preload,享有完整的 `window.utilityAPI` 上下文桥接 | **Preload 通道**:`installUniversalPinControl`<br>在 DOM 就绪时识别标题栏容器挂载图钉,通过标准 IPC `toggle-pin-window` 双向通信 |
| **分布式异构插件与工具** | BladeGame 游戏窗口、PowerShell Executor、PTYShell 终端等 | 独立沙箱 Preload(如 `blade-preload.js`、`gui/preload.js`),完全无 `window.utilityAPI` 依赖 | **主进程接管通道**:`setupGlobalWindowPinObserver`<br>监听 `app` 的 `browser-window-created`,在窗口加载就绪后安全挂载图钉,免侵入插件业务代码 |
| **现代 UI 组件化窗口** | 采用 `vcp-ui.js` 统一组件体系构建的新版窗口 | 运行时由 `VCPUI.create('WindowControls')` 或 `appPageShellFactory` 动态渲染控制栏 | **组件受控通道**:`windowControlsFactory`<br>声明式支持 `pinable: true/false`,控制器动态挂载与注销状态监听,杜绝僵尸按钮 |
| **官方现代 VCP 窗口** | 采用 `vcp-ui.js` 统一组件体系构建的新版窗口 | 由 `VCPUI.create('WindowControls')` 或 `appPageShellFactory` 原生渲染 | 原生一等公民平权渲染,天然自带 `-webkit-app-region: no-drag`,受全局 Design Token 控制 |
| **官方 Utility 受管窗口** | 挂载官方 `preloads/utility.js`,享有 `window.utilityAPI`(如超级骰子、音乐、协同、备忘录、日志、论坛) | 由 `preloads/behaviors/pinButton.js` 在 DOM 就绪后装配入控制栏 | 样式强制声明 `-webkit-app-region: no-drag !important`,通过单向广播驱动,支持防连击锁 |
| **非受管独立沙箱窗口** | 文坊 (Docx)、Loom、PowerShell 终端等非 utility 独立窗口 | **彻底阻断(零侵入让道)**:主进程不监听、不注入代码 | 保持其原生纯净度,杜绝因宿主 Header 带有全屏物理 drag 层而导致按钮“看得见点不下去”的死锁 |

### 严格的排除范围(Excluded Contexts)
为保障整体视觉与系统安全,以下视口严格排除置顶功能:
Expand Down Expand Up @@ -45,12 +45,33 @@ VCP 内部存在两种完全不同技术栈与生命周期的独立窗口,本

1. **静态点击:即时查看优先(后来者居上)**
- 当窗口保持静止时,调度体系保持**惰性(Inert / Sleep)**;
- 用户点击任何一个下层置顶窗口,Windows 原生机制将其直接激活到最顶层方便查看与打字,后台绝不强行打压或抢夺层级,**彻底根除按住瞬间“先下沉再上浮”的反复横跳与抓空失焦问题**。
2. **动态拖拽:长按持续霸榜**
- 用户点击任何一个下层置顶窗口,Windows 原生机制将其直接激活到最顶层方便查看与打字,后台绝不强行打压或抢夺层级,彻底根除按住瞬间“先下沉再上浮”的反复横跳与抓空失焦问题。
2. **动态拖拽:长按持续霸榜与硬件级节流**
- 用户按住并拖动某一窗口时,该窗口即时脱离静态群落,独占最高优先级;
- 只要用户鼠标未松开(无论是在移动还是停留在原地静止思考),霸榜状态持续有效,绝不被任何硬超时打断。
- 拖拽移动过程中施加 `dragThrottleTimer` 硬件级节流门禁,仅首帧向 OS 声明一次置顶,拖动期间彻底停止重排计算;
- 只要用户鼠标未松开(无论是在移动还是停留在原地静止思考),霸榜状态持续有效。
3. **松手落定:120ms 防抖自动规整**
- 手指松开鼠标(`moved` / `resized`)那一刻,启动 120ms 防抖计时器;
- 计时结束后,该窗口带入新坐标重新归入连通分量体系,自动按面积重排(大在下、小在上),实现优雅的手势归纳。
4. **异常脱离兜底**
- 窗口获得 `blur`(如 Alt+Tab 切走)或 `minimize`(最小化)时,自动视为拖拽动作平滑退出,确保调度锁万无一失。

---

## 5. 跨平台物理级零开销与运行时通道隔离

为保证非 Windows 用户完全无感、开发者扩展友好,以及主窗口 AI 聊天与音频流水线绝对不受后台置顶行为干扰,体系确立了以下治理标准:

1. **同步能力探测(Zero CLS & Zero IPC on Non-Win32)**:
- Preload 注入层暴露同步本地能力探测 `utilityAPI.canPin()`,首帧在 V8 内部计算完成(`process.platform === 'win32' && !isEmbeddedSurface`),不发射任何 IPC 请求;
- 现代 `VCPUI.WindowControls` 首帧直接依据该结果决定挂载 3 键还是 4 键,消除异步微任务补丁引起的累积布局位移(0 CLS);在 macOS / Linux 上物理级不挂载置顶 DOM 节点、不注册广播监听。
2. **单一事实来源与防连击保护(SSOT & Debounce)**:
- 采用纯单向事件流驱动:按钮点击只负责触发指令动作,UI 状态 100% 由主进程 `window-pinned-changed` 广播驱动,彻底杜绝 Promise 与广播双写带来的 Ping-Pong 竞态;
- 控制器层内置 150ms 防连击冷却锁,防范极端快速连击导致跨进程通道阻塞。
3. **主进程与 AI 对话通道隔离(Zero V8 Interruption)**:
- **全面告别跨进程脚本注入**:状态同步彻底拔除 `wc.executeJavaScript` 字符串注入,全部采用单向轻量 IPC 广播,杜绝抢占渲染进程微任务栈,保障主聊天窗口流式 Token 打字与麦克风采集的极致平滑;
- **硬件级拖拽节流**:窗口移动与缩放期间施加 `dragThrottleTimer` 节流门禁,拖拽过程中仅在首个脉冲提升一次,杜绝像素级高频重排与 DWM 调度开销;
- **安全与权限收敛**:IPC 接入层(`windowHandlers.js`)统一通过 `isExcludedWindow()` 进行门禁校验,主聊天视口绝对不可置顶,杜绝界面死锁。
4. **抽象平台驱动层(WindowPinDriver)**:
- 将操作系统原生特性高度适配器化(Adapter Pattern),解耦核心 AABB 空间连通分量与重排算法;
- 非 Windows 系统通过默认 NullDriver 实现零消耗运行;未来跨平台开发者只需实现对应平台的 `applyTopmost` 驱动即可秒级接入,无需修改任何上层业务逻辑。
42 changes: 21 additions & 21 deletions modules/ipc/windowHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ let ipcHandlersRegistered = false;
let forumWindowInstance = null;
let memoWindowInstance = null;
let logWindowInstance = null;
let taskWindowInstance = null;

/**
* 大体积 payload(如截图 dataURL/Blob)通过 token 在主进程内一次性缓存,
Expand Down Expand Up @@ -64,29 +63,30 @@ function initialize(mainWindow, openChildWindows) {
}
});

ipcMain.handle('supports-pin-window', (event) => {
if (!windowPinService.WindowPinDriver?.isSupported?.()) return false;
const win = BrowserWindow.fromWebContents(event.sender);
if (!win || win === mainWindow) return false;
if (event.sender !== win.webContents) return false;
if (windowPinService.isExcludedWindow?.(win, mainWindow)) return false;
return true;
});

ipcMain.handle('toggle-pin-window', (event) => {
if (process.platform !== 'win32') return false;
if (!windowPinService.WindowPinDriver?.isSupported?.()) return false;
const win = BrowserWindow.fromWebContents(event.sender);
if (!win || win === mainWindow) return false;
if (event.sender !== win.webContents) return false;
try {
const desktopHandlers = require('./desktopHandlers');
const desktopWindow = desktopHandlers.getDesktopWindow?.();
if (desktopWindow && win === desktopWindow) return false;
} catch (_) {}
if (windowPinService.isExcludedWindow?.(win, mainWindow)) return false;
return windowPinService.togglePin(win);
});

ipcMain.handle('is-window-pinned', (event) => {
if (process.platform !== 'win32') return false;
if (!windowPinService.WindowPinDriver?.isSupported?.()) return false;
const win = BrowserWindow.fromWebContents(event.sender);
if (!win || win === mainWindow) return false;
if (event.sender !== win.webContents) return false;
try {
const desktopHandlers = require('./desktopHandlers');
const desktopWindow = desktopHandlers.getDesktopWindow?.();
if (desktopWindow && win === desktopWindow) return false;
} catch (_) {}
if (windowPinService.isExcludedWindow?.(win, mainWindow)) return false;
return windowPinService.isPinned(win);
});

Expand Down Expand Up @@ -155,7 +155,7 @@ function initialize(mainWindow, openChildWindows) {
* 拿到一个一次性 token,用于在打开图片预览窗口时跨进程取数据,
* 避免把超长字符串塞到 BrowserWindow.loadURL 的 query 参数里。
*/
ipcMain.handle('image-viewer:register-payload', (event, payload = {}) => {
ipcMain.handle('image-viewer:register-payload', (_event, payload = {}) => {
cleanupExpiredImagePayloads();
const { src, title = '图片预览', theme = 'dark' } = payload || {};
if (typeof src !== 'string' || !src) {
Expand All @@ -174,7 +174,7 @@ function initialize(mainWindow, openChildWindows) {
/**
* 图片预览窗口加载完毕后通过此通道一次性拉走 payload,主进程随即清理引用。
*/
ipcMain.handle('image-viewer:consume-payload', (event, token) => {
ipcMain.handle('image-viewer:consume-payload', (_event, token) => {
if (!token || typeof token !== 'string') return null;
const payload = imageViewerPayloads.get(token);
if (!payload) return null;
Expand All @@ -190,7 +190,7 @@ function initialize(mainWindow, openChildWindows) {
* Chromium 的 Async Clipboard API 在部分版本中不接受 image/gif。
* 将原始 GIF 字节交给 Electron 主进程写入原生剪贴板格式,保留全部动画帧。
*/
ipcMain.handle('image-viewer:copy-gif', (event, gifBytes) => {
ipcMain.handle('image-viewer:copy-gif', (_event, gifBytes) => {
const buffer = Buffer.from(gifBytes || []);
const isGif = buffer.length >= 6
&& (buffer.subarray(0, 6).toString('ascii') === 'GIF87a'
Expand All @@ -210,7 +210,7 @@ function initialize(mainWindow, openChildWindows) {
return { success: true, format, size: buffer.length };
});

ipcMain.on('open-image-viewer', (event, payload = {}) => {
ipcMain.on('open-image-viewer', (_event, payload = {}) => {
const { src, title, theme } = payload || {};
if (!src) {
console.error('[WindowHandlers] open-image-viewer received empty src.');
Expand Down Expand Up @@ -286,7 +286,7 @@ function initialize(mainWindow, openChildWindows) {
});
});

ipcMain.on('open-forum-window', (event) => {
ipcMain.on('open-forum-window', (_event) => {
if (forumWindowInstance && !forumWindowInstance.isDestroyed()) {
if (!forumWindowInstance.isVisible()) {
forumWindowInstance.show();
Expand Down Expand Up @@ -344,7 +344,7 @@ function initialize(mainWindow, openChildWindows) {
});
});

ipcMain.on('open-memo-window', (event) => {
ipcMain.on('open-memo-window', (_event) => {
if (memoWindowInstance && !memoWindowInstance.isDestroyed()) {
if (!memoWindowInstance.isVisible()) {
memoWindowInstance.show();
Expand Down Expand Up @@ -402,7 +402,7 @@ function initialize(mainWindow, openChildWindows) {
});
});

ipcMain.on('open-log-window', (event) => {
ipcMain.on('open-log-window', (_event) => {
if (logWindowInstance && !logWindowInstance.isDestroyed()) {
if (!logWindowInstance.isVisible()) {
logWindowInstance.show();
Expand Down Expand Up @@ -458,7 +458,7 @@ function initialize(mainWindow, openChildWindows) {
});
});

ipcMain.on('open-task-window', async (event) => {
ipcMain.on('open-task-window', async (_event) => {
const windowService = require('../services/windowService');
const WINDOW_APP_IDS = require('../services/windowAppIds');
await windowService.open(WINDOW_APP_IDS.TASK);
Expand Down
Loading
Loading