點穿改由主程序決定並加入滑鼠診斷:修不能拖曳、不能右鍵
根本原因: 點穿狀態一直是 renderer 判定後再用 IPC 叫主程序切換。renderer 只要例外、 卡住或狀態錯亂(例如視窗永遠不取得焦點、Windows 對背景視窗的 capture 有限制, 放開的那一下落在視窗外就收不到 mouseup,pressed 從此卡在 true),主程序就 再也收不到切換指令,寵物永遠點不到。另外 2.0.8 起每 2 秒的置頂重宣告會在 右鍵選單開著時把寵物視窗抬到選單上面,2.0.10 改成整個方框接滑鼠後,被蓋住 的選單項目就點不到了。 影響: 偶發「不能拖曳、不能右鍵」,而且發生時沒有任何可查的狀態或紀錄。 修法: 1. 主程序每 50 ms 自己輪詢游標,對照 renderer 回報的人物方框與氣泡矩形 (pet:hit-rects)直接呼叫 setIgnoreMouseEvents;決策邏輯抽成 lib/hit-test.js。 renderer 沒回報或掛掉時用版面公式算方框當備援,並自動重載。 2. 拖曳不管因 mouseup、逾時(20 秒)或其他原因結束,都送 pet:drag-ended 讓 renderer 清掉按住/拖曳狀態。 3. 右鍵選單開著時暫停置頂重宣告。 4. /state 回傳 bounds 與 mouse 診斷(是否點穿、游標相對座標、是否在方框/ 氣泡、矩形來源、切換與輪詢次數、renderer 錯誤、拖曳結束原因);renderer 例外、拖曳開始/結束、選單開關寫入事件紀錄。 版號 2.0.11。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,8 @@ const MIN_WIN_W = 240; // 視窗最小寬度,保留氣泡的可讀寬度(Cod
|
||||
// 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 DRAG_MAX_MS = 20000; // 拖曳最長時間:放開事件若沒送到,別讓寵物一直跟著游標(見 endDrag)
|
||||
const { inside, petBoxRect, wantIgnore } = require("./lib/hit-test");
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
activePetId: "xiao-nian",
|
||||
@@ -58,6 +60,16 @@ let dragTimer = null;
|
||||
let cursorTimer = null;
|
||||
let topmostTimer = null;
|
||||
let lastCursor = { x: NaN, y: NaN };
|
||||
// 點穿狀態由主程序自己決定與追蹤(見 updateIgnore);renderer 只回報它量到的矩形
|
||||
let ignoring = true;
|
||||
let hitRects = null; // renderer 回報的 { box, bubble }(視窗相對座標),null 表示還沒回報
|
||||
let menuOpenedAt = 0; // 右鍵選單打開的時間,0 表示沒開
|
||||
// 給 /state 看的診斷資料:下次「點不到寵物」時不用猜,直接看這裡
|
||||
const mouseDiag = {
|
||||
toggles: 0, lastToggleAt: null, polls: 0, lastPollAt: null,
|
||||
rel: null, inBox: false, inBubble: false,
|
||||
rendererErrors: 0, lastRendererError: null, dragEnds: [],
|
||||
};
|
||||
let currentState = { anim: "idle", base: "idle", sessions: 0 };
|
||||
|
||||
// ---------- config ----------
|
||||
@@ -286,6 +298,14 @@ function createWindow() {
|
||||
reassertTopmost();
|
||||
});
|
||||
win.on("closed", () => { win = null; });
|
||||
// renderer 掛了就記下來並重載;點穿判定在主程序,寵物不會因此卡成點不到
|
||||
win.webContents.on("render-process-gone", (_e, details) => {
|
||||
appendLog(`renderer gone: ${details?.reason || "unknown"}`);
|
||||
hitRects = null;
|
||||
setTimeout(() => { if (win && !win.isDestroyed()) win.webContents.reload(); }, 500);
|
||||
});
|
||||
win.webContents.on("unresponsive", () => appendLog("renderer unresponsive"));
|
||||
win.webContents.on("responsive", () => appendLog("renderer responsive again"));
|
||||
}
|
||||
|
||||
function send(channel, data) {
|
||||
@@ -303,12 +323,15 @@ function sendInit() {
|
||||
// ---------- drag ----------
|
||||
|
||||
function startDrag(offsetX, offsetY) {
|
||||
if (!win) return;
|
||||
endDrag(false);
|
||||
if (!win || win.isDestroyed()) return;
|
||||
endDrag(false, "restart");
|
||||
let last = screen.getCursorScreenPoint();
|
||||
const startedAt = Date.now();
|
||||
appendLog(`drag start offset=${offsetX},${offsetY}`);
|
||||
applyIgnore(false);
|
||||
dragTimer = setInterval(() => {
|
||||
if (!win || Date.now() - startedAt > 60000) return endDrag(true);
|
||||
if (!win || win.isDestroyed()) return endDrag(false, "no-window");
|
||||
if (Date.now() - startedAt > DRAG_MAX_MS) return endDrag(true, "timeout");
|
||||
const p = screen.getCursorScreenPoint();
|
||||
const dx = p.x - last.x;
|
||||
last = p;
|
||||
@@ -317,16 +340,22 @@ function startDrag(offsetX, offsetY) {
|
||||
}, 16);
|
||||
}
|
||||
|
||||
function endDrag(save) {
|
||||
if (dragTimer) {
|
||||
clearInterval(dragTimer);
|
||||
dragTimer = null;
|
||||
}
|
||||
if (save && win) {
|
||||
// 視窗永遠不取得焦點(focusable:false),Windows 對背景視窗的滑鼠 capture 有限制:
|
||||
// 放開的那一下若剛好落在視窗外,renderer 收不到 mouseup。所以結束拖曳的原因不只 mouseup,
|
||||
// 而且不管誰結束的都要通知 renderer 清掉按住/拖曳狀態,免得它一直以為還在拖。
|
||||
function endDrag(save, reason = "mouseup") {
|
||||
if (!dragTimer) return;
|
||||
clearInterval(dragTimer);
|
||||
dragTimer = null;
|
||||
if (save && win && !win.isDestroyed()) {
|
||||
const [x, y] = win.getPosition();
|
||||
config.position = { x, y };
|
||||
saveConfig();
|
||||
}
|
||||
appendLog(`drag end reason=${reason} pos=${config.position ? `${config.position.x},${config.position.y}` : "?"}`);
|
||||
mouseDiag.dragEnds.push({ at: Date.now(), reason });
|
||||
if (mouseDiag.dragEnds.length > 10) mouseDiag.dragEnds.shift();
|
||||
send("pet:drag-ended");
|
||||
}
|
||||
|
||||
// ---------- cursor look ----------
|
||||
@@ -337,7 +366,8 @@ function endDrag(save) {
|
||||
// setAlwaysOnTop 在狀態沒變時可能不會真的呼叫 SetWindowPos,所以再補一個 moveTop()
|
||||
// 強制重新插回 topmost band 的最上面(視窗是 WS_EX_NOACTIVATE + focusable:false,不會搶焦點)。
|
||||
function reassertTopmost() {
|
||||
if (!win || win.isDestroyed() || !config.alwaysOnTop || dragTimer) return;
|
||||
// 選單開著時不做:moveTop 會把寵物視窗抬到選單上面,人物方框整塊接滑鼠,被蓋住的選單項目就點不到了
|
||||
if (!win || win.isDestroyed() || !config.alwaysOnTop || dragTimer || menuIsOpen()) return;
|
||||
try {
|
||||
win.setAlwaysOnTop(true, "screen-saver");
|
||||
win.moveTop();
|
||||
@@ -352,20 +382,55 @@ function startTopmostGuard() {
|
||||
screen.on("display-removed", reassertTopmost);
|
||||
}
|
||||
|
||||
function menuIsOpen() {
|
||||
// popup 的 callback 關閉時一定會來;保險起見超過 60 秒就當它已經關了
|
||||
return menuOpenedAt > 0 && Date.now() - menuOpenedAt < 60000;
|
||||
}
|
||||
|
||||
function currentHitRects() {
|
||||
if (hitRects) return hitRects;
|
||||
if (!win || win.isDestroyed()) return { box: null, bubble: null };
|
||||
// renderer 還沒回報(或掛了):用版面公式算人物方框,氣泡就當沒有
|
||||
return { box: petBoxRect(win.getBounds(), config.scale, { petW: PET_W, petH: PET_H, bubbleH: BUBBLE_H }), bubble: null };
|
||||
}
|
||||
|
||||
// 點穿只在這裡切換。以前是 renderer 判定再用 IPC 叫主程序切:renderer 一旦例外、卡住或狀態錯亂
|
||||
// (例如放開事件沒送到、pressed 一直是 true),點穿就永遠不會再更新——這是「不能拖曳也不能右鍵」
|
||||
// 最難查的一種成因。現在主程序拿自己輪詢到的游標、renderer 回報的矩形、是否拖曳中,自己決定。
|
||||
function applyIgnore(ignore) {
|
||||
if (!win || win.isDestroyed() || ignore === ignoring) return;
|
||||
ignoring = ignore;
|
||||
mouseDiag.toggles++;
|
||||
mouseDiag.lastToggleAt = Date.now();
|
||||
try { win.setIgnoreMouseEvents(ignore, { forward: true }); } catch { /* 視窗正在關閉 */ }
|
||||
send("pet:ignore-state", ignore);
|
||||
}
|
||||
|
||||
function updateIgnore(rel) {
|
||||
const { box, bubble } = currentHitRects();
|
||||
mouseDiag.rel = rel;
|
||||
mouseDiag.inBox = inside(box, rel);
|
||||
mouseDiag.inBubble = inside(bubble, rel);
|
||||
applyIgnore(wantIgnore({ rel, box, bubble, dragging: !!dragTimer }));
|
||||
}
|
||||
|
||||
function startCursorPolling() {
|
||||
cursorTimer = setInterval(() => {
|
||||
if (!win || dragTimer) return;
|
||||
const p = screen.getCursorScreenPoint();
|
||||
if (!win || win.isDestroyed()) return;
|
||||
let p;
|
||||
try { p = screen.getCursorScreenPoint(); } catch { return; }
|
||||
mouseDiag.polls++;
|
||||
mouseDiag.lastPollAt = Date.now();
|
||||
const b = win.getBounds();
|
||||
const rel = { x: p.x - b.x, y: p.y - b.y };
|
||||
updateIgnore(rel);
|
||||
if (dragTimer) return; // 拖曳中視窗跟著游標走,座標交給 drag loop
|
||||
if (p.x === lastCursor.x && p.y === lastCursor.y) return;
|
||||
lastCursor = p;
|
||||
const b = win.getBounds();
|
||||
// 點穿判定不能只靠 renderer 的 mousemove。視窗處於點穿狀態時,Electron 的
|
||||
// setIgnoreMouseEvents(true, { forward: true }) 並不保證一定會把 mousemove 轉發進來
|
||||
// (游標直接跳到寵物上常常收不到,慢慢移過去才會)。一旦在游標還停在寵物身上時
|
||||
// 進入點穿,就再也收不到事件、ignoringMouse 永遠是 true,滑鼠按下也傳不進視窗
|
||||
// → 就是「有時候卡住無法拖曳」。主程序自己輪詢絕對不會漏,所以改由這裡把
|
||||
// 視窗相對座標送過去,讓 renderer 重新做一次判定。
|
||||
send("pet:cursor-pos", { x: p.x - b.x, y: p.y - b.y });
|
||||
// hover(跳三下)與拖曳起點仍由 renderer 處理,所以座標照送。視窗處於點穿狀態時
|
||||
// Electron 的 setIgnoreMouseEvents(true, { forward: true }) 並不保證會把 mousemove 轉發進來
|
||||
// (游標直接跳到寵物上常常收不到),主程序自己輪詢絕對不會漏。
|
||||
send("pet:cursor-pos", rel);
|
||||
if (!config.followCursor) return;
|
||||
const cx = b.x + b.width / 2;
|
||||
const cy = b.y + BUBBLE_H + (PET_H * config.scale) / 2; // 人物方框的中心,與 Codex 相同
|
||||
@@ -407,7 +472,19 @@ function startServer() {
|
||||
}
|
||||
if (req.method === "GET" && req.url === "/state") {
|
||||
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ ...currentState, pet: activePet()?.id || null, scale: config.scale }));
|
||||
res.end(JSON.stringify({
|
||||
...currentState,
|
||||
pet: activePet()?.id || null,
|
||||
scale: config.scale,
|
||||
bounds: win && !win.isDestroyed() ? win.getBounds() : null,
|
||||
mouse: {
|
||||
ignoring,
|
||||
dragging: !!dragTimer,
|
||||
menuOpen: menuIsOpen(),
|
||||
rects: { ...currentHitRects(), source: hitRects ? "renderer" : "fallback" },
|
||||
...mouseDiag,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
@@ -593,10 +670,28 @@ function resetPosition() {
|
||||
// ---------- IPC ----------
|
||||
|
||||
ipcMain.on("pet:ready", () => sendInit());
|
||||
ipcMain.on("pet:ignore-mouse", (_e, ignore) => win?.setIgnoreMouseEvents(!!ignore, { forward: true }));
|
||||
function sanitizeRect(r) {
|
||||
if (!r || typeof r !== "object") return null;
|
||||
const v = ["x", "y", "w", "h"].map((k) => Number(r[k]));
|
||||
return v.every(Number.isFinite) ? { x: v[0], y: v[1], w: v[2], h: v[3] } : null;
|
||||
}
|
||||
ipcMain.on("pet:hit-rects", (_e, rects) => {
|
||||
const box = sanitizeRect(rects?.box);
|
||||
hitRects = box ? { box, bubble: sanitizeRect(rects?.bubble) } : null;
|
||||
// 氣泡剛出現在靜止的游標底下這種情況,不等下一次游標移動就重算
|
||||
if (win && !win.isDestroyed() && Number.isFinite(lastCursor.x)) {
|
||||
const b = win.getBounds();
|
||||
updateIgnore({ x: lastCursor.x - b.x, y: lastCursor.y - b.y });
|
||||
}
|
||||
});
|
||||
ipcMain.on("pet:drag-start", (_e, { offsetX, offsetY }) => startDrag(Number(offsetX) || 0, Number(offsetY) || 0));
|
||||
ipcMain.on("pet:drag-end", () => endDrag(true));
|
||||
ipcMain.on("pet:context-menu", () => buildMenu().popup({ window: win }));
|
||||
ipcMain.on("pet:context-menu", () => {
|
||||
if (!win || win.isDestroyed()) return;
|
||||
menuOpenedAt = Date.now(); // 開著的期間暫停置頂重宣告,見 reassertTopmost
|
||||
appendLog("menu open");
|
||||
buildMenu().popup({ window: win, callback: () => { menuOpenedAt = 0; appendLog("menu closed"); } });
|
||||
});
|
||||
ipcMain.on("pet:tray-icon", (_e, dataUrl) => {
|
||||
try { tray?.setImage(nativeImage.createFromDataURL(dataUrl)); } catch (err) { console.error(err); }
|
||||
});
|
||||
@@ -604,7 +699,14 @@ ipcMain.on("pet:state", (_e, state) => {
|
||||
currentState = { ...currentState, ...state };
|
||||
refreshTray();
|
||||
});
|
||||
ipcMain.on("pet:log", (_e, line) => appendLog(String(line)));
|
||||
ipcMain.on("pet:log", (_e, line) => {
|
||||
const s = String(line);
|
||||
if (s.includes("[renderer] error")) {
|
||||
mouseDiag.rendererErrors++;
|
||||
mouseDiag.lastRendererError = s.slice(0, 200);
|
||||
}
|
||||
appendLog(s);
|
||||
});
|
||||
ipcMain.on("pet:greeted", (_e, id) => {
|
||||
if (typeof id !== "string" || config.greetedPetIds.includes(id)) return;
|
||||
config.greetedPetIds.push(id);
|
||||
|
||||
Reference in New Issue
Block a user