mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
- GUI main.js: screensue 默认 duration 从 0(常驻) 改为 5 秒自动关闭 - 命令带数字 token 指定时长: "screensue 30 内容"=显示30秒 - 命令带 0 表示永不超时: "screensue 0 重要公告"=常驻直到用户手动关闭 - gui-prefs.screensueDuration 用户配置默认值(未配置时 5),命令参数优先级最高 - 设置页(app.js)新增「默认时长(秒)」输入框(placeholder 提示 0=常驻) - 回执文案区分 for Ns / persistent until closed - 服务端 device_ctl_cmdrun 工具描述同步更新(5秒默认/指定秒数/0永不超时)
2275 lines
68 KiB
JavaScript
2275 lines
68 KiB
JavaScript
const { app, BrowserWindow, ipcMain, Menu, screen } = require("electron");
|
||
let screensueWin = null; // screensue 展示窗口(独立于主窗口,显示在配置的屏幕)
|
||
// 设备桥直连远程网关:绕过系统代理(本机 clash 代理会导致 wss 被雷池 403)
|
||
try {
|
||
app.commandLine.appendSwitch("no-proxy-server");
|
||
} catch (e) {}
|
||
const path = require("path");
|
||
const fs = require("fs");
|
||
const { spawn } = require("child_process");
|
||
const http = require("http");
|
||
const crypto = require("crypto");
|
||
const { pathToFileURL } = require("url");
|
||
|
||
const CONNECTIONS_FILE = path.join(app.getPath("userData"), "connections.json");
|
||
const LOG_FILE = path.join(app.getPath("userData"), "gui.log");
|
||
function log(msg) {
|
||
try {
|
||
fs.appendFileSync(LOG_FILE, new Date().toISOString() + " " + msg + "\n");
|
||
} catch (e) {}
|
||
}
|
||
const _consoleLog = console.log,
|
||
_consoleErr = console.error;
|
||
console.log = function () {
|
||
log(Array.prototype.slice.call(arguments).join(" "));
|
||
_consoleLog.apply(null, arguments);
|
||
};
|
||
console.error = function () {
|
||
log("ERR " + Array.prototype.slice.call(arguments).join(" "));
|
||
_consoleErr.apply(null, arguments);
|
||
};
|
||
|
||
let homedProcess = null;
|
||
let mainWindow;
|
||
let authRule = null;
|
||
|
||
function installAuthRule() {
|
||
const { session } = require("electron");
|
||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||
const h = Object.assign({}, details.requestHeaders);
|
||
// 按目标 host 校验作用域:网关会把请求 302 到 login.* 域,若按 url 前缀匹配,
|
||
// 登录域自身请求会被错误附加本站 cookie。
|
||
const hu = (() => {
|
||
try {
|
||
return new URL(details.url);
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
})();
|
||
if (authRule && hu && authRule.hosts && authRule.hosts.has(hu.hostname)) {
|
||
if (authRule.headers) {
|
||
Object.keys(authRule.headers).forEach((k) => {
|
||
h[k] = authRule.headers[k];
|
||
});
|
||
}
|
||
// Electron 会自动附带 jar 中 Cookie(含外部网关 Set-Cookie 的 sl-session)。
|
||
// 这里再显式合并持久化 cookie 与 sl-session 兜底,避免网关再 302。
|
||
// 注意去重:多个 sl-session 叠加会让 portal 网关解析冲突 → 401
|
||
const extra = [authRule.cookie, authRule.slSession].filter(Boolean);
|
||
if (extra.length) {
|
||
const existing = h["Cookie"] || "";
|
||
const merged = [existing].concat(extra).filter(Boolean).join("; ");
|
||
// 按 cookie 名去重:保留第一个,后续同名丢弃(网关只认首值)
|
||
const seen = {};
|
||
const dedup = merged
|
||
.split("; ")
|
||
.map((s) => s.trim())
|
||
.filter((s) => {
|
||
const eq = s.indexOf("=");
|
||
const name = eq > 0 ? s.slice(0, eq) : s;
|
||
if (seen[name]) return false;
|
||
seen[name] = true;
|
||
return true;
|
||
})
|
||
.join("; ");
|
||
h["Cookie"] = dedup;
|
||
}
|
||
}
|
||
callback({ requestHeaders: h });
|
||
});
|
||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||
const h = Object.assign({}, details.responseHeaders || {});
|
||
const inScope = (() => {
|
||
if (details.method === "OPTIONS") return true;
|
||
if (!authRule || !authRule.urlHost) return false;
|
||
try {
|
||
return new URL(details.url).hostname === authRule.urlHost;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
})();
|
||
if (inScope) {
|
||
h["Access-Control-Allow-Origin"] = ["*"];
|
||
h["Access-Control-Allow-Methods"] = ["GET,POST,PUT,DELETE,OPTIONS"];
|
||
h["Access-Control-Allow-Headers"] = [
|
||
"Content-Type, X-API-Key, Authorization, Cookie",
|
||
];
|
||
h["Access-Control-Max-Age"] = ["86400"];
|
||
}
|
||
if (details.method === "OPTIONS") {
|
||
callback({ responseHeaders: h, statusLine: "HTTP/1.1 200 OK" });
|
||
return;
|
||
}
|
||
callback({ responseHeaders: h });
|
||
});
|
||
session.defaultSession.webRequest.onCompleted((details) => {
|
||
if (!authRule || !authRule.urlHost) return;
|
||
try {
|
||
if (new URL(details.url).hostname !== authRule.urlHost) return;
|
||
} catch (e) {
|
||
return;
|
||
}
|
||
if (details.url.indexOf("/api/") !== -1) {
|
||
log(
|
||
"[req] " +
|
||
details.method +
|
||
" " +
|
||
details.statusCode +
|
||
" " +
|
||
details.url.slice(0, 120),
|
||
);
|
||
}
|
||
});
|
||
session.defaultSession.webRequest.onErrorOccurred((details) => {
|
||
if (!authRule || !authRule.urlHost) return;
|
||
try {
|
||
if (new URL(details.url).hostname !== authRule.urlHost) return;
|
||
} catch (e) {
|
||
return;
|
||
}
|
||
log(
|
||
"[req-err] " +
|
||
details.method +
|
||
" " +
|
||
details.error +
|
||
" " +
|
||
details.url.slice(0, 120),
|
||
);
|
||
});
|
||
}
|
||
|
||
// 组装 authRule(url + 持久化 cookie + 显式 sl-session),并缓存 hosts 集合。
|
||
// 供 installAuthRule 按目标 host 做作用域匹配:jsdom 前缀匹配有误伤,host 级最稳。
|
||
async function applyAuthRule(url, cookie, headers) {
|
||
if (!url) {
|
||
authRule = null;
|
||
return;
|
||
}
|
||
let hosts;
|
||
let urlHost = "";
|
||
try {
|
||
const h = new URL(url).hostname;
|
||
urlHost = h;
|
||
hosts = new Set([h]);
|
||
} catch (e) {
|
||
hosts = new Set();
|
||
}
|
||
authRule = {
|
||
url,
|
||
urlHost,
|
||
hosts,
|
||
cookie: cookie || "",
|
||
headers: headers || {},
|
||
};
|
||
// 显式的 sl-session 从 cookie jar 里取(网关 Set-Cookie 且 HttpOnly)
|
||
authRule.slSession = await adoptSlSession(url);
|
||
}
|
||
|
||
// 从 Electron cookie jar 中取出目标 host 的 sl-session(若存在)。
|
||
async function adoptSlSession(url) {
|
||
try {
|
||
const { session } = require("electron");
|
||
const hostname = new URL(url).hostname;
|
||
const all = await session.defaultSession.cookies.get({});
|
||
const hit = (all || []).find(
|
||
(c) =>
|
||
c.name === "sl-session" &&
|
||
(c.domain || "").replace(/^\./, "") === hostname,
|
||
);
|
||
return hit ? "sl-session=" + hit.value : "";
|
||
} catch (e) {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
// 带 Electron cookie jar 的 fetch(跟随外部网关 302 → login 域 → 回跳,最多 6 跳),
|
||
// 使 POST /api/v1/login 真正触达内层 HomeAgent 并取回 homeagent_session。
|
||
async function sessionFetch(initUrl, opts) {
|
||
const { session } = require("electron");
|
||
let url = initUrl;
|
||
let resp;
|
||
for (let i = 0; i < 6; i++) {
|
||
resp = await session.defaultSession.fetch(url, opts);
|
||
if (
|
||
resp.status >= 300 &&
|
||
resp.status < 400 &&
|
||
resp.headers.get("location")
|
||
) {
|
||
url = new URL(resp.headers.get("location"), url).toString();
|
||
continue;
|
||
}
|
||
return resp;
|
||
}
|
||
return resp;
|
||
}
|
||
|
||
function loadConnections() {
|
||
try {
|
||
if (fs.existsSync(CONNECTIONS_FILE)) {
|
||
const raw = fs
|
||
.readFileSync(CONNECTIONS_FILE, "utf-8")
|
||
.replace(/^\uFEFF/, "");
|
||
const data = JSON.parse(raw);
|
||
normalizeConnections(data);
|
||
return data;
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to load connections:", e);
|
||
// 配置损坏:备份后重建,避免应用一直处于"无连接"状态
|
||
try {
|
||
const backup = CONNECTIONS_FILE + ".bak";
|
||
fs.copyFileSync(CONNECTIONS_FILE, backup);
|
||
fs.writeFileSync(
|
||
CONNECTIONS_FILE,
|
||
'{"connections":[],"currentId":null}',
|
||
"utf-8",
|
||
);
|
||
console.error("Backed up corrupt connections to", backup);
|
||
} catch (e2) {
|
||
console.error("Failed to recover connections file:", e2);
|
||
}
|
||
}
|
||
// Fallback: check app resource dir (installer writes fallback copy there)
|
||
try {
|
||
const fallback = path.join(__dirname, "connections.json");
|
||
if (fs.existsSync(fallback)) {
|
||
const data = JSON.parse(fs.readFileSync(fallback, "utf-8"));
|
||
normalizeConnections(data);
|
||
saveConnections(data);
|
||
console.log("Imported connections from app resource dir");
|
||
return data;
|
||
}
|
||
} catch (e) {
|
||
console.error("Fallback connections load failed:", e);
|
||
}
|
||
return { connections: [], currentId: null };
|
||
}
|
||
|
||
// 兼容旧数据:缺失的 type 默认为 webui(HTTP)
|
||
function normalizeConnections(data) {
|
||
if (!data || !Array.isArray(data.connections)) return;
|
||
data.connections.forEach((c) => {
|
||
if (!c.type) c.type = "webui";
|
||
if (c.type !== "cli" && c.type !== "webui") c.type = "webui";
|
||
});
|
||
}
|
||
|
||
function saveConnections(data) {
|
||
try {
|
||
fs.writeFileSync(CONNECTIONS_FILE, JSON.stringify(data, null, 2), "utf-8");
|
||
} catch (e) {
|
||
console.error("Failed to save connections:", e);
|
||
}
|
||
}
|
||
|
||
function findHomed() {
|
||
if (process.platform !== "win32") return null;
|
||
const exeDir = path.dirname(app.getPath("exe"));
|
||
const p = path.resolve(exeDir, "..", "homed.exe");
|
||
return fs.existsSync(p) ? p : null;
|
||
}
|
||
|
||
function isServerRunning() {
|
||
return new Promise((resolve) => {
|
||
const req = http.get("http://localhost:8080/", () => resolve(true));
|
||
req.on("error", () => resolve(false));
|
||
req.setTimeout(2000, () => {
|
||
req.destroy();
|
||
resolve(false);
|
||
});
|
||
});
|
||
}
|
||
|
||
function waitForServer(maxWait = 8000) {
|
||
return new Promise((resolve) => {
|
||
const start = Date.now();
|
||
const check = () => {
|
||
isServerRunning().then((running) => {
|
||
if (running) return resolve(true);
|
||
if (Date.now() - start > maxWait) return resolve(false);
|
||
setTimeout(check, 300);
|
||
});
|
||
};
|
||
check();
|
||
});
|
||
}
|
||
|
||
function startHomed() {
|
||
const homedBin = findHomed();
|
||
if (!homedBin) {
|
||
console.log("homed.exe not found near GUI, skipping auto-launch");
|
||
return;
|
||
}
|
||
const dataDir = path.resolve(path.dirname(homedBin), "data");
|
||
console.log("Starting homed:", homedBin, "-data", dataDir);
|
||
homedProcess = spawn(homedBin, ["-data", dataDir], {
|
||
stdio: "ignore",
|
||
detached: false,
|
||
windowsHide: true,
|
||
});
|
||
homedProcess.on("error", (err) => {
|
||
console.error("homed start failed:", err.message);
|
||
homedProcess = null;
|
||
});
|
||
homedProcess.on("exit", (code) => {
|
||
console.log("homed exited with code", code);
|
||
homedProcess = null;
|
||
});
|
||
}
|
||
|
||
function stopHomed() {
|
||
if (homedProcess) {
|
||
homedProcess.kill();
|
||
homedProcess = null;
|
||
}
|
||
}
|
||
|
||
function createWindow() {
|
||
const menu = Menu.buildFromTemplate([]);
|
||
Menu.setApplicationMenu(menu);
|
||
|
||
mainWindow = new BrowserWindow({
|
||
width: 1280,
|
||
height: 860,
|
||
minWidth: 900,
|
||
minHeight: 600,
|
||
title: "HomeAgent",
|
||
frame: false,
|
||
icon: path.join(__dirname, "icon.ico"),
|
||
webPreferences: {
|
||
preload: path.join(__dirname, "preload.js"),
|
||
contextIsolation: true,
|
||
nodeIntegration: false,
|
||
},
|
||
});
|
||
|
||
mainWindow.loadFile(path.join(__dirname, "renderer", "index.html"));
|
||
|
||
if (process.argv.includes("--dev")) {
|
||
mainWindow.webContents.openDevTools();
|
||
}
|
||
|
||
// 退出进托盘:拦截窗口关闭事件(exitToTray 且托盘可用时 hide 而非 close)
|
||
mainWindow.on("close", (e) => {
|
||
try {
|
||
const _p = loadGuiPrefs();
|
||
if (_p && _p.exitToTray && tray) {
|
||
e.preventDefault();
|
||
mainWindow.hide();
|
||
return;
|
||
}
|
||
} catch (err) {}
|
||
});
|
||
mainWindow.on("closed", () => {
|
||
mainWindow = null;
|
||
});
|
||
}
|
||
|
||
ipcMain.handle("window:minimize", (e) => {
|
||
BrowserWindow.fromWebContents(e.sender)?.minimize();
|
||
});
|
||
ipcMain.handle("window:toggleMaximize", (e) => {
|
||
const win = BrowserWindow.fromWebContents(e.sender);
|
||
if (!win) return;
|
||
if (win.isMaximized()) win.unmaximize();
|
||
else win.maximize();
|
||
});
|
||
ipcMain.handle("window:close", (e) => {
|
||
const win = BrowserWindow.fromWebContents(e.sender);
|
||
if (!win) return;
|
||
// 退出进托盘:偏好开启且托盘存在时隐藏而非关闭
|
||
try {
|
||
const _p = loadGuiPrefs();
|
||
console.log(
|
||
"[window:close] exitToTray=" +
|
||
!!(_p && _p.exitToTray) +
|
||
" tray=" +
|
||
!!tray,
|
||
);
|
||
if (_p && _p.exitToTray && tray) {
|
||
win.hide();
|
||
return true;
|
||
}
|
||
} catch (err) {}
|
||
win.close();
|
||
});
|
||
|
||
ipcMain.handle("connections:list", () => {
|
||
return loadConnections();
|
||
});
|
||
|
||
ipcMain.handle("connections:add", (_, conn) => {
|
||
const data = loadConnections();
|
||
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||
data.connections.push({
|
||
id,
|
||
name: conn.name || "",
|
||
url: conn.url || "",
|
||
apiKey: conn.apiKey || "",
|
||
type: conn.type === "cli" ? "cli" : "webui",
|
||
socketPath: conn.socketPath || "",
|
||
// 网关认证字段在 add 时必须一并持久化,否则新增连接后 cookie/网关标记丢失
|
||
username: conn.username || "",
|
||
password: conn.password || "",
|
||
cookie: conn.cookie || "",
|
||
headers: conn.headers || "",
|
||
gateway: !!conn.gateway,
|
||
});
|
||
if (!data.currentId) data.currentId = id;
|
||
saveConnections(data);
|
||
try {
|
||
rebuildTrayMenu();
|
||
} catch (e) {}
|
||
return data;
|
||
});
|
||
|
||
ipcMain.handle("connections:update", (_, { id, updates }) => {
|
||
const data = loadConnections();
|
||
const idx = data.connections.findIndex((c) => c.id === id);
|
||
if (idx !== -1) {
|
||
data.connections[idx] = { ...data.connections[idx], ...updates };
|
||
saveConnections(data);
|
||
}
|
||
try {
|
||
rebuildTrayMenu();
|
||
} catch (e) {}
|
||
return data;
|
||
});
|
||
|
||
ipcMain.handle("connections:delete", (_, id) => {
|
||
const data = loadConnections();
|
||
data.connections = data.connections.filter((c) => c.id !== id);
|
||
if (data.currentId === id) {
|
||
data.currentId =
|
||
data.connections.length > 0 ? data.connections[0].id : null;
|
||
}
|
||
saveConnections(data);
|
||
try {
|
||
rebuildTrayMenu();
|
||
} catch (e) {}
|
||
return data;
|
||
});
|
||
|
||
function downloadTo(src, dest) {
|
||
return new Promise((resolve, reject) => {
|
||
const mod = src.startsWith("https:") ? require("https") : http;
|
||
const req = mod.get(
|
||
src,
|
||
{
|
||
headers: {
|
||
"User-Agent":
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
},
|
||
rejectUnauthorized: false,
|
||
},
|
||
(res) => {
|
||
if (
|
||
res.statusCode >= 300 &&
|
||
res.statusCode < 400 &&
|
||
res.headers.location
|
||
) {
|
||
res.resume();
|
||
downloadTo(new URL(res.headers.location, src).toString(), dest).then(
|
||
resolve,
|
||
reject,
|
||
);
|
||
return;
|
||
}
|
||
if (res.statusCode !== 200) {
|
||
res.resume();
|
||
reject(new Error("HTTP " + res.statusCode));
|
||
return;
|
||
}
|
||
const out = fs.createWriteStream(dest);
|
||
res.pipe(out);
|
||
out.on("finish", () => out.close(resolve));
|
||
out.on("error", reject);
|
||
res.on("error", reject);
|
||
},
|
||
);
|
||
req.on("error", reject);
|
||
req.setTimeout(60000, () => req.destroy(new Error("timeout")));
|
||
});
|
||
}
|
||
|
||
ipcMain.handle("bg:cache", async (_, { src }) => {
|
||
if (!src || typeof src !== "string") return { ok: false, error: "no src" };
|
||
const dir = path.join(app.getPath("userData"), "bg-cache");
|
||
try {
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
const hash = crypto
|
||
.createHash("sha1")
|
||
.update(src)
|
||
.digest("hex")
|
||
.slice(0, 24);
|
||
let dest = null;
|
||
if (/^https?:\/\//i.test(src)) {
|
||
dest = path.join(dir, hash);
|
||
if (!fs.existsSync(dest)) {
|
||
try {
|
||
await downloadTo(src, dest);
|
||
} catch (e) {
|
||
return {
|
||
ok: false,
|
||
error: "下载失败: " + e.message,
|
||
useOriginal: true,
|
||
};
|
||
}
|
||
}
|
||
} else if (/^file:\/\//i.test(src)) {
|
||
dest = new URL(src).pathname.replace(/^\/([A-Za-z]:)/, "$1");
|
||
if (!fs.existsSync(dest))
|
||
return { ok: false, error: "文件不存在: " + src };
|
||
} else if (/^data:image\//i.test(src)) {
|
||
dest = path.join(dir, hash + ".png");
|
||
if (!fs.existsSync(dest))
|
||
fs.writeFileSync(dest, Buffer.from(src.split(",")[1] || "", "base64"));
|
||
} else {
|
||
const p = path.resolve(src);
|
||
if (!fs.existsSync(p)) return { ok: false, error: "路径不存在: " + src };
|
||
dest = path.join(dir, hash + (path.extname(p) || ""));
|
||
if (!fs.existsSync(dest)) fs.copyFileSync(p, dest);
|
||
}
|
||
return { ok: true, file: pathToFileURL(dest).href };
|
||
} catch (e) {
|
||
return { ok: false, error: e.message };
|
||
}
|
||
});
|
||
|
||
ipcMain.handle("connections:setCurrent", (_, id) => {
|
||
const data = loadConnections();
|
||
if (data.connections.some((c) => c.id === id)) {
|
||
data.currentId = id;
|
||
saveConnections(data);
|
||
}
|
||
try {
|
||
rebuildTrayMenu();
|
||
} catch (e) {}
|
||
return data;
|
||
});
|
||
|
||
async function doWebuiLogin(baseUrl, username, password, extraCookie) {
|
||
const u = baseUrl + "/api/v1/login";
|
||
const headers = {
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
};
|
||
if (extraCookie) headers["Cookie"] = extraCookie;
|
||
const resp = await sessionFetch(u, {
|
||
method: "POST",
|
||
headers,
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
// 1) Set-Cookie 直读(重定向未被 session.fetch 自动吞掉时)
|
||
const sc = resp.headers.get("set-cookie") || "";
|
||
let m = /homeagent_session=([^;]+)/.exec(sc);
|
||
// 2) 重定向被自动跟随时 Set-Cookie 可能在中间 302 上:从 cookie jar 兜底
|
||
if (!m) {
|
||
try {
|
||
const { session } = require("electron");
|
||
const hostname = new URL(u).hostname;
|
||
const all = await session.defaultSession.cookies.get({});
|
||
const hit = (all || []).find(
|
||
(c) =>
|
||
c.name === "homeagent_session" &&
|
||
(c.domain || "").replace(/^\./, "").toLowerCase() ===
|
||
hostname.toLowerCase(),
|
||
);
|
||
if (hit) m = [null, hit.value];
|
||
} catch (e) {}
|
||
}
|
||
if (!m) {
|
||
const bodyTxt = await resp.text();
|
||
throw new Error(
|
||
"login ok but no homeagent_session cookie (HTTP " +
|
||
resp.status +
|
||
" " +
|
||
bodyTxt.slice(0, 150) +
|
||
")",
|
||
);
|
||
}
|
||
return m[1];
|
||
}
|
||
|
||
ipcMain.on("log:r", (_e, m) => {
|
||
log("[r] " + m);
|
||
});
|
||
ipcMain.handle("log:r", (_e, m) => {
|
||
log("[r] " + m);
|
||
return true;
|
||
});
|
||
|
||
ipcMain.handle(
|
||
"webui:setAuth",
|
||
async (_, { url, cookie, headers, username, password }) => {
|
||
const resp = { ok: true, error: "" };
|
||
let extra = cookie || "";
|
||
try {
|
||
if (username && url) {
|
||
try {
|
||
const tok = await doWebuiLogin(url, username, password, extra);
|
||
extra = extra
|
||
? extra + "; homeagent_session=" + tok
|
||
: "homeagent_session=" + tok;
|
||
} catch (e) {
|
||
if (extra) {
|
||
log(
|
||
"[setAuth] auto-login skipped (gateway cookie mode): " +
|
||
e.message.slice(0, 120),
|
||
);
|
||
} else {
|
||
throw e;
|
||
}
|
||
}
|
||
} else if (!cookie) {
|
||
await applyAuthRule("", "", {});
|
||
return resp;
|
||
}
|
||
} catch (e) {
|
||
await applyAuthRule(url, extra || "", headers || {});
|
||
resp.ok = false;
|
||
resp.error = e.message;
|
||
log(
|
||
"[setAuth] fail url=" + (url || "") + " err=" + e.message.slice(0, 150),
|
||
);
|
||
return resp;
|
||
}
|
||
await applyAuthRule(url, extra, headers || {});
|
||
log(
|
||
"[setAuth] ok url=" +
|
||
(url || "") +
|
||
" cookieLen=" +
|
||
extra.length +
|
||
" hasTok=" +
|
||
(extra.indexOf("homeagent_session") !== -1),
|
||
);
|
||
return resp;
|
||
},
|
||
);
|
||
|
||
ipcMain.handle("webui:openLogin", (_, { url, username, password }) => {
|
||
if (!url) return { ok: false, error: "no url" };
|
||
const loginWin = new BrowserWindow({
|
||
width: 980,
|
||
height: 740,
|
||
parent: mainWindow,
|
||
modal: true,
|
||
autoHideMenuBar: true,
|
||
title: "HomeAgent - 网关联机登录",
|
||
webPreferences: { contextIsolation: true, nodeIntegration: false },
|
||
});
|
||
const u = encodeURIComponent(username || "");
|
||
const p = encodeURIComponent(password || "");
|
||
let autoFills = 0;
|
||
const tryAutofill = () => {
|
||
if (autoFills > 8) return;
|
||
autoFills++;
|
||
loginWin.webContents
|
||
.executeJavaScript(
|
||
'(function(){var pw=document.querySelector("input[type=password]");' +
|
||
'if(!pw)return false;var f=pw.closest("form")||pw.form;if(!f)return false;' +
|
||
'var ins=f.querySelectorAll("input");var filled=false;' +
|
||
'for(var i=0;i<ins.length;i++){var el=ins[i];var t=(el.type||"").toLowerCase();' +
|
||
'if(t==="password"){if(!el.value)el.value=decodeURIComponent("' +
|
||
p +
|
||
'");}' +
|
||
'else if(t!=="hidden"&&t!=="checkbox"&&t!=="submit"&&t!=="button"){' +
|
||
'if(!el.value)el.value=decodeURIComponent("' +
|
||
u +
|
||
'");}}' +
|
||
'if(f.querySelector("button")){}' +
|
||
'var sb=f.querySelector("input[type=submit],button[type=submit]");' +
|
||
"if(sb&&sb.disabled)return false;" +
|
||
"if(f.requestSubmit)f.requestSubmit();else f.submit();" +
|
||
"return true;})()",
|
||
)
|
||
.then((r) => {
|
||
if (r) log("[openLogin] autofilled gateway login form");
|
||
})
|
||
.catch((e) => log("[openLogin] autofill error: " + e.message));
|
||
};
|
||
loginWin.webContents.on("did-finish-load", () => {
|
||
if (autoFills < 3) tryAutofill();
|
||
});
|
||
loginWin.webContents.on("did-navigate", (_e, u) => {
|
||
log("[openLogin] nav: " + u);
|
||
if (autoFills < 6) setTimeout(tryAutofill, 600);
|
||
});
|
||
loginWin.webContents.on(
|
||
"did-fail-load",
|
||
(_e, code, desc, isMain, failedUrl) => {
|
||
if (isMain)
|
||
log("[openLogin] fail-load " + code + " " + desc + " @ " + failedUrl);
|
||
},
|
||
);
|
||
loginWin
|
||
.loadURL(url)
|
||
.catch((e) => console.error("login window load error:", e.message));
|
||
let grabbed = null;
|
||
loginWin.on("close", async () => {
|
||
if (grabbed) return;
|
||
try {
|
||
const all = await require("electron").session.defaultSession.cookies.get(
|
||
{},
|
||
);
|
||
const host = new URL(url).hostname;
|
||
const keep = (all || []).filter((c) => {
|
||
const d = (c.domain || "").replace(/^\./, "");
|
||
const onHost = host === d || host.endsWith("." + d);
|
||
const onSibling = /(^|\.)jianfgit\.xyz$/i.test(d);
|
||
return onHost || onSibling || /^sl-/.test(c.name || "");
|
||
});
|
||
const list = keep.map((c) => c.name + "=" + c.value);
|
||
// 目标 host 的 sl-session 一并带上(网关会话是 host 级,登录域抓取未必覆盖)
|
||
const sl = await adoptSlSession(url);
|
||
if (sl && list.indexOf(sl) === -1) list.push(sl);
|
||
log(
|
||
"[openLogin] close-grab: host=" +
|
||
host +
|
||
" kept=" +
|
||
keep.length +
|
||
" names=" +
|
||
keep.map((c) => c.name + "@" + (c.domain || "")).join(","),
|
||
);
|
||
grabbed = {
|
||
ok: true,
|
||
cookie: list.join("; "),
|
||
count: keep.length + (sl ? 1 : 0),
|
||
};
|
||
} catch (e) {
|
||
log("[openLogin] close-grab error: " + e.message);
|
||
grabbed = { ok: false, error: e.message };
|
||
}
|
||
});
|
||
loginWin.on("closed", () => {
|
||
if (mainWindow && !mainWindow.isDestroyed() && grabbed) {
|
||
mainWindow.webContents.send("webui:login-result", { ...grabbed, url });
|
||
}
|
||
});
|
||
return { ok: true };
|
||
});
|
||
|
||
// CLI 传输:通过 homed 的 unix socket(逐行 JSON 协议)发起请求。
|
||
// 认证行:/auth <apiKey>(若配置了密钥)。返回 JSON 响应行。
|
||
ipcMain.handle("cli:request", (_, { socketPath, apiKey, line }) => {
|
||
return new Promise((resolve) => {
|
||
const net = require("net");
|
||
let client;
|
||
try {
|
||
client = net.createConnection({ path: socketPath });
|
||
} catch (e) {
|
||
return resolve({ error: "create connection: " + e.message });
|
||
}
|
||
const timeout = setTimeout(() => {
|
||
try {
|
||
client.destroy();
|
||
} catch (_) {}
|
||
resolve({ error: "timeout waiting for cli response" });
|
||
}, 30000);
|
||
|
||
let buf = "";
|
||
const onData = (chunk) => {
|
||
buf += chunk.toString("utf8");
|
||
const idx = buf.indexOf("\n");
|
||
if (idx === -1) return;
|
||
const lineOut = buf.slice(0, idx);
|
||
clearTimeout(timeout);
|
||
try {
|
||
client.destroy();
|
||
} catch (_) {}
|
||
try {
|
||
resolve(JSON.parse(lineOut));
|
||
} catch (e) {
|
||
resolve({ error: "bad response: " + lineOut });
|
||
}
|
||
};
|
||
const onError = (err) => {
|
||
clearTimeout(timeout);
|
||
try {
|
||
client.destroy();
|
||
} catch (_) {}
|
||
resolve({ error: err.message });
|
||
};
|
||
|
||
client.on("error", onError);
|
||
client.on("data", onData);
|
||
client.on("connect", () => {
|
||
let next = line;
|
||
if (apiKey) next = "/auth " + apiKey + "\n" + next;
|
||
client.write(next + "\n");
|
||
});
|
||
});
|
||
});
|
||
|
||
// ============ Device Bridge: GUI 作为设备接入 remotedevice 网关 ============
|
||
// 复用 net(raw TCP)+ 手写 WS 帧;收到 {op:"cmd"} 用 spawn 在本机执行并回 cmd_result。
|
||
const devNet = require("net");
|
||
const devTls = require("tls");
|
||
const devOs = require("os");
|
||
|
||
let deviceBridge = null; // 当前活动设备桥
|
||
let deviceBridgeId = ""; // 设备 meta device_id(hello 后可用于 cmd_result)
|
||
let deviceBridgeAddr = ""; // 设备桥网关地址
|
||
// 音频/媒体接收聚合缓冲(服务端分块推送二进制→聚合→播放)
|
||
let speechAccum = null;
|
||
|
||
// 播放设备收到的音频(由 cmd_speech_end 触发,二进制已聚合)
|
||
function playDeviceAudio(audioBuf, mime, reqId) {
|
||
try {
|
||
const os = require("os");
|
||
const path = require("path");
|
||
const fs = require("fs");
|
||
const porcp = require("child_process");
|
||
const ext =
|
||
(mime || "").indexOf("mp3") === -1
|
||
? (mime || "").indexOf("ogg") === -1
|
||
? "wav"
|
||
: "ogg"
|
||
: "mp3";
|
||
const tmp = path.join(os.tmpdir(), "ha_speech_" + Date.now() + "." + ext);
|
||
fs.writeFileSync(tmp, audioBuf);
|
||
const platform = process.platform;
|
||
let cmd = null;
|
||
let args = [];
|
||
if (platform === "linux") {
|
||
if (porcp.spawnSync("which", ["aplay"]).status === 0) {
|
||
cmd = "aplay";
|
||
args = [tmp];
|
||
} else if (porcp.spawnSync("which", ["paplay"]).status === 0) {
|
||
cmd = "paplay";
|
||
args = [tmp];
|
||
} else if (porcp.spawnSync("which", ["ffplay"]).status === 0) {
|
||
cmd = "ffplay";
|
||
args = ["-nodisp", "-autoexit", "-loglevel", "quiet", tmp];
|
||
}
|
||
} else if (platform === "darwin") {
|
||
cmd = "afplay";
|
||
args = [tmp];
|
||
} else if (platform === "win32") {
|
||
cmd = "powershell";
|
||
args = [
|
||
"-Command",
|
||
"(New-Object Media.SoundPlayer '" + tmp + "').PlaySync()",
|
||
];
|
||
}
|
||
if (cmd) {
|
||
porcp.execFile(cmd, args, { timeout: 60000 }, () => {
|
||
try {
|
||
fs.unlinkSync(tmp);
|
||
} catch (e2) {}
|
||
});
|
||
// 回执:媒体收到并开始播放
|
||
sendCmdResult(
|
||
reqId || "",
|
||
baseResult(
|
||
reqId || "",
|
||
"ok",
|
||
"audio played: " + audioBuf.length + " bytes",
|
||
"",
|
||
),
|
||
);
|
||
} else {
|
||
// 无播放器,至少落盘供手动查看,回执带文件路径
|
||
sendCmdResult(
|
||
reqId || "",
|
||
baseResult(
|
||
reqId || "",
|
||
"ok",
|
||
"audio saved: " + tmp + " (" + audioBuf.length + " bytes)",
|
||
"",
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
console.log("[device-bridge] play audio error: " + e.message);
|
||
}
|
||
}
|
||
|
||
// 建立到 remotedevice WS 网关连接,返回 {send(obj), close()},消息经 onMsg 回调。
|
||
function connectDeviceWS(url, token, onMsg) {
|
||
// URL 支持完整端点(含路径/端口/TLS),不硬编码 host/path
|
||
let u;
|
||
try {
|
||
u = new URL(url || "ws://127.0.0.1:9890/api/v1/device/ws");
|
||
} catch (e) {
|
||
return Promise.reject(new Error("invalid device gateway url: " + url));
|
||
}
|
||
const isTLS = u.protocol === "wss:" || u.protocol === "https:";
|
||
const host = u.hostname;
|
||
// https/wss 默认 443,ws/http 默认 9890(remotedevice 默认端口)
|
||
const defaultPort = isTLS ? 443 : 9890;
|
||
const port = u.port ? parseInt(u.port, 10) : defaultPort;
|
||
const basePath = u.pathname || "/api/v1/device/ws";
|
||
const sep = u.search ? "&" : "?";
|
||
const path =
|
||
basePath +
|
||
(u.search || "") +
|
||
sep +
|
||
"token=" +
|
||
encodeURIComponent(token || "");
|
||
return new Promise((resolve, reject) => {
|
||
const sock = isTLS
|
||
? devTls.connect({ host, port, rejectUnauthorized: false })
|
||
: devNet.createConnection({ host, port });
|
||
sock.on("error", (e) => reject(e));
|
||
sock.on("connect", () => {
|
||
const key = crypto.randomBytes(16).toString("base64");
|
||
// 远程 wss 走 webui 反代需要门户会话 cookie
|
||
// 优先 authRule(连接认证注入);启动早期 authRule 未就绪时读 connections.json
|
||
let cookieHdr = "";
|
||
try {
|
||
let ck = "";
|
||
// 优先 connections.json 的持久 cookie(完整登录验证过,浏览器快照可能不完整)
|
||
try {
|
||
const conns = loadConnections();
|
||
const curId = conns.currentId;
|
||
const cur =
|
||
conns.connections.find((c) => c.id === curId) ||
|
||
conns.connections[0];
|
||
if (cur && cur.cookie) ck = cur.cookie;
|
||
} catch (e2) {}
|
||
if (!ck && authRule && authRule.cookie) {
|
||
ck = authRule.cookie;
|
||
}
|
||
if (ck) cookieHdr = "Cookie: " + ck + "\r\n";
|
||
console.log("[device-bridge] ws cookie len=" + (ck || "").length);
|
||
} catch (e) {}
|
||
sock.write(
|
||
"GET " +
|
||
path +
|
||
" HTTP/1.1\r\n" +
|
||
"Host: " +
|
||
host +
|
||
":" +
|
||
port +
|
||
"\r\n" +
|
||
"Upgrade: websocket\r\nConnection: Upgrade\r\n" +
|
||
"Sec-WebSocket-Key: " +
|
||
key +
|
||
"\r\nSec-WebSocket-Version: 13\r\n" +
|
||
cookieHdr +
|
||
"\r\n",
|
||
);
|
||
});
|
||
let buf = Buffer.alloc(0);
|
||
let upgraded = false;
|
||
let opened = false;
|
||
sock.on("data", (chunk) => {
|
||
buf = Buffer.concat([buf, chunk]);
|
||
if (!upgraded) {
|
||
const idx = buf.indexOf("\r\n\r\n");
|
||
if (idx === -1) return;
|
||
const head = buf.slice(0, idx).toString("utf8");
|
||
buf = buf.slice(idx + 4);
|
||
upgraded = true;
|
||
if (!head.includes("101")) {
|
||
// 保留剩余字节(响应体),记录完整响应便于排错
|
||
const body = buf.toString("utf8").slice(0, 800);
|
||
sock.destroy();
|
||
return reject(
|
||
new Error(
|
||
"WS upgrade failed: " +
|
||
head.split("\r\n")[0] +
|
||
" | BODY=" +
|
||
body +
|
||
" | REQ-PATH=" +
|
||
path,
|
||
),
|
||
);
|
||
}
|
||
opened = true;
|
||
resolve({
|
||
send: (obj) => sendDeviceFrame(sock, JSON.stringify(obj)),
|
||
__sock: sock, // 暴露底层 socket 供二进制分块发送
|
||
close: () => sock.destroy(),
|
||
});
|
||
}
|
||
while (buf.length >= 2) {
|
||
const b0 = buf[0];
|
||
const opcode = b0 & 0x0f;
|
||
const b1 = buf[1];
|
||
let len = b1 & 0x7f;
|
||
let off = 2;
|
||
if (len === 126) {
|
||
if (buf.length < 4) break;
|
||
len = buf.readUInt16BE(2);
|
||
off = 4;
|
||
} else if (len === 127) {
|
||
if (buf.length < 10) break;
|
||
len = Number(buf.readBigUInt64BE(2));
|
||
off = 10;
|
||
}
|
||
if (buf.length < off + len) break;
|
||
const payload = buf.slice(off, off + len);
|
||
buf = buf.slice(off + len);
|
||
if (opcode === 0x1) {
|
||
try {
|
||
const obj = JSON.parse(payload.toString("utf8"));
|
||
if (obj && obj.op === "cmd_speech_start") {
|
||
// 收到音频开始:初始化聚合缓冲
|
||
try {
|
||
if (speechAccum) speechAccum = null;
|
||
} catch (e2) {}
|
||
speechAccum = {
|
||
reqId: obj.req_id || "",
|
||
kind: obj.kind || "speech",
|
||
mime: obj.mime || "audio/wav",
|
||
total: obj.total || 0,
|
||
chunks: [],
|
||
got: 0,
|
||
};
|
||
continue;
|
||
}
|
||
if (obj && obj.op === "cmd_speech_end") {
|
||
// 音频结束:合并块并播放
|
||
const reqId = obj.req_id || "";
|
||
if (speechAccum) {
|
||
try {
|
||
const audio = Buffer.concat(speechAccum.chunks);
|
||
playDeviceAudio(audio, speechAccum.mime, reqId);
|
||
} catch (e2) {}
|
||
speechAccum = null;
|
||
}
|
||
continue;
|
||
}
|
||
onMsg(obj);
|
||
} catch (e) {}
|
||
} else if (opcode === 0x2) {
|
||
// 二进制帧:接收音频数据块(若处于聚合状态)
|
||
if (speechAccum) {
|
||
speechAccum.chunks.push(payload);
|
||
speechAccum.got += payload.length;
|
||
}
|
||
} else if (opcode === 0x8) {
|
||
sock.destroy();
|
||
return;
|
||
}
|
||
}
|
||
});
|
||
sock.on("error", (e) => {
|
||
if (!opened) reject(e);
|
||
});
|
||
sock.on("close", () => {
|
||
deviceBridge = null;
|
||
});
|
||
});
|
||
}
|
||
|
||
// 发送 WS text 帧(客户端加掩码)
|
||
function sendDeviceFrame(sock, text) {
|
||
const payload = Buffer.from(text, "utf8");
|
||
const mask = crypto.randomBytes(4);
|
||
const masked = Buffer.from(payload);
|
||
for (let i = 0; i < masked.length; i++) masked[i] ^= mask[i % 4];
|
||
const len = masked.length;
|
||
let hdr;
|
||
if (len < 126) {
|
||
hdr = Buffer.from([0x81, 0x80 | len]);
|
||
} else if (len < 65536) {
|
||
hdr = Buffer.alloc(4);
|
||
hdr[0] = 0x81;
|
||
hdr[1] = 0x80 | 126;
|
||
hdr.writeUInt16BE(len, 2);
|
||
} else {
|
||
hdr = Buffer.alloc(10);
|
||
hdr[0] = 0x81;
|
||
hdr[1] = 0x80 | 127;
|
||
hdr.writeBigUInt64BE(BigInt(len), 2);
|
||
}
|
||
sock.write(Buffer.concat([hdr, mask, masked]));
|
||
}
|
||
|
||
// 发送 WS 二进制帧(0x2)——用于大体积数据(如录像)分块回传
|
||
function sendDeviceBinaryFrame(sock, buf) {
|
||
const mask = crypto.randomBytes(4);
|
||
const masked = Buffer.from(buf);
|
||
for (let i = 0; i < masked.length; i++) masked[i] ^= mask[i % 4];
|
||
const len = masked.length;
|
||
let hdr;
|
||
if (len < 126) {
|
||
hdr = Buffer.from([0x82, 0x80 | len]);
|
||
} else if (len < 65536) {
|
||
hdr = Buffer.alloc(4);
|
||
hdr[0] = 0x82;
|
||
hdr[1] = 0x80 | 126;
|
||
hdr.writeUInt16BE(len, 2);
|
||
} else {
|
||
hdr = Buffer.alloc(10);
|
||
hdr[0] = 0x82;
|
||
hdr[1] = 0x80 | 127;
|
||
hdr.writeBigUInt64BE(BigInt(len), 2);
|
||
}
|
||
sock.write(Buffer.concat([hdr, mask, masked]));
|
||
}
|
||
// 通过设备通道以二进制分块回传大体积数据(如录像)。
|
||
// 协议(与服务端协商):
|
||
// cmd_data_start {op, req_id, kind, total, chunk_size, mime} —— 文本帧
|
||
// <N 个二进制帧 0x2> —— video bytes
|
||
// cmd_data_end {op, req_id, status:ok|error, error?} —— 文本帧
|
||
// 服务端按 req_id 聚合二进制块 → 存入 cmdresult,供 device_ctl_cmdresult 取回。
|
||
function sendDeviceDataChunked(reqId, kind, mime, buf) {
|
||
if (!deviceBridge || !deviceBridge.send) {
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(reqId, "error", "", "device bridge not connected"),
|
||
);
|
||
return;
|
||
}
|
||
// 分块大小 8KB
|
||
const CHUNK = 8192;
|
||
// 取底层 socket 直接发二进制帧(deviceBridge.send 是文本封装)
|
||
const total = buf.length;
|
||
// 控制帧走文本协议(deviceBridge.send 封装)
|
||
try {
|
||
// 用 send 发文本帧控制头(start)
|
||
deviceBridge.send({
|
||
op: "cmd_data_start",
|
||
req_id: reqId,
|
||
kind: kind,
|
||
mime: mime || "application/octet-stream",
|
||
total: total,
|
||
chunk_size: CHUNK,
|
||
});
|
||
} catch (e) {}
|
||
// 二进制块:需要底层 socket。这里用包装的 raw send
|
||
// sendDeviceRaw 通过 deviceBridge 的隐藏引用发二进制
|
||
const rawSock = deviceBridge.__sock;
|
||
if (rawSock && !rawSock.destroyed) {
|
||
for (let off = 0; off < total; off += CHUNK) {
|
||
sendDeviceBinaryFrame(rawSock, buf.slice(off, off + CHUNK));
|
||
}
|
||
} else {
|
||
// 无 raw socket 时回退文本 base64(服务端老协议兜底)
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(
|
||
reqId,
|
||
"ok",
|
||
"data:" + mime + ";base64," + buf.toString("base64"),
|
||
"",
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
try {
|
||
deviceBridge.send({
|
||
op: "cmd_data_end",
|
||
req_id: reqId,
|
||
status: "ok",
|
||
total: total,
|
||
});
|
||
} catch (e) {}
|
||
}
|
||
|
||
// 处理网关 WS 消息:hello_ack/bind_ack/cmd 等。命令类型由服务端 cmd_type 指定(shell/homeagent)。
|
||
function onDeviceMsg(msg) {
|
||
if (!msg || typeof msg !== "object") return;
|
||
console.log(
|
||
"[device-bridge] recv op=" +
|
||
(msg.op || "") +
|
||
" cmd_type=" +
|
||
(msg.cmd_type || "") +
|
||
" cmd=" +
|
||
String(msg.command || msg.cmd || "").slice(0, 60),
|
||
);
|
||
const op = msg.op || "";
|
||
if (op === "cmd") {
|
||
const command = msg.command || msg.cmd || "";
|
||
const reqId = msg.req_id || msg.id || "";
|
||
const cmdType = msg.cmd_type || "shell";
|
||
if (!command) return;
|
||
// 记录远控活动并刷新托盘菜单
|
||
trayLastCmd = { cmd: command, at: Date.now(), result: "执行中…" };
|
||
trayCmdCount++;
|
||
try {
|
||
rebuildTrayMenu();
|
||
} catch (e) {}
|
||
try {
|
||
if (cmdType === "homeagent" || command.indexOf("homeagent-") === 0) {
|
||
executeHomeagentCmd(command.replace(/^homeagent-/, ""), reqId);
|
||
} else {
|
||
executeShellCmd(command, reqId);
|
||
}
|
||
} catch (e) {
|
||
console.log("[device-bridge] exec error: " + e.message);
|
||
}
|
||
} else if (op === "hello_ack" || op === "bind_ack") {
|
||
console.log(
|
||
"[device-bridge] " + op + " device=" + (msg.device || deviceBridgeId),
|
||
);
|
||
try {
|
||
rebuildTrayMenu();
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
|
||
// 统一发送命令结果(回执 + 托盘更新)。兼容两种调用:sendCmdResult(resp) 或 sendCmdResult(reqId, resp)
|
||
function sendCmdResult(a, b) {
|
||
const resp = b || a;
|
||
console.log(
|
||
"[device-bridge] send result op=cmd_result status=" +
|
||
(resp.status || "") +
|
||
" req=" +
|
||
(resp.req_id || ""),
|
||
);
|
||
if (deviceBridge && deviceBridge.send) {
|
||
try {
|
||
deviceBridge.send(resp);
|
||
} catch (e) {}
|
||
}
|
||
if (trayLastCmd) {
|
||
trayLastCmd.result =
|
||
resp.status === "ok"
|
||
? String(resp.output || "").slice(0, 40)
|
||
: "错误: " + String(resp.error || "");
|
||
try {
|
||
rebuildTrayMenu();
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
|
||
function baseResult(reqId, status, output, error) {
|
||
return {
|
||
op: "cmd_result",
|
||
req_id: reqId,
|
||
device_id: deviceBridgeId,
|
||
status: status,
|
||
output: output || "",
|
||
error: error || "",
|
||
};
|
||
}
|
||
|
||
// 读取设备命令执行配置(gui-prefs.deviceBridge.exec)
|
||
function getExecConfig() {
|
||
try {
|
||
const prefs = loadGuiPrefs();
|
||
const db = prefs.deviceBridge || {};
|
||
const ex = db.exec || {};
|
||
return {
|
||
cwd: ex.cwd || "",
|
||
sandbox: ex.sandbox || "off", // off | home | box
|
||
boxDir: ex.boxDir || "",
|
||
timeout: parseInt(ex.timeout || "15", 10) || 15,
|
||
maxBuffer: (parseInt(ex.maxBuffer || "8192", 10) || 8192) * 1024,
|
||
};
|
||
} catch (e) {
|
||
return {
|
||
cwd: "",
|
||
sandbox: "off",
|
||
boxDir: "",
|
||
timeout: 15,
|
||
maxBuffer: 8192 * 1024,
|
||
};
|
||
}
|
||
}
|
||
|
||
// shell 命令:按 exec 配置(cwd/沙箱/超时)执行
|
||
function executeShellCmd(command, reqId) {
|
||
console.log(
|
||
"[device-bridge] execShell cmd=" +
|
||
String(command).slice(0, 60) +
|
||
" req=" +
|
||
reqId,
|
||
);
|
||
const cp = require("child_process");
|
||
const ex = getExecConfig();
|
||
if (!argsSafe(command)) {
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(reqId, "denied", "", "command not allowed"),
|
||
);
|
||
return;
|
||
}
|
||
// 沙箱策略:home=锁定用户主目录; box=锁定指定目录; off=cwd 或默认
|
||
const os = require("os");
|
||
let cwd = ex.cwd || os.homedir() || process.cwd();
|
||
if (ex.sandbox === "home") cwd = os.homedir() || cwd;
|
||
else if (ex.sandbox === "box" && ex.boxDir) cwd = ex.boxDir;
|
||
else if (ex.sandbox === "box" && !ex.boxDir) {
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(reqId, "denied", "", "sandbox=box 需配置 boxDir"),
|
||
);
|
||
return;
|
||
}
|
||
cp.exec(
|
||
command,
|
||
{
|
||
cwd: cwd,
|
||
timeout: ex.timeout * 1000,
|
||
maxBuffer: ex.maxBuffer,
|
||
env: process.env,
|
||
},
|
||
(err, stdout, stderr) => {
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(
|
||
reqId,
|
||
err ? "error" : "ok",
|
||
(stdout || "") + (stderr || ""),
|
||
err ? err.message : "",
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
// homeagent 内置能力分发(与服务端 device_ctl_cmdrun 的 homeagent-* 对齐)
|
||
function executeHomeagentCmd(capability, reqId) {
|
||
const name = String(capability || "")
|
||
.trim()
|
||
.split(/[ >\n]/)[0];
|
||
switch (name) {
|
||
case "camerasue": {
|
||
// 摄像头:camerasue=抓拍单张;camerasue <秒>=录制 N 秒视频,返回 base64
|
||
const argStr = String(capability || "")
|
||
.replace(/^camerasue/, "")
|
||
.trim();
|
||
const durMatch = /^\d+$/.test(argStr) ? parseInt(argStr, 10) : 0;
|
||
const isVideo = durMatch > 0;
|
||
const cp = require("child_process");
|
||
const os = require("os");
|
||
const path = require("path");
|
||
const fs = require("fs");
|
||
if (isVideo) {
|
||
// 录像:ffmpeg 录 N 秒 mp4 到临时文件
|
||
const outFile = path.join(os.tmpdir(), "ha_cam_" + Date.now() + ".mp4");
|
||
const args = [
|
||
"-f",
|
||
"v4l2",
|
||
"-i",
|
||
"/dev/video0",
|
||
"-t",
|
||
String(durMatch),
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
"-c:v",
|
||
"libx264",
|
||
"-f",
|
||
"mp4",
|
||
outFile,
|
||
];
|
||
cp.execFile(
|
||
"ffmpeg",
|
||
args,
|
||
{ timeout: (durMatch + 15) * 1000, maxBuffer: 64 * 1024 * 1024 },
|
||
(err) => {
|
||
if (err || !fs.existsSync(outFile)) {
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(
|
||
reqId,
|
||
"error",
|
||
"",
|
||
"camera record failed: " + (err ? err.message : "no file"),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
try {
|
||
const data = fs.readFileSync(outFile);
|
||
fs.unlinkSync(outFile);
|
||
// 录像以二进制分块经设备通道回传(协议 cmd_data_start/二进制帧/cmd_data_end)
|
||
sendDeviceDataChunked(reqId, "camera_video", "video/mp4", data);
|
||
} catch (e2) {
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(
|
||
reqId,
|
||
"error",
|
||
"",
|
||
"camera record read failed: " + e2.message,
|
||
),
|
||
);
|
||
}
|
||
},
|
||
);
|
||
return;
|
||
}
|
||
// 抓拍单张 jpeg
|
||
const args = [
|
||
"-f",
|
||
"v4l2",
|
||
"-i",
|
||
"/dev/video0",
|
||
"-frames:v",
|
||
"1",
|
||
"-f",
|
||
"image2pipe",
|
||
"-vcodec",
|
||
"mjpeg",
|
||
"pipe:1",
|
||
];
|
||
cp.execFile(
|
||
"ffmpeg",
|
||
args,
|
||
{ timeout: 10000, maxBuffer: 8 * 1024 * 1024 },
|
||
(err, stdout) => {
|
||
if (err || !stdout) {
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(
|
||
reqId,
|
||
"error",
|
||
"",
|
||
"camera capture failed: " + (err ? err.message : "no data"),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
const b64 = Buffer.from(stdout).toString("base64");
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(reqId, "ok", "data:image/jpeg;base64," + b64, ""),
|
||
);
|
||
},
|
||
);
|
||
return;
|
||
}
|
||
case "screensue": {
|
||
// 在配置的目标屏幕上拉起独立窗口显示内容(支持文字 / HTML)
|
||
// 默认 5 秒自动关闭;命令可带 [秒] 指定时长,0 = 永不超时(常驻,用户手动关闭)
|
||
var duration = 5;
|
||
var raw = String(capability || "")
|
||
.replace(/^screensue/, "")
|
||
.trim();
|
||
// 配置: gui-prefs.deviceBridge.screensueDisplay / screensueDuration
|
||
var dispIdx = 0;
|
||
try {
|
||
const prefs = loadGuiPrefs();
|
||
const db = prefs.deviceBridge || {};
|
||
dispIdx = parseInt(db.screensueDisplay || "0", 10) || 0;
|
||
const cfgDur = db.screensueDuration;
|
||
if (cfgDur === undefined || cfgDur === null || cfgDur === "") {
|
||
duration = 5; // 未配置时默认 5 秒
|
||
} else {
|
||
duration = parseInt(cfgDur, 10);
|
||
if (!Number.isFinite(duration) || duration < 0) duration = 5;
|
||
}
|
||
} catch (e) {}
|
||
// 参数支持 "screensue [时长秒] 内容":首个纯数字 token 作为时长(0=永不超时)
|
||
const tokens = raw.split(/\s+/);
|
||
if (tokens.length > 1 && /^\d+$/.test(tokens[0])) {
|
||
duration = parseInt(tokens[0], 10);
|
||
raw = tokens.slice(1).join(" ");
|
||
}
|
||
const display =
|
||
(screen.getAllDisplays() || [])[dispIdx] || screen.getAllDisplays()[0];
|
||
const area = display
|
||
? display.workArea
|
||
: { x: 0, y: 0, width: 800, height: 480 };
|
||
const w = Math.min(parseInt(area.width || 800, 10) - 40, 900);
|
||
const h = Math.min(parseInt(area.height || 600, 10) - 40, 560);
|
||
const html = /<\/?[a-z][\s\S]*>/i.test(raw)
|
||
? raw
|
||
: "<div style='font-family:sans-serif;display:flex;flex-direction:column;justify-content:center;align-items:center;height:100%;padding:24px;box-sizing:border-box'><h1 style='margin:0 0 16px;color:#ff7fac'>HomeAgent</h1><p style='font-size:16px;line-height:1.6;white-space:pre-wrap;word-break:break-all'>" +
|
||
String(raw || "HomeAgent 远程屏幕提示").replace(/</g, "<") +
|
||
"</p></div>";
|
||
try {
|
||
if (screensueWin && !screensueWin.isDestroyed()) screensueWin.destroy();
|
||
screensueWin = new BrowserWindow({
|
||
x: area.x + 20,
|
||
y: area.y + 20,
|
||
width: w,
|
||
height: h,
|
||
alwaysOnTop: true,
|
||
frame: false,
|
||
resizable: true,
|
||
title: "HomeAgent · screensue",
|
||
webPreferences: { nodeIntegration: false, contextIsolation: true },
|
||
});
|
||
screensueWin.setAlwaysOnTop(true, "screen-saver");
|
||
screensueWin.loadURL(
|
||
"data:text/html;charset=utf-8," +
|
||
encodeURIComponent(
|
||
"<html><head><meta charset='utf-8'><style>body{margin:0;background:#0b1020;color:#eef1f8}</style></head><body>" +
|
||
html +
|
||
"</body></html>",
|
||
),
|
||
);
|
||
const closeHint =
|
||
"<div style='position:fixed;top:8px;right:12px;font-size:12px;color:#77809a;background:rgba(20,26,44,.7);padding:2px 10px;border-radius:10px;cursor:pointer' onclick='window.close()'>× 关闭</div>";
|
||
screensueWin.webContents.on("did-finish-load", () => {
|
||
try {
|
||
screensueWin.webContents.executeJavaScript(
|
||
"document.body.insertAdjacentHTML('beforeend', '" +
|
||
closeHint.replace(/'/g, "\\'") +
|
||
"');",
|
||
);
|
||
} catch (e2) {}
|
||
});
|
||
// 显示时长:duration>0 时定时自动关闭
|
||
if (duration > 0) {
|
||
setTimeout(() => {
|
||
try {
|
||
if (screensueWin && !screensueWin.isDestroyed()) {
|
||
screensueWin.destroy();
|
||
screensueWin = null;
|
||
}
|
||
} catch (e3) {}
|
||
}, duration * 1000);
|
||
}
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(
|
||
reqId,
|
||
"ok",
|
||
"screensue shown on display " +
|
||
dispIdx +
|
||
(duration > 0
|
||
? " for " + duration + "s"
|
||
: " (persistent until closed)") +
|
||
": " +
|
||
raw.slice(0, 80),
|
||
"",
|
||
),
|
||
);
|
||
} catch (e) {
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(reqId, "error", "", "screensue failed: " + e.message),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
case "speakeruse": {
|
||
// 音频播报:speakeruse <文字> 用语音朗读(TTS)。
|
||
// 依赖系统 TTS;Linux 用 espeak/festival,macOS 用 say,Windows 用 PowerShell SAPI。
|
||
const text = String(capability || "")
|
||
.replace(/^speakeruse/, "")
|
||
.trim();
|
||
const cp = require("child_process");
|
||
const platform = process.platform;
|
||
let cmd = null;
|
||
let args = [];
|
||
if (platform === "linux") {
|
||
// 优先尝试 espeak,其次 festival
|
||
if (cp.spawnSync("which", ["espeak"]).status === 0) {
|
||
cmd = "espeak";
|
||
args = [text];
|
||
} else if (cp.spawnSync("which", ["festival"]).status === 0) {
|
||
cmd = "bash";
|
||
args = [
|
||
"-c",
|
||
"echo '" +
|
||
String(text).replace(/'/g, "'\\''") +
|
||
"' | festival --tts",
|
||
];
|
||
} else {
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(
|
||
reqId,
|
||
"error",
|
||
"",
|
||
"speakeruse: no TTS engine (espeak/festival)",
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
} else if (platform === "darwin") {
|
||
cmd = "say";
|
||
args = [text];
|
||
} else if (platform === "win32") {
|
||
cmd = "powershell";
|
||
args = [
|
||
"-Command",
|
||
"(New-Object -ComObject SAPI.SpVoice).Speak('" +
|
||
String(text).replace(/'/g, "''") +
|
||
"')",
|
||
];
|
||
} else {
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(reqId, "error", "", "speakeruse: unsupported platform"),
|
||
);
|
||
return;
|
||
}
|
||
if (!text) {
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(reqId, "error", "", "speakeruse: empty text"),
|
||
);
|
||
return;
|
||
}
|
||
cp.execFile(
|
||
cmd,
|
||
args,
|
||
{ timeout: 30000, maxBuffer: 1024 * 1024 },
|
||
(err) => {
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(
|
||
reqId,
|
||
err ? "error" : "ok",
|
||
err ? "" : "spoken: " + text.slice(0, 60),
|
||
err ? err.message : "",
|
||
),
|
||
);
|
||
},
|
||
);
|
||
return;
|
||
}
|
||
default:
|
||
sendCmdResult(
|
||
reqId,
|
||
baseResult(
|
||
reqId,
|
||
"error",
|
||
"",
|
||
"unsupported homeagent capability: " + name,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// 简单安全校验:拒绝明显危险命令
|
||
function argsSafe(cmd) {
|
||
if (!cmd) return false;
|
||
const cmdStr = String(cmd).toLowerCase();
|
||
const bad = [
|
||
"rm -rf",
|
||
"mkfs",
|
||
"dd if=",
|
||
"shutdown",
|
||
"reboot",
|
||
"curl ",
|
||
"wget ",
|
||
":(){",
|
||
"eval ",
|
||
"su ",
|
||
"sudo ",
|
||
];
|
||
for (const b of bad) {
|
||
if (cmdStr.indexOf(b) !== -1) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// 启动设备桥:url/token 来自 gui-prefs.deviceBridge(独立于连接类型)
|
||
// url 可为 ws(s)://完整端点(含路径),token 为网关 ws_token。
|
||
async function startDeviceBridge(cfg) {
|
||
if (!cfg) return;
|
||
const url = cfg.url || cfg.gateway || "";
|
||
const token = cfg.apiKey || cfg.token || "";
|
||
if (!url || !token) {
|
||
console.error("[device-bridge] missing url/token, skipped");
|
||
return;
|
||
}
|
||
deviceBridgeAddr = url;
|
||
deviceBridgeId =
|
||
"gui-" + (devOs.hostname() || "local").replace(/[^a-zA-Z0-9_-]/g, "_");
|
||
try {
|
||
const ws = await connectDeviceWS(url, token, onDeviceMsg);
|
||
deviceBridge = ws;
|
||
ws.send({
|
||
op: "hello",
|
||
device: {
|
||
device_id: deviceBridgeId,
|
||
name: "HomeAgent GUI",
|
||
kind: "computer",
|
||
caps: ["status", "cmdrun", "deviceinfo", "cmdresult"],
|
||
info: {
|
||
hostname: devOs.hostname() || "",
|
||
platform: process.platform || "",
|
||
arch: process.arch || "",
|
||
os_release: "", // 不提权读取 /etc/os-release,避免破坏沙箱;如需可在白名单命令里由 agent 探
|
||
node_version:
|
||
process.versions && process.versions.node
|
||
? process.versions.node
|
||
: "",
|
||
electron_version:
|
||
process.versions && process.versions.electron
|
||
? process.versions.electron
|
||
: "",
|
||
version: app.getVersion ? app.getVersion() : "",
|
||
cpus: devOs.cpus ? devOs.cpus().length : 0,
|
||
total_mem_bytes: devOs.totalmem ? devOs.totalmem() : 0,
|
||
},
|
||
},
|
||
});
|
||
ws.send({ op: "bind", device_id: deviceBridgeId, token });
|
||
console.log("[device-bridge] connected as " + deviceBridgeId + " @ " + url);
|
||
} catch (e) {
|
||
console.error("[device-bridge] connect failed: " + e.message);
|
||
}
|
||
}
|
||
|
||
function stopDeviceBridge() {
|
||
if (deviceBridge) {
|
||
try {
|
||
deviceBridge.close();
|
||
} catch (e) {}
|
||
deviceBridge = null;
|
||
}
|
||
}
|
||
|
||
// ============ 定时授权调度(睡眠期间自动撤销,恢复时自动恢复) ============
|
||
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;
|
||
// 托盘菜单动态数据
|
||
let trayLastCmd = null; // 最近一次 device cmd: {cmd, at, result}
|
||
let trayCmdCount = 0; // 历史 cmd 总次数
|
||
// 托盘菜单构建缓存 + 防抖(避免设备桥消息风暴时反复 setContextMenu 导致弹出卡顿)
|
||
const trayConnCache = { connLabel: "", connUrl: "" };
|
||
let trayRebuildTimer = null;
|
||
let trayMenuBuilt = false;
|
||
function buildTrayMenuTemplate() {
|
||
const electron = require("electron");
|
||
const TMenu = electron.Menu;
|
||
const tpl = [];
|
||
tpl.push({ label: "HomeAgent", enabled: true });
|
||
tpl.push({ type: "separator" });
|
||
// 远程连接状态(缓存避免每次读文件)
|
||
const connLabel = trayConnCache.connLabel || "未连接";
|
||
const connUrl = trayConnCache.connUrl || "";
|
||
tpl.push({ label: "后端: " + connLabel, enabled: true });
|
||
if (connUrl) tpl.push({ label: connUrl, enabled: true });
|
||
let online = false;
|
||
try {
|
||
if (authRule && authRule.urlHost) online = true;
|
||
} catch (e) {}
|
||
tpl.push({
|
||
label: online ? "[已连接]" : "[未连接]",
|
||
enabled: true,
|
||
});
|
||
tpl.push({ type: "separator" });
|
||
// 设备桥状态
|
||
if (deviceBridge) {
|
||
tpl.push({ label: "设备桥: [已连接]", enabled: true });
|
||
if (deviceBridgeId)
|
||
tpl.push({ label: "设备ID: " + deviceBridgeId, enabled: true });
|
||
if (deviceBridgeAddr)
|
||
tpl.push({ label: "网关: " + deviceBridgeAddr, enabled: true });
|
||
if (trayLastCmd) {
|
||
tpl.push({ label: "上次远控: " + trayLastCmd.cmd, enabled: true });
|
||
tpl.push({
|
||
label:
|
||
"结果: " +
|
||
(trayLastCmd.result || "…").slice(0, 60) +
|
||
"(" +
|
||
(trayCmdCount || 0) +
|
||
"次总数)",
|
||
enabled: true,
|
||
});
|
||
} else {
|
||
tpl.push({ label: "未收到远控命令", enabled: true });
|
||
}
|
||
} else {
|
||
tpl.push({ label: "设备桥: [未连接]", enabled: true });
|
||
}
|
||
tpl.push({ type: "separator" });
|
||
tpl.push({ label: "显示主界面", click: () => showMainWindow() });
|
||
tpl.push({
|
||
label: "退出",
|
||
click: () => {
|
||
app.isQuitting = true;
|
||
app.quit();
|
||
},
|
||
});
|
||
return TMenu.buildFromTemplate(tpl);
|
||
}
|
||
function applyTrayMenu() {
|
||
if (!tray) return;
|
||
try {
|
||
tray.setContextMenu(buildTrayMenuTemplate());
|
||
trayMenuBuilt = true;
|
||
} catch (e) {}
|
||
}
|
||
// 防抖重建:高频触发时合并,200ms 内只实际 setContextMenu 一次
|
||
function rebuildTrayMenu() {
|
||
if (!tray) return;
|
||
// 先刷新连接缓存
|
||
try {
|
||
const conns = loadConnections();
|
||
const cur =
|
||
conns.connections.find((c) => c.id === conns.currentId) ||
|
||
conns.connections[0];
|
||
if (cur) {
|
||
trayConnCache.connLabel = cur.name || "未命名";
|
||
trayConnCache.connUrl = cur.url || cur.socketPath || "";
|
||
} else {
|
||
trayConnCache.connLabel = "未连接";
|
||
trayConnCache.connUrl = "";
|
||
}
|
||
} catch (e) {}
|
||
// 首建立即(保证右键立刻有菜单),后续防抖
|
||
if (!trayMenuBuilt) {
|
||
applyTrayMenu();
|
||
return;
|
||
}
|
||
if (trayRebuildTimer) clearTimeout(trayRebuildTimer);
|
||
trayRebuildTimer = setTimeout(applyTrayMenu, 200);
|
||
}
|
||
function initTray() {
|
||
if (tray) return;
|
||
try {
|
||
const electron = require("electron");
|
||
const Tray = electron.Tray;
|
||
const nImg = electron.nativeImage;
|
||
let img = null;
|
||
const cands = [
|
||
path.join(__dirname, "icon-tray@2x.png"),
|
||
path.join(__dirname, "icon-tray.png"),
|
||
path.join(__dirname, "icon.ico"),
|
||
];
|
||
for (const c of cands) {
|
||
try {
|
||
const m = nImg.createFromPath(c);
|
||
if (m && !m.isEmpty()) {
|
||
img = m;
|
||
break;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
if (!img || img.isEmpty()) {
|
||
try {
|
||
img = nImg.createFromPath(path.join(__dirname, "icon-tray.png"));
|
||
} catch (e) {}
|
||
}
|
||
if (!img || img.isEmpty()) {
|
||
img = nImg.createEmpty();
|
||
}
|
||
tray = new Tray(img);
|
||
tray.setToolTip("HomeAgent - 个人智能管家");
|
||
rebuildTrayMenu();
|
||
tray.on("double-click", () => showMainWindow());
|
||
console.log("[tray] READY: " + (tray ? "tray-created" : "null"));
|
||
} catch (e) {
|
||
console.error("[tray] init failed (safe ignore): " + e.message);
|
||
try {
|
||
if (tray) {
|
||
tray.destroy();
|
||
tray = null;
|
||
}
|
||
} catch (e2) {}
|
||
}
|
||
}
|
||
function showMainWindow() {
|
||
try {
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
mainWindow.show();
|
||
mainWindow.focus();
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
function destroyTray() {
|
||
try {
|
||
if (tray) {
|
||
tray.destroy();
|
||
tray = null;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
app.whenReady().then(async () => {
|
||
installAuthRule();
|
||
const running = await isServerRunning();
|
||
if (!running) {
|
||
startHomed();
|
||
const started = await waitForServer();
|
||
if (started) {
|
||
console.log("homed started successfully");
|
||
} else {
|
||
console.error("homed failed to start within timeout");
|
||
}
|
||
}
|
||
// 设备桥:由 GUI 偏好(deviceBridge 开关+网关+token)驱动,独立于连接
|
||
try {
|
||
const _prefs = loadGuiPrefs();
|
||
const _db = _prefs.deviceBridge || {};
|
||
if (_db.enabled && _db.gateway && _db.token) {
|
||
startDeviceBridge({ url: _db.gateway, apiKey: _db.token });
|
||
} else if (_db.enabled) {
|
||
console.error(
|
||
"[device-bridge] enabled but missing gateway/token, skipped",
|
||
);
|
||
}
|
||
} catch (e) {
|
||
console.error("device bridge init: " + e.message);
|
||
}
|
||
try {
|
||
initTray();
|
||
} 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();
|
||
});
|
||
|
||
app.on("before-quit", () => {
|
||
stopHomed();
|
||
try {
|
||
stopDeviceBridge();
|
||
} catch (e) {}
|
||
try {
|
||
destroyTray();
|
||
} catch (e) {}
|
||
});
|
||
|
||
app.on("window-all-closed", () => {
|
||
// 退出进托盘:依偏好决定(安全降级:无托盘时退出)
|
||
let _exitTray = false;
|
||
try {
|
||
const _p = loadGuiPrefs();
|
||
_exitTray = !!(_p && _p.exitToTray);
|
||
} catch (e) {}
|
||
if (_exitTray && tray) {
|
||
try {
|
||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();
|
||
} catch (e) {}
|
||
} else {
|
||
app.quit();
|
||
}
|
||
});
|
||
|
||
app.on("activate", () => {
|
||
if (mainWindow === null) {
|
||
createWindow();
|
||
}
|
||
});
|
||
// IPC:prefs(renderer 设置页需要)
|
||
// IPC:本机设备身份(renderer 设备页需要)
|
||
ipcMain.handle("device:identity", () =>
|
||
deviceBridge
|
||
? {
|
||
device_id: deviceBridgeId,
|
||
active: true,
|
||
address: deviceBridgeAddr || "",
|
||
}
|
||
: { device_id: "", active: false, address: "" },
|
||
);
|
||
const GUI_PREFS_FILE = path.join(app.getPath("userData"), "gui-prefs.json");
|
||
|
||
function loadGuiPrefs() {
|
||
try {
|
||
if (fs.existsSync(GUI_PREFS_FILE)) {
|
||
const d = JSON.parse(fs.readFileSync(GUI_PREFS_FILE, "utf-8"));
|
||
const db = d.deviceBridge || {};
|
||
return {
|
||
autoLaunch: !!d.autoLaunch,
|
||
silentStart: !!d.silentStart,
|
||
exitToTray: d.exitToTray === undefined ? true : !!d.exitToTray,
|
||
deviceBridge: {
|
||
enabled: !!db.enabled,
|
||
gateway: db.gateway || "",
|
||
token: db.token || "",
|
||
screensueDisplay: db.screensueDisplay || "0",
|
||
screensueDuration: db.screensueDuration === undefined || db.screensueDuration === null || db.screensueDuration === "" ? "5" : String(db.screensueDuration),
|
||
exec: db.exec || {},
|
||
authSchedule: db.authSchedule || {},
|
||
},
|
||
};
|
||
}
|
||
} catch (e) {}
|
||
return {
|
||
autoLaunch: false,
|
||
silentStart: false,
|
||
exitToTray: true,
|
||
deviceBridge: {
|
||
enabled: true,
|
||
gateway: "",
|
||
token: "",
|
||
screensueDisplay: "0",
|
||
screensueDuration: "5",
|
||
exec: {},
|
||
authSchedule: {},
|
||
},
|
||
};
|
||
}
|
||
|
||
function saveGuiPrefs(p) {
|
||
try {
|
||
fs.writeFileSync(GUI_PREFS_FILE, JSON.stringify(p, null, 2), "utf-8");
|
||
} catch (e) {}
|
||
return p;
|
||
}
|
||
|
||
// 应用开机自启设置(Electron LoginItem)
|
||
function applyAutoLaunch(enabled) {
|
||
try {
|
||
app.setLoginItemSettings({
|
||
openAtLogin: enabled,
|
||
openAsHidden: true, // 开机自启时静默(Windows/macOS 支持)
|
||
path: process.execPath,
|
||
});
|
||
return true;
|
||
} catch (e) {
|
||
console.error("setLoginItemSettings failed: " + e.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 全局偏好缓存(供 createWindow 静默判断使用)
|
||
let guiPrefs = loadGuiPrefs();
|
||
|
||
// IPC:本机显示屏幕列表(用于 screensue 默认屏幕配置)
|
||
ipcMain.handle("displays:list", () => {
|
||
try {
|
||
const ds = screen.getAllDisplays() || [];
|
||
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 [];
|
||
}
|
||
});
|
||
|
||
// IPC:本机设备桥状态(启用+网关+token+连接状态)
|
||
ipcMain.handle("device-bridge:get", () => {
|
||
const p = loadGuiPrefs();
|
||
const db = p.deviceBridge || {};
|
||
return {
|
||
enabled: !!db.enabled,
|
||
gateway: db.gateway || "",
|
||
tokenSet: !!(db.token || ""),
|
||
connected: !!deviceBridge,
|
||
deviceId: deviceBridgeId,
|
||
address: deviceBridgeAddr,
|
||
screensueDisplay: db.screensueDisplay || "0",
|
||
exec: db.exec || {},
|
||
authSchedule: db.authSchedule || {},
|
||
};
|
||
});
|
||
|
||
// IPC:配置本机设备桥(开关+网关+token),保存并动态启停
|
||
ipcMain.handle("device-bridge:set", (_, cfg) => {
|
||
const cur = loadGuiPrefs();
|
||
const db = Object.assign({}, cur.deviceBridge || {}, cfg || {});
|
||
const next = Object.assign({}, cur, { deviceBridge: db });
|
||
guiPrefs = saveGuiPrefs(next);
|
||
// 动态应用:停止现有桥,按新配置启动
|
||
try {
|
||
stopDeviceBridge();
|
||
} catch (e) {}
|
||
if (db.enabled && db.gateway && db.token) {
|
||
try {
|
||
startDeviceBridge({ url: db.gateway, apiKey: db.token });
|
||
} catch (e) {
|
||
console.error("[device-bridge] start failed: " + e.message);
|
||
}
|
||
}
|
||
return {
|
||
enabled: !!db.enabled,
|
||
gateway: db.gateway || "",
|
||
connected: !!deviceBridge,
|
||
deviceId: deviceBridgeId,
|
||
address: deviceBridgeAddr,
|
||
screensueDisplay: db.screensueDisplay || "0",
|
||
exec: db.exec || {},
|
||
authSchedule: db.authSchedule || {},
|
||
};
|
||
});
|
||
|
||
// IPC:读取偏好
|
||
ipcMain.handle("prefs:get", () => {
|
||
return loadGuiPrefs();
|
||
});
|
||
// IPC:写入并应用偏好
|
||
ipcMain.handle("prefs:set", (_, p) => {
|
||
const cur = loadGuiPrefs();
|
||
const next = Object.assign({}, cur, p || {});
|
||
if (typeof next.autoLaunch === "boolean") {
|
||
next.autoLaunch = applyAutoLaunch(next.autoLaunch)
|
||
? next.autoLaunch
|
||
: false;
|
||
}
|
||
guiPrefs = saveGuiPrefs(next);
|
||
return guiPrefs;
|
||
});
|