gui: 自动重登/定时授权/副屏修复 + 排障(大量JS报错根因是cookie过期)

排障: "大量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 错误, 便于运维)
This commit is contained in:
2026-08-20 13:29:21 +08:00
parent c29abe9569
commit 257ff0ad5d
2 changed files with 298 additions and 13 deletions

View File

@ -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 || {},
};
});

View File

@ -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("<title>") !== -1 && body.indexOf("login") !== -1)
) {
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);
}
var ct = r.headers.get("content-type") || "";
if (ct.includes("json")) return r.json();
return r.text();
if (ct.includes("json")) {
try {
return JSON.parse(body);
} catch (e3) {
return body;
}
}
return body;
}
// ===== Navigation =====
@ -4899,6 +4951,7 @@ function renderDevices() {
}
var curGateway = dbc.gateway || webuiUrl || "";
var dbExec = dbc.exec || {};
var dbAuthSchedule = dbc.authSchedule || {};
var dispIdx = dbc.screensueDisplay || "0";
var dispOpts = (state.displays || [])
.map(
@ -4974,6 +5027,22 @@ function renderDevices() {
'" placeholder="沙箱目录" style="flex:1;min-width:120px;font-size:13px;padding:3px 6px;border-radius:4px;border:1px solid var(--border-color);background:var(--bg-input);color:var(--text-primary)">'
: '<input id="dev-bridge-boxdir" style="display:none">') +
"</div>" +
// 定时撤销/恢复授权(睡眠期间自动撤销,醒来自动恢复)
'<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">' +
"<label style='font-size:13px'>" +
__("定时撤销授权", "Scheduled revoke") +
'</label><label class="switch" style="margin-right:4px"><input type="checkbox" id="dev-auth-sched-enabled" ' +
(dbAuthSchedule && dbAuthSchedule.enabled ? "checked" : "") +
"><span></span></label>" +
__("撤销", "Revoke") +
' <input type="time" id="dev-auth-revoke" value="' +
escHtml((dbAuthSchedule && dbAuthSchedule.revokeTime) || "") +
'" style="font-size:13px;padding:2px 6px;border-radius:4px;border:1px solid var(--border-color);background:var(--bg-input);color:var(--text-primary)">' +
__("恢复", "Restore") +
' <input type="time" id="dev-auth-restore" value="' +
escHtml((dbAuthSchedule && dbAuthSchedule.restoreTime) || "") +
'" style="font-size:13px;padding:2px 6px;border-radius:4px;border:1px solid var(--border-color);background:var(--bg-input);color:var(--text-primary)">' +
"</div>" +
'<button class="btn btn-ghost btn-sm" onclick="saveBridgeChannel()">' +
__("保存并应用", "Save & Apply") +
"</button></div></div>";
@ -5080,6 +5149,15 @@ async function saveBridgeChannel() {
if (sandbox) ex.sandbox = sandbox.value || "off";
if (boxdir) ex.boxDir = boxdir.value.trim();
cfg.exec = ex;
// 定时撤销/恢复授权
var schedEnabled = document.getElementById("dev-auth-sched-enabled");
var schedRevoke = document.getElementById("dev-auth-revoke");
var schedRestore = document.getElementById("dev-auth-restore");
cfg.authSchedule = {
enabled: !!(schedEnabled && schedEnabled.checked),
revokeTime: schedRevoke ? schedRevoke.value || "" : "",
restoreTime: schedRestore ? schedRestore.value || "" : "",
};
var r = await window.homeagent.deviceBridge.set(cfg);
state.dbConfig = r || state.dbConfig;
toast(__("设备通道已保存并应用", "Device channel saved & applied"));