From 6b8fd07d8afb00d3446ecbe4a0b6d466137c8c72 Mon Sep 17 00:00:00 2001
From: Ldl-h <2154871764@qq.com>
Date: Wed, 7 Oct 2026 01:04:42 +0800
Subject: [PATCH] =?UTF-8?q?refactor(window):=20=E4=BC=98=E5=8C=96=E7=AA=97?=
=?UTF-8?q?=E5=8F=A3=E7=BD=AE=E9=A1=B6=E8=A3=85=E9=85=8D=E4=BD=93=E9=AA=8C?=
=?UTF-8?q?=E3=80=81=E4=BA=A4=E4=BA=92=E7=A8=B3=E5=AE=9A=E6=80=A7=E4=B8=8E?=
=?UTF-8?q?=E9=9D=9EWindows=E7=8E=AF=E5=A2=83=E9=9B=B6=E5=BD=B1=E5=93=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- [装配边界收敛] 仅面向加载 preloads/utility.js 的官方受管子窗口(超级骰子、音乐、备忘录等),废除对非受管沙箱的不稳定注入
- [深度休眠机制] 优化 utility 窗口的 MutationObserver 挂载机制,装配完成后断开整树监听,收敛为极浅层观察,正文渲染 0 消耗
- [非Win环境零影响] Preload 层暴露同步 canPin 本地门禁,macOS/Linux 首帧即保持原生 3 键布局,无 IPC 往返、无 DOM 插入、0 资源开销
- [单向广播与通道隔离] 状态由主进程 window-pinned-changed 广播统一驱动,避免 executeJavaScript 抢占 V8 微任务栈,内置 150ms 防连击锁
- [拖拽节流与防抖重排] 引入硬件级拖拽节流,移动期间仅首帧提升一次,松手 120ms 后按 AABB 碰撞面积自动重排,杜绝高频 CPU 占用
- [测试全绿] Preload 注册表契约、窗口置顶调度与 UI System 测试全部 PASS
---
docs/WINDOW_PIN_ARCHITECTURE.md | 43 +++--
modules/ipc/windowHandlers.js | 42 ++---
modules/services/windowPinService.js | 262 ++++++--------------------
modules/ui-system/vcp-ui.js | 116 +++++++++++-
preloads/api/window.js | 15 +-
preloads/behaviors/pinButton.js | 265 ++++++++++++++++++++-------
scripts/test-ui-system.mjs | 19 ++
styles/ui-system/components.css | 10 +
tests/preload-registry.test.js | 6 +-
9 files changed, 458 insertions(+), 320 deletions(-)
diff --git a/docs/WINDOW_PIN_ARCHITECTURE.md b/docs/WINDOW_PIN_ARCHITECTURE.md
index 3ed0ac5e9..cc30fd8b0 100644
--- a/docs/WINDOW_PIN_ARCHITECTURE.md
+++ b/docs/WINDOW_PIN_ARCHITECTURE.md
@@ -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`
在 DOM 就绪时识别标题栏容器挂载图钉,通过标准 IPC `toggle-pin-window` 双向通信 |
-| **分布式异构插件与工具** | BladeGame 游戏窗口、PowerShell Executor、PTYShell 终端等 | 独立沙箱 Preload(如 `blade-preload.js`、`gui/preload.js`),完全无 `window.utilityAPI` 依赖 | **主进程接管通道**:`setupGlobalWindowPinObserver`
监听 `app` 的 `browser-window-created`,在窗口加载就绪后安全挂载图钉,免侵入插件业务代码 |
-| **现代 UI 组件化窗口** | 采用 `vcp-ui.js` 统一组件体系构建的新版窗口 | 运行时由 `VCPUI.create('WindowControls')` 或 `appPageShellFactory` 动态渲染控制栏 | **组件受控通道**:`windowControlsFactory`
声明式支持 `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)
为保障整体视觉与系统安全,以下视口严格排除置顶功能:
@@ -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` 驱动即可秒级接入,无需修改任何上层业务逻辑。
diff --git a/modules/ipc/windowHandlers.js b/modules/ipc/windowHandlers.js
index 84efac3d0..6522bacef 100644
--- a/modules/ipc/windowHandlers.js
+++ b/modules/ipc/windowHandlers.js
@@ -14,7 +14,6 @@ let ipcHandlersRegistered = false;
let forumWindowInstance = null;
let memoWindowInstance = null;
let logWindowInstance = null;
-let taskWindowInstance = null;
/**
* 大体积 payload(如截图 dataURL/Blob)通过 token 在主进程内一次性缓存,
@@ -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);
});
@@ -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) {
@@ -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;
@@ -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'
@@ -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.');
@@ -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();
@@ -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();
@@ -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();
@@ -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);
diff --git a/modules/services/windowPinService.js b/modules/services/windowPinService.js
index 4a9d53faa..8df3e7364 100644
--- a/modules/services/windowPinService.js
+++ b/modules/services/windowPinService.js
@@ -73,41 +73,50 @@ function safeGetBounds(win) {
}
/**
- * 原生置顶提升:
- * Windows 下采用 setAlwaysOnTop(false -> true) 打破 Chromium 内部缓存,强制向 DWM 发送 HWND_TOPMOST。
+ * 平台置顶驱动层契约(WindowPinDriver):
+ * 抽象适配 Windows 原生置顶特性,解耦调度逻辑;非 Win32 系统通过 Null 实现零开销。
*/
-function bringWindowToTopmost(win) {
- if (!win) return;
- try {
- if (typeof win.isDestroyed === "function" && win.isDestroyed()) return;
- win._pinBypass = true;
- win.setAlwaysOnTop(false);
- win.setAlwaysOnTop(true);
- } catch (_) {
- } finally {
- if (win) win._pinBypass = false;
- }
-}
-
-function applyWindowTopmost(win, isTop) {
- if (!win) return;
- try {
- if (typeof win.isDestroyed === "function" && win.isDestroyed()) return;
- if (isTop) {
- bringWindowToTopmost(win);
- } else {
+const WindowPinDriver = {
+ isSupported() {
+ return process.platform === 'win32';
+ },
+ applyTopmost(win, isTop) {
+ if (!win) return;
+ try {
+ if (typeof win.isDestroyed === 'function' && win.isDestroyed()) return;
win._pinBypass = true;
try {
- win.setAlwaysOnTop(false);
+ if (isTop) {
+ win.setAlwaysOnTop(false);
+ // 采用 pop-up-menu 权级,配合 moveTop() 稳固 Z 序
+ win.setAlwaysOnTop(true, 'pop-up-menu', 1);
+ if (typeof win.moveTop === 'function') {
+ win.moveTop();
+ }
+ } else {
+ win.setAlwaysOnTop(false, 'normal');
+ }
} finally {
win._pinBypass = false;
}
- }
- } catch (_) {}
+ } catch (_) {}
+ }
+};
+
+/**
+ * 原生置顶提升:
+ * 采用 Driver 层进行受控提升
+ */
+function bringWindowToTopmost(win) {
+ WindowPinDriver.applyTopmost(win, true);
+}
+
+function applyWindowTopmost(win, isTop) {
+ WindowPinDriver.applyTopmost(win, isTop);
}
/**
- * 向窗口通知置顶状态变更事件(双向同步)。
+ * 向窗口广播置顶状态变更(单向 IPC,避免阻滞 V8 微任务队列)。
*/
function notifyPinnedChanged(win, isPinnedState) {
if (!win) return;
@@ -115,19 +124,7 @@ function notifyPinnedChanged(win, isPinnedState) {
if (typeof win.isDestroyed === "function" && win.isDestroyed()) return;
const wc = win.webContents;
if (wc && !wc.isDestroyed?.()) {
- wc.send("window-pinned-changed", isPinnedState);
- const pinned = Boolean(isPinnedState);
- wc.executeJavaScript(`
- (() => {
- const pinned = ${pinned};
- document.querySelectorAll('.vcp-universal-pin-btn').forEach(pinBtn => {
- pinBtn.classList.toggle('is-pinned', pinned);
- pinBtn.setAttribute('aria-pressed', String(pinned));
- pinBtn.title = pinned ? '取消置顶' : '置顶窗口';
- pinBtn.setAttribute('aria-label', pinned ? '取消置顶' : '置顶窗口');
- });
- })()
- `).catch(() => {});
+ wc.send("window-pinned-changed", Boolean(isPinnedState));
}
} catch (_) {}
}
@@ -250,7 +247,8 @@ function pin(win) {
bringWindowToTopmost(win);
- // 拖拽或缩放移动中:插队提升到最前,并保持活跃拖拽状态
+ // 拖拽节流:拖拽期间首帧提升,期间节流避免向 DWM 发送冗余命令
+ let dragThrottleTimer = null;
const onMoveOrResize = () => {
if (activeDraggingWinId !== winId) {
activeDraggingWinId = winId;
@@ -261,10 +259,20 @@ function pin(win) {
clearTimeout(settleTimer);
settleTimer = null;
}
+
+ if (!dragThrottleTimer) {
+ dragThrottleTimer = setTimeout(() => {
+ dragThrottleTimer = null;
+ }, 100);
+ }
};
// 拖拽或缩放结束(松手)/ 窗口失焦 / 最小化:防抖 120ms 后重新排定层级
const onMotionEnd = () => {
+ if (dragThrottleTimer) {
+ clearTimeout(dragThrottleTimer);
+ dragThrottleTimer = null;
+ }
if (activeDraggingWinId !== winId) return;
if (settleTimer) {
@@ -295,6 +303,10 @@ function pin(win) {
win.once("closed", onClosed);
const cleanup = () => {
+ if (dragThrottleTimer) {
+ clearTimeout(dragThrottleTimer);
+ dragThrottleTimer = null;
+ }
try {
win.removeListener("move", onMoveOrResize);
win.removeListener("resize", onMoveOrResize);
@@ -397,8 +409,6 @@ const settleWindow = () => reorderPinnedWindows();
const resettleAllIntersectingGroups = () => reorderPinnedWindows();
const assertAllPinnedWindowsAbove = () => reorderPinnedWindows();
-let globalObserverInitialized = false;
-
function isExcludedWindow(win, mainWindow) {
if (!win) return true;
try {
@@ -433,170 +443,8 @@ function isExcludedWindow(win, mainWindow) {
* 为各受管子窗口装配置顶图钉及快捷调用。
* 平台门禁:专注于 Windows 平台。
*/
-function setupGlobalWindowPinObserver(app, mainWindow) {
- if (!app || globalObserverInitialized) return;
- globalObserverInitialized = true;
-
- // 平台门禁:非 Windows 环境优雅跳过
- if (process.platform !== "win32") return;
-
- const attachToWindow = (win) => {
- if (!win || win._pinObserverAttached) return;
- win._pinObserverAttached = true;
-
- // 通过 console-message 接收免 Preload 依赖的通用图钉点击兜底
- const wc = win.webContents;
- if (wc) {
- wc.on("console-message", (_event, _level, message) => {
- if (message === "__VCP_PIN_TOGGLE__") {
- if (!isExcludedWindow(win, mainWindow)) {
- togglePin(win);
- }
- }
- });
-
- const tryInjectPinButton = () => {
- if (isExcludedWindow(win, mainWindow)) return;
- const injectScript = `
- (() => {
- // 契约分流:若窗口已有标准 Preload 接管,则由 utility.js 自行装配,避免主进程重复扫描
- if (window.utilityAPI?.togglePinWindow) return;
-
- const candidateSelectors = [
- '.blade-window-controls',
- '.window-controls-win',
- '.window-controls:not(.window-controls-mac)',
- '.mini-window-controls',
- '.vcp-ui-window-controls',
- '.titlebar-actions'
- ];
- let container = null;
- for (const sel of candidateSelectors) {
- const el = document.querySelector(sel);
- if (el) {
- if (el.classList.contains('window-controls-mac')) continue;
- container = el;
- break;
- }
- }
- if (!container) {
- const minOrClose = document.querySelector(
- 'button#blade-minimize-btn, button#win-minimize-btn, button#minimize-btn, button.btn-minimize, button.blade-window-control-close, button.vcp-ui-window-control-close, button[title*="最小化"], button[aria-label*="最小化"]'
- );
- if (minOrClose) container = minOrClose.parentElement;
- }
- if (!container) return;
-
- // 检查是否已有置顶按钮,避免重复注入
- const existingPin = container.querySelector(
- '.vcp-universal-pin-btn, .vcp-ui-window-control-pin, [aria-label*="置顶"], [title*="置顶"], [aria-label*="pin" i], [title*="pin" i], [id*="pin" i], [class*="pin-btn" i]'
- );
- if (existingPin) return;
-
- const sampleBtn = container.querySelector('button');
- const sampleClass = sampleBtn ? sampleBtn.className : 'window-control-btn';
-
- const pinBtn = document.createElement('button');
- pinBtn.type = 'button';
- pinBtn.className = (sampleClass + ' vcp-universal-pin-btn').trim();
- pinBtn.title = '置顶窗口';
- pinBtn.setAttribute('aria-label', '置顶窗口');
- pinBtn.setAttribute('aria-pressed', 'false');
-
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- svg.setAttribute('width', '11');
- svg.setAttribute('height', '11');
- svg.setAttribute('viewBox', '0 0 16 16');
- svg.setAttribute('fill', 'currentColor');
- svg.setAttribute('aria-hidden', 'true');
- svg.style.pointerEvents = 'none';
-
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- path.setAttribute('d', 'M4.5 1.5 L11.5 1.5 L10.5 4.5 L12.5 8.5 L9 8.5 L9 14.5 L7 14.5 L7 8.5 L3.5 8.5 L5.5 4.5 Z');
- svg.appendChild(path);
- pinBtn.appendChild(svg);
-
- const syncPinVisual = (pinned) => {
- const isTop = Boolean(pinned);
- document.querySelectorAll('.vcp-universal-pin-btn').forEach(btn => {
- btn.classList.toggle('is-pinned', isTop);
- btn.setAttribute('aria-pressed', String(isTop));
- btn.title = isTop ? '取消置顶' : '置顶窗口';
- btn.setAttribute('aria-label', isTop ? '取消置顶' : '置顶窗口');
- });
- };
-
- pinBtn.addEventListener('click', async (e) => {
- e.preventDefault();
- e.stopPropagation();
- if (window.utilityAPI?.togglePinWindow) {
- try {
- const isPinned = await window.utilityAPI.togglePinWindow();
- syncPinVisual(isPinned);
- } catch (_) {}
- } else {
- console.log('__VCP_PIN_TOGGLE__');
- }
- });
-
- if (window.utilityAPI?.isWindowPinned) {
- window.utilityAPI.isWindowPinned().then(syncPinVisual).catch(() => {});
- }
-
- const minBtn = container.querySelector(
- '#blade-minimize-btn, #win-minimize-btn, #minimize-btn, #minimize-notes-btn, #minimize-music-btn, #minimize-theme-btn, #minimize-translator-btn, #minimize-viewer-btn, .btn-minimize, .vcp-ui-window-control-button, [aria-label*="最小化"]'
- );
- const trayBtn = container.querySelector('#win-tray-btn');
- const insertTarget = trayBtn || minBtn;
-
- if (insertTarget && insertTarget.parentNode === container) {
- container.insertBefore(pinBtn, insertTarget);
- } else {
- container.prepend(pinBtn);
- }
-
- if (!document.getElementById('vcp-universal-pin-style')) {
- const style = document.createElement('style');
- style.id = 'vcp-universal-pin-style';
- style.textContent = \`
- .vcp-universal-pin-btn.is-pinned {
- color: var(--vcp-ui-primary, #6366f1) !important;
- background: var(--vcp-ui-primary-bg, rgba(99, 102, 241, 0.16)) !important;
- }
- .vcp-universal-pin-btn.is-pinned svg {
- transform: rotate(-15deg);
- }
- \`;
- (document.head || document.documentElement).appendChild(style);
- }
- })();
- `;
- wc.executeJavaScript(injectScript)
- .then(() => {
- if (isPinned(win)) {
- notifyPinnedChanged(win, true);
- }
- })
- .catch(() => {});
- };
-
- wc.on("did-finish-load", tryInjectPinButton);
- wc.on("dom-ready", tryInjectPinButton);
- }
- };
-
- app.on("browser-window-created", (_event, win) => {
- attachToWindow(win);
- });
-
- try {
- const { BrowserWindow } = require("electron");
- if (BrowserWindow?.getAllWindows) {
- BrowserWindow.getAllWindows().forEach((win) => {
- attachToWindow(win);
- });
- }
- } catch (_) {}
+function setupGlobalWindowPinObserver(_app, _mainWindow) {
+ // 现代 VCP 窗口统一由 WindowControls 与 utility 装配接管,废弃全局注入。
}
module.exports = {
@@ -616,4 +464,6 @@ module.exports = {
reorderPinnedWindows,
cleanupAll,
setupGlobalWindowPinObserver,
+ WindowPinDriver,
+ isExcludedWindow,
};
diff --git a/modules/ui-system/vcp-ui.js b/modules/ui-system/vcp-ui.js
index 659707d94..7b1888797 100644
--- a/modules/ui-system/vcp-ui.js
+++ b/modules/ui-system/vcp-ui.js
@@ -2,7 +2,6 @@ import { COMPONENT_MANIFEST } from './component-manifest.js';
const COMPONENTS = new Map();
const ENHANCERS = new Map();
-const VALID_SIZES = new Set(['sm', 'md', 'lg', 'xl']);
const controllerByElement = new WeakMap();
function updateRangeProgress(element) {
@@ -1970,17 +1969,99 @@ function windowControlsFactory(options = {}) {
element.className = 'vcp-ui-window-controls';
element.setAttribute('role', 'toolbar');
element.setAttribute('aria-label', '窗口控制');
- const state = { onMinimize: null, onMaximize: null, onClose: null, ...options };
+ const state = {
+ pinned: false,
+ onPin: null,
+ onMinimize: null,
+ onMaximize: null,
+ onClose: null,
+ extensions: [],
+ ...options
+ };
+
+ // 同步能力判定(0 CLS 关键):若环境存在同步 canPin() 且为 false,或用户显式配置 pin: false,则首帧即可确定不展示置顶
+ // 仅在 Windows 且为非嵌入式独立窗口时,canPin 同步返回 true
+ const isPinCapable = options.pin !== false && typeof window !== 'undefined' && Boolean(
+ typeof window.utilityAPI?.canPin === 'function'
+ ? window.utilityAPI.canPin()
+ : window.utilityAPI?.togglePinWindow
+ );
+
+ let pin = null;
+ let unlistenPinned = null;
+
+ if (isPinCapable) {
+ pin = iconButtonFactory({
+ icon: 'push_pin',
+ label: '置顶窗口',
+ size: 'sm',
+ variant: 'ghost'
+ });
+ pin.element.classList.add('vcp-ui-window-control-button', 'vcp-ui-window-control-pin');
+ }
+
const minimize = iconButtonFactory({ icon: 'remove', label: '最小化窗口', size: 'sm', variant: 'ghost' });
const maximize = iconButtonFactory({ icon: 'crop_square', label: '最大化窗口', size: 'sm', variant: 'ghost' });
const close = iconButtonFactory({ icon: 'close', label: '关闭窗口', size: 'sm', variant: 'ghost' });
- close.element.classList.add('vcp-ui-window-control-close');
- [minimize.element, maximize.element, close.element].forEach(button => {
+ close.element.classList.add('vcp-ui-window-control-button', 'vcp-ui-window-control-close');
+
+ [minimize.element, maximize.element].forEach(button => {
button.classList.add('vcp-ui-window-control-button');
});
+
const controller = makeController(element, state, current => {
- element.replaceChildren(minimize.element, maximize.element, close.element);
+ const extensions = Array.isArray(current.extensions) ? current.extensions : [];
+ const buttons = [];
+ if (pin) {
+ pin.element.classList.toggle('is-pinned', Boolean(current.pinned));
+ pin.element.setAttribute('aria-pressed', String(Boolean(current.pinned)));
+ pin.element.title = current.pinned ? '取消置顶' : '置顶窗口';
+ pin.element.setAttribute('aria-label', current.pinned ? '取消置顶' : '置顶窗口');
+ buttons.push(pin.element);
+ }
+ buttons.push(...extensions, minimize.element, maximize.element, close.element);
+ element.replaceChildren(...buttons);
+ }, () => {
+ if (typeof unlistenPinned === 'function') {
+ unlistenPinned();
+ unlistenPinned = null;
+ }
});
+
+ // 单向事件驱动与防连击:状态由主进程 onWindowPinnedChanged 驱动
+ if (pin) {
+ let isToggling = false;
+ controller._listen(pin.element, 'click', async (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (typeof state.onPin === 'function') {
+ state.onPin();
+ return;
+ }
+ if (isToggling) return;
+ isToggling = true;
+ try {
+ await window.utilityAPI?.togglePinWindow?.();
+ } catch (err) {
+ console.warn('[VCPUI][WindowControls] togglePinWindow failed:', err);
+ } finally {
+ setTimeout(() => { isToggling = false; }, 150);
+ }
+ });
+
+ if (typeof window.utilityAPI?.onWindowPinnedChanged === 'function') {
+ unlistenPinned = window.utilityAPI.onWindowPinnedChanged(isPinned => {
+ if (!controller.destroyed) controller.update({ pinned: Boolean(isPinned) });
+ });
+ }
+
+ window.utilityAPI?.isWindowPinned?.().then(isPinned => {
+ if (!controller.destroyed && Boolean(isPinned) !== controller.element.querySelector('.vcp-ui-window-control-pin')?.classList.contains('is-pinned')) {
+ controller.update({ pinned: Boolean(isPinned) });
+ }
+ }).catch(() => {});
+ }
+
minimize.element.addEventListener('click', () => {
if (typeof state.onMinimize === 'function') state.onMinimize();
else window.utilityAPI?.minimizeWindow?.();
@@ -1995,7 +2076,6 @@ function windowControlsFactory(options = {}) {
});
return controller;
}
-
function appPageShellFactory(options = {}) {
const element = document.createElement('div');
element.className = 'vcp-ui-page-shell vcp-ui-scope';
@@ -2112,7 +2192,29 @@ function ensureFeedbackHost() {
if (feedbackHost?.isConnected) return feedbackHost;
feedbackHost = document.createElement('div');
feedbackHost.className = 'vcp-ui-feedback-host vcp-ui-scope';
- feedbackHost.innerHTML = '