mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 02:18:06 +00:00
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
This commit is contained in:
869
cmd/gui/main.js
869
cmd/gui/main.js
@ -1,4 +1,5 @@
|
|||||||
const { app, BrowserWindow, ipcMain, Menu } = require("electron");
|
const { app, BrowserWindow, ipcMain, Menu, screen } = require("electron");
|
||||||
|
let screensueWin = null; // screensue 展示窗口(独立于主窗口,显示在配置的屏幕)
|
||||||
// 设备桥直连远程网关:绕过系统代理(本机 clash 代理会导致 wss 被雷池 403)
|
// 设备桥直连远程网关:绕过系统代理(本机 clash 代理会导致 wss 被雷池 403)
|
||||||
try {
|
try {
|
||||||
app.commandLine.appendSwitch("no-proxy-server");
|
app.commandLine.appendSwitch("no-proxy-server");
|
||||||
@ -53,10 +54,25 @@ function installAuthRule() {
|
|||||||
}
|
}
|
||||||
// Electron 会自动附带 jar 中 Cookie(含外部网关 Set-Cookie 的 sl-session)。
|
// Electron 会自动附带 jar 中 Cookie(含外部网关 Set-Cookie 的 sl-session)。
|
||||||
// 这里再显式合并持久化 cookie 与 sl-session 兜底,避免网关再 302。
|
// 这里再显式合并持久化 cookie 与 sl-session 兜底,避免网关再 302。
|
||||||
|
// 注意去重:多个 sl-session 叠加会让 portal 网关解析冲突 → 401
|
||||||
const extra = [authRule.cookie, authRule.slSession].filter(Boolean);
|
const extra = [authRule.cookie, authRule.slSession].filter(Boolean);
|
||||||
if (extra.length) {
|
if (extra.length) {
|
||||||
const existing = h["Cookie"] || "";
|
const existing = h["Cookie"] || "";
|
||||||
h["Cookie"] = [existing].concat(extra).filter(Boolean).join("; ");
|
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 });
|
callback({ requestHeaders: h });
|
||||||
@ -794,6 +810,80 @@ const devOs = require("os");
|
|||||||
let deviceBridge = null; // 当前活动设备桥
|
let deviceBridge = null; // 当前活动设备桥
|
||||||
let deviceBridgeId = ""; // 设备 meta device_id(hello 后可用于 cmd_result)
|
let deviceBridgeId = ""; // 设备 meta device_id(hello 后可用于 cmd_result)
|
||||||
let deviceBridgeAddr = ""; // 设备桥网关地址
|
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 回调。
|
// 建立到 remotedevice WS 网关连接,返回 {send(obj), close()},消息经 onMsg 回调。
|
||||||
function connectDeviceWS(url, token, onMsg) {
|
function connectDeviceWS(url, token, onMsg) {
|
||||||
@ -890,6 +980,7 @@ function connectDeviceWS(url, token, onMsg) {
|
|||||||
opened = true;
|
opened = true;
|
||||||
resolve({
|
resolve({
|
||||||
send: (obj) => sendDeviceFrame(sock, JSON.stringify(obj)),
|
send: (obj) => sendDeviceFrame(sock, JSON.stringify(obj)),
|
||||||
|
__sock: sock, // 暴露底层 socket 供二进制分块发送
|
||||||
close: () => sock.destroy(),
|
close: () => sock.destroy(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -913,8 +1004,42 @@ function connectDeviceWS(url, token, onMsg) {
|
|||||||
buf = buf.slice(off + len);
|
buf = buf.slice(off + len);
|
||||||
if (opcode === 0x1) {
|
if (opcode === 0x1) {
|
||||||
try {
|
try {
|
||||||
onMsg(JSON.parse(payload.toString("utf8")));
|
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) {}
|
} catch (e) {}
|
||||||
|
} else if (opcode === 0x2) {
|
||||||
|
// 二进制帧:接收音频数据块(若处于聚合状态)
|
||||||
|
if (speechAccum) {
|
||||||
|
speechAccum.chunks.push(payload);
|
||||||
|
speechAccum.got += payload.length;
|
||||||
|
}
|
||||||
} else if (opcode === 0x8) {
|
} else if (opcode === 0x8) {
|
||||||
sock.destroy();
|
sock.destroy();
|
||||||
return;
|
return;
|
||||||
@ -954,13 +1079,104 @@ function sendDeviceFrame(sock, text) {
|
|||||||
sock.write(Buffer.concat([hdr, mask, masked]));
|
sock.write(Buffer.concat([hdr, mask, masked]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理网关 WS 消息:hello_ack/bind_ack/cmd 等
|
// 发送 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) {
|
function onDeviceMsg(msg) {
|
||||||
if (!msg || typeof msg !== "object") return;
|
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 || "";
|
const op = msg.op || "";
|
||||||
if (op === "cmd") {
|
if (op === "cmd") {
|
||||||
const command = msg.command || msg.cmd || "";
|
const command = msg.command || msg.cmd || "";
|
||||||
const reqId = msg.req_id || msg.id || "";
|
const reqId = msg.req_id || msg.id || "";
|
||||||
|
const cmdType = msg.cmd_type || "shell";
|
||||||
if (!command) return;
|
if (!command) return;
|
||||||
// 记录远控活动并刷新托盘菜单
|
// 记录远控活动并刷新托盘菜单
|
||||||
trayLastCmd = { cmd: command, at: Date.now(), result: "执行中…" };
|
trayLastCmd = { cmd: command, at: Date.now(), result: "执行中…" };
|
||||||
@ -968,57 +1184,14 @@ function onDeviceMsg(msg) {
|
|||||||
try {
|
try {
|
||||||
rebuildTrayMenu();
|
rebuildTrayMenu();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
const cp = require("child_process");
|
try {
|
||||||
if (argsSafe(command)) {
|
if (cmdType === "homeagent" || command.indexOf("homeagent-") === 0) {
|
||||||
cp.exec(
|
executeHomeagentCmd(command.replace(/^homeagent-/, ""), reqId);
|
||||||
command,
|
} else {
|
||||||
{ timeout: 15000, maxBuffer: 8192 },
|
executeShellCmd(command, reqId);
|
||||||
(err, stdout, stderr) => {
|
|
||||||
const resp = {
|
|
||||||
op: "cmd_result",
|
|
||||||
req_id: reqId,
|
|
||||||
device_id: deviceBridgeId,
|
|
||||||
status: err ? "error" : "ok",
|
|
||||||
output: (stdout || "") + (stderr || ""),
|
|
||||||
error: err ? err.message : "",
|
|
||||||
};
|
|
||||||
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) {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const resp = {
|
|
||||||
op: "cmd_result",
|
|
||||||
req_id: reqId,
|
|
||||||
device_id: deviceBridgeId,
|
|
||||||
status: "denied",
|
|
||||||
output: "",
|
|
||||||
error: "command not allowed",
|
|
||||||
};
|
|
||||||
if (deviceBridge && deviceBridge.send) {
|
|
||||||
try {
|
|
||||||
deviceBridge.send(resp);
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
if (trayLastCmd) {
|
|
||||||
trayLastCmd.result = "已拒绝(白名单)";
|
|
||||||
try {
|
|
||||||
rebuildTrayMenu();
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[device-bridge] exec error: " + e.message);
|
||||||
}
|
}
|
||||||
} else if (op === "hello_ack" || op === "bind_ack") {
|
} else if (op === "hello_ack" || op === "bind_ack") {
|
||||||
console.log(
|
console.log(
|
||||||
@ -1030,6 +1203,417 @@ function onDeviceMsg(msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 统一发送命令结果(回执 + 托盘更新)。兼容两种调用: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, "<") +
|
||||||
|
"</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)。
|
||||||
|
// 依赖系统 TTS;Linux 用 espeak/festival,macOS 用 say,Windows 用 PowerShell SAPI。
|
||||||
|
const text = String(capability || "")
|
||||||
|
.replace(/^speakeruse/, "")
|
||||||
|
.trim();
|
||||||
|
const cp = require("child_process");
|
||||||
|
const platform = process.platform;
|
||||||
|
let cmd = null;
|
||||||
|
let args = [];
|
||||||
|
if (platform === "linux") {
|
||||||
|
// 优先尝试 espeak,其次 festival
|
||||||
|
if (cp.spawnSync("which", ["espeak"]).status === 0) {
|
||||||
|
cmd = "espeak";
|
||||||
|
args = [text];
|
||||||
|
} else if (cp.spawnSync("which", ["festival"]).status === 0) {
|
||||||
|
cmd = "bash";
|
||||||
|
args = [
|
||||||
|
"-c",
|
||||||
|
"echo '" +
|
||||||
|
String(text).replace(/'/g, "'\\''") +
|
||||||
|
"' | festival --tts",
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
sendCmdResult(
|
||||||
|
reqId,
|
||||||
|
baseResult(
|
||||||
|
reqId,
|
||||||
|
"error",
|
||||||
|
"",
|
||||||
|
"speakeruse: no TTS engine (espeak/festival)",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (platform === "darwin") {
|
||||||
|
cmd = "say";
|
||||||
|
args = [text];
|
||||||
|
} else if (platform === "win32") {
|
||||||
|
cmd = "powershell";
|
||||||
|
args = [
|
||||||
|
"-Command",
|
||||||
|
"(New-Object -ComObject SAPI.SpVoice).Speak('" +
|
||||||
|
String(text).replace(/'/g, "''") +
|
||||||
|
"')",
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
sendCmdResult(
|
||||||
|
reqId,
|
||||||
|
baseResult(reqId, "error", "", "speakeruse: unsupported platform"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
sendCmdResult(
|
||||||
|
reqId,
|
||||||
|
baseResult(reqId, "error", "", "speakeruse: empty text"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cp.execFile(
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
{ timeout: 30000, maxBuffer: 1024 * 1024 },
|
||||||
|
(err) => {
|
||||||
|
sendCmdResult(
|
||||||
|
reqId,
|
||||||
|
baseResult(
|
||||||
|
reqId,
|
||||||
|
err ? "error" : "ok",
|
||||||
|
err ? "" : "spoken: " + text.slice(0, 60),
|
||||||
|
err ? err.message : "",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
sendCmdResult(
|
||||||
|
reqId,
|
||||||
|
baseResult(
|
||||||
|
reqId,
|
||||||
|
"error",
|
||||||
|
"",
|
||||||
|
"unsupported homeagent capability: " + name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 简单安全校验:拒绝明显危险命令
|
// 简单安全校验:拒绝明显危险命令
|
||||||
function argsSafe(cmd) {
|
function argsSafe(cmd) {
|
||||||
if (!cmd) return false;
|
if (!cmd) return false;
|
||||||
@ -1114,79 +1698,98 @@ function stopDeviceBridge() {
|
|||||||
// ============ 系统托盘(惰性 + 安全降级) ============
|
// ============ 系统托盘(惰性 + 安全降级) ============
|
||||||
let tray = null;
|
let tray = null;
|
||||||
// 托盘菜单动态数据
|
// 托盘菜单动态数据
|
||||||
const trayLastCmd = null; // 最近一次 device cmd: {cmd, at, result}
|
let trayLastCmd = null; // 最近一次 device cmd: {cmd, at, result}
|
||||||
const trayCmdCount = 0; // 历史 cmd 总次数
|
let trayCmdCount = 0; // 历史 cmd 总次数
|
||||||
function rebuildTrayMenu() {
|
// 托盘菜单构建缓存 + 防抖(避免设备桥消息风暴时反复 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;
|
if (!tray) return;
|
||||||
try {
|
try {
|
||||||
const electron = require("electron");
|
tray.setContextMenu(buildTrayMenuTemplate());
|
||||||
const TMenu = electron.Menu;
|
trayMenuBuilt = true;
|
||||||
const tpl = [];
|
} catch (e) {}
|
||||||
// 标题
|
}
|
||||||
tpl.push({ label: "HomeAgent", enabled: false });
|
// 防抖重建:高频触发时合并,200ms 内只实际 setContextMenu 一次
|
||||||
tpl.push({ type: "separator" });
|
function rebuildTrayMenu() {
|
||||||
// 远程连接状态
|
if (!tray) return;
|
||||||
let connLabel = "未连接";
|
// 先刷新连接缓存
|
||||||
let connUrl = "";
|
try {
|
||||||
try {
|
const conns = loadConnections();
|
||||||
const conns = loadConnections();
|
const cur =
|
||||||
const cur =
|
conns.connections.find((c) => c.id === conns.currentId) ||
|
||||||
conns.connections.find((c) => c.id === conns.currentId) ||
|
conns.connections[0];
|
||||||
conns.connections[0];
|
if (cur) {
|
||||||
if (cur) {
|
trayConnCache.connLabel = cur.name || "未命名";
|
||||||
connLabel = cur.name || "未命名";
|
trayConnCache.connUrl = cur.url || cur.socketPath || "";
|
||||||
connUrl = cur.url || cur.socketPath || "";
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
tpl.push({ label: "后端: " + connLabel, enabled: false });
|
|
||||||
if (connUrl) tpl.push({ label: connUrl, enabled: false });
|
|
||||||
let online = false;
|
|
||||||
try {
|
|
||||||
if (authRule && authRule.urlHost) online = true;
|
|
||||||
} catch (e) {}
|
|
||||||
tpl.push({
|
|
||||||
label: online ? "[已连接]" : "[未连接]",
|
|
||||||
enabled: false,
|
|
||||||
});
|
|
||||||
tpl.push({ type: "separator" });
|
|
||||||
// 设备桥状态
|
|
||||||
if (deviceBridge) {
|
|
||||||
tpl.push({ label: "设备桥: [已连接]", enabled: false });
|
|
||||||
if (deviceBridgeId)
|
|
||||||
tpl.push({ label: "设备ID: " + deviceBridgeId, enabled: false });
|
|
||||||
if (deviceBridgeAddr)
|
|
||||||
tpl.push({ label: "网关: " + deviceBridgeAddr, enabled: false });
|
|
||||||
if (trayLastCmd) {
|
|
||||||
tpl.push({ label: "上次远控: " + trayLastCmd.cmd, enabled: false });
|
|
||||||
tpl.push({
|
|
||||||
label:
|
|
||||||
"结果: " +
|
|
||||||
(trayLastCmd.result || "…").slice(0, 60) +
|
|
||||||
"(" +
|
|
||||||
(trayCmdCount || 0) +
|
|
||||||
"次总数)",
|
|
||||||
enabled: false,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
tpl.push({ label: "未收到远控命令", enabled: false });
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
tpl.push({ label: "设备桥: [未连接]", enabled: false });
|
trayConnCache.connLabel = "未连接";
|
||||||
|
trayConnCache.connUrl = "";
|
||||||
}
|
}
|
||||||
tpl.push({ type: "separator" });
|
} catch (e) {}
|
||||||
tpl.push({ label: "显示主界面", click: () => showMainWindow() });
|
// 首建立即(保证右键立刻有菜单),后续防抖
|
||||||
tpl.push({
|
if (!trayMenuBuilt) {
|
||||||
label: "退出",
|
applyTrayMenu();
|
||||||
click: () => {
|
return;
|
||||||
app.isQuitting = true;
|
|
||||||
app.quit();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const tmenu = TMenu.buildFromTemplate(tpl);
|
|
||||||
tray.setContextMenu(tmenu);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[tray] rebuild failed: " + e.message);
|
|
||||||
}
|
}
|
||||||
|
if (trayRebuildTimer) clearTimeout(trayRebuildTimer);
|
||||||
|
trayRebuildTimer = setTimeout(applyTrayMenu, 200);
|
||||||
}
|
}
|
||||||
function initTray() {
|
function initTray() {
|
||||||
if (tray) return;
|
if (tray) return;
|
||||||
@ -1348,7 +1951,7 @@ function loadGuiPrefs() {
|
|||||||
autoLaunch: false,
|
autoLaunch: false,
|
||||||
silentStart: false,
|
silentStart: false,
|
||||||
exitToTray: true,
|
exitToTray: true,
|
||||||
deviceBridge: { enabled: false, gateway: "", token: "" },
|
deviceBridge: { enabled: true, gateway: "", token: "" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1377,6 +1980,24 @@ function applyAutoLaunch(enabled) {
|
|||||||
// 全局偏好缓存(供 createWindow 静默判断使用)
|
// 全局偏好缓存(供 createWindow 静默判断使用)
|
||||||
let guiPrefs = loadGuiPrefs();
|
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+连接状态)
|
// IPC:本机设备桥状态(启用+网关+token+连接状态)
|
||||||
ipcMain.handle("device-bridge:get", () => {
|
ipcMain.handle("device-bridge:get", () => {
|
||||||
const p = loadGuiPrefs();
|
const p = loadGuiPrefs();
|
||||||
@ -1388,6 +2009,8 @@ ipcMain.handle("device-bridge:get", () => {
|
|||||||
connected: !!deviceBridge,
|
connected: !!deviceBridge,
|
||||||
deviceId: deviceBridgeId,
|
deviceId: deviceBridgeId,
|
||||||
address: deviceBridgeAddr,
|
address: deviceBridgeAddr,
|
||||||
|
screensueDisplay: db.screensueDisplay || "0",
|
||||||
|
exec: db.exec || {},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -45,4 +45,7 @@ contextBridge.exposeInMainWorld("homeagent", {
|
|||||||
get: () => ipcRenderer.invoke("device-bridge:get"),
|
get: () => ipcRenderer.invoke("device-bridge:get"),
|
||||||
set: (cfg) => ipcRenderer.invoke("device-bridge:set", cfg),
|
set: (cfg) => ipcRenderer.invoke("device-bridge:set", cfg),
|
||||||
},
|
},
|
||||||
|
displays: {
|
||||||
|
list: () => ipcRenderer.invoke("displays:list"),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -28,6 +28,7 @@ const state = {
|
|||||||
connections: [],
|
connections: [],
|
||||||
currentConn: null,
|
currentConn: null,
|
||||||
devices: [],
|
devices: [],
|
||||||
|
displays: [],
|
||||||
selfDeviceId: "",
|
selfDeviceId: "",
|
||||||
selfGateway: "",
|
selfGateway: "",
|
||||||
};
|
};
|
||||||
@ -321,6 +322,18 @@ function timeAgo(t) {
|
|||||||
return Math.floor(m / 60) + __("小时前", "h ago");
|
return Math.floor(m / 60) + __("小时前", "h ago");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成客户端唯一消息 ID(服务端据此去重,避免断线/重试重放)
|
||||||
|
function clientMsgId() {
|
||||||
|
return (
|
||||||
|
"cli-" +
|
||||||
|
Date.now().toString(36) +
|
||||||
|
"-" +
|
||||||
|
Math.random().toString(36).slice(2, 8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 最近一次发送的消息 ID(防重复重发提示用)
|
||||||
|
var lastClientMsgId = null;
|
||||||
|
|
||||||
function toast(m, isError, warn) {
|
function toast(m, isError, warn) {
|
||||||
var t = document.getElementById("toast");
|
var t = document.getElementById("toast");
|
||||||
t.textContent = m;
|
t.textContent = m;
|
||||||
@ -573,17 +586,31 @@ async function refreshAll() {
|
|||||||
await loadCmdHistory();
|
await loadCmdHistory();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
try {
|
try {
|
||||||
if (state.currentConn && state.currentConn.type === "device") {
|
// 设备列表:webui 连接时经 webui 反代 /api/v1/device/online 拉取(反代只挂 /api/v1/device/ 前缀)
|
||||||
var d = await api("/device");
|
if (
|
||||||
state.devices = d.devices || [];
|
state.currentConn &&
|
||||||
|
state.currentConn.type === "webui" &&
|
||||||
|
state.currentConn.url
|
||||||
|
) {
|
||||||
|
var d = await api("/device/online");
|
||||||
|
state.devices = (d && d.devices) || [];
|
||||||
|
} else {
|
||||||
|
state.devices = [];
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state.devices = [];
|
state.devices = [];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// 本机显示器列表(screensue 默认屏幕配置用)
|
||||||
|
try {
|
||||||
|
if (window.homeagent && window.homeagent.displays) {
|
||||||
|
state.displays = (await window.homeagent.displays.list()) || [];
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
// 本机设备桥身份:设备桥由 gui-prefs 驱动,独立于当前连接类型
|
// 本机设备桥身份:设备桥由 gui-prefs 驱动,独立于当前连接类型
|
||||||
if (window.homeagent && window.homeagent.deviceBridge) {
|
if (window.homeagent && window.homeagent.deviceBridge) {
|
||||||
var dbinfo = await window.homeagent.deviceBridge.get();
|
var dbinfo = await window.homeagent.deviceBridge.get();
|
||||||
|
state.dbConfig = dbinfo || state.dbConfig;
|
||||||
if (dbinfo && dbinfo.enabled) {
|
if (dbinfo && dbinfo.enabled) {
|
||||||
if (dbinfo.deviceId) {
|
if (dbinfo.deviceId) {
|
||||||
state.selfDeviceId = dbinfo.deviceId;
|
state.selfDeviceId = dbinfo.deviceId;
|
||||||
@ -893,7 +920,7 @@ function buildChatLayout() {
|
|||||||
html +=
|
html +=
|
||||||
'<div class="card"><h2>' +
|
'<div class="card"><h2>' +
|
||||||
__("对话", "Chat") +
|
__("对话", "Chat") +
|
||||||
' <span id="chat-stage" class="badge" style="font-size:10px;font-weight:400;display:none">' +
|
' <span id="chat-stage" class="badge" style="font-size:12px;font-weight:400;display:none">' +
|
||||||
escHtml(state.chatStage || "") +
|
escHtml(state.chatStage || "") +
|
||||||
'</span></h2><div class="chat-messages" id="chat-msgs">';
|
'</span></h2><div class="chat-messages" id="chat-msgs">';
|
||||||
if (state.messages.length === 0) {
|
if (state.messages.length === 0) {
|
||||||
@ -1429,7 +1456,7 @@ function renderChatStarmap() {
|
|||||||
(!window.THREE && window._THREE_FAILED !== undefined)
|
(!window.THREE && window._THREE_FAILED !== undefined)
|
||||||
) {
|
) {
|
||||||
cont.innerHTML =
|
cont.innerHTML =
|
||||||
'<p style="color:var(--text-muted);padding:20px;text-align:center;font-size:11px">' +
|
'<p style="color:var(--text-muted);padding:20px;text-align:center;font-size:13px">' +
|
||||||
__(
|
__(
|
||||||
"3D 星图不可用(CDN 加载失败)",
|
"3D 星图不可用(CDN 加载失败)",
|
||||||
"Star map unavailable (CDN load failed)",
|
"Star map unavailable (CDN load failed)",
|
||||||
@ -1781,7 +1808,7 @@ async function sendChat() {
|
|||||||
var btn = document.getElementById("chat-send-btn");
|
var btn = document.getElementById("chat-send-btn");
|
||||||
var text = inp.value.trim();
|
var text = inp.value.trim();
|
||||||
if (!text || state.chatLoading) return;
|
if (!text || state.chatLoading) return;
|
||||||
if (state.currentConn && state.currentConn.type === "device") {
|
if (state.currentConn && state.currentConn.type === "cli") {
|
||||||
toast(
|
toast(
|
||||||
__(
|
__(
|
||||||
"设备网关连接不支持聊天",
|
"设备网关连接不支持聊天",
|
||||||
@ -1802,34 +1829,35 @@ async function sendChat() {
|
|||||||
btn.textContent = "";
|
btn.textContent = "";
|
||||||
rerenderChat();
|
rerenderChat();
|
||||||
try {
|
try {
|
||||||
// webui 连接:触发式 + SSE 流式接管(避免 POST 与 SSE 双通道重复渲染卡死)
|
// webui 连接:同步 POST 等完整回复(服务端 X-Trigger-Only 也返回 response;SSE 公网不稳时靠同步兜底)
|
||||||
var isWebui = !state.currentConn || state.currentConn.type !== "cli";
|
var isWebui = !state.currentConn || state.currentConn.type !== "cli";
|
||||||
var r = null;
|
var r = null;
|
||||||
|
// 唯一消息 ID:服务端据此去重(断线重放/超时重试不再重复处理)
|
||||||
|
var cid = clientMsgId();
|
||||||
|
lastClientMsgId = cid;
|
||||||
try {
|
try {
|
||||||
if (isWebui) {
|
r = await api("/chat", {
|
||||||
// 触发请求:短超时确认受理,回复靠 SSE
|
method: "POST",
|
||||||
r = await api("/chat", {
|
body: JSON.stringify({ message: text, client_msg_id: cid }),
|
||||||
method: "POST",
|
timeout: 120000,
|
||||||
body: JSON.stringify({ message: text }),
|
});
|
||||||
timeout: 15000,
|
|
||||||
headers: { "X-Trigger-Only": "1" },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
r = await api("/chat", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ message: text }),
|
|
||||||
timeout: 120000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 触发请求超时/失败:不阻塞 UI,等 SSE 兜底;若 SSE 也无响应则报错
|
// 同步超时/失败:不阻塞 UI,等 SSE 兜底;若 SSE 也无响应则报错。
|
||||||
|
// 明确提示"可能已发送",避免用户在超时后重复点击导致服务端收到多条相同消息
|
||||||
console.warn("[sendChat] trigger failed: " + e.message);
|
console.warn("[sendChat] trigger failed: " + e.message);
|
||||||
|
toast(
|
||||||
|
__(
|
||||||
|
"请求超时(可能已发送,请稍候或在收到回复前勿重复发送)",
|
||||||
|
"Request timeout (may have been sent; wait for reply before resending)",
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
r = null;
|
r = null;
|
||||||
}
|
}
|
||||||
state.chatStage = __("AI 回复中...", "AI replying...");
|
state.chatStage = __("AI 回复中...", "AI replying...");
|
||||||
var last = state.messages[state.messages.length - 1];
|
var last = state.messages[state.messages.length - 1];
|
||||||
// CLI/device 无 SSE:POST 完整结果直接填充
|
// 无论 webui/cli:同步 POST 拿到完整回复就直接填充(SSE 公网不稳靠此兜底)
|
||||||
if (!isWebui && r) {
|
if (r && r.response) {
|
||||||
if (last && last.role === "assistant" && last._streaming) {
|
if (last && last.role === "assistant" && last._streaming) {
|
||||||
last.content = r.response || __("(无响应)", "(no response)");
|
last.content = r.response || __("(无响应)", "(no response)");
|
||||||
last._grow = true;
|
last._grow = true;
|
||||||
@ -1852,7 +1880,7 @@ async function sendChat() {
|
|||||||
}
|
}
|
||||||
state.chatFinalIdx = state.messages.length - 1;
|
state.chatFinalIdx = state.messages.length - 1;
|
||||||
}
|
}
|
||||||
if (!isWebui) rerenderChat();
|
rerenderChat();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state.messages.push({
|
state.messages.push({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@ -1878,7 +1906,7 @@ async function queryMemoryChat() {
|
|||||||
try {
|
try {
|
||||||
var data = await api("/memory?q=" + encodeURIComponent(q) + "&depth=2");
|
var data = await api("/memory?q=" + encodeURIComponent(q) + "&depth=2");
|
||||||
r.innerHTML =
|
r.innerHTML =
|
||||||
'<pre style="font-size:11px">' +
|
'<pre style="font-size:13px">' +
|
||||||
escHtml(JSON.stringify(data, null, 2)) +
|
escHtml(JSON.stringify(data, null, 2)) +
|
||||||
"</pre>";
|
"</pre>";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -1901,7 +1929,7 @@ async function queryMemoryContext() {
|
|||||||
var summary = data?.summary || "";
|
var summary = data?.summary || "";
|
||||||
var entities = data?.entities || [];
|
var entities = data?.entities || [];
|
||||||
var tk = data?.token_estimate || 0;
|
var tk = data?.token_estimate || 0;
|
||||||
var html = '<div style="font-size:11px">';
|
var html = '<div style="font-size:13px">';
|
||||||
if (summary)
|
if (summary)
|
||||||
html +=
|
html +=
|
||||||
'<div class="kv-row"><span class="key">' +
|
'<div class="kv-row"><span class="key">' +
|
||||||
@ -1924,7 +1952,7 @@ async function queryMemoryContext() {
|
|||||||
"</span></div>";
|
"</span></div>";
|
||||||
}
|
}
|
||||||
html +=
|
html +=
|
||||||
'<pre style="font-size:11px;margin-top:8px">' +
|
'<pre style="font-size:13px;margin-top:8px">' +
|
||||||
escHtml(ctx) +
|
escHtml(ctx) +
|
||||||
"</pre></div>";
|
"</pre></div>";
|
||||||
r.innerHTML = html;
|
r.innerHTML = html;
|
||||||
@ -1945,7 +1973,7 @@ async function searchKnowledgeChat() {
|
|||||||
try {
|
try {
|
||||||
var data = await api("/knowledge?q=" + encodeURIComponent(q));
|
var data = await api("/knowledge?q=" + encodeURIComponent(q));
|
||||||
r.innerHTML =
|
r.innerHTML =
|
||||||
'<pre style="font-size:11px">' +
|
'<pre style="font-size:13px">' +
|
||||||
escHtml(JSON.stringify(data, null, 2)) +
|
escHtml(JSON.stringify(data, null, 2)) +
|
||||||
"</pre>";
|
"</pre>";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -2067,7 +2095,7 @@ function renderTerminals() {
|
|||||||
if (cnt) cnt.textContent = list.length;
|
if (cnt) cnt.textContent = list.length;
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
r.innerHTML =
|
r.innerHTML =
|
||||||
'<p style="color:var(--text-muted);padding:8px;text-align:center;font-size:11px">' +
|
'<p style="color:var(--text-muted);padding:8px;text-align:center;font-size:13px">' +
|
||||||
__("暂无终端会话", "No terminal sessions") +
|
__("暂无终端会话", "No terminal sessions") +
|
||||||
"</p>";
|
"</p>";
|
||||||
return;
|
return;
|
||||||
@ -2087,13 +2115,13 @@ function renderTerminals() {
|
|||||||
"</span>";
|
"</span>";
|
||||||
}
|
}
|
||||||
html +=
|
html +=
|
||||||
'<div style="border:1px solid var(--border-color);border-radius:6px;margin-bottom:4px;font-size:11px">';
|
'<div style="border:1px solid var(--border-color);border-radius:6px;margin-bottom:4px;font-size:13px">';
|
||||||
html +=
|
html +=
|
||||||
'<div style="display:flex;align-items:center;gap:6px;padding:6px 8px;cursor:pointer;background:var(--bg-hover)" onclick="var d=document.getElementById(\'' +
|
'<div style="display:flex;align-items:center;gap:6px;padding:6px 8px;cursor:pointer;background:var(--bg-hover)" onclick="var d=document.getElementById(\'' +
|
||||||
detailId +
|
detailId +
|
||||||
"');d.style.display=d.style.display==='none'?'block':'none'\">";
|
"');d.style.display=d.style.display==='none'?'block':'none'\">";
|
||||||
html +=
|
html +=
|
||||||
'<span style="font-family:monospace;font-size:10px;flex:1">' +
|
'<span style="font-family:monospace;font-size:12px;flex:1">' +
|
||||||
escHtml(t.id || "-") +
|
escHtml(t.id || "-") +
|
||||||
"</span>";
|
"</span>";
|
||||||
html +=
|
html +=
|
||||||
@ -2107,7 +2135,7 @@ function renderTerminals() {
|
|||||||
(running ? __("运行中", "Running") : __("已关闭", "Closed")) +
|
(running ? __("运行中", "Running") : __("已关闭", "Closed")) +
|
||||||
"</span>";
|
"</span>";
|
||||||
html +=
|
html +=
|
||||||
'<span style="color:var(--text-muted);font-size:10px">' +
|
'<span style="color:var(--text-muted);font-size:12px">' +
|
||||||
escHtml(t.created_at || "") +
|
escHtml(t.created_at || "") +
|
||||||
"</span>";
|
"</span>";
|
||||||
html += "</div>";
|
html += "</div>";
|
||||||
@ -2147,13 +2175,13 @@ function renderCmdHistory() {
|
|||||||
if (cnt) cnt.textContent = running.length;
|
if (cnt) cnt.textContent = running.length;
|
||||||
if (running.length === 0) {
|
if (running.length === 0) {
|
||||||
r.innerHTML =
|
r.innerHTML =
|
||||||
'<p style="color:var(--text-muted);padding:8px;text-align:center;font-size:11px">' +
|
'<p style="color:var(--text-muted);padding:8px;text-align:center;font-size:13px">' +
|
||||||
__("暂无运行中的命令", "No running commands") +
|
__("暂无运行中的命令", "No running commands") +
|
||||||
"</p>";
|
"</p>";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var html =
|
var html =
|
||||||
'<table style="font-size:10px"><tr><th>' +
|
'<table style="font-size:12px"><tr><th>' +
|
||||||
__("命令", "Command") +
|
__("命令", "Command") +
|
||||||
"</th><th>" +
|
"</th><th>" +
|
||||||
__("状态", "Status") +
|
__("状态", "Status") +
|
||||||
@ -2177,7 +2205,7 @@ function renderCmdHistory() {
|
|||||||
"</tr>";
|
"</tr>";
|
||||||
if (out) {
|
if (out) {
|
||||||
html +=
|
html +=
|
||||||
'<tr><td colspan="3" style="padding:0"><pre style="margin:0;padding:4px 8px;max-height:120px;overflow:auto;background:var(--bg-input);border-radius:4px;font-size:10px;color:var(--text-secondary)">' +
|
'<tr><td colspan="3" style="padding:0"><pre style="margin:0;padding:4px 8px;max-height:120px;overflow:auto;background:var(--bg-input);border-radius:4px;font-size:12px;color:var(--text-secondary)">' +
|
||||||
escHtml(out.substring(0, 2000)) +
|
escHtml(out.substring(0, 2000)) +
|
||||||
"</pre></td></tr>";
|
"</pre></td></tr>";
|
||||||
}
|
}
|
||||||
@ -2650,7 +2678,7 @@ function renderHealthResult(r) {
|
|||||||
'">' +
|
'">' +
|
||||||
(c.status || "unknown") +
|
(c.status || "unknown") +
|
||||||
"</span>" +
|
"</span>" +
|
||||||
'<span style="color:var(--text-muted);font-size:11px">' +
|
'<span style="color:var(--text-muted);font-size:13px">' +
|
||||||
escHtml(c.detail || "") +
|
escHtml(c.detail || "") +
|
||||||
"</span></div>";
|
"</span></div>";
|
||||||
});
|
});
|
||||||
@ -3208,7 +3236,7 @@ function renderOneSettings() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
var descHtml = desc
|
var descHtml = desc
|
||||||
? '<p style="font-size:11px;color:var(--text-muted);margin:-6px 0 10px">' +
|
? '<p style="font-size:13px;color:var(--text-muted);margin:-6px 0 10px">' +
|
||||||
escHtml(desc) +
|
escHtml(desc) +
|
||||||
"</p>"
|
"</p>"
|
||||||
: "";
|
: "";
|
||||||
@ -3343,7 +3371,7 @@ function renderOneSettings() {
|
|||||||
html +=
|
html +=
|
||||||
'<div class="card"><h2>' +
|
'<div class="card"><h2>' +
|
||||||
__("MCP 服务器", "MCP Servers") +
|
__("MCP 服务器", "MCP Servers") +
|
||||||
'</h2><p style="font-size:11px;color:var(--text-muted);margin-bottom:8px">' +
|
'</h2><p style="font-size:13px;color:var(--text-muted);margin-bottom:8px">' +
|
||||||
__(
|
__(
|
||||||
"配置 Model Context Protocol 服务端连接",
|
"配置 Model Context Protocol 服务端连接",
|
||||||
"Configure Model Context Protocol server connections",
|
"Configure Model Context Protocol server connections",
|
||||||
@ -3852,7 +3880,7 @@ function renderGuiPrefs() {
|
|||||||
label +
|
label +
|
||||||
"</div>" +
|
"</div>" +
|
||||||
(desc
|
(desc
|
||||||
? '<div style="font-size:11px;color:var(--text-muted)">' +
|
? '<div style="font-size:13px;color:var(--text-muted)">' +
|
||||||
desc +
|
desc +
|
||||||
"</div>"
|
"</div>"
|
||||||
: "") +
|
: "") +
|
||||||
@ -3954,8 +3982,7 @@ function renderConnSection() {
|
|||||||
__("连接类型", "Type") +
|
__("连接类型", "Type") +
|
||||||
'</label><select id="conn-type" onchange="toggleConnType()">' +
|
'</label><select id="conn-type" onchange="toggleConnType()">' +
|
||||||
'<option value="webui">WebUI (HTTP)</option>' +
|
'<option value="webui">WebUI (HTTP)</option>' +
|
||||||
'<option value="cli">CLI (unix socket)</option>' +
|
'<option value="cli">CLI (unix socket)</option></select>' +
|
||||||
'<option value="device">设备网关 (remotedevice)</option></select>' +
|
|
||||||
'<div id="conn-addr-webui"><label>' +
|
'<div id="conn-addr-webui"><label>' +
|
||||||
__("地址", "URL") +
|
__("地址", "URL") +
|
||||||
'</label><input id="conn-url" placeholder="http://localhost:18080"></div>' +
|
'</label><input id="conn-url" placeholder="http://localhost:18080"></div>' +
|
||||||
@ -4032,11 +4059,6 @@ function toggleConnType() {
|
|||||||
t === "cli" ? "block" : "none";
|
t === "cli" ? "block" : "none";
|
||||||
document.getElementById("conn-auth-webui").style.display =
|
document.getElementById("conn-auth-webui").style.display =
|
||||||
t === "cli" ? "none" : "block";
|
t === "cli" ? "none" : "block";
|
||||||
// device 类型不适用 webui 网关登录
|
|
||||||
var gwField = document.getElementById("conn-gw");
|
|
||||||
if (gwField)
|
|
||||||
gwField.closest("label, div").style.display =
|
|
||||||
t === "device" ? "none" : "block";
|
|
||||||
toggleGwFields();
|
toggleGwFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4365,38 +4387,6 @@ async function saveConnForm() {
|
|||||||
testBtn.disabled = false;
|
testBtn.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (ctype === "device") {
|
|
||||||
// 设备网关:直接探活 /api/v1/device(带 token)
|
|
||||||
var devTest;
|
|
||||||
try {
|
|
||||||
devTest = await fetch(url + "/api/v1/device", {
|
|
||||||
headers: apiKey ? { "X-API-Key": apiKey } : {},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
toast(
|
|
||||||
__("无法连接到设备网关 ", "Cannot connect to device gateway ") +
|
|
||||||
url +
|
|
||||||
": " +
|
|
||||||
e.message,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
testBtn.textContent = __("保存", "Save");
|
|
||||||
testBtn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!devTest.ok) {
|
|
||||||
toast(
|
|
||||||
__("设备网关测试失败: ", "Device gateway test failed: ") +
|
|
||||||
devTest.status +
|
|
||||||
"(" +
|
|
||||||
(await devTest.text()).slice(0, 120) +
|
|
||||||
")",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
testBtn.textContent = __("保存", "Save");
|
|
||||||
testBtn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (window.homeagent && window.homeagent.webui) {
|
if (window.homeagent && window.homeagent.webui) {
|
||||||
if (gwEnabled && !cookie) {
|
if (gwEnabled && !cookie) {
|
||||||
@ -4497,31 +4487,19 @@ async function saveConnForm() {
|
|||||||
testBtn.textContent = __("保存", "Save");
|
testBtn.textContent = __("保存", "Save");
|
||||||
testBtn.disabled = false;
|
testBtn.disabled = false;
|
||||||
var connData =
|
var connData =
|
||||||
ctype === "device"
|
ctype === "cli"
|
||||||
? {
|
? { name: name, type: "cli", socketPath: sock, url: "", apiKey: apiKey }
|
||||||
|
: {
|
||||||
name: name,
|
name: name,
|
||||||
type: "device",
|
type: "webui",
|
||||||
url: url,
|
url: url,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
username: "",
|
username: username,
|
||||||
password: "",
|
password: password,
|
||||||
cookie: "",
|
cookie: cookie,
|
||||||
headers: "",
|
headers: headers,
|
||||||
gateway: false,
|
gateway: gwEnabled,
|
||||||
}
|
};
|
||||||
: ctype === "cli"
|
|
||||||
? { name: name, type: "cli", socketPath: sock, url: "", apiKey: apiKey }
|
|
||||||
: {
|
|
||||||
name: name,
|
|
||||||
type: "webui",
|
|
||||||
url: url,
|
|
||||||
apiKey: apiKey,
|
|
||||||
username: username,
|
|
||||||
password: password,
|
|
||||||
cookie: cookie,
|
|
||||||
headers: headers,
|
|
||||||
gateway: gwEnabled,
|
|
||||||
};
|
|
||||||
var data;
|
var data;
|
||||||
if (editingConnId) {
|
if (editingConnId) {
|
||||||
data = await window.homeagent.connections.update(editingConnId, connData);
|
data = await window.homeagent.connections.update(editingConnId, connData);
|
||||||
@ -4572,8 +4550,7 @@ connectSSE = () => {
|
|||||||
}
|
}
|
||||||
if (!state.currentConn) return;
|
if (!state.currentConn) return;
|
||||||
// CLI/device 连接无 SSE 通道,聊天走同步
|
// CLI/device 连接无 SSE 通道,聊天走同步
|
||||||
if (state.currentConn.type === "cli" || state.currentConn.type === "device")
|
if (state.currentConn.type === "cli") return;
|
||||||
return;
|
|
||||||
connectFetchSSE(state.currentConn.url + "/api/v1/chat/events");
|
connectFetchSSE(state.currentConn.url + "/api/v1/chat/events");
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -4910,6 +4887,97 @@ function renderDevices() {
|
|||||||
) +
|
) +
|
||||||
"</p>";
|
"</p>";
|
||||||
}
|
}
|
||||||
|
// 设备通道配置(独立于连接类型:devicced 是 GUI 组件,默认走 webui 反代端口)
|
||||||
|
var dbc = state.dbConfig || {};
|
||||||
|
var webuiUrl = "";
|
||||||
|
if (
|
||||||
|
state.currentConn &&
|
||||||
|
state.currentConn.type === "webui" &&
|
||||||
|
state.currentConn.url
|
||||||
|
) {
|
||||||
|
webuiUrl = state.currentConn.url.replace(/\/+$/, "") + "/api/v1/device/ws";
|
||||||
|
}
|
||||||
|
var curGateway = dbc.gateway || webuiUrl || "";
|
||||||
|
var dbExec = dbc.exec || {};
|
||||||
|
var dispIdx = dbc.screensueDisplay || "0";
|
||||||
|
var dispOpts = (state.displays || [])
|
||||||
|
.map(
|
||||||
|
(d) =>
|
||||||
|
'<option value="' +
|
||||||
|
d.index +
|
||||||
|
'"' +
|
||||||
|
(String(d.index) === String(dispIdx) ? " selected" : "") +
|
||||||
|
">" +
|
||||||
|
escHtml(d.name) +
|
||||||
|
(d.size ? " (" + escHtml(d.size) + ")" : "") +
|
||||||
|
(d.primary ? " 主" : "") +
|
||||||
|
"</option>",
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
selfHtml +=
|
||||||
|
'<div class="kv-row"><span class="key">' +
|
||||||
|
__("设备通道", "Device Channel") +
|
||||||
|
'</span><span class="val" style="flex-direction:column;align-items:stretch;gap:4px">' +
|
||||||
|
'<div style="display:flex;gap:6px;flex-wrap:wrap">' +
|
||||||
|
'<span style="font-size:13px;color:var(--text-muted)">' +
|
||||||
|
(dbc.connected
|
||||||
|
? __("设备桥已连接", "Bridge connected")
|
||||||
|
: __("设备桥未连接", "Bridge not connected")) +
|
||||||
|
(dbc.deviceId ? " · " + escHtml(dbc.deviceId) : "") +
|
||||||
|
"</span></div>" +
|
||||||
|
'<input id="dev-bridge-gw" value="' +
|
||||||
|
escHtml(curGateway) +
|
||||||
|
'" style="width:100%;font-size:13px;padding:4px 6px;border-radius:4px;border:1px solid var(--border-color);background:var(--bg-input);color:var(--text-primary)">' +
|
||||||
|
'<input id="dev-bridge-token" value="' +
|
||||||
|
escHtml(dbc.tokenSet ? "" : "") +
|
||||||
|
'" placeholder="' +
|
||||||
|
__("ws_token(留空保留已存)", "ws_token (empty keeps stored)") +
|
||||||
|
'" type="password" style="width:100%;font-size:13px;padding:4px 6px;border-radius:4px;border:1px solid var(--border-color);background:var(--bg-input);color:var(--text-primary)">' +
|
||||||
|
'<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">' +
|
||||||
|
"<label style='font-size:13px'>" +
|
||||||
|
__("screensue 屏幕", "screensue display") +
|
||||||
|
'</label><select id="dev-bridge-display" style="font-size:13px;padding:3px 6px;border-radius:4px;border:1px solid var(--border-color);background:var(--bg-input);color:var(--text-primary)">' +
|
||||||
|
(dispOpts || '<option value="0">默认</option>') +
|
||||||
|
"</select>" +
|
||||||
|
"</div>" +
|
||||||
|
'<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">' +
|
||||||
|
"<label style='font-size:13px'>" +
|
||||||
|
__("cmdrun 目录", "cmdrun cwd") +
|
||||||
|
'</label><input id="dev-bridge-cwd" value="' +
|
||||||
|
escHtml(dbExec.cwd || "") +
|
||||||
|
'" placeholder="' +
|
||||||
|
__("留空=用户主目录", "empty=home dir") +
|
||||||
|
'" style="flex:1;min-width:120px;font-size:13px;padding:3px 6px;border-radius:4px;border:1px solid var(--border-color);background:var(--bg-input);color:var(--text-primary)">' +
|
||||||
|
"</div>" +
|
||||||
|
'<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">' +
|
||||||
|
"<label style='font-size:13px'>" +
|
||||||
|
__("沙箱", "Sandbox") +
|
||||||
|
'</label><select id="dev-bridge-sandbox" style="font-size:13px;padding:3px 6px;border-radius:4px;border:1px solid var(--border-color);background:var(--bg-input);color:var(--text-primary)">' +
|
||||||
|
'<option value="off"' +
|
||||||
|
((dbExec.sandbox || "off") === "off" ? " selected" : "") +
|
||||||
|
">" +
|
||||||
|
__("不限", "off") +
|
||||||
|
"</option>" +
|
||||||
|
'<option value="home"' +
|
||||||
|
(dbExec.sandbox === "home" ? " selected" : "") +
|
||||||
|
">" +
|
||||||
|
__("主目录", "home") +
|
||||||
|
"</option>" +
|
||||||
|
'<option value="box"' +
|
||||||
|
(dbExec.sandbox === "box" ? " selected" : "") +
|
||||||
|
">" +
|
||||||
|
__("指定目录", "box") +
|
||||||
|
"</option></select>" +
|
||||||
|
(dbExec.sandbox === "box"
|
||||||
|
? '<input id="dev-bridge-boxdir" value="' +
|
||||||
|
escHtml(dbExec.boxDir || "") +
|
||||||
|
'" placeholder="沙箱目录" style="flex:1;min-width:120px;font-size:13px;padding:3px 6px;border-radius:4px;border:1px solid var(--border-color);background:var(--bg-input);color:var(--text-primary)">'
|
||||||
|
: '<input id="dev-bridge-boxdir" style="display:none">') +
|
||||||
|
"</div>" +
|
||||||
|
'<button class="btn btn-ghost btn-sm" onclick="saveBridgeChannel()">' +
|
||||||
|
__("保存并应用", "Save & Apply") +
|
||||||
|
"</button></div></div>";
|
||||||
|
|
||||||
selfHtml += "</div>";
|
selfHtml += "</div>";
|
||||||
var html =
|
var html =
|
||||||
selfHtml +
|
selfHtml +
|
||||||
@ -4956,7 +5024,7 @@ function renderDevices() {
|
|||||||
html +=
|
html +=
|
||||||
"<tr><td><b>" +
|
"<tr><td><b>" +
|
||||||
escHtml(d.name || d.device_id) +
|
escHtml(d.name || d.device_id) +
|
||||||
'</b><br><span style="font-size:11px;color:var(--text-muted)">' +
|
'</b><br><span style="font-size:13px;color:var(--text-muted)">' +
|
||||||
escHtml(d.device_id) +
|
escHtml(d.device_id) +
|
||||||
"</span></td><td>" +
|
"</span></td><td>" +
|
||||||
escHtml(d.kind || "-") +
|
escHtml(d.kind || "-") +
|
||||||
@ -4986,9 +5054,45 @@ function renderDevices() {
|
|||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保存设备通道配置(网关 + token + 启用),调主进程 deviceBridge:set
|
||||||
|
async function saveBridgeChannel() {
|
||||||
|
try {
|
||||||
|
if (!window.homeagent || !window.homeagent.deviceBridge) {
|
||||||
|
toast(__("设备桥不可用", "Device bridge unavailable"), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var gw = (document.getElementById("dev-bridge-gw").value || "").trim();
|
||||||
|
var tok = (document.getElementById("dev-bridge-token").value || "").trim();
|
||||||
|
if (!gw) {
|
||||||
|
toast(__("请填设备通道地址", "Set device channel URL first"), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var cfg = { enabled: true, gateway: gw };
|
||||||
|
if (tok) cfg.token = tok;
|
||||||
|
// 能力配置:screensue 屏幕 / cmdrun 目录 / 沙箱
|
||||||
|
var disp = document.getElementById("dev-bridge-display");
|
||||||
|
if (disp) cfg.screensueDisplay = disp.value || "0";
|
||||||
|
var cwd = document.getElementById("dev-bridge-cwd");
|
||||||
|
var sandbox = document.getElementById("dev-bridge-sandbox");
|
||||||
|
var boxdir = document.getElementById("dev-bridge-boxdir");
|
||||||
|
var ex = {};
|
||||||
|
if (cwd) ex.cwd = cwd.value.trim();
|
||||||
|
if (sandbox) ex.sandbox = sandbox.value || "off";
|
||||||
|
if (boxdir) ex.boxDir = boxdir.value.trim();
|
||||||
|
cfg.exec = ex;
|
||||||
|
var r = await window.homeagent.deviceBridge.set(cfg);
|
||||||
|
state.dbConfig = r || state.dbConfig;
|
||||||
|
toast(__("设备通道已保存并应用", "Device channel saved & applied"));
|
||||||
|
refreshAll();
|
||||||
|
renderDevices();
|
||||||
|
} catch (e) {
|
||||||
|
toast(__("保存失败: ", "Save failed: ") + e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deviceRefresh() {
|
async function deviceRefresh() {
|
||||||
try {
|
try {
|
||||||
var d = await api("/device");
|
var d = await api("/device/online");
|
||||||
state.devices = d.devices || [];
|
state.devices = d.devices || [];
|
||||||
renderDevices();
|
renderDevices();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -45,8 +45,8 @@
|
|||||||
--bg-input: rgba(13, 18, 34, 0.75);
|
--bg-input: rgba(13, 18, 34, 0.75);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.06);
|
--bg-hover: rgba(255, 255, 255, 0.06);
|
||||||
--text-primary: #eef1f8;
|
--text-primary: #eef1f8;
|
||||||
--text-secondary: #a7b0c4;
|
--text-secondary: #b8c1d6;
|
||||||
--text-muted: #77809a;
|
--text-muted: #93a0b8;
|
||||||
--border-color: rgba(255, 255, 255, 0.09);
|
--border-color: rgba(255, 255, 255, 0.09);
|
||||||
--accent: #ff7fac;
|
--accent: #ff7fac;
|
||||||
--accent-bg: rgba(255, 127, 172, 0.14);
|
--accent-bg: rgba(255, 127, 172, 0.14);
|
||||||
@ -98,8 +98,8 @@
|
|||||||
--bg-input: rgba(255, 224, 233, 0.55);
|
--bg-input: rgba(255, 224, 233, 0.55);
|
||||||
--bg-hover: rgba(255, 127, 172, 0.08);
|
--bg-hover: rgba(255, 127, 172, 0.08);
|
||||||
--text-primary: #3b2030;
|
--text-primary: #3b2030;
|
||||||
--text-secondary: #7a5c6b;
|
--text-secondary: #6b4b5c;
|
||||||
--text-muted: #a48a96;
|
--text-muted: #8f6f7d;
|
||||||
--border-color: rgba(201, 36, 98, 0.14);
|
--border-color: rgba(201, 36, 98, 0.14);
|
||||||
--accent: #c92462;
|
--accent: #c92462;
|
||||||
--accent-bg: #ffe4e9;
|
--accent-bg: #ffe4e9;
|
||||||
@ -290,7 +290,7 @@ body {
|
|||||||
transition:
|
transition:
|
||||||
background 0.2s,
|
background 0.2s,
|
||||||
color 0.2s;
|
color 0.2s;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@ -707,13 +707,13 @@ body.maximized .tb-max svg {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
.card h2 {
|
.card h2 {
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.card h3 {
|
.card h3 {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin: 16px 0 8px;
|
margin: 16px 0 8px;
|
||||||
@ -901,7 +901,7 @@ select {
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
outline: none;
|
outline: none;
|
||||||
@ -923,9 +923,9 @@ textarea {
|
|||||||
}
|
}
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: 3px;
|
margin-bottom: 4px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
pre {
|
pre {
|
||||||
@ -1597,10 +1597,10 @@ code {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.settings-tabs span {
|
.settings-tabs span {
|
||||||
padding: 6px 14px;
|
padding: 7px 16px;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
@ -1619,18 +1619,18 @@ code {
|
|||||||
}
|
}
|
||||||
.settings-key {
|
.settings-key {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 3px;
|
||||||
}
|
}
|
||||||
.kv-row {
|
.kv-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 6px 0;
|
padding: 7px 0;
|
||||||
border-bottom: 1px solid var(--kv-border);
|
border-bottom: 1px solid var(--kv-border);
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.kv-row .key {
|
.kv-row .key {
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
width: 180px;
|
width: 180px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@ -1716,8 +1716,8 @@ code {
|
|||||||
vertical-align: 1px;
|
vertical-align: 1px;
|
||||||
}
|
}
|
||||||
.conn-item .conn-url {
|
.conn-item .conn-url {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
.conn-item .conn-actions {
|
.conn-item .conn-actions {
|
||||||
|
|||||||
Reference in New Issue
Block a user