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 = '
'; + + const loadingLayer = document.createElement('div'); + loadingLayer.className = 'vcp-ui-loading-layer'; + loadingLayer.hidden = true; + + const spinner = document.createElement('span'); + spinner.className = 'vcp-ui-icon vcp-ui-spinner'; + spinner.setAttribute('aria-hidden', 'true'); + spinner.textContent = 'progress_activity'; + + const loadingLabel = document.createElement('span'); + loadingLabel.className = 'vcp-ui-loading-label'; + loadingLabel.textContent = '正在处理'; + + loadingLayer.append(spinner, loadingLabel); + + const toastStack = document.createElement('div'); + toastStack.className = 'vcp-ui-toast-stack'; + + const dialogHost = document.createElement('div'); + dialogHost.className = 'vcp-ui-dialog-host'; + + feedbackHost.append(loadingLayer, toastStack, dialogHost); document.body.append(feedbackHost); return feedbackHost; } diff --git a/preloads/api/window.js b/preloads/api/window.js index 980451aab..58cdc8b95 100644 --- a/preloads/api/window.js +++ b/preloads/api/window.js @@ -1,4 +1,4 @@ -'use strict'; + // 窗口外壳:最小化/最大化/关闭、置顶、托盘、窗口生命周期、平台信息。 // 主进程:modules/ipc/windowHandlers.js、modules/services/windowPinService.js、main.js @@ -24,11 +24,20 @@ module.exports = { onWindowUnmaximized: onSignal('window-unmaximized').roles('chat', 'utility'), onWindowOccluded: on('window-occluded').roles('desktop'), - // 窗口置顶(utility 子窗口标题栏的置顶按钮,见 behaviors/pinButton.js) + // 窗口置顶(utility 子窗口标题栏的置顶按钮,见 behaviors/pinButton.js 与 modules/ui-system/vcp-ui.js) + // 同步能力门禁:仅 Windows 平台且非嵌入式标签页支持置顶,首帧 0 开销同步返回,根除异步探测带来的 CLS 布局跳变 + canPin: custom('query', null, ({ isEmbeddedSurface }) => () => { + return process.platform === 'win32' && !isEmbeddedSurface; + }).roles('utility'), + supportsPin: custom('query', 'supports-pin-window', ({ ipcRenderer, isEmbeddedSurface }) => { + return async () => { + if (process.platform !== 'win32' || isEmbeddedSurface) return false; + return ipcRenderer.invoke('supports-pin-window'); + }; + }).roles('utility'), togglePinWindow: invoke('toggle-pin-window').roles('utility'), isWindowPinned: invoke('is-window-pinned').roles('utility'), onWindowPinnedChanged: on('window-pinned-changed').roles('utility'), - minimizeToTray: send('minimize-to-tray').roles('chat', 'utility'), closeApp: send('close-app').roles('chat'), windowReady: send('window-lifecycle:ready', (appId, payload = {}) => [{ appId, ...payload }]).roles('utility'), diff --git a/preloads/behaviors/pinButton.js b/preloads/behaviors/pinButton.js index 3cc47302d..8c7dbff12 100644 --- a/preloads/behaviors/pinButton.js +++ b/preloads/behaviors/pinButton.js @@ -1,26 +1,23 @@ -'use strict'; + /** - * 子窗口标题栏置顶按钮(仅 utility 角色,仅 Windows)。 - * - * 自动在窗口控制按钮组里插入一个置顶按钮,并与主进程的置顶状态保持同步: - * - 点击时调用 togglePinWindow,按返回值刷新按钮 - * - 挂载时调用 isWindowPinned 读取初始状态 - * - 订阅 onWindowPinnedChanged,接收主进程广播的状态变化(快捷键、托盘等途径触发的置顶) + * 子窗口标题栏置顶按钮行为模块(仅 utility 角色,Windows 专属)。 * - * 不挂载的页面:主聊天窗口(main.html)、桌面(desktop.html)、内嵌标签页与 iframe。 - * 主进程侧见 modules/services/windowPinService.js,架构说明见 docs/WINDOW_PIN_ARCHITECTURE.md。 + * 优化说明: + * 1. 范围明确:仅面向使用 utility.js 的官方受管子窗口(骰子、音乐、备忘录等),非受管沙箱不介入。 + * 2. 交互保障:样式强制声明 -webkit-app-region: no-drag,避免被标题栏拖拽层截断点击。 + * 3. 深度休眠:按钮就绪后断开全局 DOM 监听,仅保留极浅层监听;正文内容更新 0 开销。 */ const CONTAINER_SELECTORS = [ + '.blade-window-controls', '.window-controls-win', - '.window-controls:not(.window-controls-mac)', + '.window-controls', '.mini-window-controls', - '.blade-window-controls', - '.vcp-ui-window-controls', + '.titlebar-actions .window-controls', + '.titlebar .window-controls', + '#custom-title-bar .window-controls', ]; - -const MINIMIZE_BUTTON_SELECTOR = '#win-minimize-btn, #minimize-btn, #minimize-notes-btn, #minimize-music-btn, #minimize-theme-btn, #minimize-translator-btn, #minimize-viewer-btn, #blade-minimize-btn, .btn-minimize, .vcp-ui-window-control-button, [aria-label*="最小化"]'; const EXISTING_PIN_SELECTOR = '.vcp-universal-pin-btn, .vcp-ui-window-control-pin'; function isExcludedWindowContext() { @@ -46,14 +43,14 @@ function isExcludedWindowContext() { return true; } - // iframe + // iframe 子框架 try { if (window.top !== window) return true; } catch { return true; } - // 内嵌标签页 + // 内嵌标签页与嵌入式会话 return Boolean( document.documentElement?.dataset?.vcpEmbeddedApp === 'true' || new URLSearchParams(window.location.search).has('vcpEmbedded') || @@ -80,6 +77,14 @@ function ensurePinStyle() { const style = document.createElement('style'); style.id = 'vcp-universal-pin-style'; style.textContent = ` + .vcp-universal-pin-btn { + flex-shrink: 0; + box-sizing: border-box; + transition: color 0.15s ease, background-color 0.15s ease; + } + .vcp-universal-pin-btn svg { + transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1); + } .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; @@ -92,71 +97,99 @@ function ensurePinStyle() { } /** - * @param {object} ctx core/expose.js 的上下文 - * @param {object} roleApi utility 角色的 API,需要 togglePinWindow / isWindowPinned / onWindowPinnedChanged + * @param {object} _ctx core/expose.js 的上下文 + * @param {object} roleApi utility 角色的 API,必须包含 togglePinWindow / isWindowPinned / onWindowPinnedChanged */ -function installPinButton(ctx, roleApi) { +function installPinButton(_ctx, roleApi) { if (typeof document === 'undefined') return; - // 当前实现只在 Windows 上实机调优过 + // 当前能力专注于 Windows 平台 if (process.platform !== 'win32') return; + // 门禁:仅对拥有完整 utility 置顶 API 的受管子窗口生效 + if (!roleApi?.togglePinWindow || !roleApi?.onWindowPinnedChanged) return; + + let currentPinnedState = false; + let initialFetched = false; + let isToggling = false; const renderPinned = (isPinned) => { - const pinned = Boolean(isPinned); + currentPinnedState = Boolean(isPinned); document.querySelectorAll('.vcp-universal-pin-btn').forEach((btn) => { - btn.classList.toggle('is-pinned', pinned); - btn.setAttribute('aria-pressed', String(pinned)); - btn.title = pinned ? '取消置顶' : '置顶窗口'; + btn.classList.toggle('is-pinned', currentPinnedState); + btn.setAttribute('aria-pressed', String(currentPinnedState)); + btn.title = currentPinnedState ? '取消置顶' : '置顶窗口'; }); }; - // 旧实现这里调用的是 ops.subscribe(channel, mapper),只返回了订阅工厂而没有传入回调, - // 监听从未注册,主进程广播的置顶变化收不到。现在直接使用角色 API 订阅。 + // 纯单向事件流:统一由主进程广播驱动 roleApi.onWindowPinnedChanged(renderPinned); + const fetchInitialStateOnce = () => { + if (initialFetched) return; + initialFetched = true; + roleApi.isWindowPinned().then((isPinned) => { + renderPinned(isPinned); + }).catch(() => {}); + }; + const mountInto = (container) => { const sampleBtn = container.querySelector('button'); const pinBtn = document.createElement('button'); pinBtn.type = 'button'; pinBtn.className = `${sampleBtn ? sampleBtn.className : 'window-control-btn'} vcp-universal-pin-btn`.trim(); - pinBtn.title = '置顶窗口'; + pinBtn.title = currentPinnedState ? '取消置顶' : '置顶窗口'; pinBtn.setAttribute('aria-label', '置顶窗口'); - pinBtn.setAttribute('aria-pressed', 'false'); + pinBtn.setAttribute('aria-pressed', String(currentPinnedState)); + if (currentPinnedState) pinBtn.classList.add('is-pinned'); pinBtn.appendChild(createPinIcon()); + // 防连击与单向事件流:通过 roleApi.togglePinWindow 触发命令,状态统一由 onWindowPinnedChanged 驱动 pinBtn.addEventListener('click', async (event) => { event.preventDefault(); event.stopPropagation(); + if (isToggling) return; + isToggling = true; try { - renderPinned(await roleApi.togglePinWindow()); + await roleApi.togglePinWindow(); } catch (error) { console.warn('[UniversalPin] Toggle failed:', error); + } finally { + setTimeout(() => { isToggling = false; }, 150); } }); - // 优先插在托盘按钮或最小化按钮左侧 - const insertTarget = container.querySelector('#win-tray-btn') || container.querySelector(MINIMIZE_BUTTON_SELECTOR); + // 优先插在托盘按钮或最小化按钮左侧(全面兼容超级骰子 #minimize-dice-btn、音乐、协同等) + const insertTarget = container.querySelector('#win-tray-btn') + || container.querySelector('#minimize-dice-btn, #minimize-music-btn, #minimize-btn, button[title*="最小化"], button[aria-label*="最小化"], .window-control, button'); if (insertTarget && insertTarget.parentNode === container) { container.insertBefore(pinBtn, insertTarget); } else { container.prepend(pinBtn); } + + return pinBtn; }; - const tryMount = () => { - if (isExcludedWindowContext()) return; - const targets = Array.from(document.querySelectorAll(CONTAINER_SELECTORS.join(', '))) - .filter((container) => !container.querySelector(EXISTING_PIN_SELECTOR) - && !container.classList.contains('window-controls-mac')); - if (!targets.length) return; + // 状态机:支持“全树搜寻模式”与“目标深度休眠模式” + let rootObserver = null; + let narrowObserver = null; + let mountedPinBtn = null; + let mountedContainer = null; + let mountScheduled = false; - targets.forEach(mountInto); - ensurePinStyle(); - roleApi.isWindowPinned().then((isPinned) => { - if (isPinned) renderPinned(true); - }).catch(() => {}); + const cleanupNarrowObserver = () => { + if (narrowObserver) { + narrowObserver.disconnect(); + narrowObserver = null; + } + }; + + const cleanupRootObserver = () => { + if (rootObserver) { + rootObserver.disconnect(); + rootObserver = null; + } }; - let mountScheduled = false; const scheduleMount = () => { if (mountScheduled) return; mountScheduled = true; @@ -166,43 +199,137 @@ function installPinButton(ctx, roleApi) { }); }; - // 页面会动态渲染标题栏,DOM 变化时重新尝试挂载 - const observeDom = () => { - const target = document.documentElement || document.body; - if (!target) return; - const observer = new MutationObserver((mutations) => { - const onlySelf = mutations.every((m) => m.target?.classList?.contains?.('vcp-universal-pin-btn')); - if (!onlySelf) scheduleMount(); + // 深度休眠态:一旦按钮成功在 DOM 中稳定,立即断开全局树观察,收敛为对标题栏的精准浅层观察 + const enterDeepSleep = (container, pinBtn) => { + cleanupRootObserver(); + cleanupNarrowObserver(); + mountedContainer = container; + mountedPinBtn = pinBtn; + + const watchTarget = container.parentElement || container; + narrowObserver = new MutationObserver(() => { + // 只要图钉按钮依然连接在当前 DOM 树上,坚决不做任何操作,彻底阻断空转汇报 + if (pinBtn.isConnected && container.isConnected) { + return; + } + // 否则说明标题栏被外部 UI 框架重绘或销毁,唤醒并自愈 + cleanupNarrowObserver(); + mountedContainer = null; + mountedPinBtn = null; + scheduleMount(); + startRootObserver(); }); - observer.observe(target, { + + narrowObserver.observe(watchTarget, { + childList: true, + subtree: false, // 严禁 subtree,仅监听直接容器的替换 + }); + }; + + const startRootObserver = () => { + if (rootObserver) return; + const root = document.body || document.documentElement; + if (!root) return; + + rootObserver = new MutationObserver((mutations) => { + // 智能特征哨兵:正文内容(消息、日志、文本)变动绝不唤醒调度 + let relevant = false; + for (let i = 0; i < mutations.length; i++) { + const mut = mutations[i]; + if (mut.addedNodes.length > 0) { + for (let j = 0; j < mut.addedNodes.length; j++) { + const node = mut.addedNodes[j]; + if (node.nodeType === 1) { // ELEMENT_NODE + const name = node.className || ''; + const tag = node.tagName || ''; + if (typeof name === 'string' && ( + name.includes('control') || + name.includes('header') || + name.includes('title') || + name.includes('blade') || + name.includes('vcp-ui') || + tag === 'HEADER' || + tag === 'NAV' + )) { + relevant = true; + break; + } + } + } + } + if (relevant) break; + } + if (relevant) { + scheduleMount(); + } + }); + + rootObserver.observe(root, { childList: true, subtree: true, - attributes: true, - attributeFilter: ['class', 'style'], }); }; + const tryMount = () => { + if (isExcludedWindowContext()) { + document.querySelectorAll('.vcp-universal-pin-btn').forEach((btn) => btn.remove()); + cleanupNarrowObserver(); + cleanupRootObserver(); + return; + } + + // 避让检测:若窗口已存在现代 UI WindowControls,适配器自动注销退出 + if (document.querySelector('.vcp-ui-window-controls')) { + document.querySelectorAll('.vcp-universal-pin-btn').forEach((btn) => btn.remove()); + cleanupNarrowObserver(); + cleanupRootObserver(); + return; + } + + // 若当前按钮依然在文档中正常工作,无需重复扫描 + if (mountedPinBtn?.isConnected && mountedContainer?.isConnected) { + return; + } + const targets = Array.from(document.querySelectorAll(CONTAINER_SELECTORS.join(', '))) + .filter((container) => !container.querySelector(EXISTING_PIN_SELECTOR) + && !container.classList.contains('window-controls-mac')); + if (!targets.length) { + startRootObserver(); + return; + } + + let firstBtn = null; + let firstContainer = null; + for (const container of targets) { + const btn = mountInto(container); + if (!firstBtn) { + firstBtn = btn; + firstContainer = container; + } + } + + ensurePinStyle(); + fetchInitialStateOnce(); + + // 成功挂载,立即进入深度休眠! + if (firstContainer && firstBtn) { + enterDeepSleep(firstContainer, firstBtn); + } + }; + if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - scheduleMount(); - observeDom(); - }, { once: true }); + document.addEventListener('DOMContentLoaded', scheduleMount, { once: true }); } else { scheduleMount(); - observeDom(); } - // 兜底轮询:应对主进程卡顿或超长异步加载,挂载成功或 3 秒后停止 - let pollCount = 0; - const pollInterval = setInterval(() => { - pollCount += 1; - scheduleMount(); - if (document.querySelector(EXISTING_PIN_SELECTOR) || pollCount >= 30) { - clearInterval(pollInterval); - } - }, 100); + // 初始启动搜寻 + startRootObserver(); - window.addEventListener('load', scheduleMount, { once: true }); + window.addEventListener('unload', () => { + cleanupNarrowObserver(); + cleanupRootObserver(); + }, { once: true }); } -module.exports = { installPinButton }; \ No newline at end of file +module.exports = { installPinButton }; diff --git a/scripts/test-ui-system.mjs b/scripts/test-ui-system.mjs index eb63fb1b7..2aafcd2ba 100644 --- a/scripts/test-ui-system.mjs +++ b/scripts/test-ui-system.mjs @@ -606,6 +606,25 @@ const behaviorWindowControls = VCPUI.create('WindowControls', { }); assert.equal(behaviorWindowControls.element.querySelectorAll('.vcp-ui-window-control-button').length, 3, 'WindowControls must mark every clickable host as a no-drag control'); + +// 验证同步 canPin 门禁契约与零 CLS 抖动行为: +// 1. 当 canPin() 为 true 时,构造即具备 4 个按钮,0 延迟、0 抖动 +window.utilityAPI.canPin = () => true; +window.utilityAPI.togglePinWindow = async () => true; +const pinCapableControls = VCPUI.create('WindowControls'); +assert.equal(pinCapableControls.element.querySelectorAll('.vcp-ui-window-control-button').length, 4, + 'Windows pin-capable WindowControls must mount pin button synchronously on first frame (0 CLS)'); +assert.ok(pinCapableControls.element.querySelector('.vcp-ui-window-control-pin'), + 'pin button must be present when canPin is true'); + +// 2. 当 canPin() 为 false(非 Win32 或嵌入式标签页)时,首帧同步仅挂载 3 个按钮,绝对不出现置顶 +window.utilityAPI.canPin = () => false; +const nonPinControls = VCPUI.create('WindowControls'); +assert.equal(nonPinControls.element.querySelectorAll('.vcp-ui-window-control-button').length, 3, + 'Non-pin WindowControls must mount exactly 3 buttons with 0 DOM residue'); +assert.equal(nonPinControls.element.querySelector('.vcp-ui-window-control-pin'), null, + 'pin button must be absent when canPin is false'); +delete window.utilityAPI.canPin; const uiComponentsCss = fs.readFileSync(new URL('../styles/ui-system/components.css', import.meta.url), 'utf8'); const nextUiCss = fs.readFileSync(new URL('../styles/ui-next.css', import.meta.url), 'utf8'); const notificationSystemCss = fs.readFileSync(new URL('../styles/ui-system/notifications.css', import.meta.url), 'utf8'); diff --git a/styles/ui-system/components.css b/styles/ui-system/components.css index 8c335f830..418b7df8b 100644 --- a/styles/ui-system/components.css +++ b/styles/ui-system/components.css @@ -540,6 +540,16 @@ -webkit-app-region: no-drag; } + :is(html, html.vcp-global-settings-host) .vcp-ui-scope .vcp-ui-window-control-pin.is-pinned { + color: var(--vcp-ui-primary, #6366f1); + background: var(--vcp-ui-primary-bg, rgba(99, 102, 241, 0.16)); + } + + :is(html, html.vcp-global-settings-host) .vcp-ui-scope .vcp-ui-window-control-pin.is-pinned .vcp-ui-icon, + :is(html, html.vcp-global-settings-host) .vcp-ui-scope .vcp-ui-window-control-pin.is-pinned svg { + transform: rotate(-15deg); + } + :is(html, html.vcp-global-settings-host) .vcp-ui-scope .vcp-ui-window-control-close:hover { color: var(--vcp-ui-danger); background: var(--vcp-ui-danger-bg); diff --git a/tests/preload-registry.test.js b/tests/preload-registry.test.js index 78ec10083..bde5290a6 100644 --- a/tests/preload-registry.test.js +++ b/tests/preload-registry.test.js @@ -1,4 +1,4 @@ -'use strict'; + // preloads/api/*.js 的契约测试:注册表可加载、角色可见性、以及使用这些 preload 的窗口都关闭了沙箱。 const test = require('node:test'); @@ -24,7 +24,7 @@ test('注册表加载成功,每个角色都有可见 API', () => { }); test('除本地 API 外,每个条目都声明了 IPC 通道', () => { - const localOnly = new Set(['getPathForFile']); + const localOnly = new Set(['getPathForFile', 'canPin']); for (const api of apis) { if (localOnly.has(api.name)) continue; assert.equal(typeof api.channel, 'string', `${api.name} 缺少 channel`); @@ -59,7 +59,7 @@ test('主聊天语音 API 对 chat 可见,且不存在私自存盘的录音通 }); test('置顶 API 只对 utility 可见', () => { - for (const name of ['togglePinWindow', 'isWindowPinned', 'onWindowPinnedChanged']) { + for (const name of ['canPin', 'supportsPin', 'togglePinWindow', 'isWindowPinned', 'onWindowPinnedChanged']) { assert.deepEqual(byName.get(name).roles, ['utility']); } });