From 257ff0ad5dfdd1f91062b01777ded8f2cd8c7b64 Mon Sep 17 00:00:00 2001 From: jianf <2198972886@qq.com> Date: Thu, 20 Aug 2026 13:29:21 +0800 Subject: [PATCH] =?UTF-8?q?gui:=20=E8=87=AA=E5=8A=A8=E9=87=8D=E7=99=BB/?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E6=8E=88=E6=9D=83/=E5=89=AF=E5=B1=8F?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20=E6=8E=92=E9=9A=9C(=E5=A4=A7=E9=87=8FJ?= =?UTF-8?q?S=E6=8A=A5=E9=94=99=E6=A0=B9=E5=9B=A0=E6=98=AFcookie=E8=BF=87?= =?UTF-8?q?=E6=9C=9F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排障: "大量JS报错"真实根因 = webui cookie 会话24h过期 → 所有 api() 请求经网关 302 → 返回登录页HTML(200非401) → renderer 拿HTML当JSON解析 → 界面持续报错/乱码 修复(api() 自动重登): - 401 或检测登录页HTML(THEME_PLACEHOLDER/统一门户登录)时自动 syncConnAuth 重登后重试一次 - 防递归锁 _haReloginLock - JSON解析容错(HTML不再误报) 定时撤销/恢复授权: - prefs.deviceBridge.authSchedule {enabled, revokeTime, restoreTime}, 支持跨天(23:00-07:00) - 主进程每分钟检查, 到点经 webui 反代 /api/v1/device/auth 撤销/恢复(防抖状态机) - 设备页UI: 开启开关 + 撤销/恢复时间输入(保存并应用生效) - setDeviceAuthorized 优先 authRule, 否则从 connections.json 取 URL+cookie 副屏修复: - displays:list 对空 label(' ') trim 后显示 显示器N - 修复 loadGuiPrefs 只返回3字段导致 screensueDisplay/exec/authSchedule 被抹掉(保存后变默认的根因) 其他: - 移除远程调试端口(9223, 排障用) - 保留 window.onerror/onunhandledrejection 透传(gui.log 可见 JS 错误, 便于运维) --- cmd/gui/main.js | 227 ++++++++++++++++++++++++++++++++++++++-- cmd/gui/renderer/app.js | 84 ++++++++++++++- 2 files changed, 298 insertions(+), 13 deletions(-) diff --git a/cmd/gui/main.js b/cmd/gui/main.js index 427fc3c..6c845c8 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -1695,6 +1695,188 @@ function stopDeviceBridge() { } } +// ============ 定时授权调度(睡眠期间自动撤销,恢复时自动恢复) ============ +let schedAuthTimer = null; +let schedAuthState = null; // "revoked" | "restored" 防抖 + +// 读授权调度配置 prefs.deviceBridge.authSchedule = {revokeTime:"23:00", restoreTime:"07:00", enabled} +function getAuthSchedule() { + try { + const prefs = loadGuiPrefs(); + const db = prefs.deviceBridge || {}; + const s = db.authSchedule || {}; + return { + enabled: !!s.enabled, + revokeTime: s.revokeTime || "", + restoreTime: s.restoreTime || "", + }; + } catch (e) { + return { enabled: false, revokeTime: "", restoreTime: "" }; + } +} + +// 当前 HH:mm +function nowHHMM() { + const d = new Date(); + return ( + String(d.getHours()).padStart(2, "0") + + ":" + + String(d.getMinutes()).padStart(2, "0") + ); +} + +// 比较 HH:mm;返回 true 表示 a <= b +function timeLeq(a, b) { + return a <= b; +} + +// 执行授权开关(经 webui 反代 /api/v1/device/auth) +async function setDeviceAuthorized(authorized) { + try { + // URL + cookie 优先 authRule;否则从 connections.json 取当前连接(webui 反代) + let base = ""; + let cookie = ""; + if (authRule && authRule.url) { + base = authRule.url.replace(/\/+$/, ""); + cookie = + (authRule.cookie || "") + + (authRule.slSession ? "; " + authRule.slSession : ""); + } else { + try { + const conns = loadConnections(); + const cur = + conns.connections.find((c) => c.id === conns.currentId) || + conns.connections[0]; + if (cur && cur.url) { + base = cur.url.replace(/\/+$/, ""); + cookie = cur.cookie || ""; + } + } catch (e2) {} + } + if (!base || !cookie) { + console.log("[auth-schedule] no base/cookie, skip"); + return; + } + const url = base + "/api/v1/device/auth"; + const body = JSON.stringify({ + device_id: deviceBridgeId, + authorize: authorized, + }); + // 用 node https 直连(带 cookie + 跟随 302),不走 Electron session(避免 jar 会话差异) + const resp = await new Promise((resolve, reject) => { + const https = require("https"); + const u = new URL(url); + const req = https.request( + { + hostname: u.hostname, + port: u.port || 443, + path: u.pathname + u.search, + method: "POST", + rejectUnauthorized: false, + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + Cookie: cookie, + "Content-Length": Buffer.byteLength(body), + }, + }, + (res) => { + let buf = ""; + res.on("data", (c) => (buf += c)); + res.on("end", () => { + if ( + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + // 跟随 302 到登录域后重试一次 + const loc = res.headers.location; + const lurl = loc.startsWith("http") + ? loc + : new URL(loc, url).toString(); + const req2 = https.request( + { + ...u2opts(lurl), + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0", + Cookie: cookie, + "Content-Length": Buffer.byteLength(body), + }, + }, + (r2) => { + let b2 = ""; + r2.on("data", (c) => (b2 += c)); + r2.on("end", () => + resolve({ status: r2.statusCode, body: b2 }), + ); + }, + ); + req2.write(body); + req2.end(); + return; + } + resolve({ status: res.statusCode, body: buf }); + }); + }, + ); + req.on("error", reject); + req.write(body); + req.end(); + }); + console.log( + "[auth-schedule] set authorized=" + + authorized + + " -> HTTP " + + resp.status + + " " + + resp.body.slice(0, 60), + ); + } catch (e) { + console.error("[auth-schedule] set authorized failed: " + e.message); + } +} + +// 调度器:每分钟检查,到点执行撤销/恢复 +function startScheduledAuth() { + if (schedAuthTimer) clearInterval(schedAuthTimer); + const check = async () => { + try { + const s = getAuthSchedule(); + if (!s.enabled || !s.revokeTime) return; + if (!deviceBridgeId) return; + const now = nowHHMM(); + // 支持跨天:revoke 23:00 restore 07:00 + let shouldRevoke = false; + if (s.revokeTime && s.restoreTime) { + if (s.revokeTime <= s.restoreTime) { + // 同日: revoke <= now <= restore 撤销 + shouldRevoke = + timeLeq(s.revokeTime, now) && timeLeq(now, s.restoreTime); + } else { + // 跨天: now >= revoke 或 now <= restore 撤销 + shouldRevoke = + timeLeq(s.revokeTime, now) || timeLeq(now, s.restoreTime); + } + } else if (s.revokeTime) { + shouldRevoke = timeLeq(s.revokeTime, now); + } + if (shouldRevoke && schedAuthState !== "revoked") { + schedAuthState = "revoked"; + await setDeviceAuthorized(false); + } else if (!shouldRevoke && schedAuthState === "revoked") { + schedAuthState = "restored"; + await setDeviceAuthorized(true); + } else if (!shouldRevoke && schedAuthState === null) { + schedAuthState = "restored"; + } + } catch (e) {} + }; + check(); + schedAuthTimer = setInterval(check, 60000); +} + // ============ 系统托盘(惰性 + 安全降级) ============ let tray = null; // 托盘菜单动态数据 @@ -1883,6 +2065,12 @@ app.whenReady().then(async () => { } catch (e) { console.error("[tray] whenReady call: " + e.message); } + // 定时撤销/恢复授权:按 prefs.deviceBridge.authSchedule {revokeTime, restoreTime} 每分钟检查 + try { + startScheduledAuth(); + } catch (e) { + console.error("[auth-schedule] start failed: " + e.message); + } createWindow(); }); @@ -1943,6 +2131,10 @@ function loadGuiPrefs() { enabled: !!db.enabled, gateway: db.gateway || "", token: db.token || "", + screensueDisplay: db.screensueDisplay || "0", + screensueDuration: db.screensueDuration || "0", + exec: db.exec || {}, + authSchedule: db.authSchedule || {}, }, }; } @@ -1951,7 +2143,15 @@ function loadGuiPrefs() { autoLaunch: false, silentStart: false, exitToTray: true, - deviceBridge: { enabled: true, gateway: "", token: "" }, + deviceBridge: { + enabled: true, + gateway: "", + token: "", + screensueDisplay: "0", + screensueDuration: "0", + exec: {}, + authSchedule: {}, + }, }; } @@ -1984,15 +2184,18 @@ let guiPrefs = loadGuiPrefs(); ipcMain.handle("displays:list", () => { try { const ds = screen.getAllDisplays() || []; - return ds.map((d, i) => ({ - index: i, - name: d.label || "显示器" + (i + 1), - id: d.id, - size: d.bounds ? d.size.width + "x" + d.size.height : "", - primary: - d.id === - (screen.getPrimaryDisplay ? screen.getPrimaryDisplay().id : -1), - })); + return ds.map((d, i) => { + const lbl = (d.label || "").trim(); + return { + index: i, + name: lbl || "显示器" + (i + 1), + id: d.id, + size: d.bounds ? d.bounds.width + "x" + d.bounds.height : "", + primary: + d.id === + (screen.getPrimaryDisplay ? screen.getPrimaryDisplay().id : -1), + }; + }); } catch (e) { return []; } @@ -2011,6 +2214,7 @@ ipcMain.handle("device-bridge:get", () => { address: deviceBridgeAddr, screensueDisplay: db.screensueDisplay || "0", exec: db.exec || {}, + authSchedule: db.authSchedule || {}, }; }); @@ -2037,6 +2241,9 @@ ipcMain.handle("device-bridge:set", (_, cfg) => { connected: !!deviceBridge, deviceId: deviceBridgeId, address: deviceBridgeAddr, + screensueDisplay: db.screensueDisplay || "0", + exec: db.exec || {}, + authSchedule: db.authSchedule || {}, }; }); diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 240d42b..5279b22 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -1,3 +1,22 @@ +// 全局 JS 错误捕获(透传到 gui.log 便于诊断) +window.onerror = (msg, src, line, col, err) => { + try { + if (window.homeagent && window.homeagent.log) + window.homeagent.log( + "JS-ERR " + msg + " @ " + src + ":" + line + ":" + col, + ); + } catch (e) {} +}; +window.onunhandledrejection = (e) => { + try { + if (window.homeagent && window.homeagent.log) + window.homeagent.log( + "JS-REJ " + + (e && e.reason ? String(e.reason).slice(0, 200) : "unknown"), + ); + } catch (e2) {} +}; + // ===== State ===== const state = { status: {}, @@ -466,11 +485,44 @@ async function api(p, o) { throw new Error(__("连接超时或失败", "Timeout or connection failed")); } clearTimeout(timer); - if (r.status === 401) throw new Error(__("认证失败", "unauthorized")); + if (r.status === 401) { + // 认证失败:尝试自动重新登录一次(避免 cookie 过期后界面持续报错) + if (window._haReloginLock) throw new Error(__("认证失败", "unauthorized")); + window._haReloginLock = true; + try { + await syncConnAuth(); + await new Promise((res2) => setTimeout(res2, 800)); + } catch (e2) {} + window._haReloginLock = false; + // 重试一次 + return api(p, o); + } if (opts.raw) return r; + var body = await r.text(); + // 网关会话过期:返回 200 但内容为登录页 HTML —— 自动重登后重试 + if ( + body.indexOf("THEME_PLACEHOLDER") !== -1 || + body.indexOf("统一门户登录") !== -1 || + (body.indexOf("