Files
HomeAgent/cmd/gui/main.js
jianf c29abe9569 gui: 设备命令/能力完整实现 + 二进制分块 + 消息ID去重 + 交互优化
设备命令执行对齐服务端 cmd_type 契约:
- onDeviceMsg 解析 msg.cmd_type: homeagent->能力分发 / shell->白名单执行
- 统一回执 sendCmdResult(兼容双参), 全链路回执修复
- 修复 pi-lens auto-fix 把 trayLastCmd 改 const 导致的 TypeError(命令未执行/无回执根因)

homeagent 能力:
- screensue: 独立窗口显示文字/HTML, 可配默认屏幕(displays:list IPC), 支持时长参数 "screensue [秒] 内容", 默认常驻
- camerasue: 抓拍单张 jpeg(base64文本); camerasue <N秒>=录像 mp4(libx264), 二进制分块经设备通道回传
- speakeruse: 接收服务端二进制音频(WS 0x2), 聚合后播放(aplay/paplay/ffplay/afplay/SoundPlayer)

二进制分块协议(设备<->网关):
- 录像(设备->网关): cmd_data_start/0x2帧/cmd_data_end
- 音频(网关->设备): cmd_speech_start/0x2帧/cmd_speech_end
- 设备端 WS 读写侧都支持 0x2 帧; N/A 服务端 readFrame 需配套(已发群)

消息重放/交互:
- sendChat 带 client_msg_id 唯一ID(服务端可去重), 超时明确提示勿重复发送
- 托盘菜单: 信息项 enabled(不再灰字)+200ms防抖+连接缓存, 首建立即弹出
- 托盘显示连接/设备桥/远控活动状态

UI:
- 设备页本机卡片: 设备通道配置(网关/ws_token/screensue屏幕/cmdrun目录/沙箱)
- 设备列表经 webui 反代 /api/v1/device/online 拉取
- 连接管理移除 device 类型(设备独立为 GUI 组件), toggleConnType/saveConnForm 清理
- 字体对比度提升(text-muted/secondary重调色), 内联小字 10/11px->12/13px

preload: 新增 displays.list IPC
2026-08-20 09:07:41 +08:00

2059 lines
61 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
);
});
}
// 组装 authRuleurl + 持久化 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 默认为 webuiHTTP
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 网关 ============
// 复用 netraw 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_idhello 后可用于 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 默认 443ws/http 默认 9890remotedevice 默认端口)
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
var duration = 0; // 显示时长(秒)0=常驻
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;
duration = parseInt(db.screensueDuration || "0", 10) || 0;
} catch (e) {}
// 参数支持 "screensue [时长秒] 内容":首个纯数字 token 作为时长
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, "&lt;") +
"</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" : "") +
": " +
raw.slice(0, 80),
"",
),
);
} catch (e) {
sendCmdResult(
reqId,
baseResult(reqId, "error", "", "screensue failed: " + e.message),
);
}
return;
}
case "speakeruse": {
// 音频播报speakeruse <文字> 用语音朗读TTS
// 依赖系统 TTSLinux 用 espeak/festivalmacOS 用 sayWindows 用 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 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);
}
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();
}
});
// IPCprefsrenderer 设置页需要)
// 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 || "",
},
};
}
} catch (e) {}
return {
autoLaunch: false,
silentStart: false,
exitToTray: true,
deviceBridge: { enabled: true, gateway: "", token: "" },
};
}
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) => ({
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),
}));
} 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 || {},
};
});
// 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,
};
});
// 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;
});