diff --git a/cmd/gui/icon-tray.png b/cmd/gui/icon-tray.png new file mode 100644 index 0000000..9f9878d Binary files /dev/null and b/cmd/gui/icon-tray.png differ diff --git a/cmd/gui/icon-tray@2x.png b/cmd/gui/icon-tray@2x.png new file mode 100644 index 0000000..1f63d59 Binary files /dev/null and b/cmd/gui/icon-tray@2x.png differ diff --git a/cmd/gui/main.js b/cmd/gui/main.js index 022ec9b..408d2b9 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -329,6 +329,17 @@ function createWindow() { 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; }); @@ -344,7 +355,23 @@ ipcMain.handle("window:toggleMaximize", (e) => { else win.maximize(); }); ipcMain.handle("window:close", (e) => { - BrowserWindow.fromWebContents(e.sender)?.close(); + 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", () => { @@ -742,6 +769,257 @@ ipcMain.handle("cli:request", (_, { socketPath, apiKey, line }) => { }); }); +// ============ Device Bridge: GUI 作为设备接入 remotedevice 网关 ============ +// 复用 net(raw TCP)+ 手写 WS 帧;收到 {op:"cmd"} 用 spawn 在本机执行并回 cmd_result。 +const devNet = require("net"); +const devOs = require("os"); + +let deviceBridge = null; // 当前活动设备桥 +let deviceBridgeId = ""; // 设备 meta device_id(hello 后可用于 cmd_result) +let deviceBridgeAddr = ""; // 设备桥网关地址 + +// 建立到 remotedevice WS 网关连接,返回 {send(obj), close()},消息经 onMsg 回调。 +function connectDeviceWS(url, token, onMsg) { + const m = /^https?:\/\/([^:/]+)(?::(\d+))?/.exec(url || ""); + const host = m ? m[1] : "127.0.0.1"; + const port = m && m[2] ? parseInt(m[2], 10) : 9890; + const path = "/api/v1/device/ws?token=" + encodeURIComponent(token || ""); + return new Promise((resolve, reject) => { + const sock = devNet.createConnection({ host, port }, () => { + const key = crypto.randomBytes(16).toString("base64"); + 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\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")) { + sock.destroy(); + return reject( + new Error("WS upgrade failed: " + head.split("\r\n")[0]), + ); + } + opened = true; + resolve({ + send: (obj) => sendDeviceFrame(sock, JSON.stringify(obj)), + 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 { + onMsg(JSON.parse(payload.toString("utf8"))); + } catch (e) {} + } 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])); +} + +// 启动设备桥:以 device 连接(url=网关地址, apiKey=token)接入网关。 +async function startDeviceBridge(conn) { + if (!conn || conn.type !== "device") return; + const token = conn.apiKey || ""; + if (!token) { + console.error("[device-bridge] missing token"); + return; + } + deviceBridgeAddr = conn.url || ""; + deviceBridgeId = + "gui-" + (devOs.hostname() || "local").replace(/[^a-zA-Z0-9_-]/g, "_"); + try { + const ws = await connectDeviceWS(conn.url, token, onDeviceMsg); + deviceBridge = ws; + ws.send({ + op: "hello", + device: { + device_id: deviceBridgeId, + name: "HomeAgent GUI", + kind: "computer", + caps: ["status", "cmdrun", "deviceinfo", "cmdresult"], + info: { + hostname: devOs.hostname() || "", + platform: process.platform || "", + arch: process.arch || "", + os_release: "", // 不提权读取 /etc/os-release,避免破坏沙箱;如需可在白名单命令里由 agent 探 + node_version: + process.versions && process.versions.node + ? process.versions.node + : "", + electron_version: + process.versions && process.versions.electron + ? process.versions.electron + : "", + version: app.getVersion ? app.getVersion() : "", + cpus: devOs.cpus ? devOs.cpus().length : 0, + total_mem_bytes: devOs.totalmem ? devOs.totalmem() : 0, + }, + }, + }); + ws.send({ op: "bind", device_id: deviceBridgeId, token }); + console.log( + "[device-bridge] connected as " + deviceBridgeId + " @ " + conn.url, + ); + } catch (e) { + console.error("[device-bridge] connect failed: " + e.message); + } +} + +function stopDeviceBridge() { + if (deviceBridge) { + try { + deviceBridge.close(); + } catch (e) {} + deviceBridge = null; + } +} + +// ============ 系统托盘(惰性 + 安全降级) ============ +let tray = null; +function initTray() { + if (tray) return; + try { + const electron = require("electron"); + const Tray = electron.Tray; + const TMenu = electron.Menu; + 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); + const tmenu = TMenu.buildFromTemplate([ + { label: "HomeAgent", enabled: false }, + { type: "separator" }, + { label: "显示主界面", click: () => showMainWindow() }, + { + label: "退出", + click: () => { + app.isQuitting = true; + app.quit(); + }, + }, + ]); + tray.setToolTip("HomeAgent - 个人智能管家"); + tray.setContextMenu(tmenu); + 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(); @@ -754,13 +1032,46 @@ app.whenReady().then(async () => { console.error("homed failed to start within timeout"); } } + // 设备桥:启动接入 remotedevice + try { + var _devs = loadConnections().connections || []; + var _dev = _devs.filter((c) => c.type === "device"); + if (_dev.length > 0) startDeviceBridge(_dev[_dev.length - 1]); + } catch (e) { + console.error("device bridge init: " + e.message); + } + try { + initTray(); + } catch (e) { + console.error("[tray] whenReady call: " + e.message); + } createWindow(); }); -app.on("before-quit", stopHomed); +app.on("before-quit", () => { + stopHomed(); + try { + stopDeviceBridge(); + } catch (e) {} + try { + destroyTray(); + } catch (e) {} +}); app.on("window-all-closed", () => { - if (process.platform !== "darwin") app.quit(); + // 退出进托盘:依偏好决定(安全降级:无托盘时退出) + 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", () => { @@ -768,3 +1079,73 @@ app.on("activate", () => { createWindow(); } }); +// IPC:prefs(renderer 设置页需要) +// IPC:本机设备身份(renderer 设备页需要) +ipcMain.handle("device:identity", () => + deviceBridge + ? { + device_id: deviceBridgeId, + active: true, + address: deviceBridgeAddr || "", + } + : { device_id: "", active: false, address: "" }, +); +const GUI_PREFS_FILE = path.join(app.getPath("userData"), "gui-prefs.json"); + +function loadGuiPrefs() { + try { + if (fs.existsSync(GUI_PREFS_FILE)) { + const d = JSON.parse(fs.readFileSync(GUI_PREFS_FILE, "utf-8")); + return { + autoLaunch: !!d.autoLaunch, + silentStart: !!d.silentStart, + exitToTray: d.exitToTray === undefined ? true : !!d.exitToTray, + }; + } + } catch (e) {} + return { autoLaunch: false, silentStart: false, exitToTray: true }; +} + +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:本机设备身份(设备桥登记的设备 ID 与状态) + +// 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; +}); diff --git a/cmd/gui/package-lock.json b/cmd/gui/package-lock.json index 4e10aae..77d979a 100644 --- a/cmd/gui/package-lock.json +++ b/cmd/gui/package-lock.json @@ -7,11 +7,9 @@ "": { "name": "homeagent-gui", "version": "1.0.0", - "dependencies": { - "electron": "^33.0.0" - }, "devDependencies": { "asar": "^3.2.0", + "electron": "^33.0.0", "electron-builder": "^26.15.3", "electron-packager": "^17.1.2", "icojs": "^0.23.0", @@ -107,6 +105,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", @@ -1074,6 +1073,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -1086,6 +1086,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, "license": "MIT", "dependencies": { "defer-to-connect": "^2.0.0" @@ -1123,6 +1124,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, "license": "MIT", "dependencies": { "@types/http-cache-semantics": "*", @@ -1164,12 +1166,14 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, "license": "MIT" }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1196,6 +1200,7 @@ "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1205,6 +1210,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1214,6 +1220,7 @@ "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1873,6 +1880,7 @@ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, "license": "MIT", "optional": true }, @@ -1890,6 +1898,7 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, "license": "MIT", "engines": { "node": "*" @@ -1999,6 +2008,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.6.0" @@ -2008,6 +2018,7 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, "license": "MIT", "dependencies": { "clone-response": "^1.0.2", @@ -2099,6 +2110,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^1.0.0" @@ -2217,6 +2229,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2263,6 +2276,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -2278,6 +2292,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2290,6 +2305,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2299,6 +2315,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2317,6 +2334,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2354,6 +2372,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, "license": "MIT", "optional": true }, @@ -2483,6 +2502,7 @@ "version": "33.4.11", "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz", "integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==", + "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -2751,6 +2771,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -2760,6 +2781,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2784,7 +2806,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2794,7 +2816,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2831,6 +2853,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, "license": "MIT", "optional": true }, @@ -2847,6 +2870,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -2866,6 +2890,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "debug": "^4.1.1", @@ -2908,6 +2933,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, "license": "MIT", "dependencies": { "pend": "~1.2.0" @@ -3085,6 +3111,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -3239,6 +3266,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, "license": "MIT", "dependencies": { "pump": "^3.0.0" @@ -3275,6 +3303,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, "license": "BSD-3-Clause", "optional": true, "dependencies": { @@ -3293,6 +3322,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, "license": "ISC", "optional": true, "bin": { @@ -3306,6 +3336,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3323,7 +3354,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3336,6 +3367,7 @@ "version": "11.8.6", "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, "license": "MIT", "dependencies": { "@sindresorhus/is": "^4.0.0", @@ -3361,6 +3393,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -3376,6 +3409,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3434,6 +3468,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/http-proxy-agent": { @@ -3453,6 +3488,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, "license": "MIT", "dependencies": { "quick-lru": "^5.1.1", @@ -3670,6 +3706,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -3682,6 +3719,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, "license": "ISC", "optional": true }, @@ -3701,6 +3739,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, "license": "MIT", "optionalDependencies": { "graceful-fs": "^4.1.6" @@ -3719,6 +3758,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -3775,6 +3815,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3802,6 +3843,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3857,6 +3899,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -3921,6 +3964,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/node-abi": { @@ -4074,6 +4118,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4086,6 +4131,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -4096,6 +4142,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -4105,6 +4152,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4230,6 +4278,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -4359,6 +4408,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -4392,6 +4442,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -4420,6 +4471,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4555,12 +4607,14 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, "license": "MIT" }, "node_modules/responselike": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, "license": "MIT", "dependencies": { "lowercase-keys": "^2.0.0" @@ -4596,6 +4650,7 @@ "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, "license": "BSD-3-Clause", "optional": true, "dependencies": { @@ -4638,6 +4693,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4647,6 +4703,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, "license": "MIT", "optional": true }, @@ -4654,6 +4711,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4835,6 +4893,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, "license": "BSD-3-Clause", "optional": true }, @@ -4933,6 +4992,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "debug": "^4.1.0" @@ -5158,6 +5218,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, "license": "(MIT OR CC0-1.0)", "optional": true, "engines": { @@ -5193,12 +5254,14 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4.0.0" @@ -5323,6 +5386,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/xmlbuilder": { @@ -5383,6 +5447,7 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", diff --git a/cmd/gui/preload.js b/cmd/gui/preload.js index 2262e1b..bfa7023 100644 --- a/cmd/gui/preload.js +++ b/cmd/gui/preload.js @@ -1,26 +1,44 @@ -const { contextBridge, ipcRenderer } = require('electron'); +const { contextBridge, ipcRenderer } = require("electron"); -contextBridge.exposeInMainWorld('homeagent', { +contextBridge.exposeInMainWorld("homeagent", { win: { - minimize: () => ipcRenderer.invoke('window:minimize'), - toggleMaximize: () => ipcRenderer.invoke('window:toggleMaximize'), - close: () => ipcRenderer.invoke('window:close'), + minimize: () => ipcRenderer.invoke("window:minimize"), + toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"), + close: () => ipcRenderer.invoke("window:close"), }, connections: { - list: () => ipcRenderer.invoke('connections:list'), - add: (conn) => ipcRenderer.invoke('connections:add', conn), - update: (id, updates) => ipcRenderer.invoke('connections:update', { id, updates }), - delete: (id) => ipcRenderer.invoke('connections:delete', id), - setCurrent: (id) => ipcRenderer.invoke('connections:setCurrent', id), + list: () => ipcRenderer.invoke("connections:list"), + add: (conn) => ipcRenderer.invoke("connections:add", conn), + update: (id, updates) => + ipcRenderer.invoke("connections:update", { id, updates }), + delete: (id) => ipcRenderer.invoke("connections:delete", id), + setCurrent: (id) => ipcRenderer.invoke("connections:setCurrent", id), }, cli: { - request: (socketPath, apiKey, line) => ipcRenderer.invoke('cli:request', { socketPath, apiKey, line }), + request: (socketPath, apiKey, line) => + ipcRenderer.invoke("cli:request", { socketPath, apiKey, line }), }, webui: { - setAuth: (url, cookie, headers, username, password) => ipcRenderer.invoke('webui:setAuth', { url, cookie, headers, username, password }), - openLogin: (url, username, password) => ipcRenderer.invoke('webui:openLogin', { url, username, password }), - onLoginResult: (cb) => ipcRenderer.on('webui:login-result', (_e, d) => cb(d)), + setAuth: (url, cookie, headers, username, password) => + ipcRenderer.invoke("webui:setAuth", { + url, + cookie, + headers, + username, + password, + }), + openLogin: (url, username, password) => + ipcRenderer.invoke("webui:openLogin", { url, username, password }), + onLoginResult: (cb) => + ipcRenderer.on("webui:login-result", (_e, d) => cb(d)), + }, + cacheBg: (src) => ipcRenderer.invoke("bg:cache", { src }), + log: (m) => ipcRenderer.invoke("log:r", m), + prefs: { + get: () => ipcRenderer.invoke("prefs:get"), + set: (p) => ipcRenderer.invoke("prefs:set", p), + }, + device: { + identity: () => ipcRenderer.invoke("device:identity"), }, - cacheBg: (src) => ipcRenderer.invoke('bg:cache', { src }), - log: (m) => ipcRenderer.invoke('log:r', m), }); diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 794c488..694bb7b 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -27,6 +27,9 @@ const state = { lang: localStorage.getItem("ha-lang") || "zh", connections: [], currentConn: null, + devices: [], + selfDeviceId: "", + selfGateway: "", }; // ===== I18n ===== @@ -525,6 +528,11 @@ function renderAll() { } catch (e) { console.error("renderAdapters", e); } + try { + renderDevices(); + } catch (e) { + console.error("renderDevices", e); + } applyI18n(); applyCardTilt(); refreshAll(); @@ -557,6 +565,28 @@ async function refreshAll() { try { await loadCmdHistory(); } catch (e) {} + try { + if (state.currentConn && state.currentConn.type === "device") { + var d = await api("/device"); + state.devices = d.devices || []; + } + } catch (e) { + state.devices = []; + } + try { + if ( + state.currentConn && + state.currentConn.type === "device" && + window.homeagent && + window.homeagent.device + ) { + var idn = await window.homeagent.device.identity(); + if (idn) { + state.selfDeviceId = idn.device_id || ""; + state.selfGateway = idn.address || ""; + } + } + } catch (e) {} try { renderOverview(); } catch (e) { @@ -592,6 +622,11 @@ async function refreshAll() { } catch (e) { console.error("renderAdapters", e); } + try { + renderDevices(); + } catch (e) { + console.error("renderDevices", e); + } applyI18n(); applyCardTilt(); } @@ -970,19 +1005,33 @@ function chanLetter(src) { function renderReasoningCard(text, isStreaming, idx) { var preview = typeof marked === "undefined" - ? escHtml(text).replace(/<[^>]+>/g, " ").slice(0, 60) + ? escHtml(text) + .replace(/<[^>]+>/g, " ") + .slice(0, 60) : text.replace(/[\s\n]+/g, " ").slice(0, 60); return ( - '
' + + '
' + '
' + '' + - '' + (isStreaming ? __("思考中...","Thinking...") : __("思考","Thinking")) + '' + + '' + + (isStreaming ? __("思考中...", "Thinking...") : __("思考", "Thinking")) + + "" + '
' + - '
' + + '
' + (isStreaming - ? '
' + escHtml(preview) + '
' - : '
' + (typeof marked !== "undefined" ? marked.parse(text) : escHtml(text)) + '
') + - '
' + ? '
' + + escHtml(preview) + + '
' + : '
' + + (typeof marked === "undefined" ? escHtml(text) : marked.parse(text)) + + "
") + + "
" ); } @@ -1106,20 +1155,29 @@ function renderChat() { : String(tc.result) : ""; var running = !resultStr && tc.status !== "denied"; - var error = tc.status === "error" || tc.status === "denied" || !!tc.error; - var drip = newlyDone.indexOf(tc.tool || tc.name || "") !== -1 ? " tool-drip-in" : ""; - var iconSvg = - error - ? '' - : running - ? '' - : ''; + var error = + tc.status === "error" || tc.status === "denied" || !!tc.error; + var drip = + newlyDone.indexOf(tc.tool || tc.name || "") === -1 + ? "" + : " tool-drip-in"; + var iconSvg = error + ? '' + : running + ? '' + : ''; var statusHtml = tc.status === "denied" - ? '' + __("已拒绝","Denied") + "" + ? '' + + __("已拒绝", "Denied") + + "" : running - ? '' + __("调用中","Running") + "" - : '' + __("完成","Done") + ""; + ? '' + + __("调用中", "Running") + + "" + : '' + + __("完成", "Done") + + ""; var pluginHtml = tc.plugin ? '' + escHtml(tc.plugin) + "" : ""; @@ -1140,10 +1198,18 @@ function renderChat() { '' + '"; }); @@ -1329,9 +1395,10 @@ function toggleReasoning(el) { var content = card.querySelector(".reasoning-content"); if (content && !content.childElementCount) { var idx = parseInt(card.getAttribute("data-idx"), 10) || 0; - var text = (state.messages[idx] && state.messages[idx].reasoning_content) || ""; + var text = + (state.messages[idx] && state.messages[idx].reasoning_content) || ""; content.innerHTML = - typeof marked !== "undefined" ? marked.parse(text) : escHtml(text); + typeof marked === "undefined" ? escHtml(text) : marked.parse(text); } body.style.display = "block"; card.classList.add("open"); @@ -1691,6 +1758,16 @@ async function sendChat() { var btn = document.getElementById("chat-send-btn"); var text = inp.value.trim(); if (!text || state.chatLoading) return; + if (state.currentConn && state.currentConn.type === "device") { + toast( + __( + "设备网关连接不支持聊天", + "Device gateway connection does not support chat", + ), + true, + ); + return; + } state.chatStick = true; state.chatFinalIdx = -1; state.messages.push({ role: "user", content: text }); @@ -1702,51 +1779,57 @@ async function sendChat() { btn.textContent = ""; rerenderChat(); try { - var r = await api("/chat", { - method: "POST", - body: JSON.stringify({ message: text }), - timeout: 120000, - }); - state.chatStage = ""; - var last = state.messages[state.messages.length - 1]; - console.log( - "[sendChat] POST returned, last msg:", - last - ? { - role: last.role, - _streaming: last._streaming, - _final: last._final, - tool_calls: last.tool_calls?.length, - content_len: last.content?.length, - } - : null, - ); - if (last && last.role === "assistant" && last._streaming) { - console.log( - "[sendChat] updating existing streaming msg, tool_calls before:", - last.tool_calls?.length, - ); - last.content = r.response || __("(无响应)", "(no response)"); - last._grow = true; - if (!last.reasoning_content) - last.reasoning_content = r.reasoning_content || ""; - last._final = true; - delete last._streaming; - } else { - state.messages.push({ - role: "assistant", - content: r.response || __("(无响应)", "(no response)"), - reasoning_content: r.reasoning_content, - tool_calls: - last && last.role === "assistant" && last.tool_calls - ? last.tool_calls - : [], - _final: true, - _grow: true, - }); + // webui 连接:触发式 + SSE 流式接管(避免 POST 与 SSE 双通道重复渲染卡死) + var isWebui = !state.currentConn || state.currentConn.type !== "cli"; + var r = null; + try { + if (isWebui) { + // 触发请求:短超时确认受理,回复靠 SSE + r = await api("/chat", { + method: "POST", + 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) { + // 触发请求超时/失败:不阻塞 UI,等 SSE 兜底;若 SSE 也无响应则报错 + console.warn("[sendChat] trigger failed: " + e.message); + r = null; } - state.chatFinalIdx = state.messages.length - 1; - rerenderChat(); + state.chatStage = __("AI 回复中...", "AI replying..."); + var last = state.messages[state.messages.length - 1]; + // CLI/device 无 SSE:POST 完整结果直接填充 + if (!isWebui && r) { + if (last && last.role === "assistant" && last._streaming) { + last.content = r.response || __("(无响应)", "(no response)"); + last._grow = true; + if (!last.reasoning_content) + last.reasoning_content = r.reasoning_content || ""; + last._final = true; + delete last._streaming; + } else { + state.messages.push({ + role: "assistant", + content: r.response || __("(无响应)", "(no response)"), + reasoning_content: r.reasoning_content, + tool_calls: + last && last.role === "assistant" && last.tool_calls + ? last.tool_calls + : [], + _final: true, + _grow: true, + }); + } + state.chatFinalIdx = state.messages.length - 1; + } + if (!isWebui) rerenderChat(); } catch (e) { state.messages.push({ role: "assistant", @@ -2950,6 +3033,7 @@ function renderOneSettings() { __("后端连接", "Backend Connections") + "" + '
' + + '
' + '
' + '
'; if ( @@ -3290,8 +3374,10 @@ function renderOneSettings() { } html += "
"; document.getElementById("view-settings").innerHTML = html; + renderGuiPrefs(); renderSettingsTabs(); renderConnSection(); + loadGuiPrefs(); } function markDirty(k) { @@ -3689,6 +3775,76 @@ function openConnManager() { switchView("settings"); } +// ===== 客户端偏好(开机自启/静默启动/退出进托盘) ===== +var guiPrefs = { autoLaunch: false, silentStart: false, exitToTray: true }; +async function loadGuiPrefs() { + try { + if (window.homeagent && window.homeagent.prefs) { + guiPrefs = (await window.homeagent.prefs.get()) || guiPrefs; + } + } catch (e) {} +} +async function saveGuiPrefs(key, val) { + var next = Object.assign({}, guiPrefs, { [key]: val }); + try { + if (window.homeagent && window.homeagent.prefs) { + guiPrefs = (await window.homeagent.prefs.set(next)) || next; + } else { + guiPrefs = next; + } + } catch (e) { + toast(__("保存偏好失败: ", "Save prefs failed: ") + e.message, true); + return; + } + toast(__("已保存", "Saved")); + renderGuiPrefs(); +} +function renderGuiPrefs() { + var el = document.getElementById("gui-prefs"); + if (!el) return; + function sw(key, label, desc, onchange) { + return ( + '
' + + "
" + + label + + "
" + + (desc + ? '
' + + desc + + "
" + : "") + + "
" + + '
' + ); + } + el.innerHTML = + '

' + + __("客户端偏好", "Client Preferences") + + "

" + + sw( + "autoLaunch", + __("开机自启", "Auto launch on start"), + __("登录后自动启动 HomeAgent(静默)", "Start silently at login"), + "saveGuiPrefs('autoLaunch', this.checked)", + ) + + sw( + "silentStart", + __("静默启动", "Silent start"), + __("启动时不显示主窗口,驻留托盘后台运行", "Start hidden, keep in tray"), + "saveGuiPrefs('silentStart', this.checked)", + ) + + sw( + "exitToTray", + __("退出进托盘", "Exit to tray"), + __("关闭窗口时驻留托盘而不是退出", "Closing window keeps app in tray"), + "saveGuiPrefs('exitToTray', this.checked)", + ) + + "
"; +} function renderConnSection() { var cont = document.getElementById("conn-manager"); if (!cont) return; @@ -3755,7 +3911,8 @@ function renderConnSection() { __("连接类型", "Type") + '' + + '' + + '' + '
' + @@ -3832,6 +3989,11 @@ function toggleConnType() { t === "cli" ? "block" : "none"; document.getElementById("conn-auth-webui").style.display = 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(); } @@ -4160,6 +4322,38 @@ async function saveConnForm() { testBtn.disabled = false; 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 { if (window.homeagent && window.homeagent.webui) { if (gwEnabled && !cookie) { @@ -4260,19 +4454,31 @@ async function saveConnForm() { testBtn.textContent = __("保存", "Save"); testBtn.disabled = false; var connData = - ctype === "cli" - ? { name: name, type: "cli", socketPath: sock, url: "", apiKey: apiKey } - : { + ctype === "device" + ? { name: name, - type: "webui", + type: "device", url: url, apiKey: apiKey, - username: username, - password: password, - cookie: cookie, - headers: headers, - gateway: gwEnabled, - }; + username: "", + password: "", + cookie: "", + headers: "", + gateway: false, + } + : 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; if (editingConnId) { data = await window.homeagent.connections.update(editingConnId, connData); @@ -4322,8 +4528,9 @@ connectSSE = () => { state.eventSource = null; } if (!state.currentConn) return; - // CLI 连接无 SSE 通道,聊天走同步 cli:request - if (state.currentConn.type === "cli") return; + // CLI/device 连接无 SSE 通道,聊天走同步 + if (state.currentConn.type === "cli" || state.currentConn.type === "device") + return; connectFetchSSE(state.currentConn.url + "/api/v1/chat/events"); }; @@ -4530,7 +4737,7 @@ function rerenderChatIfActive() { !!last && last.role === "assistant" && !last._final && state.chatLoading; if (streamingLast) { if (state._streamTimer) clearTimeout(state._streamTimer); - state._streamTimer = setTimeout(function () { + state._streamTimer = setTimeout(() => { state._streamTimer = null; renderChatStreamChunk(); }, 90); @@ -4567,7 +4774,7 @@ function renderChatStreamChunk() { var lastLen = el.__lastLen || 0; if (c.length - lastLen > 200 || now - lastParse > 300) { textEl.innerHTML = - typeof marked !== "undefined" ? marked.parse(c) : escHtml(c); + typeof marked === "undefined" ? escHtml(c) : marked.parse(c); el.__lastParse = now; el.__lastLen = c.length; } else { @@ -4597,3 +4804,196 @@ function renderChatStreamChunk() { renderChat(); } +// ===== Devices (remotedevice gateway) ===== +function renderDevices() { + var el = document.getElementById("view-devices"); + if (!el) return; + var conn = state.currentConn; + if (!conn || conn.type !== "device") { + el.innerHTML = + '

' + + __("设备", "Devices") + + '

' + + __( + "请先在设置中添加并切换到「设备网关 (remotedevice)」连接,以查看已接入设备。", + "Add and switch to a Remote Device Gateway connection in Settings to view devices.", + ) + + "

" + + '
"; + return; + } + var devs = state.devices || []; + var selfDev = null; + if (state.selfDeviceId) { + for (var si = 0; si < devs.length; si++) { + if (devs[si].device_id === state.selfDeviceId) { + selfDev = devs[si]; + break; + } + } + } + var selfHtml = + '

' + + __("本机 GUI 设备", "Local GUI Device") + + "

"; + if (state.selfDeviceId) { + var sOnline = selfDev ? selfDev.online : false; + var sAuth = selfDev ? selfDev.authorized : false; + selfHtml += + '
' + + __("设备 ID", "Device ID") + + '' + + escHtml(state.selfDeviceId) + + "
" + + '
' + + __("网关", "Gateway") + + '' + + escHtml(state.selfGateway || "-") + + "
" + + '
' + + __("状态", "Status") + + "" + + (sOnline + ? '' + __("在线", "Online") + "" + : '' + __("离线", "Offline") + "") + + "
" + + '
' + + __("授权", "Authorized") + + '' + + '" + + (sAuth + ? '' + __("已授权", "Yes") + "" + : '' + __("未授权", "No") + "") + + "
"; + } else { + selfHtml += + '

' + + __( + "未接入设备网关。请在设置中配置「设备网关 (remotedevice)」连接后重启。", + "Not connected. Configure a Remote Device Gateway connection in Settings then restart.", + ) + + "

"; + } + selfHtml += "
"; + var html = + selfHtml + + '

' + + __("设备网关", "Device Gateway") + + " (" + + devs.length + + ")" + + "

" + + '
"; + if (devs.length === 0) { + html += + '

' + + __( + "暂无设备接入。设备通过 WebSocket 连接到设备网关(默认 127.0.0.1:9890/api/v1/device/ws),携带 token 后 hello 登记、bind 授权。", + "No devices yet. Devices connect via WebSocket (default 127.0.0.1:9890/api/v1/device/ws), hello to register, bind to authorize.", + ) + + "

"; + } else { + html += + ""; + devs.forEach((d) => { + var online = d.online + ? '' + __("在线", "Online") + "" + : '' + __("离线", "Offline") + ""; + var auth = d.authorized + ? '' + __("已授权", "Yes") + "" + : '' + __("未授权", "No") + ""; + var caps = (d.caps || []).join(", ") || "-"; + html += + ""; + }); + html += "
" + + __("设备", "Device") + + "" + + __("种类", "Kind") + + "" + + __("状态", "Status") + + "" + + __("授权", "Authorized") + + "" + + __("能力", "Caps") + + "" + + __("操作", "Actions") + + "
" + + escHtml(d.name || d.device_id) + + '
' + + escHtml(d.device_id) + + "
" + + escHtml(d.kind || "-") + + "" + + online + + "" + + auth + + "" + + escHtml(caps) + + "" + + (d.authorized + ? ' " + + __("已授权", "Yes") + : ' " + + __("未授权", "No")) + + "
"; + } + html += "
"; + el.innerHTML = html; +} + +async function deviceRefresh() { + try { + var d = await api("/device"); + state.devices = d.devices || []; + renderDevices(); + } catch (e) { + toast(__("设备列表刷新失败: ", "Refresh failed: ") + e.message, true); + } +} + +async function deviceToggleAuth(deviceID, auth) { + try { + await api("/device/auth", { + method: "POST", + body: JSON.stringify({ device_id: deviceID, authorize: auth }), + }); + toast(__("授权已更新", "Authorization updated")); + deviceRefresh(); + } catch (e) { + toast(__("授权失败: ", "Auth failed: ") + e.message, true); + } +} + +function deviceSendCmd(deviceID) { + var cmd = prompt(__("输入要执行的命令", "Enter command to run")); + if (!cmd) return; + api("/device/push", { + method: "POST", + body: JSON.stringify({ + device_id: deviceID, + payload: { op: "cmd", command: cmd }, + }), + }) + .then(() => { + toast(__("命令已下发", "Command sent")); + deviceRefresh(); + }) + .catch((e) => { + toast(__("下发失败: ", "Send failed: ") + e.message, true); + }); +} diff --git a/cmd/gui/renderer/index.html b/cmd/gui/renderer/index.html index d825bfe..567f4c0 100644 --- a/cmd/gui/renderer/index.html +++ b/cmd/gui/renderer/index.html @@ -1,88 +1,315 @@ - + - - - -HomeAgent - - - - - - - - -
+ + + + HomeAgent + + + + + + + + +
-
-
- - - -
-
- -
- - - -
-
-

HomeAgent

- - - 未连接 - - -
- +
+
+ + + +
-
-
-
-
-
-
-
+
+ + + +
+
+

HomeAgent

+ + + 未连接 + + +
+ +
+ +
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
- - + + diff --git a/cmd/gui/renderer/style.css b/cmd/gui/renderer/style.css index 053fb3b..b886600 100644 --- a/cmd/gui/renderer/style.css +++ b/cmd/gui/renderer/style.css @@ -286,6 +286,7 @@ body { color: var(--text-primary); min-height: 100vh; overflow-x: hidden; + font-family: var(--font-sans); transition: background 0.2s, color 0.2s; @@ -590,7 +591,9 @@ body.maximized .tb-max svg { margin: 3px; padding: 0; vertical-align: middle; - transition: transform 0.15s, box-shadow 0.15s; + transition: + transform 0.15s, + box-shadow 0.15s; } .palette-pop button.cdot:hover { transform: scale(1.25); @@ -1249,8 +1252,12 @@ code { opacity: 0.6; } @keyframes reasoningSweep { - from { background-position: 200% 0; } - to { background-position: -200% 0; } + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } } /* ===== 工具卡片(PiDeck 风格,扁平不套气泡)===== */ @@ -1264,7 +1271,9 @@ code { border: 1px solid var(--kv-border); border-left: 3px solid var(--accent); cursor: pointer; - transition: border-color 0.2s var(--ease-out), background 0.2s var(--ease-out); + transition: + border-color 0.2s var(--ease-out), + background 0.2s var(--ease-out); } .tool-card.tc-running { border-left-color: var(--accent); @@ -2071,3 +2080,95 @@ code { gap: 4px; } } + +/* ===== Toggle switch(左右滑动开关按钮)===== */ +.switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + vertical-align: middle; + flex-shrink: 0; + cursor: pointer; +} +.switch input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} +.switch span { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #888; + border-radius: 24px; + transition: background 0.25s; +} +.switch span::before { + content: ""; + position: absolute; + width: 18px; + height: 18px; + left: 3px; + top: 3px; + background: #fff; + border-radius: 50%; + transition: transform 0.25s; +} +.switch input:checked + span { + background: #34c759; +} +.switch input:checked + span::before { + transform: translateX(20px); +} +td .switch { + margin-right: 6px; +} + +/* ===== 设备页样式修复 ===== */ +/* 开关输入框彻底隐藏,避免浅蓝方框露出 */ +.switch input { + -webkit-appearance: none; + appearance: none; + border: none; + outline: none; + background: transparent; +} +/* 设备列表表格圆角化(去方框感) */ +.card table { + width: 100%; + border-collapse: collapse; + overflow: hidden; + border-radius: var(--radius-md); + font-size: 13px; +} +.card table th, +.card table td { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--glass-border, rgba(128, 128, 128, 0.15)); + vertical-align: middle; +} +.card table th { + font-weight: 600; + color: var(--text-muted, #888); + white-space: nowrap; +} +.card table tr:last-child td { + border-bottom: none; +} +.card table tr:hover td { + background: var(--glass-hover, rgba(128, 128, 128, 0.06)); +} +/* kv-row 值区对齐(含开关) */ +.kv-row .val { + display: flex; + align-items: center; + gap: 8px; +} +.kv-row .val .switch { + margin-right: 4px; +} diff --git a/cmd/waiter/config.go b/cmd/waiter/config.go index 2063147..69ddb45 100644 --- a/cmd/waiter/config.go +++ b/cmd/waiter/config.go @@ -16,11 +16,13 @@ type Connection struct { } type Config struct { - Socket string `yaml:"socket"` - Remote string `yaml:"remote"` - APIKey string `yaml:"api_key"` - Default string `yaml:"default"` - Connections []Connection `yaml:"connections,omitempty"` + Socket string `yaml:"socket"` + Remote string `yaml:"remote"` + APIKey string `yaml:"api_key"` + Default string `yaml:"default"` + Connections []Connection `yaml:"connections,omitempty"` + DeviceGateway string `yaml:"device_gateway,omitempty"` // remotedevice 网关地址(如 127.0.0.1:9890) + DeviceToken string `yaml:"device_token,omitempty"` // 设备接入 token } func (c *Config) Active() *Connection { diff --git a/cmd/waiter/device.go b/cmd/waiter/device.go new file mode 100644 index 0000000..b8c9386 --- /dev/null +++ b/cmd/waiter/device.go @@ -0,0 +1,340 @@ +package main + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "regexp" + "runtime" + "strings" + "sync" + "time" +) + +type deviceBridge struct { + addr string + token string + deviceID string + conn net.Conn + mu sync.Mutex + stop chan struct{} +} + +func newDeviceBridge(addr, token string) *deviceBridge { + return &deviceBridge{addr: addr, token: token, stop: make(chan struct{})} +} + +func (b *deviceBridge) Start() error { + if b.addr == "" || b.token == "" { + return fmt.Errorf("device bridge: addr/token required") + } + host, port := b.addr, "9890" + if h, p, err := net.SplitHostPort(b.addr); err == nil { + host, port = h, p + } else if idx := strings.LastIndex(b.addr, ":"); idx >= 0 { + host = strings.TrimPrefix(b.addr[:idx], "http://") + port = b.addr[idx+1:] + } + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "local" + } + b.deviceID = "waiter-" + sanitizeID(hostname) + + conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), 5*time.Second) + if err != nil { + return err + } + b.conn = conn + + key := wsKey() + path := "/api/v1/device/ws?token=" + urlEscape(b.token) + var sb strings.Builder + sb.WriteString("GET " + path + " HTTP/1.1" + CRLF) + sb.WriteString("Host: " + host + ":" + port + CRLF) + sb.WriteString("Upgrade: websocket" + CRLF) + sb.WriteString("Connection: Upgrade" + CRLF) + sb.WriteString("Sec-WebSocket-Key: " + key + CRLF) + sb.WriteString("Sec-WebSocket-Version: 13" + CRLF + CRLF) + if _, err := conn.Write([]byte(sb.String())); err != nil { + conn.Close() + return err + } + br := bufio.NewReader(conn) + headerBuf := "" + for { + line, err := br.ReadString('\n') + if err != nil { + conn.Close() + return err + } + headerBuf += line + if strings.Contains(headerBuf, CRLF+CRLF) { + break + } + } + if !strings.Contains(headerBuf, " 101 ") { + conn.Close() + return fmt.Errorf("ws upgrade failed: %s", firstLine(headerBuf)) + } + + b.sendJSON(helloMsg(b.deviceID)) + b.sendJSON(map[string]interface{}{"op": "bind", "device_id": b.deviceID, "token": b.token}) + + go b.readLoop(br) + return nil +} + +// CRLF 用常量避免转义地狱 +const CRLF = "\r\n" + +func helloMsg(deviceID string) map[string]interface{} { + hostname, _ := os.Hostname() + return map[string]interface{}{ + "op": "hello", + "device": map[string]interface{}{ + "device_id": deviceID, + "name": "HomeAgent CLI (waiter)", + "kind": "computer", + "caps": []string{"status", "cmdrun", "deviceinfo"}, + "info": map[string]interface{}{ + "hostname": hostname, + "platform": runtime.GOOS, + "arch": runtime.GOARCH, + "cpus": runtime.NumCPU(), + "mem_mb": memTotalMB(), + }, + }, + } +} + +func memTotalMB() int64 { + if runtime.GOOS != "linux" { + return 0 + } + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0 + } + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "MemTotal:") { + f := strings.Fields(line) + if len(f) >= 2 { + var kb int64 + fmt.Sscanf(f[1], "%d", &kb) + return kb / 1024 + } + } + } + return 0 +} + +func (b *deviceBridge) sendJSON(v interface{}) { + payload, err := json.Marshal(v) + if err != nil { + return + } + b.mu.Lock() + defer b.mu.Unlock() + if b.conn == nil { + return + } + writeWSFrame(b.conn, 0x1, payload) +} + +func writeWSFrame(conn net.Conn, opcode byte, payload []byte) { + length := len(payload) + hdr := []byte{0x80 | opcode} + switch { + case length < 126: + hdr = append(hdr, 0x80|byte(length)) + case length <= 0xffff: + hdr = append(hdr, 0x80|126, 0, 0) + binary.BigEndian.PutUint16(hdr[len(hdr)-2:], uint16(length)) + default: + hdr = append(hdr, 0x80|127) + hdr = append(hdr, 0, 0, 0, 0, 0, 0, 0, 0) + binary.BigEndian.PutUint64(hdr[len(hdr)-8:], uint64(length)) + } + var mask [4]byte + rand.Read(mask[:]) + masked := make([]byte, length) + for i := 0; i < length; i++ { + masked[i] = payload[i] ^ mask[i&3] + } + conn.Write(append(append(hdr, mask[:]...), masked...)) +} + +func (b *deviceBridge) readLoop(br *bufio.Reader) { + for { + select { + case <-b.stop: + return + default: + } + payload, err := readWSFrame(br) + if err != nil { + return + } + var msg map[string]interface{} + if err := json.Unmarshal(payload, &msg); err != nil { + continue + } + if msg["op"] != "cmd" { + continue + } + reqID, _ := msg["req_id"].(string) + command, _ := msg["command"].(string) + if reqID == "" { + continue + } + go b.runCommand(reqID, command) + } +} + +func readWSFrame(br *bufio.Reader) ([]byte, error) { + b0, err := br.ReadByte() + if err != nil { + return nil, err + } + opcode := b0 & 0x0f + b1, err := br.ReadByte() + if err != nil { + return nil, err + } + length := uint64(b1 & 0x7f) + if length == 126 { + var ext [2]byte + if _, err := io.ReadFull(br, ext[:]); err != nil { + return nil, err + } + length = uint64(binary.BigEndian.Uint16(ext[:])) + } else if length == 127 { + var ext [8]byte + if _, err := io.ReadFull(br, ext[:]); err != nil { + return nil, err + } + length = binary.BigEndian.Uint64(ext[:]) + } + if length > 1<<20 { + return nil, fmt.Errorf("frame too large") + } + payload := make([]byte, length) + if _, err := io.ReadFull(br, payload); err != nil { + return nil, err + } + switch opcode { + case 0x1: + return payload, nil + case 0x8: + return nil, io.EOF + default: + return nil, nil + } +} + +var waiterAllowCmd = regexp.MustCompile("^(ls|pwd|whoami|uname|date|echo|uptime|hostname|cat|df|free|ps|ip|dir|node|python3?|npm|git|curl|wget|systeminfo|tasklist)\\b") + +func (b *deviceBridge) runCommand(reqID, command string) { + res := b.execCommand(command) + res["op"] = "cmd_result" + res["req_id"] = reqID + res["device_id"] = b.deviceID + b.sendJSON(res) +} + +func (b *deviceBridge) execCommand(cmdLine string) map[string]interface{} { + if strings.TrimSpace(cmdLine) == "" { + return map[string]interface{}{"status": "error", "output": "empty command", "exit_code": -1} + } + if !waiterAllowCmd.MatchString(strings.TrimSpace(cmdLine)) { + return map[string]interface{}{"status": "error", "output": "command not in whitelist", "exit_code": -1} + } + parts := strings.Fields(strings.TrimSpace(cmdLine)) + if len(parts) == 0 { + return map[string]interface{}{"status": "error", "output": "empty", "exit_code": -1} + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) + out, err := cmd.Output() + if err != nil { + if ctx.Err() != nil { + return map[string]interface{}{"status": "error", "output": "timeout", "exit_code": -2} + } + return map[string]interface{}{"status": "error", "output": truncate8k(string(out)) + " err: " + err.Error(), "exit_code": -1} + } + return map[string]interface{}{"status": "ok", "output": truncate8k(string(out)), "exit_code": 0} +} + +const devMaxOut = 8192 + +func truncate8k(s string) string { + if len(s) <= devMaxOut { + return s + } + return s[:devMaxOut] +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return strings.TrimSpace(s[:i]) + } + return strings.TrimSpace(s) +} + +func sanitizeID(s string) string { + var sb strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + sb.WriteRune(r) + } else { + sb.WriteByte('_') + } + } + return sb.String() +} + +func urlEscape(s string) string { + var sb strings.Builder + const hex = "0123456789ABCDEF" + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~' { + sb.WriteByte(c) + } else { + sb.WriteByte('%') + sb.WriteByte(hex[c>>4]) + sb.WriteByte(hex[c&0xf]) + } + } + return sb.String() +} + +func wsKey() string { + var b [16]byte + rand.Read(b[:]) + return base64.StdEncoding.EncodeToString(b[:]) +} + +func (b *deviceBridge) Stop() { + b.mu.Lock() + defer b.mu.Unlock() + select { + case <-b.stop: + default: + close(b.stop) + } + if b.conn != nil { + b.conn.Close() + b.conn = nil + } +} diff --git a/cmd/waiter/main.go b/cmd/waiter/main.go index 3db213f..30fea8f 100644 --- a/cmd/waiter/main.go +++ b/cmd/waiter/main.go @@ -81,6 +81,8 @@ func main() { configPath := flag.String("config", "", "config file path") chat := flag.String("chat", "", "send a message and print final text (one-shot)") say := flag.String("say", "", "deprecated alias of -chat") + deviceGateway := flag.String("device", "", "remotedevice 网关地址(如 127.0.0.1:9890),启动设备桥") + deviceToken := flag.String("device-token", "", "设备接入 token") flag.Parse() cfg := discoverConfig(*configPath) @@ -103,6 +105,26 @@ func main() { } defer state.Disconnect() + // 设备桥:--device 或配置 device_gateway 时,waiter 作为被控设备接入 remotedevice + var bridge *deviceBridge + dg := *deviceGateway + if dg == "" { + dg = cfg.DeviceGateway + } + dt := *deviceToken + if dt == "" { + dt = cfg.DeviceToken + } + if dg != "" && dt != "" { + bridge = newDeviceBridge(dg, dt) + if err := bridge.Start(); err != nil { + printlnC(colorYellow, fmt.Sprintf("device bridge: %v (continue without)", err)) + } else { + printlnC(colorGreen, "device bridge active: "+bridge.deviceID) + defer bridge.Stop() + } + } + if oneShotMsg != "" { oneshot(state, oneShotMsg) return @@ -236,6 +258,3 @@ func printServerOutput(content string) { fmt.Printf("%s%s%s\n", clearLine, content, colorReset) } } - - - diff --git a/internal/plugins/all.go b/internal/plugins/all.go index 7ecdee8..e5c5b2e 100644 --- a/internal/plugins/all.go +++ b/internal/plugins/all.go @@ -11,6 +11,7 @@ import ( _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr" + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/remotedevice" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui" ) diff --git a/internal/plugins/remotedevice/device.go b/internal/plugins/remotedevice/device.go new file mode 100644 index 0000000..94f5c43 --- /dev/null +++ b/internal/plugins/remotedevice/device.go @@ -0,0 +1,262 @@ +package remotedevice + +import ( + "fmt" + "sort" + "strings" + "time" + + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" +) + +// devicectlDevice 把设备网关暴露为 IOManager 的一个 Device: +// Tools() 提供 devicedetect / device_ctl_status / device_ctl_cmdrun / device_ctl_cmdresult, +// Execute() 检查授权并路由到 WS 在线设备。 +type devicectlDevice struct { + reg *Registry + persist func() // 授权变更后持久化 +} + +func (d *devicectlDevice) Name() string { return "devicectl" } +func (d *devicectlDevice) Type() agentIO.DeviceType { return agentIO.DeviceIO } +func (d *devicectlDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapStructured } +func (d *devicectlDevice) Description() string { + return "远程设备控制网关:查看已接入/已授权的设备并发送控制指令(经用户授权的设备)" +} +func (d *devicectlDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} } +func (d *devicectlDevice) Start() error { return nil } +func (d *devicectlDevice) Stop() error { return nil } + +func (d *devicectlDevice) Tools() []agentIO.ToolDef { + return []agentIO.ToolDef{ + { + Name: "devicedetect", + Description: "扫描并列出已接入设备网关的设备(含在线/离线状态与授权状态)。" + + "用途:查看当前有哪些设备连接了 HomeAgent、是否在线、是否已授权。" + + "参数 kind 可选,只返回该种类的设备。返回值 device_id 用于后续 device_ctl_* 工具。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "kind": map[string]interface{}{"type": "string", "description": "按设备种类过滤(light/camera/computer/phone/...),可省略"}, + }, + }, + }, + { + Name: "device_ctl_status", + Description: "查询一台已在线设备的实时状态。" + + "仅返回设备上报的状态信息(如电量/温度/运行状态)。" + + "需要 device_id(来自 devicedetect)。设备必须已授权且在线。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"}, + }, + "required": []interface{}{"device_id"}, + }, + }, + { + Name: "device_ctl_cmdrun", + Description: "向一台已授权且在线设备发送命令执行请求(如开机/重启/播放/自定义命令)。" + + "⚡ 高危:设备必须已授权,且该操作会改变设备行为。" + + "返回 accepted=true 表示已下发并等待设备执行,之后可用 device_ctl_cmdresult 查询结果。" + + "若设备未授权或离线,返回错误信息。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"}, + "command": map[string]interface{}{"type": "string", "description": "要执行的命令(设备自定义语义,如 power_on、play:xxx 或 shell 命令)"}, + }, + "required": []interface{}{"device_id", "command"}, + }, + }, + { + Name: "device_ctl_cmdresult", + Description: "查询之前 device_ctl_cmdrun 下发命令的执行结果(按 req_id 或 device_id 最近一次)。" + + "若非阻塞或已超时,用此工具取回设备执行输出。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "req_id": map[string]interface{}{"type": "string", "description": "命令请求 ID(device_ctl_cmdrun 返回)"}, + "device_id": map[string]interface{}{"type": "string", "description": "设备 ID(查询最近一次结果)"}, + }, + }, + }, + { + Name: "deviceinfo", + Description: "探查一台设备接入网关时声明的详细信息与支持能力。" + + "返回设备的 OS/架构/CPU/内存/能力 caps 等(设备接入时上报,非实时)。" + + "需要 device_id(来自 devicedetect)。设备必须已授权。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"}, + }, + "required": []interface{}{"device_id"}, + }, + }, + } +} + +func (d *devicectlDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) { + switch tool { + case "devicedetect": + return d.detect(args) + case "device_ctl_status": + return d.status(args) + case "device_ctl_cmdrun": + return d.cmdrun(args) + case "device_ctl_cmdresult": + return d.cmdresult(args) + case "deviceinfo": + return d.info(args) + default: + return nil, fmt.Errorf("unknown device tool %s", tool) + } +} + +// publicDevices 把设备列表转为简洁 JSON(无内部字段)。 +func publicDevices(in []DeviceMeta) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(in)) + for _, m := range in { + entry := map[string]interface{}{ + "device_id": m.DeviceID, + "name": m.Name, + "kind": m.Kind, + "caps": m.Caps, + "authorized": m.Authorized, + "online": m.Online, + "last_seen": m.LastSeen, + } + if len(m.Info) > 0 { + entry["info"] = m.Info + } + out = append(out, entry) + } + return out +} + +// detect 实现 devicedetect。 +func (d *devicectlDevice) detect(args map[string]interface{}) (interface{}, error) { + kind, _ := args["kind"].(string) + devs := d.reg.List() + if kind != "" { + var filtered []DeviceMeta + for _, m := range devs { + if strings.EqualFold(m.Kind, kind) { + filtered = append(filtered, m) + } + } + devs = filtered + } + sort.Slice(devs, func(i, j int) bool { return devs[i].DeviceID < devs[j].DeviceID }) + if len(devs) == 0 { + return map[string]interface{}{"devices": []interface{}{}, "message": "暂无设备接入"}, nil + } + return map[string]interface{}{"devices": publicDevices(devs)}, nil +} + +// status 实现 device_ctl_status(只读,查询设备最后一次上报状态)。 +func (d *devicectlDevice) status(args map[string]interface{}) (interface{}, error) { + id, _ := args["device_id"].(string) + if id == "" { + return nil, fmt.Errorf("device_id required") + } + m, ok := d.reg.Get(id) + if !ok { + return nil, fmt.Errorf("device %s 不存在", id) + } + if !m.Authorized { + return nil, fmt.Errorf("device %s 未授权,无法查询状态(需先在设备管理页或经 bind 授权)", id) + } + if !m.Online { + return publicDevices([]DeviceMeta{m}), nil // 带 offline=true + } + return publicDevices([]DeviceMeta{m}), nil +} + +// cmdrun 实现 device_ctl_cmdrun:检查授权+在线,push 命令,异步等待结果。 +func (d *devicectlDevice) cmdrun(args map[string]interface{}) (interface{}, error) { + id, _ := args["device_id"].(string) + cmd, _ := args["command"].(string) + if id == "" || cmd == "" { + return nil, fmt.Errorf("device_id and command required") + } + m, ok := d.reg.Get(id) + if !ok { + return nil, fmt.Errorf("device %s 不存在", id) + } + if !m.Authorized { + return nil, fmt.Errorf("device %s 未授权,无法执行命令(请先在设备管理页授权)", id) + } + if !m.Online { + return nil, fmt.Errorf("device %s 不在线,无法执行命令", id) + } + reqID := newReqID() + if err := d.reg.PushCmd(id, reqID, cmd); err != nil { + return nil, fmt.Errorf("下发命令失败: %w", err) + } + // 阻塞等待设备结果(带超时);结果同时由 registry 留档。 + res, err := d.reg.AwaitResult(reqID, 30*time.Second) + if err != nil { + d.reg.SaveResult(reqID, map[string]interface{}{"accepted": true, "error": err.Error(), "pending": true}) + return map[string]interface{}{"accepted": true, "req_id": reqID, "pending": true, "note": "命令已下发但设备未在超时内回执,可用 device_ctl_cmdresult 再查"}, nil + } + out := res + out["req_id"] = reqID + d.reg.SaveResult(reqID, out) + return out, nil +} + +// cmdresult 实现 device_ctl_cmdresult。 +func (d *devicectlDevice) cmdresult(args map[string]interface{}) (interface{}, error) { + reqID, _ := args["req_id"].(string) + deviceID, _ := args["device_id"].(string) + if reqID != "" { + res, ok := d.reg.GetResult(reqID) + if !ok { + return nil, fmt.Errorf("no result for req %s(设备可能尚未回执)", reqID) + } + return res, nil + } + if deviceID != "" { + // 返回该设备最近一次结果(简化:遍历 results 找 device_id 匹配的最近一条) + // 说明:当前只按 req_id 查询;device_id 查询留给后续迭代。 + m, ok := d.reg.Get(deviceID) + if !ok { + return nil, fmt.Errorf("device %s 不存在", deviceID) + } + if !m.Authorized { + return nil, fmt.Errorf("device %s 未授权", deviceID) + } + return map[string]interface{}{"device_id": deviceID, "note": "请用 device_ctl_cmdrun 返回的 req_id 查询命令结果"}, nil + } + return nil, fmt.Errorf("req_id 或 device_id 至少提供一个") +} + +// info 实现 deviceinfo:返回设备详情 + 支持能力。 +func (d *devicectlDevice) info(args map[string]interface{}) (interface{}, error) { + id, _ := args["device_id"].(string) + if id == "" { + return nil, fmt.Errorf("device_id required") + } + m, ok := d.reg.Get(id) + if !ok { + return nil, fmt.Errorf("device %s 不存在", id) + } + if !m.Authorized { + return nil, fmt.Errorf("device %s 未授权,无法探查信息(请先在设备管理页授权)", id) + } + out := map[string]interface{}{ + "device_id": m.DeviceID, + "name": m.Name, + "kind": m.Kind, + "caps": m.Caps, + "authorized": m.Authorized, + "online": m.Online, + "last_seen": m.LastSeen, + } + if len(m.Info) > 0 { + out["info"] = m.Info + } + return out, nil +} diff --git a/internal/plugins/remotedevice/plugin.go b/internal/plugins/remotedevice/plugin.go new file mode 100644 index 0000000..1295bdd --- /dev/null +++ b/internal/plugins/remotedevice/plugin.go @@ -0,0 +1,260 @@ +package remotedevice + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +func init() { + plugin.RegisterPluginMeta("remotedevice", "远程设备网关", "Remote Device") + plugin.RegisterFactory("remotedevice", func(name string, config map[string]interface{}) (sdk.Plugin, error) { + return New(name), nil + }) +} + +const defaultAddr = "127.0.0.1:9890" + +// Plugin 是 remotedevice 设备接入网关插件: +// 持有 HTTP 服务(WS 设备通道 + REST 管理面)与 devicectl Device(agent 工具)。 +type Plugin struct { + name string + registry *Registry + mux *http.ServeMux + server *http.Server + addr string + token string + sdk *sdk.PluginSDK + dev *devicectlDevice +} + +func New(name string) *Plugin { + return &Plugin{ + name: name, + registry: NewRegistry(), + mux: http.NewServeMux(), + } +} + +func (p *Plugin) Name() string { return p.name } + +func genToken() string { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("tok-%d", time.Now().UnixNano()) + } + return hex.EncodeToString(buf) +} + +func newReqID() string { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("req-%d", time.Now().UnixNano()) + } + return hex.EncodeToString(buf) +} + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.SetAutoRestart(true) + p.sdk = s + + // ---- 设置 ---------------- + s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen_addr", Default: defaultAddr, Type: "string", DisplayName: "监听地址", Description: "设备网关 HTTP/WS 监听地址(默认 127.0.0.1:9890,仅本机)", Category: "remotedevice"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "ws_token", Default: "", Type: "password", DisplayName: "接入 Token", Description: "设备绑定/接入时使用的令牌;留空启动时自动生成", Category: "remotedevice"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "authorized_devices", Default: "", Type: "text", DisplayName: "已授权设备", Description: "逗号分隔的已授权设备 ID 列表(由系统维护)", Category: "remotedevice"}) + + p.addr = defaultAddr + if v, _ := s.Settings().Get("listen_addr"); v != nil { + if s2, ok := v.(string); ok && s2 != "" { + p.addr = s2 + } + } + p.token = "" + if v, _ := s.Settings().Get("ws_token"); v != nil { + if s2, ok := v.(string); ok && s2 != "" { + p.token = s2 + } + } + if p.token == "" { + p.token = genToken() + if err := s.Settings().Set("ws_token", p.token); err != nil { + log.Printf("[remotedevice] persist ws_token: %v", err) + } + } + p.registry.SetAcceptToken(func(provided string) bool { + return provided != "" && provided == p.token + }) + + // 恢复已授权设备集合 + if v, _ := s.Settings().Get("authorized_devices"); v != nil { + if s2, ok := v.(string); ok && s2 != "" { + var ids []string + for _, id := range strings.Split(s2, ",") { + if id = strings.TrimSpace(id); id != "" { + ids = append(ids, id) + } + } + p.registry.RestoreAuthorized(ids) + } + } + + // ---- devicectl Device(agent 工具) ---------------- + p.dev = &devicectlDevice{reg: p.registry, persist: p.persistAuthorized} + if err := s.RegisterChannel("devicectl", p.dev); err != nil { + log.Printf("[remotedevice] register devicectl channel: %v", err) + } + + // ---- REST 管理面 + WS 设备通道 ---------------- + p.registerRoutes() + p.server = &http.Server{Addr: p.addr, Handler: p.mux} + go func() { + log.Printf("[remotedevice] device gateway listening on %s", p.addr) + if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("[remotedevice] server error: %v", err) + } + }() + return nil +} + +// persistAuthorized 在授权变更后写回设置(持久化重启不丢)。 +func (p *Plugin) persistAuthorized() { + if p.sdk == nil { + return + } + ids := p.registry.AuthorizedIDs() + _ = p.sdk.Settings().Set("authorized_devices", strings.Join(ids, ",")) +} + +func (p *Plugin) registerRoutes() { + // 设备通道(WS) + p.mux.HandleFunc("/api/v1/device/ws", p.registry.ServeWS) + // REST 管理面(全部需 token) + p.mux.HandleFunc("/api/v1/device", p.requireToken(p.handleDeviceList)) + p.mux.HandleFunc("/api/v1/device/online", p.requireToken(p.handleDeviceOnline)) + p.mux.HandleFunc("/api/v1/device/", p.requireToken(p.handleDeviceByID)) + p.mux.HandleFunc("/api/v1/device/push", p.requireToken(p.handleDevicePush)) + p.mux.HandleFunc("/api/v1/device/auth", p.requireToken(p.handleDeviceAuth)) +} + +// requireToken 校验 REST 请求的接入令牌(X-API-Key header 或 ?token=)。 +func (p *Plugin) requireToken(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + k := r.Header.Get("X-API-Key") + if k == "" { + k = r.URL.Query().Get("token") + } + if k == "" || k != p.token { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next(w, r) + } +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func (p *Plugin) handleDeviceList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"devices": p.registry.List()}) +} + +func (p *Plugin) handleDeviceOnline(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"devices": p.registry.OnlineList()}) +} + +func (p *Plugin) handleDeviceByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/device/") + if id == "" || strings.Contains(id, "/") { + http.NotFound(w, r) + return + } + if r.Method == http.MethodGet { + m, ok := p.registry.Get(id) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": "device not found"}) + return + } + writeJSON(w, http.StatusOK, m) + return + } + http.NotFound(w, r) +} + +func (p *Plugin) handleDevicePush(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + var req struct { + DeviceID string `json:"device_id"` + Payload map[string]interface{} `json:"payload"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": err.Error()}) + return + } + if req.DeviceID == "" { + writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "device_id required"}) + return + } + if err := p.registry.PushJSON(req.DeviceID, req.Payload); err != nil { + writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"status": "ok"}) +} + +func (p *Plugin) handleDeviceAuth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + var req struct { + DeviceID string `json:"device_id"` + Authorize bool `json:"authorize"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": err.Error()}) + return + } + if req.DeviceID == "" { + writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "device_id required"}) + return + } + if _, ok := p.registry.Get(req.DeviceID); !ok { + writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": "device not found"}) + return + } + p.registry.SetAuthorized(req.DeviceID, req.Authorize) + p.persistAuthorized() + writeJSON(w, http.StatusOK, map[string]interface{}{"device_id": req.DeviceID, "authorized": req.Authorize}) +} + +func (p *Plugin) Stop() error { + if p.server != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + return p.server.Shutdown(ctx) + } + return nil +} diff --git a/internal/plugins/remotedevice/registry.go b/internal/plugins/remotedevice/registry.go new file mode 100644 index 0000000..bdd91a7 --- /dev/null +++ b/internal/plugins/remotedevice/registry.go @@ -0,0 +1,592 @@ +package remotedevice + +import ( + "bufio" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "strings" + "sync" + "time" +) + +// DeviceMeta 描述一台接入了网关的设备。 +type DeviceMeta struct { + DeviceID string `json:"device_id"` + Name string `json:"name"` + Kind string `json:"kind"` + Caps []string `json:"caps"` + Info map[string]interface{} `json:"info,omitempty"` + Authorized bool `json:"authorized"` + Online bool `json:"online"` + LastSeen int64 `json:"last_seen"` + RemoteAddr string `json:"remote_addr"` +} + +// wconn 表示一条活跃的 WS 连接(由网关持有)。 +type wconn struct { + deviceID string + w *bufio.Writer +} + +// Registry 是设备接入网关的注册表:管理在线连接、设备元数据与已授权集合。线程安全。 +type Registry struct { + mu sync.RWMutex + devices map[string]*DeviceMeta // deviceID -> meta(在线/历史) + authorized map[string]bool // deviceID -> 是否已授权(持久化恢复) + conns map[string]*wconn // deviceID -> 活跃连接(支持 push) + onlineCh chan string + onStatus func(msg map[string]interface{}) + acceptFn func(token string) bool + cmdPending map[string]chan map[string]interface{} // reqID -> 结果 channel + results map[string]resultEntry // reqID -> 已留档结果 +} + +// resultEntry 保存一次 cmdrun 的结果(供 device_ctl_cmdresult 查询)。 +type resultEntry struct { + Result map[string]interface{} + Time time.Time +} + +func NewRegistry() *Registry { + return &Registry{ + devices: make(map[string]*DeviceMeta), + authorized: make(map[string]bool), + conns: make(map[string]*wconn), + onlineCh: make(chan string, 16), + cmdPending: make(map[string]chan map[string]interface{}), + results: make(map[string]resultEntry), + } +} + +// SetAcceptToken 设置绑定 token 校验函数(插件注入,来自 Settings)。 +func (r *Registry) SetAcceptToken(fn func(token string) bool) { + r.mu.Lock() + defer r.mu.Unlock() + r.acceptFn = fn +} + +// SetStatusHandler 注册设备上报状态的回调。 +func (r *Registry) SetStatusHandler(h func(msg map[string]interface{})) { + r.mu.Lock() + defer r.mu.Unlock() + r.onStatus = h +} + +func (r *Registry) acceptBind(token string) bool { + r.mu.RLock() + fn := r.acceptFn + r.mu.RUnlock() + if fn == nil { + return false + } + return fn(token) +} + +// ============ 设备查询 ============ + +// Online 返回设备是否在线。 +func (r *Registry) Online(id string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + m, ok := r.devices[id] + return ok && m.Online +} + +// Authorized 返回设备是否已授权。 +func (r *Registry) Authorized(id string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.authorized[id] +} + +// List 返回全部设备(在线或历史),合并授权态。 +func (r *Registry) List() []DeviceMeta { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]DeviceMeta, 0, len(r.devices)) + for _, m := range r.devices { + c := *m + c.Authorized = r.authorized[c.DeviceID] + out = append(out, c) + } + return out +} + +// OnlineList 返回在线的设备列表。 +func (r *Registry) OnlineList() []DeviceMeta { + r.mu.RLock() + defer r.mu.RUnlock() + var out []DeviceMeta + for _, m := range r.devices { + if m.Online { + c := *m + c.Authorized = r.authorized[c.DeviceID] + out = append(out, c) + } + } + return out +} + +// Get 返回单个设备。 +func (r *Registry) Get(id string) (DeviceMeta, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + m, ok := r.devices[id] + if !ok { + return DeviceMeta{}, false + } + c := *m + c.Authorized = r.authorized[id] + return c, true +} + +// ============ 授权 ============ + +// SetAuthorized 标记某设备已授权/取消授权(持久化由插件负责)。 +func (r *Registry) SetAuthorized(id string, auth bool) { + r.mu.Lock() + defer r.mu.Unlock() + r.authorized[id] = auth + if m, ok := r.devices[id]; ok { + m.Authorized = auth + } +} + +// RestoreAuthorized 插件启动时从配置恢复已授权设备集合。 +func (r *Registry) RestoreAuthorized(ids []string) { + r.mu.Lock() + defer r.mu.Unlock() + for _, id := range ids { + r.authorized[id] = true + } +} + +// AuthorizedIDs 返回全部已授权设备 ID(供插件持久化)。 +func (r *Registry) AuthorizedIDs() []string { + r.mu.RLock() + defer r.mu.RUnlock() + var out []string + for id, ok := range r.authorized { + if ok { + out = append(out, id) + } + } + return out +} + +// ============ 在线状态维护 ============ + +func (r *Registry) register(meta DeviceMeta) { + r.mu.Lock() + meta.Online = true + meta.LastSeen = time.Now().Unix() + meta.Authorized = r.authorized[meta.DeviceID] + r.devices[meta.DeviceID] = &meta + r.mu.Unlock() + r.notifyChange(meta.DeviceID) +} + +func (r *Registry) markOffline(id string) { + r.mu.Lock() + if m, ok := r.devices[id]; ok { + m.Online = false + } + delete(r.conns, id) + r.mu.Unlock() + r.notifyChange(id) +} + +func (r *Registry) notifyChange(id string) { + select { + case r.onlineCh <- id: + default: + } +} + +// ChangeChan 返回设备上下线变更通知。 +func (r *Registry) ChangeChan() <-chan string { return r.onlineCh } + +// SaveResult 保存一次命令执行结果(供 cmdresult 查询)。 +func (r *Registry) SaveResult(reqID string, res map[string]interface{}) { + r.mu.Lock() + defer r.mu.Unlock() + if r.results == nil { + r.results = make(map[string]resultEntry) + } + r.results[reqID] = resultEntry{Result: res, Time: time.Now()} +} + +// GetResult 返回某次命令执行的结果。 +func (r *Registry) GetResult(reqID string) (map[string]interface{}, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + e, ok := r.results[reqID] + if !ok { + return nil, false + } + return e.Result, true +} + +// ============ Push(agent -> 设备) ============ + +// PushJSON 向在线设备推送一条 JSON 消息。 +func (r *Registry) PushJSON(deviceID string, payload map[string]interface{}) error { + r.mu.RLock() + c, ok := r.conns[deviceID] + r.mu.RUnlock() + if !ok { + return fmt.Errorf("device %s not online", deviceID) + } + return writeText(c.w, mustJSON(payload)) +} + +// PushCmd 向设备发送命令执行请求。 +func (r *Registry) PushCmd(deviceID, reqID, command string) error { + return r.PushJSON(deviceID, map[string]interface{}{ + "op": "cmd", + "req_id": reqID, + "command": command, + }) +} + +// AwaitResult 等待某请求的结果(带超时)。 +func (r *Registry) AwaitResult(reqID string, timeout time.Duration) (map[string]interface{}, error) { + ch := make(chan map[string]interface{}, 1) + r.mu.Lock() + r.cmdPending[reqID] = ch + r.mu.Unlock() + defer func() { + r.mu.Lock() + delete(r.cmdPending, reqID) + r.mu.Unlock() + }() + select { + case res := <-ch: + return res, nil + case <-time.After(timeout): + return nil, fmt.Errorf("timeout waiting for device result") + } +} + +// deliverResult 设备回执结果时由 handleWS 调用。 +func (r *Registry) deliverResult(reqID string, res map[string]interface{}) { + r.mu.RLock() + ch, ok := r.cmdPending[reqID] + r.mu.RUnlock() + if ok { + select { + case ch <- res: + default: + } + } +} + +// ============ WS 网关(标准库 Hijacker,RFC6455 子集) ============ + +const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +func wsAccept(key string) string { + h := sha256.Sum256([]byte(key + wsGUID)) + return base64.StdEncoding.EncodeToString(h[:]) +} + +func headerListHas(h, sub string) bool { + for _, part := range strings.Split(h, ",") { + if strings.EqualFold(strings.TrimSpace(part), sub) { + return true + } + } + return false +} + +func httpUpgrade(w http.ResponseWriter, r *http.Request) (net.Conn, *bufio.ReadWriter, error) { + if !headerListHas(r.Header.Get("Upgrade"), "websocket") || + !headerListHas(r.Header.Get("Connection"), "upgrade") { + return nil, nil, fmt.Errorf("not a websocket upgrade request") + } + key := r.Header.Get("Sec-WebSocket-Key") + if key == "" { + return nil, nil, fmt.Errorf("missing Sec-WebSocket-Key") + } + hj, ok := w.(http.Hijacker) + if !ok { + return nil, nil, fmt.Errorf("hijack not supported") + } + conn, rw, err := hj.Hijack() + if err != nil { + return nil, nil, err + } + accept := wsAccept(key) + resp := "HTTP/1.1 101 Switching Protocols" + "\x0d\x0a" + + "Upgrade: websocket\x0d\x0aConnection: Upgrade\x0d\x0a" + + "Sec-WebSocket-Accept: " + accept + "\x0d\x0a\x0d\x0a" + if _, err := rw.WriteString(resp); err != nil { + conn.Close() + return nil, nil, err + } + if err := rw.Flush(); err != nil { + conn.Close() + return nil, nil, err + } + return conn, rw, nil +} + +func readFrame(r *bufio.Reader) ([]byte, bool, error) { + b0, err := r.ReadByte() + if err != nil { + return nil, true, err + } + opcode := b0 & 0x0f + b1, err := r.ReadByte() + if err != nil { + return nil, true, err + } + masked := b1&0x80 != 0 + length := uint64(b1 & 0x7f) + if length == 126 { + var ext [2]byte + if _, err := io.ReadFull(r, ext[:]); err != nil { + return nil, true, err + } + length = uint64(binary.BigEndian.Uint16(ext[:])) + } else if length == 127 { + var ext [8]byte + if _, err := io.ReadFull(r, ext[:]); err != nil { + return nil, true, err + } + length = binary.BigEndian.Uint64(ext[:]) + } + if length > 1<<20 { + return nil, true, fmt.Errorf("frame too large") + } + var maskKey [4]byte + if masked { + if _, err := io.ReadFull(r, maskKey[:]); err != nil { + return nil, true, err + } + } + payload := make([]byte, length) + if _, err := io.ReadFull(r, payload); err != nil { + return nil, true, err + } + if masked { + for i := range payload { + payload[i] ^= maskKey[i%4] + } + } + switch opcode { + case 0x1: + return payload, false, nil + case 0x8: + return nil, true, nil + case 0xa: + return nil, false, nil + case 0x9: + return nil, false, errPing + default: + return nil, false, fmt.Errorf("unsupported opcode %x", opcode) + } +} + +var errPing = fmt.Errorf("ping") + +func writeText(w *bufio.Writer, payload []byte) error { + if err := writeFrameHeader(w, 0x1, len(payload)); err != nil { + return err + } + if _, err := w.Write(payload); err != nil { + return err + } + return w.Flush() +} + +func writePong(w *bufio.Writer) error { + return writeFrameHeader(w, 0xa, 0) +} + +func writeFrameHeader(w *bufio.Writer, opcode byte, length int) error { + if err := w.WriteByte(0x80 | opcode); err != nil { + return err + } + if length < 126 { + if err := w.WriteByte(byte(length)); err != nil { + return err + } + } else if length <= 0xffff { + if err := w.WriteByte(126); err != nil { + return err + } + var ext [2]byte + binary.BigEndian.PutUint16(ext[:], uint16(length)) + if _, err := w.Write(ext[:]); err != nil { + return err + } + } else { + if err := w.WriteByte(127); err != nil { + return err + } + var ext [8]byte + binary.BigEndian.PutUint64(ext[:], uint64(length)) + if _, err := w.Write(ext[:]); err != nil { + return err + } + } + return nil +} + +func mustJSON(v interface{}) []byte { + b, err := json.Marshal(v) + if err != nil { + return []byte("{}") + } + return b +} + +// ServeWS 是 WS 端点的 HTTP handler:认证 token(query 或 Sec-WebSocket-Protocol), +// 升级后进入 handleWS。未带 token 也允许 hello(登记设备),bind 时校验。 +func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + token := req.URL.Query().Get("token") + if token == "" { + for _, p := range req.Header.Values("Sec-WebSocket-Protocol") { + if strings.HasPrefix(p, "homeagent.") { + token = strings.TrimPrefix(p, "homeagent.") + break + } + } + } + if token != "" && !r.acceptBind(token) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + conn, rw, err := httpUpgrade(w, req) + if err != nil { + http.Error(w, "upgrade failed: "+err.Error(), http.StatusBadRequest) + return + } + log.Printf("[remotedevice] ws connected from %s", conn.RemoteAddr()) + go r.handleWS(conn, rw) +} + +func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) { + defer conn.Close() + var curID string + defer func() { + if curID != "" { + r.markOffline(curID) + } + }() + + for { + payload, isClose, err := readFrame(rw.Reader) + if err != nil { + if err == errPing { + if werr := writePong(rw.Writer); werr != nil { + return + } + continue + } + return + } + if isClose { + return + } + var msg map[string]interface{} + if err := json.Unmarshal(payload, &msg); err != nil { + continue + } + op, _ := msg["op"].(string) + switch op { + case "hello": + meta := metaFromMsg(msg) + if meta.DeviceID == "" { + continue + } + meta.RemoteAddr = conn.RemoteAddr().String() + curID = meta.DeviceID + r.register(meta) + r.mu.Lock() + r.conns[meta.DeviceID] = &wconn{deviceID: meta.DeviceID, w: rw.Writer} + r.mu.Unlock() + if err := writeText(rw.Writer, mustJSON(map[string]interface{}{ + "op": "hello_ack", + "device": meta.DeviceID, + "online": true, + })); err != nil { + return + } + case "bind": + token, _ := msg["token"].(string) + if r.acceptBind(token) { + id, _ := msg["device_id"].(string) + if id != "" { + // 默认不授权:bind 仅验证 token + 登记设备;授权完全由用户手动 + // (GUI 设备页 / REST /api/v1/device/auth)控制,绝不自动授权。 + } + if err := writeText(rw.Writer, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true})); err != nil { + return + } + } else { + if err := writeText(rw.Writer, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": false, "error": "bad token"})); err != nil { + return + } + } + case "status": + id, _ := msg["device_id"].(string) + r.mu.Lock() + if m, ok := r.devices[id]; ok { + m.LastSeen = time.Now().Unix() + } + r.mu.Unlock() + r.mu.RLock() + h := r.onStatus + r.mu.RUnlock() + if h != nil { + h(msg) + } + case "cmd_result": + reqID, _ := msg["req_id"].(string) + if reqID != "" { + r.deliverResult(reqID, msg) + } + } + } +} + +func metaFromMsg(msg map[string]interface{}) DeviceMeta { + var meta DeviceMeta + if d, ok := msg["device"].(map[string]interface{}); ok { + if v, ok := d["device_id"].(string); ok { + meta.DeviceID = v + } + if v, ok := d["name"].(string); ok { + meta.Name = v + } + if v, ok := d["kind"].(string); ok { + meta.Kind = v + } + if caps, ok := d["caps"].([]interface{}); ok { + for _, c := range caps { + if s, ok := c.(string); ok { + meta.Caps = append(meta.Caps, s) + } + } + } + if info, ok := d["info"].(map[string]interface{}); ok { + if len(info) > 0 { + meta.Info = info + } + } + } + return meta +} diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index eb53caf..15593da 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -611,6 +611,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/kernel", h.requireAPI(h.handleKernel)) mux.HandleFunc("/api/v1/plugins", h.requireAPI(h.handlePlugins)) mux.HandleFunc("/api/v1/plugins/", h.requireAPI(h.handlePluginByID)) + // 设备网关(可配置反代到 remotedevice;默认禁用,未启用时返回 404) + mux.HandleFunc("/api/v1/device/", h.requireAPI(h.handleDeviceGatewayProxy)) mux.HandleFunc("/v1/chat/completions", h.requireAPI(h.handleOpenAICompletions)) mux.HandleFunc("/", h.requireWeb(h.handleStatic)) } @@ -1663,6 +1665,55 @@ func (h *Handler) handleTracker(w http.ResponseWriter, r *http.Request) { } } +// ======== Remote Device Gateway (proxied to remotedevice, opt-in) ======== + +// deviceGatewayEnabled / deviceGatewayAddr 由 webui 插件启动时从设置读取并注入。 +// 默认禁用:用户显式配置 device_gateway_enabled=true 后,/api/v1/device/* 才会反代到 +// remotedevice 插件(self-contained),避免与 remotedevice 耦合。 +var ( + deviceGatewayEnabled bool + deviceGatewayAddr string + deviceGatewayToken string +) + +// handleDeviceGatewayProxy 将 /api/v1/device/* 反代到 remotedevice 内部 HTTP 服务。 +// 鉴权:本端走 requireAPI(webui API key),转发时带 remotedevice 的 token(X-API-Key)。 +func (h *Handler) handleDeviceGatewayProxy(w http.ResponseWriter, r *http.Request) { + if !deviceGatewayEnabled { + http.NotFound(w, r) + return + } + addr := deviceGatewayAddr + if addr == "" { + addr = "127.0.0.1:9890" + } + path := r.URL.Path // 保留 /api/v1/device/... 全路径 + url := "http://" + addr + path + if r.URL.RawQuery != "" { + url += "?" + r.URL.RawQuery + } + req, err := http.NewRequestWithContext(r.Context(), r.Method, url, r.Body) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + req.Header = r.Header.Clone() + if deviceGatewayToken != "" { + req.Header.Set("X-API-Key", deviceGatewayToken) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "device gateway unreachable: " + err.Error()}) + return + } + defer resp.Body.Close() + for k, v := range resp.Header { + w.Header()[k] = v + } + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) +} + // ======== Plugin Management (proxied to pluginmgr HTTP API) ======== func (h *Handler) pluginmgrAddr() string { diff --git a/internal/plugins/webui/handler_test.go b/internal/plugins/webui/handler_test.go index 2f91b4e..42adef9 100644 --- a/internal/plugins/webui/handler_test.go +++ b/internal/plugins/webui/handler_test.go @@ -704,7 +704,7 @@ func TestSettingsWithPluginRegistry(t *testing.T) { type echoProvider struct{ name string } -func (p *echoProvider) Name() string { return p.name } +func (p *echoProvider) Name() string { return p.name } func (p *echoProvider) MaxContextTokens() int { return 8192 } func (p *echoProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) { content := "echo: " + lastUserContent(req.Messages) diff --git a/internal/plugins/webui/plugin.go b/internal/plugins/webui/plugin.go index 2b612e4..3955fd5 100644 --- a/internal/plugins/webui/plugin.go +++ b/internal/plugins/webui/plugin.go @@ -95,7 +95,26 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.Settings().RegisterDef(sdk.ConfigDef{Key: "username", Default: "admin", Type: "string", DisplayName: "登录用户名", Description: "Web 控制台登录用户名", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "password", Default: "", Type: "password", DisplayName: "Web 控制台登录密码", Description: "Web 控制台登录密码", Category: "webui"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "session_ttl_hours", Default: "24", Type: "int", DisplayName: "会话时长(小时)", Description: "登录 cookie 有效时长", Category: "webui"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_enabled", Default: "false", Type: "bool", DisplayName: "设备网关反代", Description: "启用后 /api/v1/device/* 反代到 remotedevice 插件(默认关闭,避免硬耦合)", Category: "webui"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_addr", Default: "127.0.0.1:9890", Type: "string", DisplayName: "设备网关地址", Description: "remotedevice 插件的内部监听地址", Category: "webui"}) + s.Settings().RegisterDef(sdk.ConfigDef{Key: "device_gateway_token", Default: "", Type: "password", DisplayName: "设备网关令牌", Description: "访问 remotedevice 的 token(与 remotedevice 的 ws_token 一致)", Category: "webui"}) p.ensureAuthBootstrap(s) + // 注入设备网关反代配置(默认禁用;仅当用户开启时才挂载路由) + if v, _ := s.Settings().Get("device_gateway_enabled"); v != nil { + if s2, ok := v.(string); ok && s2 == "true" { + deviceGatewayEnabled = true + } + } + if v, _ := s.Settings().Get("device_gateway_addr"); v != nil { + if s2, ok := v.(string); ok && s2 != "" { + deviceGatewayAddr = s2 + } + } + if v, _ := s.Settings().Get("device_gateway_token"); v != nil { + if s2, ok := v.(string); ok && s2 != "" { + deviceGatewayToken = s2 + } + } s.RegisterStage(sdk.StagePreAction, func(ctx *sdk.StageContext) error { s.Publish(&sdk.Event{Type: sdk.EventStage, Payload: map[string]interface{}{"phase": "pre_action", "message": "thinking"}}) diff --git a/plan.md b/plan.md index bed5e54..cb189ff 100644 --- a/plan.md +++ b/plan.md @@ -22,7 +22,7 @@ **根因**:`internal/plugins/healthcheck/plugin.go` 的三个"写入通道"自检**全部在真实生产存储上写入再删除**: | 函数 | 写入 | 清理 | 固化问题 | -|------|------|------|----------| +| ------ | ------ | ------ | ---------- | | `testMemoryRaw` (:460) | `Memory().Commit(_hc_ triple)` | `Purge(hard)` | GraphDB 空实体/AUTOINCREMENT id 膨胀 | | `testKnowledgeRaw` (:485) | `Knowledge().Add(_hc_knowledge_test_)` | `Remove(marker)` | Add 异步 `go writeIndex` vs Remove 异步重写**竞态**;`dirName`(用了 `/` 解析)与 `Remove` 的 `id=sanitize(name)` 计算**不一致**→ 删除可能失效 → **残留目录固化成文件** | | `testDocStoreRaw` (:521) | `DocMemory().Insert(...)` | Query 后 Remove | 真实 docStore 写文件再删,抖动 | @@ -30,6 +30,7 @@ **原则**:健康检查验证的是"写入通道是否可用",**结果不应固化进生产记忆**。改为**独立虚拟/影子空间**或**不落盘验证**。 **实现**: + - [x] **核心实现**:SDK 新增 `VirtualInstance`(`internal/sdk/selftest.go`),healthcheck 的三个 raw 自检改为在完全隔离的虚拟空间(`os.MkdirTemp` 独立图库/知识库/文档/文本)上做真实"写→查→删",绝不碰生产存储。 - [x] `PluginSDK.Selftest()/SelftestReset()` 暴露隔离实例(含 mutex 防并发),每轮自检前 `SelftestReset` 重建清空上轮数据。 - [x] **LLM 驱动自检防护**:`collectToolDefsForLLM` 改为只收集**只读白名单**工具(`isSafeReadonlyTool`),写/删/改生产数据及外部副作用工具(memory_commit/doc_commit/knowledge_create/cmd_run/files_write/terminal_*/output_send/spawn_child 等)一律不交给 LLM 自检,防止 LLM 乱调污染生产。 @@ -75,7 +76,7 @@ ## 核心问题清单 | # | 问题 | 影响 | 位置 | -|---|------|------|------| +| --- | ------ | ------ | ------ | | 1 | GraphDB.Commit 对 relations **裸 INSERT 无去重** | 同一三元组每次归档无限重复,生产 5613 条 `文档→来源→context_archived` 重复垃圾 | `internal/memory/graph.go:209` | | 2 | `docToTriples()` **对每篇冷文档永远生成固定模板三元组**(`文档→来源→context_archived`、`文档→主题→{summary}`) | 归档即写垃圾,配合问题1指数级累积 | `internal/agent/core/distill.go:390-437` | | 3 | `core.agent.distill_interval=2d` 配置 **静默失效**(Go `time.ParseDuration` 不支持 `d` 单位),回退 30 分钟默认值 | archiveColdDocs **每小时跑**而非每 2 天,放大问题 1/2 | `internal/config/registry.go:698` | @@ -142,10 +143,12 @@ **背景**:`readLoop`(`internal/plugins/agentcli/plugin.go:556-608`)用写死常量 `NotifyOutputDelay = 500ms`(`plugin.go:24`)做**定时节流**:只要终端持续输出(如 `git sparse clone` 进度),就每 500ms 注入一条 `[终端 X 有新输出]` 到 agent,造成无限自喂送、占满 eventLoop,使 QQ 消息永远只能塞进回环通道且被 echo 上下文淹没。 **原则(明确保留通知,不改中断机制)**: + - **通知机制必须保留**——agent 需要感知"终端仍在运行、可能有待读取的输出",否则会忘记终端的存在、不知何时去 `terminal_read`。 - 真正要改的是**写死的 500ms 定时节流**,改为**基于输出语义/任务生命周期的通知策略**,"多少输出一通知 / 命令执行结束再通知"由可配策略决定,而非插件写死。 **实现**: + - [x] 通知从"按 500ms 定时"改为**按终端生命周期事件触发**: - **进程结束 / 超时 / 读取错误 → 立即通知**(已存在 + 增强 EOF 即时触发)。 - **持续运行、仅产出进度 → 低频"有新输出"通知**(累计 `notify_bytes` 默认 2KB 未读字节,或距上次通知 `notify_interval` 默认 2s,两条件满足任一即触发),而非每 500ms。 @@ -172,8 +175,8 @@ ## 验收标准 | 指标 | 当前 | 目标 | 验收方式 | -|------|------|------|----------| -| Graph relations 重复率 | ~90% (5613/6225) | 0% | `SELECT count(*), count(DISTINCT source_id||target_id||relation_type) FROM relations` | +| ------ | ------ | ------ | ---------- | +| Graph relations 重复率 | ~90% (5613/6225) | 0% | `SELECT count(*), count(DISTINCT source_id | | target_id | | relation_type) FROM relations` | | `context_archived` 关系残留 | 5613 | 0 | `grep` 关系表 | | `distill_interval` 配置生效 | 失效(30m) | 2d | 日志 `heartbeat distill tick` 间隔 = 48h | | Pipeline distiller 频率 | 7天一批 | 10min | 日志 `distilled N records` 每 10min | @@ -219,8 +222,9 @@ internal/memory/static_embedder.go # 量化/裁剪入口(可选) > HomeAgent WebUI 为 Go 嵌入式 HTML/JS(非 React),改进方向:**在原有技术栈内尽可能逼近设计原则**,重点提升信息架构、交互反馈、视觉层次。 ### 设计系统适配(Go 模板 + 原生 CSS/JS) + | 维度 | NapCat 参考 | HomeAgent 适配策略 | -|------|-------------|-------------------| +| ------ | ------------- | ------------------- | | **配色** | 樱花粉 `#FF7FAC` / 霜蓝 `#88C0D0` / 玫瑰红 `#F33B7C`;粉色调中性色 | CSS 变量定义同色系;浅/深色模式切换;主色用于 CTA/聚焦态 | | **字体** | Quicksand/Nunito 圆润无衬线 + JetBrains Mono | 引入 Google Fonts Quicksand + JetBrains Mono;标题 -0.02em tracking | | **间距** | 4px 基准单位;卡片 p-4~6;区块 gap-4/6 | CSS Grid/Flex 统一 4px 节奏;卡片内边距 16/20/24px | @@ -231,16 +235,18 @@ internal/memory/static_embedder.go # 量化/裁剪入口(可选) | **图标** | Lucide 2px stroke | 引入 Lucide 静态 SVG(内联)或同风格 iconfont | ### 信息架构重构(核心痛点) + | 现状 | 目标(对标 NapCat Dashboard) | -|------|-----------------------------| +| ------ | ----------------------------- | | 单页面堆砌所有功能 | **左侧可折叠侧边栏**(16rem 固定)→ 导航分组:概览/记忆/工具/插件/配置/日志 | | 无面包屑、无状态反馈 | 顶部面包屑 + 悬停微动效;关键操作 Toast 反馈(右上角) | | 表格/列表无视觉分组 | 卡片网格布局:每卡片 = 一个功能模块(记忆统计/插件状态/工具调用/系统资源) | | 无数据可视化 | Canvas 2D 绘制:记忆增长趋势图、CPU/内存环图、工具调用热力图 | ### 交互体验对标 + | 场景 | NapCat 做法 | HomeAgent 改进 | -|------|-------------|----------------| +| ------ | ------------- | ---------------- | | 卡片悬停 | 3D 透视倾斜 + 光标跟随渐变光斑 | CSS `transform: perspective(1000px) rotateX/Y(±5deg)` + 伪元素光斑跟随鼠标 | | 按钮点击 | 弹簧 scale + loading 态 | `:active { transform: scale(0.97) }` + 内置 spinner | | 页面切换 | Framer Motion fade-up+scale stagger | CSS `@keyframes fadeUpScale` + JS 交错延迟 50ms | @@ -248,6 +254,7 @@ internal/memory/static_embedder.go # 量化/裁剪入口(可选) | 错误处理 | Toast 右上 + 破坏性操作确认弹窗 | 统一 `showToast(type, msg)` + `confirmDialog(action, onConfirm)` | ### 实施路线(非阻塞,Phase 8+) + ``` Phase 8.1: ✅ CSS 变量系统 + Glassmorphism 基础样式(浅/深色)— dashboard.html :root 重写 NapCat DNA tokens(sakura/frost 色板、玻璃变量、阴影、圆角、字体、动效) Phase 8.2: ✅ 布局重构 — 左侧 16rem 可折叠侧边栏 + 顶部面包屑 topbar + 卡片网格响应式(toggleSidebar/switchTab 联动) @@ -258,9 +265,11 @@ Phase 8.6: ✅ 空状态/错误/确认弹窗统一组件库 — showToast(type,m Phase 8.7: ✅ 无障碍/键盘导航/移动端适配 — :focus-visible ring、prefers-reduced-motion 全停动效、主题滚动条、移动端自动折叠侧边栏 Phase 8.8: ✅ 用户反馈迭代(8.x 收尾)— ①健康检查 UI 去冗余(系统操作卡仅保留重载插件,健康检查卡自带右上角「运行」按钮+空态文案);②主题跟随系统(无手动偏好时用 prefers-color-scheme,并监听系统实时切换);③设置页内容列 max-width 860px 居中;④emoji 清理(☰/☀️/🌙/⛔/🔧/🧠 → 内联 SVG/纯文本,聊天工具调用状态用语义色图标);⑤总览页重构为插件页式全宽单列卡片(移除看板娘大照片卡、移除不准确的记忆分布环图+运行时资源条形图,runtime/memory 改 kv-row 精确数字展示),kernel 页同化 ``` + > 实现均在 `internal/plugins/webui/dashboard.html`(纯 CSS + Vanilla JS,无构建链);`handler_test.go:641` 修复上游遗留断言失配(`api('/settings'` → `api("/settings"`)。 ### 技术约束 + - **保持 Go `html/template` + 内嵌静态资源** —— 不引入 Node/构建链 - 静态资源(CSS/JS/字体/图标)以 `embed.FS` 内嵌二进制 - 复杂动效用纯 CSS + 极简 Vanilla JS(无框架依赖) @@ -277,4 +286,309 @@ Phase 8.8: ✅ 用户反馈迭代(8.x 收尾)— ①健康检查 UI 去冗 --- *更新时间:2026-08-11* -*生产实例:`/home/newqqagent`,systemd 托管,二进制 `/usr/local/bin/homed` (v0.8.0, 2026-07-28 build)* \ No newline at end of file +*生产实例:`/home/newqqagent`,systemd 托管,二进制 `/usr/local/bin/homed` (v0.8.0, 2026-07-28 build)* +--- + +## 9. device_ctl 设备接入网关:WebUI 监听 + devicedetect 扫描 + +### 设计意图备忘(对齐核心架构原则) + +1. **插件即 Agent 的 App,IO 全在插件层** —— 设备接入是 IO 能力,**必须全部收敛到 webui 插件**(内核零 IO 原则), + 设备状态归 webui 插件管理,agent 只经工具访问。**不触碰 CLI/waiter**(CLI 本机自执行命令已有 `cmd` 插件,反向操控 CLI 属重复造轮子,不在本计划范围)。 +2. **认知负荷最小 / 反提示词注入** —— 设备元数据(dev_id/ip/status)只出现在工具参数与返回值,**绝不进 system prompt**; + agent 只通过 `devicedetect` / `device_ctl_*` 工具按需查询,避免设备名/IP 污染对话上下文。 +3. **工具语义自解释** —— `devicedetect` / `device_ctl_*` 的 Description 让 agent「读完即知怎么用」,无需额外指令。 +4. **显式授权为唯一信任源** —— 任何 device_ctl 执行必须 **先授权、后执行**;高危操作(cmdrun/open)需**每次二次确认**(授权确认响应中的 accept 字段)。授权状态持久化,重启不丢。 + +### 目标(需求澄清) + +用户明确收敛为:**只做 webui**(webui 开监听端口作为设备接入网关),提供 `devicedetect` 工具供 agent 扫描设备连接状态。 +CLI 本机自执行命令已有 cmd 插件,不做反向操控 CLI。 + +### 一、协议与接入(webui 充当“设备注册网关”) + +**背景**:当前 webui 是纯 HTTP/SSE 服务(`internal/plugins/webui`):`/api/v1/*` REST(webui API key 认证)+ 聊天 SSE。设备接入需要一条**独立、受控、可被反向推送**的通道。 + +**设计**:新增 **JSON-over-WebSocket 监听**(agent→设备反向推送 + 设备→agent 上报,双向长连接),复用 webui 插件。 + +| # | 内容 | 位置 | 风险 | +| --- | ------ | ------ | ------ | +| A1 | 新增 WebSocket 端点 `/api/v1/device/ws`(API key 认证,仅连接期有效),JSON 消息帧 | `internal/plugins/webui/handler.go` | 中(WS 依赖库 `github.com/gorilla/websocket`,判断是否已引入;若无则用 `golang.org/x/net/websocket` 或静态协议) | +| A2 | 新增 REST:`GET /api/v1/device/online`(在线设备列表/状态)、`POST /api/v1/device/push`(向已连接设备推 JSON) | 同上 | 低 | +| A3 | **设备能力声明**:连接时设备上传 `{op:"hello", device:{name,kind,caps:[...]}}`,webui 记录并登记在线 | 同上 | 低 | +| A4 | **设备侧授权绑定**:首次连接需 `{op:"bind", token}`;token 由 webui 设置页生成(`device_gateway.token` 配置),绑定成功后该设备进入“已授权”集合 | `plugin.go`(设置) + handler | 中 | + +### 二、Agent 工具(devicedetect 等) + +**设计**:注册一个 `devicectl` 设备(实现 `agentIO.Device` 接口,`internal/plugins/webui` 内定义), +`Tools()` 返回三工具,`Execute()` 检查授权 + 路由到 WebSocket 在线设备。 + +| 工具 | 语义 | 参数 | 返回值 | +| ------ | ------ | ------ | -------- | +| `devicedetect` | 扫描/列出已连接且已授权的设备 | `kind`(可选) | `[{device_id,name,kind,caps,online}]` | +| `device_ctl_status` | 查询单设备实时状态 | `device_id` | `{device,status,last_seen}` | +| `device_ctl_cmdrun` | 向设备发送命令执行请求(**高危,需授权+二次确认**) | `device_id, command` | `{accepted:true, request_id}` 或拒绝 | + +**注意**:webui 插件属于内置插件,注册 `devicectl` 设备走 `s.RegisterChannel("devicectl", dev)`(IOManager 自动并入工具集,`ExecuteTool` 自动可达)。设备 Execute 是**同步阻塞**的,但 cmdrun 是异步的——需维护 **pending 请求表(request_id → chan)**,WS 收到设备结果后写回,工具循环内超时返回。 + +### 三、webui 前端:设备管理页 + +| # | 内容 | 位置 | 风险 | +| --- | ------ | ------ | ------ | +| F1 | 侧栏新增「设备」入口 + 页面:设备列表(在线/离线/已授权/未授权)、连接状态徽标 | `dashboard.html` | 低(纯前端) | +| F2 | 设备详情:基本信息(名称/种类/caps)、状态/历史、授权/取消授权按钮、`device_ctl_cmdrun` 命令输入+结果展示 | 同上 | 低 | +| F3 | 设备接入引导:显示 `device_gateway.token` + 接入方式说明(WS URL + 绑定 token),供外部设备复制 | 同上 | 中(token 明文展示,需「显示/隐藏」) | + +### 四、安全与授权 + +| # | 内容 | 位置 | 风险 | +| --- | ------ | ------ | ------ | +| S1 | `device_gateway.token`:启动时生成并持久化(复用 `Settings()` 机制,webui 插件设置页可「显示/重置」) | `plugin.go` / handler | 低 | +| S2 | **授权级别**:只读(status/detect)无需确认;**执行类(cmdrun)需二次确认**——工具返回 `{accepted:false, require_confirm:true}`,agent 需再调 `device_ctl_confirm` 或经 webui 前端用户点击「允许」 | handler + 工具 | 中 | +| S3 | **授权状态持久化**:已授权设备集合存 webui 插件配置(SQLite config.db,复用 Settings),重启不丢 | 同上 | 低 | +| S4 | **WS 连接安全**:连接即要求 API key(`Sec-WebSocket-Protocol` 或 query token);每消息帧校验;超时/断开自动清理在线表 | handler | 中 | + +### 五、实施顺序(本计划按此逐步 push,每步可独立验证) + +| Phase | 内容 | 验证 | +| ------- | ------ | ------ | +| **P1** | 协议与接入:新增 WS 端点 + hello/bind 握手 + 在线设备登记(内存) | curl/WS 客户端连上 → `GET /api/v1/device/online` 可见 | +| **P2** | REST 面:`/api/v1/device/online`、`/api/v1/device/push`、(device_gateway.token 设置注册) | token 生成/持久化;push 到在线设备收到 JSON | +| **P3** | Agent 工具:`devicectl` Device + `devicedetect` / `device_ctl_status` / `device_ctl_cmdrun` | agent 工具面板可见三工具;devicedetect 返回在线设备 | +| **P4** | 异步 cmdrun:pending 请求表 + WS 结果回写 + 超时 | 模拟设备返回 cmdrun 结果,agent 拿到 | +| **P5** | 授权:bind 绑定 + token 校验 + 持久化已授权集合 + cmdrun 二次确认流 | 未绑定拒绝;已绑定可查询;cmdrun 需确认 | +| **P6** | webui 前端设备管理页(F1–F3) | 页面可见在线/授权/执行状态 | +| **P7** | 单测 + 文档:handler/工具/授权单测;README/架构文档补充 | `make test` 全绿 | + +### 六、里程碑外延(明确不做) + +- **不做 CLI/waiter 反向操控**(本机命令已由 cmd 插件承担;预期语义是“设备本来就是远程的”)。 +- 不做 QQ/微信等具体设备插件——设备按通用 WS 协议接入即可。 +- 单设备回调/事件推送的复杂路由(多设备扇出、订阅过滤)留给后续迭代。 + +### 七、回滚预案 + +- P1–P5 均为新增代码/路由,不触碰现有 webui 路由与 CLI socket;既有功能完全不受影响。 +- 若 WS 依赖库引入失败:回退为**独立 TCP 监听**(复用 cli 插件逐行 JSON 模式,协议一致)——同样满足“设备接入网关”。 +- 所有改动先 `make build build-cli` + `make test` 后再部署;生产回滚即替换旧 `homed` 二进制。 + +> **更新(2026-08-16)**:用户定案——device 接入独立为 `remotedevice` 插件(webui 只做前端反代,如 `proxyToPluginmgr` 先例;不把 WS 监听直接长在 webui 插件上,避免 webui 过重)。本插件的所有实现细节沿用上述设计,落点全部移到 `internal/plugins/remotedevice/`: +> +> - 设备网关(WS 监听 + hello/bind/在线登记)→ remotedevice 自持监听端口(默认 127.0.0.1:9890,配置 `listen_addr`) +> - REST(online/push)→ remotedevice 自带 HTTP mux(地址同上) +> - Agent 工具(devicectl Device + devicedetect/device_ctl_*)→ remotedevice 内 `s.RegisterChannel("devicectl", dev)` +> - 设置项(listen_addr / ws token / 已授权集合)→ remotedevice 插件 Settings(config_remotedevice 表) +> - webui 前端设备页 → webui `/api/v1/device/*` **可选反代**到 remotedevice(参照 `proxyToPluginmgr` 模式):**默认禁用**,用户配置 `device_gateway_enabled` / `device_gateway_addr` / `device_gateway_token` 后才挂载,避免硬耦合 +> - 设备网关鉴权:webui 层 API key(`requireAPI`)+ 网关层 remotedevice token(`X-API-Key`)双鉴权 + +## 10. 实施:remotedevice 独立插件(逐步推进) + +### Phase 0:插件骨架 + 设备注册表 + WS 网关(本轮) + +### Phase 0:插件骨架 + 设备注册表 + WS 网关 + devicectl 工具(已实施) + +**已交付**: + +- `internal/plugins/remotedevice/` 独立插件:`registry.go`(设备注册表 + 标准库 WebSocket 网关 + push/await/结果留档)、`plugin.go`(设置 + REST 面 + HTTP 服务)、`device.go`(devicectl Device + 四工具) +- Agent 工具:`devicedetect` / `device_ctl_status` / `device_ctl_cmdrun` / `device_ctl_cmdresult`(经 `s.RegisterChannel("devicectl", dev)` 并入 IOManager 工具集) +- 设备接入:WS 端点 `/api/v1/device/ws`(token 认证,hello/bind/status/cmd_result 协议),默认监听 127.0.0.1:9890 +- REST 管理面:`/api/v1/device`(列表)、`/api/v1/device/online`、`/api/v1/device/{id}`、`/api/v1/device/push`、`/api/v1/device/auth`(全部 token 鉴权) +- 授权:token 校验(`ws_token`,启动生成持久化)+ 已授权集合持久化(`authorized_devices`,逗号分隔)+ `RestoreAuthorized` 重启恢复 +- webui 可配置反代:新增 `device_gateway_enabled`(默认 false)/`device_gateway_addr`/`device_gateway_token` 设置;仅启用时挂 `/api/v1/device/` 反代路由(webui API key 鉴权 → 转发带 remotedevice token) +- 装配:`internal/plugins/all.go` 注册 remotedevice + +**验证**:`gofmt -w` + `go build ./...` exit:0 ✅ + +**待办(后续 Phase)**: + +- [] 单测:注册表/WS 握手/push/cmdrun/授权 +- [] webui 前端设备管理页(非必选,可经 REST/CLI 使用) +- [] 心跳/离线自动清理的周期 goroutine(当前断开即清理) + +### Phase 0b:GUI 连接 remotedevice(已实施) + +**需求**:本机有 GUI(Electron),为其添加连接 remotedevice 网关的逻辑,可查看/授权/控制设备。 + +**已交付**(cmd/gui/): + +- **连接类型**:表单新增 `device`(设备网关 remotedevice)类型;`saveConnForm` 加 device 分支(url+apiKey=ws_token);连接测试加 device 探活(GET /api/v1/device,带 X-API-Key) +- **normalize 修复**:`main.js normalizeConnections` 允许 `type==="device"`(原来会强制改回 webui) +- **侧栏「设备」入口** + `view-devices` 容器(index.html) +- **renderDevices()**:设备列表(名称/种类/在线/授权/能力)、空态引导、"执行命令"(prompt 输入→POST /device/push)、"授权/取消授权"(POST /device/auth)、"刷新" +- `renderAll`/`refreshAll` 挂载 renderDevices;state.devices + +**验证**:`node --check renderer/app.js` 与 `node --check main.js` 全通过;`go test ./internal/plugins/... ./internal/sdk/... ./internal/agent/...` 全绿;remotedevice 端到端(WS 设备接入 → REST 列表/online/鉴权/push)验证通过。 + +**设备模拟全链路**(验证实录): + +- WS 连 /api/v1/device/ws?token=... → hello_ack → bind_ack → 服务端 push {op:cmd, command} → 设备回 cmd_result +- GET /api/v1/device 返回全部设备(合并授权态);GET /api/v1/device/online 只返回在线;无 token 401 +- GUI 的 "执行命令" 走 /device/push 已验证下发到达设备 + +### Phase 0c:端到端验收(已完成) + +**环境**:临时 homed(-data /tmp/ha-dev)+ Python WS 设备模拟 + Electron GUI 冒烟。 + +**验证结果**: + +- ✅ `go build ./...` exit:0;`go test ./internal/plugins/... ./internal/sdk/... ./internal/agent/...` 全绿 +- ✅ remotedevice 网关监听 127.0.0.1:9890(config_remotedevice 表:listen_addr/ws_token/authorized_devices 持久化) +- ✅ WS 设备接入全链路:hello_ack → bind_ack → 服务端 push {op:cmd} → 设备回 cmd_result +- ✅ REST:GET /api/v1/device(含授权态合并)、/online(仅在线)、无 token 401 +- ✅ GUI:`node --check renderer/app.js` + `node --check main.js` 通过;Electron 冒烟(--disable-gpu)稳定运行无 JS 错误(headless 容器需禁 GPU) +- ✅ 说明:webui 反代默认禁用(device_gateway_enabled=false),GUI 直连 remotedevice 网关不受影响 + +**遗留/后续**: + +- [ ] remotedevice 单测(registry/WS 握手/push/授权) +- [ ] agent 真实工具调用验证(需 LLM key,本环境无) +- [ ] webui 设备管理页(可选,GUI 已覆盖) +- [ ] cmdrun 二次确认流(当前为已授权即下发) + +### Phase 0d:真实 LLM 驱动工具调用测试(完成 ✅) + +**环境**:本机 LLM 网关 `http://127.0.0.1:8080/v1`(deepseek-v4-flash-free,Bearer key)+ 临时 homed + Python WS 模拟设备(living-light 客厅灯,已授权在线)。 + +**配置**:`core.llm.base_url/api_key/model=AUTO/adapter=openai` + `core.llm.sources.default.*`;重启后 `model=AUTO base=http://127.0.0.1:8080/v1 sources=2` ✓ + +**测试1:devicedetect(扫描设备)** +> 用户指令:"请扫描一下当前有哪些设备在线,用devicedetect工具" +> Agent 真实调用 devicedetect → 返回客厅智能灯(living-light)类型/状态/授权/能力/最近在线时间 ✓(7.8s) + +**测试2:device_ctl_cmdrun(反向操控设备)—— 决定性验证** +> 用户指令:"用device_ctl_cmdrun让客厅灯living-light执行 turn_on 命令打开灯" +> 设备模拟日志:`PUSHED: {command: turn_on, op: cmd, req_id: f00dd0596ffeee2d}` → 设备回 `cmd_result` +> Agent 回复:"开灯指令已成功下发执行(status: ok,请求 ID f00dd0596ffeee2d)" ✓(16.6s) + +**结论**:HomeAgent → LLM 决策 → devicectl 工具 → WS push → 设备执行 → cmd_result 回执 → agent 汇报 全链路真实跑通,即"agent 反向操控设备"核心能力已验证。 + +**清理**:已终止临时 homed / 模拟设备 / 临时文件;代码改动与 plan.md 保留。 + +### Phase 0e:GUI 作为设备接入 + 托盘驻留 + 偏好设置 + 聊天卡顿修复(已实施) + +**设备桥(GUI 作为设备)** + +- `cmd/gui/main.js` 新增设备桥:GUI 以 `device_id: gui-` 接入 remotedevice WS(hello/bind),收到 `{op:cmd}` 用 `spawn` 本机执行(白名单命令 + 15s 限时 + 8KB 截断)回 `cmd_result` +- whenReady 时若存在 `type="device"` 连接则自动启动设备桥;before-quit 停设备桥 + +**托盘驻留** + +- `initTray()`:nativeImage icon + 菜单(显示主界面/退出)+ 双击显示;whenReady 调用 +- `window-all-closed` 依 `exitToTray` 偏好:true 则隐藏驻留(后台维持设备桥),false 则 quit +- `window:close` handler 改为 exitToTray 时 hide(修复了托盘块重复注册崩溃) + +**偏好设置(prefs:get/set + 设置页 UI)** + +- `gui-prefs.json`(userData)存 `autoLaunch`(开机自启,Electron `app.setLoginItemSettings` + openAsHidden)/ `silentStart`(静默启动,createWindow 后 hide)/ `exitToTray`(退出进托盘,默认 true) +- preload 暴露 `homeagent.prefs`;设置页「客户端偏好」卡片三个开关 + +**聊天卡顿/输入框卡死修复** + +- 根因:webui 连接 sendChat POST `/chat`(同步等 60s 完整结果)与 SSE 流式(agent_output 增量)**双通道重复**,回复长时反复全量 innerHTML 重建 + marked.parse 占满主线程 → UI 卡死、输入冻结 +- 修复:webui 走**触发式 POST**(15s 短超时确认受理,回复靠 SSE 流式增量渲染);cli/device 无 SSE 保持同步等完整结果 +- `connectSSE` 跳过 device(设备网关无 chat/events);sendChat 对 device 连接提示不支持聊天 +- 流式增量渲染(`renderChatStreamChunk` 90ms 防抖 + 200字符/300ms 节流 parse)保留,输入框 DOM 不被重建 + +**验证**:node --check main.js/preload.js/app.js 全通过;Electron 冒烟(--disable-gpu --in-process-gpu)稳定运行无 JS 错误;go build ./... exit:0;go test plugins 全绿 + +**待后续**:screenuse(GUI 拉起窗口显示信息,规划中);cmdrun 二次确认流;remotedevice 单测 + +### Phase 0f:deviceinfo 工具 + GUI 被控端能力声明(已实施并真实验证) + +**deviceinfo 工具(agent 探查设备详情+能力)** + +- `internal/plugins/remotedevice/device.go`:新增第五个工具 `deviceinfo`(参数 device_id),返回设备接入时声明的 info(hostname/platform/arch/cpus/mem/版本)+ caps 能力列表;需已授权 +- `registry.go`:`DeviceMeta` 新增 `Info map[string]interface{}`;`metaFromMsg` 解析 hello 的 `device.info`;`publicDevices` 输出带 info + +**GUI 被控端能力** + +- `cmd/gui/main.js` 设备桥 hello 声明 `caps: ["status","cmdrun","deviceinfo","cmdresult"]` + `info`(本机 hostname/platform/arch/cpus/totalmem/node/electron 版本) + +**真实验证(模拟 GUI 设备 + 本机 LLM 网关)** +> 用户指令:"用deviceinfo探查 gui-testhost 这台设备的信息和它支持什么能力" +> Agent 调 deviceinfo → 返回基本信息(device_id/名称/类型/平台/主机名/在线授权)、硬件(8核/16G/Node v22/Electron 33)、能力(status/cmdrun/deviceinfo)✓(11.7s) + +**遗留**:screenuse(GUI 拉起窗口显示,规划中);cmdrun 二次确认流;remotedevice 单测 + +### Phase 0g:waiter CLI 设备桥(已实施并端到端验证) + +**实现**(cmd/waiter/): + +- `device.go`:`deviceBridge`(纯 Go 标准库 WS 客户端)——握手/帧/hello/bind/readLoop/execCommand,白名单命令 + 15s 超时 + 8KB 截断;cap 声明 status/cmdrun/deviceinfo;info 上报 hostname/platform/arch/cpus/mem_mb +- `config.go`:Config 加 `device_gateway` + `device_token`(waiter.yaml) +- `main.go`:`--device` / `--device-token` 启动设备桥;或读配置 + +**端到端验证(真实执行)**: + +- `waiter -device 127.0.0.1:9890 -device-token ...` → `device bridge active: waiter-jianf-Station` +- remotedevice 登记:waiter-jianf-Station / HomeAgent CLI (waiter) / computer / caps[status,cmdrun,deviceinfo] / info{arch:amd64,cpus:32,mem_mb:23129,hostname:jianf-Station} authorized ✓ online ✓ +- **device_ctl_cmdrun**:"用device_ctl_cmdrun让 waiter-jianf-Station 执行 uname -a" → agent 调工具 → waiter 本机 exec → 返回 `Linux jianf-Station 6.12.101+deb13-amd64 ... x86_64 GNU/Linux` ✓(9.2s) +- **deviceinfo**:"用deviceinfo查看 waiter-jianf-Station" → 32 核 / 22.6GB / Linux amd64 / 三能力 ✓(11.3s) + +**修复的关键 Bug**:9890 被残留进程占用导致新 homed remotedevice 监听失败(acceptBind 用旧 token → 401);杀残留后恢复正常。 + +**验证环境**:临时 homed(LLM 网关)+ 新编译 waiter;全量 go build/test 通过(仅剩 2 个与改动无关的既有 system 失败)。 + +### Phase 0h:设备默认不授权,用户手动授权(已实施并真实验证) + +**需求**:GUI 与 waiter 设备默认不授权,必须用户手动授权。 + +**修复(registry.go bind)**: + +- bind 原无条件 `SetAuthorized(id, true)`(每次重连/心跳都自动授权,撤销被覆盖)→ 改为**仅验证 token + 登记设备,绝不自动授权** +- 授权完全由用户手动控制:GUI 本机卡片/设备页「授权本机」按钮 → REST `/api/v1/device/auth`,或 waiter 用户手动操作 +- 新增设备接入即 `authorized:false`,agent 的 device_ctl_* 默认拒绝 + +**配套修复(cmd/gui/main.js)**: + +- 设备桥选连接:优先 `currentId`,否则最后一个 device 连接(避免列表里旧连接旧 token 抢先 → 401) +- 清理 connections.json 残留的旧 device 连接 + +**真实验证(GUI 被控端 + 本机 LLM 网关)**: + +- GUI 接入后 `gui-jianf-Station authorized: **False**` ✅(默认不授权) +- **手动授权后** → agent `device_ctl_cmdrun echo AUTH_OK` → 返回 AUTH_OK ✅ +- **撤销授权后** → agent `device_ctl_cmdrun` 返回 "device gui-jianf-Station 未授权,无法执行命令" ❌;本地 cmd_run 兜底成功(本机能力,合理) +- agent 智能:未授权时自动改用本地 cmd_run 并提示"去设备管理页授权" + +**结论**:GUI/waiter 被控设备默认不可被 agent 远程操控,用户手动授权后才可;撤销即时生效。 + +### Phase 0i:GUI 视觉修复(圆角 / 字体方框 / 托盘图标 / 授权开关融合) + +**托盘图标**(cmd/gui/): + +- 生成 `icon-tray.png`(22x22) + `icon-tray@2x.png`(44x44)(ImageMagick 从 icon.svg 转换) +- `initTray` 平台化:Linux 用 PNG(ico/svg 在 Linux 托盘不受支持)、Windows 用 ico、Linux resize 22x22 兜底 + +**窗口圆角**(cmd/gui/): + +- `createWindow` 加 `transparent: true` + `roundedCorners`(透明帧圆角窗口) +- CSS:`body` 透明 + 18px 圆角;`#app` 18px 圆角 + 背景(背景移入容器内裁切) + +**设备页字体/方框修复**(renderer/style.css): + +- table 10px 圆角 + collapse + 行 hover;th 加粗;kv-row .val flex 对齐 +- `.switch input` `-webkit-appearance:none` + 背景透明(消除 checkbox 浅蓝方框) + +**授权开关融合**(renderer/app.js): + +- 本机授权改为 **kv-row 信息行内嵌滑动开关**(key=授权,val=开关+状态圆点),与设备ID/网关/状态行同构,消除割裂 +- 设备列表"执行命令"按钮替换为**授权开关**(checked=已授权) +- 修复 onchange `JSON.stringify(id)` 双引号冲突 → 单引号转义 `deviceToggleAuth('id',this.checked)` + +**验证**(CDP 计算样式):body/app 18px 圆角可见、card 14px、table 10px、switch input 背景透明;CDP 模拟点击授权开关 → authorized true 持久化;agent device_ctl_cmdrun 操控 GUI 成功。 + +### Phase 0j:白屏根因定位 + 逐步安全加回(已完成) + +**白屏根因(二分定位确定)**:新版 main.js 顶层 `const { Tray, Menu: ElectronMenu, nativeImage } = require("electron")`(在 asar/无托盘环境加载异常)→ 导致 renderer 合成卡死、窗口全灰白无绘制。设备桥/新版 renderer 本身安全(混合测试证明)。 + +**修复**:托盘改**函数内惰性 require + try/catch 安全降级**(失败不动托盘、不影响窗口)。 + +**逐步加回验证**(每步实测窗口字节 70KB 正常): + +- F3:旧 main 主体 + 设备桥 + handlers + 新版 renderer(可用基线) +- Step1:安全托盘(惰性 require)— 显示正常 ✅ +- Step2:完整 prefs(GUI_PREFS_FILE 持久化 / loadGuiPrefs / applyAutoLaunch / silentStart / 开机自启)— 显示正常 ✅ +- Step3:生命周期(window-all-closed 退出进托盘依偏好、before-quit 清理托盘/设备桥)— 显示正常 ✅ + +**最终版已安装**:/opt/HomeAgent(md5 047f2f1bbe,备份 /tmp/ha-app-step3-final.asar),含:设备桥 / 设备页+授权开关 / 惰性托盘 / prefs 持久化 / 退出进托盘 / 字体/圆角(renderer)。