From 7060571df1b08f78baa7c264dbd0fa1627a28243 Mon Sep 17 00:00:00 2001 From: JianMiau Date: Mon, 24 Aug 2026 14:36:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E8=A2=AB=20VS=20Code=20?= =?UTF-8?q?=E8=93=8B=E4=BD=8F=EF=BC=9A=E5=AE=9A=E6=9C=9F=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E5=AE=A3=E5=91=8A=E7=BD=AE=E9=A0=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 摘要: 每 2 秒重新宣告一次置頂,並在螢幕配置變動與啟動後各補一次。 根本原因: Windows 會把視窗踢出 topmost band,卻留著 WS_EX_TOPMOST 樣式位元。 以 Win32 檢查實際情況:寵物視窗的 exStyle 是 0x08280028(含 WS_EX_TOPMOST), 但 z-order 排在 Z=4,而兩個 TOPMOST=false 的 VS Code 視窗排在 Z=1 與 Z=2, 也就是它們蓋在寵物上面。Electron 這邊仍以為自己是置頂的, setAlwaysOnTop 已經在建立視窗時與勾選選單時都設過,所以不會自己修好。 影響: 寵物被 VS Code 之類的一般視窗蓋住,只能重啟或重新勾一次選單的「永遠置頂」。 修法: - 新增 reassertTopmost():setAlwaysOnTop(true, "screen-saver") 之後再呼叫 moveTop()。 單靠 setAlwaysOnTop 不夠——狀態沒變時它可能不會真的呼叫 SetWindowPos; moveTop() 會強制 SetWindowPos,對 WS_EX_TOPMOST 的視窗即為插回 topmost band 最上面。 視窗是 WS_EX_NOACTIVATE + focusable:false(已用 Win32 確認),不會搶焦點。 - 每 TOPMOST_REASSERT_MS(2000)呼叫一次;display-metrics-changed / added / removed 也各接一次,因為螢幕配置變動最容易觸發降級。 - ready-to-show 的 showInactive 後補一次;選單勾選置頂時也補一次。 - 拖曳中(dragTimer 存在)跳過,避免和每 16 ms 的位置更新打架。 - 結束時清掉 timer。 驗證: - 先從外部對執行中的視窗呼叫 SetWindowPos(HWND_TOPMOST) 確認這正是有效解: 寵物由 Z=4 移到 Z=0,兩個 VS Code 視窗退到 Z=5、Z=6。 - 再以改好的版本實測守護行為,用 SetWindowPos(HWND_BOTTOM) 模擬 Windows 的降級: 基準 petZ=0 TOPMOST=true 上方 0 個;強制降級後 petZ=17 TOPMOST=false 上方 4 個 (VS Code 兩個、CM 等);1 秒後回到 petZ=0 TOPMOST=true 上方 0 個;3 秒後維持。 - 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar,確認版本 2.0.8 與八項改動都在出貨檔案裡。 Co-Authored-By: Claude Fable 5 --- README.md | 1 + main.js | 31 ++++++++++++++++++++++++++++++- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 804e6e9..c6b3a9f 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ Claude Code ──hook(stdin JSON)──▶ hook/claude-pet-hook.js ──PO - **點穿**:視窗預設 `setIgnoreMouseEvents(true, { forward: true })`,renderer 讀 canvas 該點的 alpha(或是否壓在氣泡上)來即時切換,所以透明處永遠點得到底下的視窗。判定的座標有兩個來源:renderer 自己的 mousemove,以及**主程序每 50 ms 的游標輪詢**。後者是必要的——視窗處於點穿狀態時 Electron 的 `forward` 不保證會把 mousemove 轉發進來(游標直接跳到寵物上常常收不到),只靠 mousemove 會在「游標還停在寵物身上時進入點穿」之後再也收不到事件,變成永遠卡在點穿、按不下去也拖不動。另外每一格畫完也會用最後已知座標重驗一次,因為換格會改變游標底下的像素。 - **拖曳**:由主程序用 `screen.getCursorScreenPoint()` 每 16 ms 更新視窗位置,游標離開視窗也不會掉;方向由水平位移決定。 +- **置頂要定期重新宣告**:Windows 有時候會把視窗踢出 topmost band,卻留著 `WS_EX_TOPMOST` 樣式位元——從外部檢查看到 `TOPMOST=true`,但 z-order 排在一般視窗(例如 VS Code)之下,寵物就被蓋住,而 Electron 這邊仍以為自己是置頂的,不會自己修好。所以每 2 秒重新宣告一次,並在螢幕配置變動時立刻補一次。`setAlwaysOnTop` 在狀態沒變時可能不會真的呼叫 `SetWindowPos`,因此再補一個 `moveTop()` 強制插回 topmost band 最上面;視窗是 `WS_EX_NOACTIVATE` + `focusable: false`,不會搶焦點。 - **行為邏輯以 Codex 為準**,細節見[與 Codex 的對照](#與-codex-的對照)。 - **顯示尺寸與 sprite 來源尺寸分開**:`CELL_W/CELL_H`(192 × 208)只用來從 spritesheet 取格子,畫到畫面上的是 `PET_W/PET_H`(126 × 137,Codex 的 mascot 尺寸)。視窗寬度另有 `MIN_WIN_W = 240` 的下限,免得寵物變小後氣泡被截掉。 - **Spritesheet 以 data URL 傳給 renderer**,避免 `file://` 跨來源污染 canvas 導致 `getImageData` 失敗。 diff --git a/main.js b/main.js index c727253..094ffa6 100644 --- a/main.js +++ b/main.js @@ -37,6 +37,7 @@ const PAD_BOTTOM = 8; const MIN_WIN_W = 240; // 視窗最小寬度,保留氣泡的可讀寬度(Codex 的氣泡也遠寬於寵物本身) // Codex 的 mascot 寬度可調範圍是 80–224 px(ike() 的 clamp);以 126 為 100%,這幾檔都落在範圍內 const SCALES = [0.65, 0.8, 1, 1.25, 1.5, 1.75]; +const TOPMOST_REASSERT_MS = 2000; // 多久重新宣告一次置頂(見 reassertTopmost) const DEFAULT_CONFIG = { activePetId: "xiao-nian", @@ -55,6 +56,7 @@ let tray = null; let pets = []; let dragTimer = null; let cursorTimer = null; +let topmostTimer = null; let lastCursor = { x: NaN, y: NaN }; let currentState = { anim: "idle", base: "idle", sessions: 0 }; @@ -279,7 +281,10 @@ function createWindow() { if (config.alwaysOnTop) win.setAlwaysOnTop(true, "screen-saver"); win.setIgnoreMouseEvents(true, { forward: true }); win.loadFile(path.join(APP_DIR, "renderer", "index.html")); - win.once("ready-to-show", () => win.showInactive()); + win.once("ready-to-show", () => { + win.showInactive(); + reassertTopmost(); + }); win.on("closed", () => { win = null; }); } @@ -326,6 +331,27 @@ function endDrag(save) { // ---------- cursor look ---------- +// Windows 有時候會把視窗踢出 topmost band,卻留著 WS_EX_TOPMOST 樣式位元: +// 從外部檢查會看到 TOPMOST=true,但 z-order 排在一般視窗(例如 VS Code)之下, +// 於是寵物就被蓋住,而 Electron 這邊仍以為自己是置頂的,不會自己修好。 +// setAlwaysOnTop 在狀態沒變時可能不會真的呼叫 SetWindowPos,所以再補一個 moveTop() +// 強制重新插回 topmost band 的最上面(視窗是 WS_EX_NOACTIVATE + focusable:false,不會搶焦點)。 +function reassertTopmost() { + if (!win || win.isDestroyed() || !config.alwaysOnTop || dragTimer) return; + try { + win.setAlwaysOnTop(true, "screen-saver"); + win.moveTop(); + } catch { /* 視窗正在關閉就算了 */ } +} + +function startTopmostGuard() { + topmostTimer = setInterval(reassertTopmost, TOPMOST_REASSERT_MS); + // 螢幕配置變動最容易觸發降級,變動後立刻補一次 + screen.on("display-metrics-changed", reassertTopmost); + screen.on("display-added", reassertTopmost); + screen.on("display-removed", reassertTopmost); +} + function startCursorPolling() { cursorTimer = setInterval(() => { if (!win || dragTimer) return; @@ -488,6 +514,7 @@ function buildMenu() { config.alwaysOnTop = item.checked; saveConfig(); win?.setAlwaysOnTop(item.checked, "screen-saver"); + if (item.checked) reassertTopmost(); }, }, { @@ -605,6 +632,7 @@ if (!app.requestSingleInstanceLock()) { createTray(); startServer(); startCursorPolling(); + startTopmostGuard(); appendLog(`started pet=${activePet()?.id || "none"} scale=${config.scale} port=${config.port}`); }); @@ -614,6 +642,7 @@ if (!app.requestSingleInstanceLock()) { app.on("before-quit", () => { if (cursorTimer) clearInterval(cursorTimer); + if (topmostTimer) clearInterval(topmostTimer); endDrag(false); }); } diff --git a/package-lock.json b/package-lock.json index 63f058f..6f80cd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-pet", - "version": "2.0.7", + "version": "2.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-pet", - "version": "2.0.7", + "version": "2.0.8", "devDependencies": { "electron": "^43.4.1", "electron-builder": "^26.0.12" diff --git a/package.json b/package.json index 79aecb5..be14387 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-pet", - "version": "2.0.7", + "version": "2.0.8", "description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 2:9 個動作列 + 16 方向追視)", "main": "main.js", "private": true,