mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
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:
227
cmd/gui/main.js
227
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 || {},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user