Files
HomeAgent/cmd/gui/main.js

2670 lines
83 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, desktopCapturer, clipboard } = 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;
// 读取设备音频输出配置gui-prefs.deviceBridge.audio
function getAudioConfig() {
try {
const prefs = loadGuiPrefs();
const db = prefs.deviceBridge || {};
const au = db.audio || {};
return { device: au.device || "default" };
} catch (e) {
return { device: "default" };
}
}
// 枚举本机可用音频输出设备aplay -L 解析:设备名顶格、描述缩进)
function listAudioDevices() {
const porcp = require("child_process");
const out = [];
const SKIP_PREFIXES = ["lavrate", "samplerate", "speex", "jack", "oss",
"upmix", "vdownmix", "speexrate"];
try {
const r = porcp.spawnSync("aplay", ["-L"], { encoding: "utf8" });
const lines = ((r.stdout || "") + "\n").split("\n");
for (let i = 0; i < lines.length; i++) {
const ln = lines[i];
if (!ln || ln[0] === " " || ln[0] === "\t") continue; // 跳过缩进的描述行
const name = ln.trim();
if (!name || /^(Default PCM|List of|Plug PCM)/.test(name)) continue;
if (SKIP_PREFIXES.some((s) => name === s)) continue; // 插件类,非实体设备
// 只保留可实际输出的设备类型;纯 hw: 裸设备不支持自动通道转换
// mono→stereo易失败改用 plughw:带转换。pipewire/pulse/sysdefault 亦可。
const useful = name === "default" || name === "pipewire" ||
name === "pulse" || name === "sysdefault" ||
/^plughw:/.test(name) || /^sysdefault:/.test(name);
if (!useful) continue;
let desc = "";
// 下一行可能是描述(缩进)
if (lines[i + 1] && (lines[i + 1][0] === " " || lines[i + 1][0] === "\t"))
desc = lines[i + 1].trim();
out.push({ name, desc });
}
} catch (e) {}
if (!out.find((d) => d.name === "default"))
out.unshift({ name: "default", desc: "系统默认设备" });
return out;
}
// 播放设备收到的音频(由 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 au = getAudioConfig();
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";
// 支持用户选择的输出设备(默认 default
args = au.device && au.device !== "default" ? ["-D", au.device, tmp] : [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 via " + (au.device || "default"),
"",
),
);
} 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
// 默认 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, "&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"
: " (persistent until closed)") +
": " +
raw.slice(0, 80),
"",
),
);
} catch (e) {
sendCmdResult(
reqId,
baseResult(reqId, "error", "", "screensue failed: " + e.message),
);
}
return;
}
case "screensee": {
// screensee截取指定屏幕并返回 jpeg base64 data URL供 agent 视觉模型看屏)
// 屏幕索引沿用 gui-prefs.deviceBridge.screensueDisplay与 screensue 同源配置。
try {
const prefs = loadGuiPrefs();
const db = prefs.deviceBridge || {};
let dispIdx = parseInt(db.screensueDisplay || "0", 10) || 0;
desktopCapturer
.getSources({
types: ["screen"],
thumbnailSize: { width: 1920, height: 1080 },
fetchWindowIcons: false,
})
.then((sources) => {
if (!sources || sources.length === 0) {
sendCmdResult(
reqId,
baseResult(reqId, "error", "", "screensee: no screen source"),
);
return;
}
// 按配置索引选屏,越界回退到主屏(0)
if (dispIdx >= sources.length) dispIdx = 0;
const src = sources[dispIdx];
const thumb = src.thumbnail;
if (!thumb || thumb.isEmpty()) {
sendCmdResult(
reqId,
baseResult(
reqId,
"error",
"",
"screensee: empty thumbnail for screen " + dispIdx,
),
);
return;
}
const jpeg = thumb.toJPEG(80); // 质量 80平衡清晰度与传输体积
const b64 = Buffer.from(jpeg).toString("base64");
console.log(
"[device-bridge] screensee screen=" +
dispIdx +
" bytes=" +
jpeg.length,
);
sendCmdResult(
reqId,
baseResult(
reqId,
"ok",
"data:image/jpeg;base64," + b64,
"",
),
);
})
.catch((e) => {
sendCmdResult(
reqId,
baseResult(reqId, "error", "", "screensee failed: " + e.message),
);
});
} catch (e) {
sendCmdResult(
reqId,
baseResult(reqId, "error", "", "screensee 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;
}
case "computeruse": {
// computeruseagent 控制鼠标/键盘在目标屏幕操作(点击/移动/滚动/按键/输入)
// 参数homeagent-computeruse {"x":100,"y":200,"action":"click","button":"left"}
// action: click/doubleclick/rightclick/move/scroll/type/keypress
// x/y: 相对屏幕左上角(px)scroll 用 dx/dy; type 用 text
const cp = require("child_process");
const platform = process.platform;
const raw = String(capability || "")
.replace(/^computeruse/, "")
.trim();
// 解析 JSON 参数
let params = {};
const jsonM = raw.match(/\{[\s\S]*\}/);
if (jsonM) {
try {
params = JSON.parse(jsonM[0]);
} catch (e) {
sendCmdResult(
reqId,
baseResult(
reqId,
"error",
"",
"computeruse: invalid JSON params: " + e.message,
),
);
return;
}
} else {
// 支持 "x y action" 简写
const t = raw.split(/\s+/).filter(Boolean);
params.x = parseFloat(t[0]);
params.y = parseFloat(t[1]);
params.action = t[2] || "click";
}
const action = params.action || "click";
// 跨平台输入模拟Linux=xdotool / macOS=cliclick(或用osascript) / Windows=PowerShell user32
const os_ = platform === "win32" ? "win32" : platform === "darwin" ? "darwin" : "linux";
let tool = null; // {cmd, args, shell}
if (os_ === "linux") {
if (cp.spawnSync("which", ["xdotool"]).status === 0) tool = { cmd: "xdotool", shell: false };
} else if (os_ === "darwin") {
if (cp.spawnSync("which", ["cliclick"]).status === 0) tool = { cmd: "cliclick", shell: false };
} // win32 用 PowerShell 内联,下面单独处理
if (os_ === "linux" && !tool) {
sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse: xdotool not installed"));
return;
}
if (os_ === "darwin" && !tool) {
sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse: cliclick not installed"));
return;
}
// 目标屏幕左上角原点偏移:沿用 screensueDisplay坐标相对该屏幕
let dispIdx = 0;
try {
const prefs = loadGuiPrefs();
dispIdx = parseInt((prefs.deviceBridge || {}).screensueDisplay || "0", 10) || 0;
} catch (e) {}
const displays = screen.getAllDisplays() || [];
if (dispIdx >= displays.length) dispIdx = 0;
const disp = displays[dispIdx];
const ox = disp ? (disp.bounds.x || 0) : 0;
const oy = disp ? (disp.bounds.y || 0) : 0;
const absX = Math.round(ox + (parseFloat(params.x) || 0));
const absY = Math.round(oy + (parseFloat(params.y) || 0));
const run = (cmdStr, done) => {
if (os_ === "win32") {
// Windows: 用 PowerShell user32 SendInput
cp.execFile(
"powershell",
["-NoProfile", "-Command", cmdStr],
{ timeout: 15000, maxBuffer: 1024 * 1024 },
(err) => {
sendCmdResult(
reqId,
baseResult(
reqId,
err ? "error" : "ok",
err ? "" : "computeruse " + action + " @ (" + absX + "," + absY + ")" + (done ? " " + done : ""),
err ? err.message : "",
),
);
},
);
return;
}
if (os_ === "darwin") {
// macOS cliclick
cp.execFile(
"cliclick",
cmdStr,
{ timeout: 15000, maxBuffer: 1024 * 1024 },
(err) => {
sendCmdResult(
reqId,
baseResult(
reqId,
err ? "error" : "ok",
err ? "" : "computeruse " + action + " @ (" + absX + "," + absY + ")" + (done ? " " + done : ""),
err ? err.message : "",
),
);
},
);
return;
}
// Linux xdotool
cp.execFile("xdotool", cmdStr, { timeout: 15000, maxBuffer: 1024 * 1024 }, (err) => {
sendCmdResult(
reqId,
baseResult(
reqId,
err ? "error" : "ok",
err ? "" : "computeruse " + action + " @ (" + absX + "," + absY + ")" + (done ? " " + done : ""),
err ? err.message : "",
),
);
});
};
// Windows 命令构建
const winCmd = (body) =>
"Add-Type -AssemblyName System.Windows.Forms; Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int x,int y); [DllImport(\"user32.dll\")] public static extern void mouse_event(uint dwFlags,uint dx,uint dy,uint dwData,uint dwExtraInfo);' -Name U -Namespace W; [U]::SetCursorPos(" + absX + "," + absY + "); " + body;
if (os_ === "win32") {
const btnDown = params.button === "right" ? 0x0008 : params.button === "middle" ? 0x0020 : 0x0002;
const btnUp = params.button === "right" ? 0x0010 : params.button === "middle" ? 0x0040 : 0x0004;
switch (action) {
case "move":
run(winCmd("")); return;
case "click":
run(winCmd("[U]::mouse_event(" + btnDown + ",0,0,0,0); [U]::mouse_event(" + btnUp + ",0,0,0,0);")); return;
case "doubleclick":
run(winCmd("[U]::mouse_event(" + btnDown + ",0,0,0,0); [U]::mouse_event(" + btnUp + ",0,0,0,0); Start-Sleep -Milliseconds 50; [U]::mouse_event(" + btnDown + ",0,0,0,0); [U]::mouse_event(" + btnUp + ",0,0,0,0);")); return;
case "rightclick":
run(winCmd("[U]::mouse_event(0x0008,0,0,0,0); [U]::mouse_event(0x0010,0,0,0,0);")); return;
case "scroll":
run(winCmd("[U]::mouse_event(0x0800,0,0," + String(Math.round((params.dy || 120) * 120)) + ",0);")); return;
case "keypress":
run("Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('" + String(params.key || params.text || "").replace(/'/g, "").replace(/\+/g, "{+}").replace(/\^/g, "{^}").replace(/%/g, "{%}") + "')"); return;
case "type":
run("Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('" + String(params.text || "").replace(/\+/g, "{+}").replace(/\^/g, "{^}").replace(/%/g, "{%}").replace(/~/g, "{~}") + "')"); return;
default:
sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse: unknown action " + action)); return;
}
}
// Linux / macOS 通过工具 argv
const L = (a) => {
if (os_ === "darwin") {
// cliclick 参数: c:x,y / m:v1,v2 / w:+,-
const pos = absX + "," + absY;
switch (a[0]) {
case "move": return ["m:" + pos];
case "click": return ["c:" + pos];
case "doubleclick": return ["dc:" + pos];
case "rightclick": return ["c:" + pos];
case "scroll": return ["w:" + (params.dy > 0 ? "+" : "-")];
default: return a;
}
}
return a;
};
switch (action) {
case "move":
run(L(["mousemove", String(absX), String(absY)]));
return;
case "click": {
const btn = params.button === "right" ? 3 : params.button === "middle" ? 2 : 1;
run(L(["mousemove", String(absX), String(absY), "click", String(btn)]) );
return;
}
case "doubleclick":
run(L(["mousemove", String(absX), String(absY), "click", "--repeat", "2", "--delay", "50", "1"]));
return;
case "rightclick":
run(L(["mousemove", String(absX), String(absY), "click", "3"]));
return;
case "scroll":
run(L(["mousemove", String(absX), String(absY), "scroll", "--button", "5", String(Math.round(params.dy || params.y || 0))]));
return;
case "keypress":
run(L(["key", String(params.key || params.text || "")]));
return;
case "type": {
// macOS cliclick 无 type用 osascript
if (os_ === "darwin") {
const t = String(params.text || "");
cp.execFile(
"osascript",
["-e", 'tell application "System Events" to keystroke ' + JSON.stringify(t)],
{ timeout: 15000, maxBuffer: 1024 * 1024 },
(err) => {
sendCmdResult(
reqId,
baseResult(reqId, err ? "error" : "ok", err ? "" : "typed: " + t.slice(0, 40), err ? err.message : ""),
);
},
);
return;
}
const t = String(params.text || "");
const shellSafe = "'" + String(t).replace(/'/g, "'\\''") + "'";
cp.execFile(
"bash",
["-c", "xdotool type " + shellSafe],
{ timeout: 15000, maxBuffer: 1024 * 1024 },
(err) => {
sendCmdResult(
reqId,
baseResult(reqId, err ? "error" : "ok", err ? "" : "typed: " + t.slice(0, 40), err ? err.message : ""),
);
},
);
return;
}
default:
sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse: unknown action " + action));
return;
}
}
case "clipboardsee": {
// 读取设备剪切板当前文字内容(仅文本)。隐私提示:可能含密码,仅用户明确要求时读。
// 协议: homeagent-clipboardsee
try {
const txt = clipboard.readText();
const has = !!(txt && txt.length);
sendCmdResult(
reqId,
Object.assign(baseResult(reqId, "ok", has ? String(txt) : "", ""), {
content: has ? "ok" : "empty",
}),
);
} catch (e) {
sendCmdResult(reqId, baseResult(reqId, "error", "", "clipboardsee failed: " + e.message));
}
return;
}
case "clipboardsue": {
// 写入文字到设备剪切板(用户可直接 Ctrl+V 粘贴)。
// 协议: homeagent-clipboardsue <文字>
const txt = String(capability || "")
.replace(/^clipboardsue/, "")
.trim();
try {
clipboard.writeText(txt);
sendCmdResult(
reqId,
baseResult(
reqId,
"ok",
"written " + Buffer.byteLength(txt) + " bytes: " + txt.slice(0, 60),
"",
),
);
} catch (e) {
sendCmdResult(reqId, baseResult(reqId, "error", "", "clipboardsue failed: " + e.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",
"computeruse",
"screensee",
"clipboardsee",
"clipboardsue",
],
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();
}
});
// 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 || "",
screensueDisplay: db.screensueDisplay || "0",
screensueDuration: db.screensueDuration === undefined || db.screensueDuration === null || db.screensueDuration === "" ? "5" : String(db.screensueDuration),
exec: db.exec || {},
authSchedule: db.authSchedule || {},
audio: db.audio || {},
},
};
}
} catch (e) {}
return {
autoLaunch: false,
silentStart: false,
exitToTray: true,
deviceBridge: {
enabled: true,
gateway: "",
token: "",
screensueDisplay: "0",
screensueDuration: "5",
exec: {},
authSchedule: {},
audio: {},
},
};
}
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本机音频输出设备列表供设备通道选择声卡
ipcMain.handle("audio:list", () => {
try {
return listAudioDevices();
} 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 || {},
audio: db.audio || {},
};
});
// 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 || {},
audio: db.audio || {},
};
});
// 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;
});