const { app, BrowserWindow, ipcMain, Menu, screen, desktopCapturer } = require("electron"); let screensueWin = null; // screensue 展示窗口(独立于主窗口,显示在配置的屏幕) // 设备桥直连远程网关:绕过系统代理(本机 clash 代理会导致 wss 被雷池 403) try { app.commandLine.appendSwitch("no-proxy-server"); } catch (e) {} const path = require("path"); const fs = require("fs"); const { spawn } = require("child_process"); const http = require("http"); const crypto = require("crypto"); const { pathToFileURL } = require("url"); const CONNECTIONS_FILE = path.join(app.getPath("userData"), "connections.json"); const LOG_FILE = path.join(app.getPath("userData"), "gui.log"); function log(msg) { try { fs.appendFileSync(LOG_FILE, new Date().toISOString() + " " + msg + "\n"); } catch (e) {} } const _consoleLog = console.log, _consoleErr = console.error; console.log = function () { log(Array.prototype.slice.call(arguments).join(" ")); _consoleLog.apply(null, arguments); }; console.error = function () { log("ERR " + Array.prototype.slice.call(arguments).join(" ")); _consoleErr.apply(null, arguments); }; let homedProcess = null; let mainWindow; let authRule = null; function installAuthRule() { const { session } = require("electron"); session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { const h = Object.assign({}, details.requestHeaders); // 按目标 host 校验作用域:网关会把请求 302 到 login.* 域,若按 url 前缀匹配, // 登录域自身请求会被错误附加本站 cookie。 const hu = (() => { try { return new URL(details.url); } catch (e) { return null; } })(); if (authRule && hu && authRule.hosts && authRule.hosts.has(hu.hostname)) { if (authRule.headers) { Object.keys(authRule.headers).forEach((k) => { h[k] = authRule.headers[k]; }); } // Electron 会自动附带 jar 中 Cookie(含外部网关 Set-Cookie 的 sl-session)。 // 这里再显式合并持久化 cookie 与 sl-session 兜底,避免网关再 302。 // 注意去重:多个 sl-session 叠加会让 portal 网关解析冲突 → 401 const extra = [authRule.cookie, authRule.slSession].filter(Boolean); if (extra.length) { const existing = h["Cookie"] || ""; const merged = [existing].concat(extra).filter(Boolean).join("; "); // 按 cookie 名去重:保留第一个,后续同名丢弃(网关只认首值) const seen = {}; const dedup = merged .split("; ") .map((s) => s.trim()) .filter((s) => { const eq = s.indexOf("="); const name = eq > 0 ? s.slice(0, eq) : s; if (seen[name]) return false; seen[name] = true; return true; }) .join("; "); h["Cookie"] = dedup; } } callback({ requestHeaders: h }); }); session.defaultSession.webRequest.onHeadersReceived((details, callback) => { const h = Object.assign({}, details.responseHeaders || {}); const inScope = (() => { if (details.method === "OPTIONS") return true; if (!authRule || !authRule.urlHost) return false; try { return new URL(details.url).hostname === authRule.urlHost; } catch (e) { return false; } })(); if (inScope) { h["Access-Control-Allow-Origin"] = ["*"]; h["Access-Control-Allow-Methods"] = ["GET,POST,PUT,DELETE,OPTIONS"]; h["Access-Control-Allow-Headers"] = [ "Content-Type, X-API-Key, Authorization, Cookie", ]; h["Access-Control-Max-Age"] = ["86400"]; } if (details.method === "OPTIONS") { callback({ responseHeaders: h, statusLine: "HTTP/1.1 200 OK" }); return; } callback({ responseHeaders: h }); }); session.defaultSession.webRequest.onCompleted((details) => { if (!authRule || !authRule.urlHost) return; try { if (new URL(details.url).hostname !== authRule.urlHost) return; } catch (e) { return; } if (details.url.indexOf("/api/") !== -1) { log( "[req] " + details.method + " " + details.statusCode + " " + details.url.slice(0, 120), ); } }); session.defaultSession.webRequest.onErrorOccurred((details) => { if (!authRule || !authRule.urlHost) return; try { if (new URL(details.url).hostname !== authRule.urlHost) return; } catch (e) { return; } log( "[req-err] " + details.method + " " + details.error + " " + details.url.slice(0, 120), ); }); } // 组装 authRule(url + 持久化 cookie + 显式 sl-session),并缓存 hosts 集合。 // 供 installAuthRule 按目标 host 做作用域匹配:jsdom 前缀匹配有误伤,host 级最稳。 async function applyAuthRule(url, cookie, headers) { if (!url) { authRule = null; return; } let hosts; let urlHost = ""; try { const h = new URL(url).hostname; urlHost = h; hosts = new Set([h]); } catch (e) { hosts = new Set(); } authRule = { url, urlHost, hosts, cookie: cookie || "", headers: headers || {}, }; // 显式的 sl-session 从 cookie jar 里取(网关 Set-Cookie 且 HttpOnly) authRule.slSession = await adoptSlSession(url); } // 从 Electron cookie jar 中取出目标 host 的 sl-session(若存在)。 async function adoptSlSession(url) { try { const { session } = require("electron"); const hostname = new URL(url).hostname; const all = await session.defaultSession.cookies.get({}); const hit = (all || []).find( (c) => c.name === "sl-session" && (c.domain || "").replace(/^\./, "") === hostname, ); return hit ? "sl-session=" + hit.value : ""; } catch (e) { return ""; } } // 带 Electron cookie jar 的 fetch(跟随外部网关 302 → login 域 → 回跳,最多 6 跳), // 使 POST /api/v1/login 真正触达内层 HomeAgent 并取回 homeagent_session。 async function sessionFetch(initUrl, opts) { const { session } = require("electron"); let url = initUrl; let resp; for (let i = 0; i < 6; i++) { resp = await session.defaultSession.fetch(url, opts); if ( resp.status >= 300 && resp.status < 400 && resp.headers.get("location") ) { url = new URL(resp.headers.get("location"), url).toString(); continue; } return resp; } return resp; } function loadConnections() { try { if (fs.existsSync(CONNECTIONS_FILE)) { const raw = fs .readFileSync(CONNECTIONS_FILE, "utf-8") .replace(/^\uFEFF/, ""); const data = JSON.parse(raw); normalizeConnections(data); return data; } } catch (e) { console.error("Failed to load connections:", e); // 配置损坏:备份后重建,避免应用一直处于"无连接"状态 try { const backup = CONNECTIONS_FILE + ".bak"; fs.copyFileSync(CONNECTIONS_FILE, backup); fs.writeFileSync( CONNECTIONS_FILE, '{"connections":[],"currentId":null}', "utf-8", ); console.error("Backed up corrupt connections to", backup); } catch (e2) { console.error("Failed to recover connections file:", e2); } } // Fallback: check app resource dir (installer writes fallback copy there) try { const fallback = path.join(__dirname, "connections.json"); if (fs.existsSync(fallback)) { const data = JSON.parse(fs.readFileSync(fallback, "utf-8")); normalizeConnections(data); saveConnections(data); console.log("Imported connections from app resource dir"); return data; } } catch (e) { console.error("Fallback connections load failed:", e); } return { connections: [], currentId: null }; } // 兼容旧数据:缺失的 type 默认为 webui(HTTP) function normalizeConnections(data) { if (!data || !Array.isArray(data.connections)) return; data.connections.forEach((c) => { if (!c.type) c.type = "webui"; if (c.type !== "cli" && c.type !== "webui") c.type = "webui"; }); } function saveConnections(data) { try { fs.writeFileSync(CONNECTIONS_FILE, JSON.stringify(data, null, 2), "utf-8"); } catch (e) { console.error("Failed to save connections:", e); } } function findHomed() { if (process.platform !== "win32") return null; const exeDir = path.dirname(app.getPath("exe")); const p = path.resolve(exeDir, "..", "homed.exe"); return fs.existsSync(p) ? p : null; } function isServerRunning() { return new Promise((resolve) => { const req = http.get("http://localhost:8080/", () => resolve(true)); req.on("error", () => resolve(false)); req.setTimeout(2000, () => { req.destroy(); resolve(false); }); }); } function waitForServer(maxWait = 8000) { return new Promise((resolve) => { const start = Date.now(); const check = () => { isServerRunning().then((running) => { if (running) return resolve(true); if (Date.now() - start > maxWait) return resolve(false); setTimeout(check, 300); }); }; check(); }); } function startHomed() { const homedBin = findHomed(); if (!homedBin) { console.log("homed.exe not found near GUI, skipping auto-launch"); return; } const dataDir = path.resolve(path.dirname(homedBin), "data"); console.log("Starting homed:", homedBin, "-data", dataDir); homedProcess = spawn(homedBin, ["-data", dataDir], { stdio: "ignore", detached: false, windowsHide: true, }); homedProcess.on("error", (err) => { console.error("homed start failed:", err.message); homedProcess = null; }); homedProcess.on("exit", (code) => { console.log("homed exited with code", code); homedProcess = null; }); } function stopHomed() { if (homedProcess) { homedProcess.kill(); homedProcess = null; } } function createWindow() { const menu = Menu.buildFromTemplate([]); Menu.setApplicationMenu(menu); mainWindow = new BrowserWindow({ width: 1280, height: 860, minWidth: 900, minHeight: 600, title: "HomeAgent", frame: false, icon: path.join(__dirname, "icon.ico"), webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, nodeIntegration: false, }, }); mainWindow.loadFile(path.join(__dirname, "renderer", "index.html")); if (process.argv.includes("--dev")) { mainWindow.webContents.openDevTools(); } // 退出进托盘:拦截窗口关闭事件(exitToTray 且托盘可用时 hide 而非 close) mainWindow.on("close", (e) => { try { const _p = loadGuiPrefs(); if (_p && _p.exitToTray && tray) { e.preventDefault(); mainWindow.hide(); return; } } catch (err) {} }); mainWindow.on("closed", () => { mainWindow = null; }); } ipcMain.handle("window:minimize", (e) => { BrowserWindow.fromWebContents(e.sender)?.minimize(); }); ipcMain.handle("window:toggleMaximize", (e) => { const win = BrowserWindow.fromWebContents(e.sender); if (!win) return; if (win.isMaximized()) win.unmaximize(); else win.maximize(); }); ipcMain.handle("window:close", (e) => { const win = BrowserWindow.fromWebContents(e.sender); if (!win) return; // 退出进托盘:偏好开启且托盘存在时隐藏而非关闭 try { const _p = loadGuiPrefs(); console.log( "[window:close] exitToTray=" + !!(_p && _p.exitToTray) + " tray=" + !!tray, ); if (_p && _p.exitToTray && tray) { win.hide(); return true; } } catch (err) {} win.close(); }); ipcMain.handle("connections:list", () => { return loadConnections(); }); ipcMain.handle("connections:add", (_, conn) => { const data = loadConnections(); const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); data.connections.push({ id, name: conn.name || "", url: conn.url || "", apiKey: conn.apiKey || "", type: conn.type === "cli" ? "cli" : "webui", socketPath: conn.socketPath || "", // 网关认证字段在 add 时必须一并持久化,否则新增连接后 cookie/网关标记丢失 username: conn.username || "", password: conn.password || "", cookie: conn.cookie || "", headers: conn.headers || "", gateway: !!conn.gateway, }); if (!data.currentId) data.currentId = id; saveConnections(data); try { rebuildTrayMenu(); } catch (e) {} return data; }); ipcMain.handle("connections:update", (_, { id, updates }) => { const data = loadConnections(); const idx = data.connections.findIndex((c) => c.id === id); if (idx !== -1) { data.connections[idx] = { ...data.connections[idx], ...updates }; saveConnections(data); } try { rebuildTrayMenu(); } catch (e) {} return data; }); ipcMain.handle("connections:delete", (_, id) => { const data = loadConnections(); data.connections = data.connections.filter((c) => c.id !== id); if (data.currentId === id) { data.currentId = data.connections.length > 0 ? data.connections[0].id : null; } saveConnections(data); try { rebuildTrayMenu(); } catch (e) {} return data; }); function downloadTo(src, dest) { return new Promise((resolve, reject) => { const mod = src.startsWith("https:") ? require("https") : http; const req = mod.get( src, { headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", }, rejectUnauthorized: false, }, (res) => { if ( res.statusCode >= 300 && res.statusCode < 400 && res.headers.location ) { res.resume(); downloadTo(new URL(res.headers.location, src).toString(), dest).then( resolve, reject, ); return; } if (res.statusCode !== 200) { res.resume(); reject(new Error("HTTP " + res.statusCode)); return; } const out = fs.createWriteStream(dest); res.pipe(out); out.on("finish", () => out.close(resolve)); out.on("error", reject); res.on("error", reject); }, ); req.on("error", reject); req.setTimeout(60000, () => req.destroy(new Error("timeout"))); }); } ipcMain.handle("bg:cache", async (_, { src }) => { if (!src || typeof src !== "string") return { ok: false, error: "no src" }; const dir = path.join(app.getPath("userData"), "bg-cache"); try { fs.mkdirSync(dir, { recursive: true }); const hash = crypto .createHash("sha1") .update(src) .digest("hex") .slice(0, 24); let dest = null; if (/^https?:\/\//i.test(src)) { dest = path.join(dir, hash); if (!fs.existsSync(dest)) { try { await downloadTo(src, dest); } catch (e) { return { ok: false, error: "下载失败: " + e.message, useOriginal: true, }; } } } else if (/^file:\/\//i.test(src)) { dest = new URL(src).pathname.replace(/^\/([A-Za-z]:)/, "$1"); if (!fs.existsSync(dest)) return { ok: false, error: "文件不存在: " + src }; } else if (/^data:image\//i.test(src)) { dest = path.join(dir, hash + ".png"); if (!fs.existsSync(dest)) fs.writeFileSync(dest, Buffer.from(src.split(",")[1] || "", "base64")); } else { const p = path.resolve(src); if (!fs.existsSync(p)) return { ok: false, error: "路径不存在: " + src }; dest = path.join(dir, hash + (path.extname(p) || "")); if (!fs.existsSync(dest)) fs.copyFileSync(p, dest); } return { ok: true, file: pathToFileURL(dest).href }; } catch (e) { return { ok: false, error: e.message }; } }); ipcMain.handle("connections:setCurrent", (_, id) => { const data = loadConnections(); if (data.connections.some((c) => c.id === id)) { data.currentId = id; saveConnections(data); } try { rebuildTrayMenu(); } catch (e) {} return data; }); async function doWebuiLogin(baseUrl, username, password, extraCookie) { const u = baseUrl + "/api/v1/login"; const headers = { "Content-Type": "application/json", Accept: "application/json", }; if (extraCookie) headers["Cookie"] = extraCookie; const resp = await sessionFetch(u, { method: "POST", headers, body: JSON.stringify({ username, password }), }); // 1) Set-Cookie 直读(重定向未被 session.fetch 自动吞掉时) const sc = resp.headers.get("set-cookie") || ""; let m = /homeagent_session=([^;]+)/.exec(sc); // 2) 重定向被自动跟随时 Set-Cookie 可能在中间 302 上:从 cookie jar 兜底 if (!m) { try { const { session } = require("electron"); const hostname = new URL(u).hostname; const all = await session.defaultSession.cookies.get({}); const hit = (all || []).find( (c) => c.name === "homeagent_session" && (c.domain || "").replace(/^\./, "").toLowerCase() === hostname.toLowerCase(), ); if (hit) m = [null, hit.value]; } catch (e) {} } if (!m) { const bodyTxt = await resp.text(); throw new Error( "login ok but no homeagent_session cookie (HTTP " + resp.status + " " + bodyTxt.slice(0, 150) + ")", ); } return m[1]; } ipcMain.on("log:r", (_e, m) => { log("[r] " + m); }); ipcMain.handle("log:r", (_e, m) => { log("[r] " + m); return true; }); ipcMain.handle( "webui:setAuth", async (_, { url, cookie, headers, username, password }) => { const resp = { ok: true, error: "" }; let extra = cookie || ""; try { if (username && url) { try { const tok = await doWebuiLogin(url, username, password, extra); extra = extra ? extra + "; homeagent_session=" + tok : "homeagent_session=" + tok; } catch (e) { if (extra) { log( "[setAuth] auto-login skipped (gateway cookie mode): " + e.message.slice(0, 120), ); } else { throw e; } } } else if (!cookie) { await applyAuthRule("", "", {}); return resp; } } catch (e) { await applyAuthRule(url, extra || "", headers || {}); resp.ok = false; resp.error = e.message; log( "[setAuth] fail url=" + (url || "") + " err=" + e.message.slice(0, 150), ); return resp; } await applyAuthRule(url, extra, headers || {}); log( "[setAuth] ok url=" + (url || "") + " cookieLen=" + extra.length + " hasTok=" + (extra.indexOf("homeagent_session") !== -1), ); return resp; }, ); ipcMain.handle("webui:openLogin", (_, { url, username, password }) => { if (!url) return { ok: false, error: "no url" }; const loginWin = new BrowserWindow({ width: 980, height: 740, parent: mainWindow, modal: true, autoHideMenuBar: true, title: "HomeAgent - 网关联机登录", webPreferences: { contextIsolation: true, nodeIntegration: false }, }); const u = encodeURIComponent(username || ""); const p = encodeURIComponent(password || ""); let autoFills = 0; const tryAutofill = () => { if (autoFills > 8) return; autoFills++; loginWin.webContents .executeJavaScript( '(function(){var pw=document.querySelector("input[type=password]");' + 'if(!pw)return false;var f=pw.closest("form")||pw.form;if(!f)return false;' + 'var ins=f.querySelectorAll("input");var filled=false;' + 'for(var i=0;i { if (r) log("[openLogin] autofilled gateway login form"); }) .catch((e) => log("[openLogin] autofill error: " + e.message)); }; loginWin.webContents.on("did-finish-load", () => { if (autoFills < 3) tryAutofill(); }); loginWin.webContents.on("did-navigate", (_e, u) => { log("[openLogin] nav: " + u); if (autoFills < 6) setTimeout(tryAutofill, 600); }); loginWin.webContents.on( "did-fail-load", (_e, code, desc, isMain, failedUrl) => { if (isMain) log("[openLogin] fail-load " + code + " " + desc + " @ " + failedUrl); }, ); loginWin .loadURL(url) .catch((e) => console.error("login window load error:", e.message)); let grabbed = null; loginWin.on("close", async () => { if (grabbed) return; try { const all = await require("electron").session.defaultSession.cookies.get( {}, ); const host = new URL(url).hostname; const keep = (all || []).filter((c) => { const d = (c.domain || "").replace(/^\./, ""); const onHost = host === d || host.endsWith("." + d); const onSibling = /(^|\.)jianfgit\.xyz$/i.test(d); return onHost || onSibling || /^sl-/.test(c.name || ""); }); const list = keep.map((c) => c.name + "=" + c.value); // 目标 host 的 sl-session 一并带上(网关会话是 host 级,登录域抓取未必覆盖) const sl = await adoptSlSession(url); if (sl && list.indexOf(sl) === -1) list.push(sl); log( "[openLogin] close-grab: host=" + host + " kept=" + keep.length + " names=" + keep.map((c) => c.name + "@" + (c.domain || "")).join(","), ); grabbed = { ok: true, cookie: list.join("; "), count: keep.length + (sl ? 1 : 0), }; } catch (e) { log("[openLogin] close-grab error: " + e.message); grabbed = { ok: false, error: e.message }; } }); loginWin.on("closed", () => { if (mainWindow && !mainWindow.isDestroyed() && grabbed) { mainWindow.webContents.send("webui:login-result", { ...grabbed, url }); } }); return { ok: true }; }); // CLI 传输:通过 homed 的 unix socket(逐行 JSON 协议)发起请求。 // 认证行:/auth (若配置了密钥)。返回 JSON 响应行。 ipcMain.handle("cli:request", (_, { socketPath, apiKey, line }) => { return new Promise((resolve) => { const net = require("net"); let client; try { client = net.createConnection({ path: socketPath }); } catch (e) { return resolve({ error: "create connection: " + e.message }); } const timeout = setTimeout(() => { try { client.destroy(); } catch (_) {} resolve({ error: "timeout waiting for cli response" }); }, 30000); let buf = ""; const onData = (chunk) => { buf += chunk.toString("utf8"); const idx = buf.indexOf("\n"); if (idx === -1) return; const lineOut = buf.slice(0, idx); clearTimeout(timeout); try { client.destroy(); } catch (_) {} try { resolve(JSON.parse(lineOut)); } catch (e) { resolve({ error: "bad response: " + lineOut }); } }; const onError = (err) => { clearTimeout(timeout); try { client.destroy(); } catch (_) {} resolve({ error: err.message }); }; client.on("error", onError); client.on("data", onData); client.on("connect", () => { let next = line; if (apiKey) next = "/auth " + apiKey + "\n" + next; client.write(next + "\n"); }); }); }); // ============ Device Bridge: GUI 作为设备接入 remotedevice 网关 ============ // 复用 net(raw TCP)+ 手写 WS 帧;收到 {op:"cmd"} 用 spawn 在本机执行并回 cmd_result。 const devNet = require("net"); const devTls = require("tls"); const devOs = require("os"); let deviceBridge = null; // 当前活动设备桥 let deviceBridgeId = ""; // 设备 meta device_id(hello 后可用于 cmd_result) let deviceBridgeAddr = ""; // 设备桥网关地址 // 音频/媒体接收聚合缓冲(服务端分块推送二进制→聚合→播放) let speechAccum = null; // 读取设备音频输出配置(gui-prefs.deviceBridge.audio) function getAudioConfig() { try { const prefs = loadGuiPrefs(); const db = prefs.deviceBridge || {}; const au = db.audio || {}; return { device: au.device || "default" }; } catch (e) { return { device: "default" }; } } // 枚举本机可用音频输出设备(aplay -L 解析:设备名顶格、描述缩进) function listAudioDevices() { const porcp = require("child_process"); const out = []; const SKIP_PREFIXES = ["lavrate", "samplerate", "speex", "jack", "oss", "upmix", "vdownmix", "speexrate"]; try { const r = porcp.spawnSync("aplay", ["-L"], { encoding: "utf8" }); const lines = ((r.stdout || "") + "\n").split("\n"); for (let i = 0; i < lines.length; i++) { const ln = lines[i]; if (!ln || ln[0] === " " || ln[0] === "\t") continue; // 跳过缩进的描述行 const name = ln.trim(); if (!name || /^(Default PCM|List of|Plug PCM)/.test(name)) continue; if (SKIP_PREFIXES.some((s) => name === s)) continue; // 插件类,非实体设备 // 只保留可实际输出的设备类型;纯 hw: 裸设备不支持自动通道转换 // (mono→stereo)易失败,改用 plughw:(带转换)。pipewire/pulse/sysdefault 亦可。 const useful = name === "default" || name === "pipewire" || name === "pulse" || name === "sysdefault" || /^plughw:/.test(name) || /^sysdefault:/.test(name); if (!useful) continue; let desc = ""; // 下一行可能是描述(缩进) if (lines[i + 1] && (lines[i + 1][0] === " " || lines[i + 1][0] === "\t")) desc = lines[i + 1].trim(); out.push({ name, desc }); } } catch (e) {} if (!out.find((d) => d.name === "default")) out.unshift({ name: "default", desc: "系统默认设备" }); return out; } // 播放设备收到的音频(由 cmd_speech_end 触发,二进制已聚合) function playDeviceAudio(audioBuf, mime, reqId) { try { const os = require("os"); const path = require("path"); const fs = require("fs"); const porcp = require("child_process"); const au = getAudioConfig(); const ext = (mime || "").indexOf("mp3") === -1 ? (mime || "").indexOf("ogg") === -1 ? "wav" : "ogg" : "mp3"; const tmp = path.join(os.tmpdir(), "ha_speech_" + Date.now() + "." + ext); fs.writeFileSync(tmp, audioBuf); const platform = process.platform; let cmd = null; let args = []; if (platform === "linux") { if (porcp.spawnSync("which", ["aplay"]).status === 0) { cmd = "aplay"; // 支持用户选择的输出设备(默认 default) args = au.device && au.device !== "default" ? ["-D", au.device, tmp] : [tmp]; } else if (porcp.spawnSync("which", ["paplay"]).status === 0) { cmd = "paplay"; args = [tmp]; } else if (porcp.spawnSync("which", ["ffplay"]).status === 0) { cmd = "ffplay"; args = ["-nodisp", "-autoexit", "-loglevel", "quiet", tmp]; } } else if (platform === "darwin") { cmd = "afplay"; args = [tmp]; } else if (platform === "win32") { cmd = "powershell"; args = [ "-Command", "(New-Object Media.SoundPlayer '" + tmp + "').PlaySync()", ]; } if (cmd) { porcp.execFile(cmd, args, { timeout: 60000 }, () => { try { fs.unlinkSync(tmp); } catch (e2) {} }); // 回执:媒体收到并开始播放 sendCmdResult( reqId || "", baseResult( reqId || "", "ok", "audio played: " + audioBuf.length + " bytes via " + (au.device || "default"), "", ), ); } else { // 无播放器,至少落盘供手动查看,回执带文件路径 sendCmdResult( reqId || "", baseResult( reqId || "", "ok", "audio saved: " + tmp + " (" + audioBuf.length + " bytes)", "", ), ); } } catch (e) { console.log("[device-bridge] play audio error: " + e.message); } } // 建立到 remotedevice WS 网关连接,返回 {send(obj), close()},消息经 onMsg 回调。 function connectDeviceWS(url, token, onMsg) { // URL 支持完整端点(含路径/端口/TLS),不硬编码 host/path let u; try { u = new URL(url || "ws://127.0.0.1:9890/api/v1/device/ws"); } catch (e) { return Promise.reject(new Error("invalid device gateway url: " + url)); } const isTLS = u.protocol === "wss:" || u.protocol === "https:"; const host = u.hostname; // https/wss 默认 443,ws/http 默认 9890(remotedevice 默认端口) const defaultPort = isTLS ? 443 : 9890; const port = u.port ? parseInt(u.port, 10) : defaultPort; const basePath = u.pathname || "/api/v1/device/ws"; const sep = u.search ? "&" : "?"; const path = basePath + (u.search || "") + sep + "token=" + encodeURIComponent(token || ""); return new Promise((resolve, reject) => { const sock = isTLS ? devTls.connect({ host, port, rejectUnauthorized: false }) : devNet.createConnection({ host, port }); sock.on("error", (e) => reject(e)); sock.on("connect", () => { const key = crypto.randomBytes(16).toString("base64"); // 远程 wss 走 webui 反代需要门户会话 cookie // 优先 authRule(连接认证注入);启动早期 authRule 未就绪时读 connections.json let cookieHdr = ""; try { let ck = ""; // 优先 connections.json 的持久 cookie(完整登录验证过,浏览器快照可能不完整) try { const conns = loadConnections(); const curId = conns.currentId; const cur = conns.connections.find((c) => c.id === curId) || conns.connections[0]; if (cur && cur.cookie) ck = cur.cookie; } catch (e2) {} if (!ck && authRule && authRule.cookie) { ck = authRule.cookie; } if (ck) cookieHdr = "Cookie: " + ck + "\r\n"; console.log("[device-bridge] ws cookie len=" + (ck || "").length); } catch (e) {} sock.write( "GET " + path + " HTTP/1.1\r\n" + "Host: " + host + ":" + port + "\r\n" + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + "Sec-WebSocket-Key: " + key + "\r\nSec-WebSocket-Version: 13\r\n" + cookieHdr + "\r\n", ); }); let buf = Buffer.alloc(0); let upgraded = false; let opened = false; sock.on("data", (chunk) => { buf = Buffer.concat([buf, chunk]); if (!upgraded) { const idx = buf.indexOf("\r\n\r\n"); if (idx === -1) return; const head = buf.slice(0, idx).toString("utf8"); buf = buf.slice(idx + 4); upgraded = true; if (!head.includes("101")) { // 保留剩余字节(响应体),记录完整响应便于排错 const body = buf.toString("utf8").slice(0, 800); sock.destroy(); return reject( new Error( "WS upgrade failed: " + head.split("\r\n")[0] + " | BODY=" + body + " | REQ-PATH=" + path, ), ); } opened = true; resolve({ send: (obj) => sendDeviceFrame(sock, JSON.stringify(obj)), __sock: sock, // 暴露底层 socket 供二进制分块发送 close: () => sock.destroy(), }); } while (buf.length >= 2) { const b0 = buf[0]; const opcode = b0 & 0x0f; const b1 = buf[1]; let len = b1 & 0x7f; let off = 2; if (len === 126) { if (buf.length < 4) break; len = buf.readUInt16BE(2); off = 4; } else if (len === 127) { if (buf.length < 10) break; len = Number(buf.readBigUInt64BE(2)); off = 10; } if (buf.length < off + len) break; const payload = buf.slice(off, off + len); buf = buf.slice(off + len); if (opcode === 0x1) { try { const obj = JSON.parse(payload.toString("utf8")); if (obj && obj.op === "cmd_speech_start") { // 收到音频开始:初始化聚合缓冲 try { if (speechAccum) speechAccum = null; } catch (e2) {} speechAccum = { reqId: obj.req_id || "", kind: obj.kind || "speech", mime: obj.mime || "audio/wav", total: obj.total || 0, chunks: [], got: 0, }; continue; } if (obj && obj.op === "cmd_speech_end") { // 音频结束:合并块并播放 const reqId = obj.req_id || ""; if (speechAccum) { try { const audio = Buffer.concat(speechAccum.chunks); playDeviceAudio(audio, speechAccum.mime, reqId); } catch (e2) {} speechAccum = null; } continue; } onMsg(obj); } catch (e) {} } else if (opcode === 0x2) { // 二进制帧:接收音频数据块(若处于聚合状态) if (speechAccum) { speechAccum.chunks.push(payload); speechAccum.got += payload.length; } } else if (opcode === 0x8) { sock.destroy(); return; } } }); sock.on("error", (e) => { if (!opened) reject(e); }); sock.on("close", () => { deviceBridge = null; }); }); } // 发送 WS text 帧(客户端加掩码) function sendDeviceFrame(sock, text) { const payload = Buffer.from(text, "utf8"); const mask = crypto.randomBytes(4); const masked = Buffer.from(payload); for (let i = 0; i < masked.length; i++) masked[i] ^= mask[i % 4]; const len = masked.length; let hdr; if (len < 126) { hdr = Buffer.from([0x81, 0x80 | len]); } else if (len < 65536) { hdr = Buffer.alloc(4); hdr[0] = 0x81; hdr[1] = 0x80 | 126; hdr.writeUInt16BE(len, 2); } else { hdr = Buffer.alloc(10); hdr[0] = 0x81; hdr[1] = 0x80 | 127; hdr.writeBigUInt64BE(BigInt(len), 2); } sock.write(Buffer.concat([hdr, mask, masked])); } // 发送 WS 二进制帧(0x2)——用于大体积数据(如录像)分块回传 function sendDeviceBinaryFrame(sock, buf) { const mask = crypto.randomBytes(4); const masked = Buffer.from(buf); for (let i = 0; i < masked.length; i++) masked[i] ^= mask[i % 4]; const len = masked.length; let hdr; if (len < 126) { hdr = Buffer.from([0x82, 0x80 | len]); } else if (len < 65536) { hdr = Buffer.alloc(4); hdr[0] = 0x82; hdr[1] = 0x80 | 126; hdr.writeUInt16BE(len, 2); } else { hdr = Buffer.alloc(10); hdr[0] = 0x82; hdr[1] = 0x80 | 127; hdr.writeBigUInt64BE(BigInt(len), 2); } sock.write(Buffer.concat([hdr, mask, masked])); } // 通过设备通道以二进制分块回传大体积数据(如录像)。 // 协议(与服务端协商): // cmd_data_start {op, req_id, kind, total, chunk_size, mime} —— 文本帧 // —— video bytes // cmd_data_end {op, req_id, status:ok|error, error?} —— 文本帧 // 服务端按 req_id 聚合二进制块 → 存入 cmdresult,供 device_ctl_cmdresult 取回。 function sendDeviceDataChunked(reqId, kind, mime, buf) { if (!deviceBridge || !deviceBridge.send) { sendCmdResult( reqId, baseResult(reqId, "error", "", "device bridge not connected"), ); return; } // 分块大小 8KB const CHUNK = 8192; // 取底层 socket 直接发二进制帧(deviceBridge.send 是文本封装) const total = buf.length; // 控制帧走文本协议(deviceBridge.send 封装) try { // 用 send 发文本帧控制头(start) deviceBridge.send({ op: "cmd_data_start", req_id: reqId, kind: kind, mime: mime || "application/octet-stream", total: total, chunk_size: CHUNK, }); } catch (e) {} // 二进制块:需要底层 socket。这里用包装的 raw send // sendDeviceRaw 通过 deviceBridge 的隐藏引用发二进制 const rawSock = deviceBridge.__sock; if (rawSock && !rawSock.destroyed) { for (let off = 0; off < total; off += CHUNK) { sendDeviceBinaryFrame(rawSock, buf.slice(off, off + CHUNK)); } } else { // 无 raw socket 时回退文本 base64(服务端老协议兜底) sendCmdResult( reqId, baseResult( reqId, "ok", "data:" + mime + ";base64," + buf.toString("base64"), "", ), ); return; } try { deviceBridge.send({ op: "cmd_data_end", req_id: reqId, status: "ok", total: total, }); } catch (e) {} } // 处理网关 WS 消息:hello_ack/bind_ack/cmd 等。命令类型由服务端 cmd_type 指定(shell/homeagent)。 function onDeviceMsg(msg) { if (!msg || typeof msg !== "object") return; console.log( "[device-bridge] recv op=" + (msg.op || "") + " cmd_type=" + (msg.cmd_type || "") + " cmd=" + String(msg.command || msg.cmd || "").slice(0, 60), ); const op = msg.op || ""; if (op === "cmd") { const command = msg.command || msg.cmd || ""; const reqId = msg.req_id || msg.id || ""; const cmdType = msg.cmd_type || "shell"; if (!command) return; // 记录远控活动并刷新托盘菜单 trayLastCmd = { cmd: command, at: Date.now(), result: "执行中…" }; trayCmdCount++; try { rebuildTrayMenu(); } catch (e) {} try { if (cmdType === "homeagent" || command.indexOf("homeagent-") === 0) { executeHomeagentCmd(command.replace(/^homeagent-/, ""), reqId); } else { executeShellCmd(command, reqId); } } catch (e) { console.log("[device-bridge] exec error: " + e.message); } } else if (op === "hello_ack" || op === "bind_ack") { console.log( "[device-bridge] " + op + " device=" + (msg.device || deviceBridgeId), ); try { rebuildTrayMenu(); } catch (e) {} } } // 统一发送命令结果(回执 + 托盘更新)。兼容两种调用:sendCmdResult(resp) 或 sendCmdResult(reqId, resp) function sendCmdResult(a, b) { const resp = b || a; console.log( "[device-bridge] send result op=cmd_result status=" + (resp.status || "") + " req=" + (resp.req_id || ""), ); if (deviceBridge && deviceBridge.send) { try { deviceBridge.send(resp); } catch (e) {} } if (trayLastCmd) { trayLastCmd.result = resp.status === "ok" ? String(resp.output || "").slice(0, 40) : "错误: " + String(resp.error || ""); try { rebuildTrayMenu(); } catch (e) {} } } function baseResult(reqId, status, output, error) { return { op: "cmd_result", req_id: reqId, device_id: deviceBridgeId, status: status, output: output || "", error: error || "", }; } // 读取设备命令执行配置(gui-prefs.deviceBridge.exec) function getExecConfig() { try { const prefs = loadGuiPrefs(); const db = prefs.deviceBridge || {}; const ex = db.exec || {}; return { cwd: ex.cwd || "", sandbox: ex.sandbox || "off", // off | home | box boxDir: ex.boxDir || "", timeout: parseInt(ex.timeout || "15", 10) || 15, maxBuffer: (parseInt(ex.maxBuffer || "8192", 10) || 8192) * 1024, }; } catch (e) { return { cwd: "", sandbox: "off", boxDir: "", timeout: 15, maxBuffer: 8192 * 1024, }; } } // shell 命令:按 exec 配置(cwd/沙箱/超时)执行 function executeShellCmd(command, reqId) { console.log( "[device-bridge] execShell cmd=" + String(command).slice(0, 60) + " req=" + reqId, ); const cp = require("child_process"); const ex = getExecConfig(); if (!argsSafe(command)) { sendCmdResult( reqId, baseResult(reqId, "denied", "", "command not allowed"), ); return; } // 沙箱策略:home=锁定用户主目录; box=锁定指定目录; off=cwd 或默认 const os = require("os"); let cwd = ex.cwd || os.homedir() || process.cwd(); if (ex.sandbox === "home") cwd = os.homedir() || cwd; else if (ex.sandbox === "box" && ex.boxDir) cwd = ex.boxDir; else if (ex.sandbox === "box" && !ex.boxDir) { sendCmdResult( reqId, baseResult(reqId, "denied", "", "sandbox=box 需配置 boxDir"), ); return; } cp.exec( command, { cwd: cwd, timeout: ex.timeout * 1000, maxBuffer: ex.maxBuffer, env: process.env, }, (err, stdout, stderr) => { sendCmdResult( reqId, baseResult( reqId, err ? "error" : "ok", (stdout || "") + (stderr || ""), err ? err.message : "", ), ); }, ); } // homeagent 内置能力分发(与服务端 device_ctl_cmdrun 的 homeagent-* 对齐) function executeHomeagentCmd(capability, reqId) { const name = String(capability || "") .trim() .split(/[ >\n]/)[0]; switch (name) { case "camerasue": { // 摄像头:camerasue=抓拍单张;camerasue <秒>=录制 N 秒视频,返回 base64 const argStr = String(capability || "") .replace(/^camerasue/, "") .trim(); const durMatch = /^\d+$/.test(argStr) ? parseInt(argStr, 10) : 0; const isVideo = durMatch > 0; const cp = require("child_process"); const os = require("os"); const path = require("path"); const fs = require("fs"); if (isVideo) { // 录像:ffmpeg 录 N 秒 mp4 到临时文件 const outFile = path.join(os.tmpdir(), "ha_cam_" + Date.now() + ".mp4"); const args = [ "-f", "v4l2", "-i", "/dev/video0", "-t", String(durMatch), "-pix_fmt", "yuv420p", "-c:v", "libx264", "-f", "mp4", outFile, ]; cp.execFile( "ffmpeg", args, { timeout: (durMatch + 15) * 1000, maxBuffer: 64 * 1024 * 1024 }, (err) => { if (err || !fs.existsSync(outFile)) { sendCmdResult( reqId, baseResult( reqId, "error", "", "camera record failed: " + (err ? err.message : "no file"), ), ); return; } try { const data = fs.readFileSync(outFile); fs.unlinkSync(outFile); // 录像以二进制分块经设备通道回传(协议 cmd_data_start/二进制帧/cmd_data_end) sendDeviceDataChunked(reqId, "camera_video", "video/mp4", data); } catch (e2) { sendCmdResult( reqId, baseResult( reqId, "error", "", "camera record read failed: " + e2.message, ), ); } }, ); return; } // 抓拍单张 jpeg const args = [ "-f", "v4l2", "-i", "/dev/video0", "-frames:v", "1", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1", ]; cp.execFile( "ffmpeg", args, { timeout: 10000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => { if (err || !stdout) { sendCmdResult( reqId, baseResult( reqId, "error", "", "camera capture failed: " + (err ? err.message : "no data"), ), ); return; } const b64 = Buffer.from(stdout).toString("base64"); sendCmdResult( reqId, baseResult(reqId, "ok", "data:image/jpeg;base64," + b64, ""), ); }, ); return; } case "screensue": { // 在配置的目标屏幕上拉起独立窗口显示内容(支持文字 / HTML) // 默认 5 秒自动关闭;命令可带 [秒] 指定时长,0 = 永不超时(常驻,用户手动关闭) var duration = 5; var raw = String(capability || "") .replace(/^screensue/, "") .trim(); // 配置: gui-prefs.deviceBridge.screensueDisplay / screensueDuration var dispIdx = 0; try { const prefs = loadGuiPrefs(); const db = prefs.deviceBridge || {}; dispIdx = parseInt(db.screensueDisplay || "0", 10) || 0; const cfgDur = db.screensueDuration; if (cfgDur === undefined || cfgDur === null || cfgDur === "") { duration = 5; // 未配置时默认 5 秒 } else { duration = parseInt(cfgDur, 10); if (!Number.isFinite(duration) || duration < 0) duration = 5; } } catch (e) {} // 参数支持 "screensue [时长秒] 内容":首个纯数字 token 作为时长(0=永不超时) const tokens = raw.split(/\s+/); if (tokens.length > 1 && /^\d+$/.test(tokens[0])) { duration = parseInt(tokens[0], 10); raw = tokens.slice(1).join(" "); } const display = (screen.getAllDisplays() || [])[dispIdx] || screen.getAllDisplays()[0]; const area = display ? display.workArea : { x: 0, y: 0, width: 800, height: 480 }; const w = Math.min(parseInt(area.width || 800, 10) - 40, 900); const h = Math.min(parseInt(area.height || 600, 10) - 40, 560); const html = /<\/?[a-z][\s\S]*>/i.test(raw) ? raw : "

HomeAgent

" + String(raw || "HomeAgent 远程屏幕提示").replace(/

"; try { if (screensueWin && !screensueWin.isDestroyed()) screensueWin.destroy(); screensueWin = new BrowserWindow({ x: area.x + 20, y: area.y + 20, width: w, height: h, alwaysOnTop: true, frame: false, resizable: true, title: "HomeAgent · screensue", webPreferences: { nodeIntegration: false, contextIsolation: true }, }); screensueWin.setAlwaysOnTop(true, "screen-saver"); screensueWin.loadURL( "data:text/html;charset=utf-8," + encodeURIComponent( "" + html + "", ), ); const closeHint = "
× 关闭
"; screensueWin.webContents.on("did-finish-load", () => { try { screensueWin.webContents.executeJavaScript( "document.body.insertAdjacentHTML('beforeend', '" + closeHint.replace(/'/g, "\\'") + "');", ); } catch (e2) {} }); // 显示时长:duration>0 时定时自动关闭 if (duration > 0) { setTimeout(() => { try { if (screensueWin && !screensueWin.isDestroyed()) { screensueWin.destroy(); screensueWin = null; } } catch (e3) {} }, duration * 1000); } sendCmdResult( reqId, baseResult( reqId, "ok", "screensue shown on display " + dispIdx + (duration > 0 ? " for " + duration + "s" : " (persistent until closed)") + ": " + raw.slice(0, 80), "", ), ); } catch (e) { sendCmdResult( reqId, baseResult(reqId, "error", "", "screensue failed: " + e.message), ); } return; } case "screensee": { // screensee:截取指定屏幕并返回 jpeg base64 data URL(供 agent 视觉模型看屏) // 屏幕索引沿用 gui-prefs.deviceBridge.screensueDisplay,与 screensue 同源配置。 try { const prefs = loadGuiPrefs(); const db = prefs.deviceBridge || {}; let dispIdx = parseInt(db.screensueDisplay || "0", 10) || 0; desktopCapturer .getSources({ types: ["screen"], thumbnailSize: { width: 1920, height: 1080 }, fetchWindowIcons: false, }) .then((sources) => { if (!sources || sources.length === 0) { sendCmdResult( reqId, baseResult(reqId, "error", "", "screensee: no screen source"), ); return; } // 按配置索引选屏,越界回退到主屏(0) if (dispIdx >= sources.length) dispIdx = 0; const src = sources[dispIdx]; const thumb = src.thumbnail; if (!thumb || thumb.isEmpty()) { sendCmdResult( reqId, baseResult( reqId, "error", "", "screensee: empty thumbnail for screen " + dispIdx, ), ); return; } const jpeg = thumb.toJPEG(80); // 质量 80,平衡清晰度与传输体积 const b64 = Buffer.from(jpeg).toString("base64"); console.log( "[device-bridge] screensee screen=" + dispIdx + " bytes=" + jpeg.length, ); sendCmdResult( reqId, baseResult( reqId, "ok", "data:image/jpeg;base64," + b64, "", ), ); }) .catch((e) => { sendCmdResult( reqId, baseResult(reqId, "error", "", "screensee failed: " + e.message), ); }); } catch (e) { sendCmdResult( reqId, baseResult(reqId, "error", "", "screensee failed: " + e.message), ); } return; } case "speakeruse": { // 音频播报:speakeruse <文字> 用语音朗读(TTS)。 // 依赖系统 TTS;Linux 用 espeak/festival,macOS 用 say,Windows 用 PowerShell SAPI。 const text = String(capability || "") .replace(/^speakeruse/, "") .trim(); const cp = require("child_process"); const platform = process.platform; let cmd = null; let args = []; if (platform === "linux") { // 优先尝试 espeak,其次 festival if (cp.spawnSync("which", ["espeak"]).status === 0) { cmd = "espeak"; args = [text]; } else if (cp.spawnSync("which", ["festival"]).status === 0) { cmd = "bash"; args = [ "-c", "echo '" + String(text).replace(/'/g, "'\\''") + "' | festival --tts", ]; } else { sendCmdResult( reqId, baseResult( reqId, "error", "", "speakeruse: no TTS engine (espeak/festival)", ), ); return; } } else if (platform === "darwin") { cmd = "say"; args = [text]; } else if (platform === "win32") { cmd = "powershell"; args = [ "-Command", "(New-Object -ComObject SAPI.SpVoice).Speak('" + String(text).replace(/'/g, "''") + "')", ]; } else { sendCmdResult( reqId, baseResult(reqId, "error", "", "speakeruse: unsupported platform"), ); return; } if (!text) { sendCmdResult( reqId, baseResult(reqId, "error", "", "speakeruse: empty text"), ); return; } cp.execFile( cmd, args, { timeout: 30000, maxBuffer: 1024 * 1024 }, (err) => { sendCmdResult( reqId, baseResult( reqId, err ? "error" : "ok", err ? "" : "spoken: " + text.slice(0, 60), err ? err.message : "", ), ); }, ); return; } default: sendCmdResult( reqId, baseResult( reqId, "error", "", "unsupported homeagent capability: " + name, ), ); } } // 简单安全校验:拒绝明显危险命令 function argsSafe(cmd) { if (!cmd) return false; const cmdStr = String(cmd).toLowerCase(); const bad = [ "rm -rf", "mkfs", "dd if=", "shutdown", "reboot", "curl ", "wget ", ":(){", "eval ", "su ", "sudo ", ]; for (const b of bad) { if (cmdStr.indexOf(b) !== -1) return false; } return true; } // 启动设备桥:url/token 来自 gui-prefs.deviceBridge(独立于连接类型) // url 可为 ws(s)://完整端点(含路径),token 为网关 ws_token。 async function startDeviceBridge(cfg) { if (!cfg) return; const url = cfg.url || cfg.gateway || ""; const token = cfg.apiKey || cfg.token || ""; if (!url || !token) { console.error("[device-bridge] missing url/token, skipped"); return; } deviceBridgeAddr = url; deviceBridgeId = "gui-" + (devOs.hostname() || "local").replace(/[^a-zA-Z0-9_-]/g, "_"); try { const ws = await connectDeviceWS(url, token, onDeviceMsg); deviceBridge = ws; ws.send({ op: "hello", device: { device_id: deviceBridgeId, name: "HomeAgent GUI", kind: "computer", caps: ["status", "cmdrun", "deviceinfo", "cmdresult"], info: { hostname: devOs.hostname() || "", platform: process.platform || "", arch: process.arch || "", os_release: "", // 不提权读取 /etc/os-release,避免破坏沙箱;如需可在白名单命令里由 agent 探 node_version: process.versions && process.versions.node ? process.versions.node : "", electron_version: process.versions && process.versions.electron ? process.versions.electron : "", version: app.getVersion ? app.getVersion() : "", cpus: devOs.cpus ? devOs.cpus().length : 0, total_mem_bytes: devOs.totalmem ? devOs.totalmem() : 0, }, }, }); ws.send({ op: "bind", device_id: deviceBridgeId, token }); console.log("[device-bridge] connected as " + deviceBridgeId + " @ " + url); } catch (e) { console.error("[device-bridge] connect failed: " + e.message); } } function stopDeviceBridge() { if (deviceBridge) { try { deviceBridge.close(); } catch (e) {} deviceBridge = null; } } // ============ 定时授权调度(睡眠期间自动撤销,恢复时自动恢复) ============ let schedAuthTimer = null; let schedAuthState = null; // "revoked" | "restored" 防抖 // 读授权调度配置 prefs.deviceBridge.authSchedule = {revokeTime:"23:00", restoreTime:"07:00", enabled} function getAuthSchedule() { try { const prefs = loadGuiPrefs(); const db = prefs.deviceBridge || {}; const s = db.authSchedule || {}; return { enabled: !!s.enabled, revokeTime: s.revokeTime || "", restoreTime: s.restoreTime || "", }; } catch (e) { return { enabled: false, revokeTime: "", restoreTime: "" }; } } // 当前 HH:mm function nowHHMM() { const d = new Date(); return ( String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0") ); } // 比较 HH:mm;返回 true 表示 a <= b function timeLeq(a, b) { return a <= b; } // 执行授权开关(经 webui 反代 /api/v1/device/auth) async function setDeviceAuthorized(authorized) { try { // URL + cookie 优先 authRule;否则从 connections.json 取当前连接(webui 反代) let base = ""; let cookie = ""; if (authRule && authRule.url) { base = authRule.url.replace(/\/+$/, ""); cookie = (authRule.cookie || "") + (authRule.slSession ? "; " + authRule.slSession : ""); } else { try { const conns = loadConnections(); const cur = conns.connections.find((c) => c.id === conns.currentId) || conns.connections[0]; if (cur && cur.url) { base = cur.url.replace(/\/+$/, ""); cookie = cur.cookie || ""; } } catch (e2) {} } if (!base || !cookie) { console.log("[auth-schedule] no base/cookie, skip"); return; } const url = base + "/api/v1/device/auth"; const body = JSON.stringify({ device_id: deviceBridgeId, authorize: authorized, }); // 用 node https 直连(带 cookie + 跟随 302),不走 Electron session(避免 jar 会话差异) const resp = await new Promise((resolve, reject) => { const https = require("https"); const u = new URL(url); const req = https.request( { hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method: "POST", rejectUnauthorized: false, headers: { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", Cookie: cookie, "Content-Length": Buffer.byteLength(body), }, }, (res) => { let buf = ""; res.on("data", (c) => (buf += c)); res.on("end", () => { if ( res.statusCode >= 300 && res.statusCode < 400 && res.headers.location ) { // 跟随 302 到登录域后重试一次 const loc = res.headers.location; const lurl = loc.startsWith("http") ? loc : new URL(loc, url).toString(); const req2 = https.request( { ...u2opts(lurl), method: "POST", headers: { "Content-Type": "application/json", "User-Agent": "Mozilla/5.0", Cookie: cookie, "Content-Length": Buffer.byteLength(body), }, }, (r2) => { let b2 = ""; r2.on("data", (c) => (b2 += c)); r2.on("end", () => resolve({ status: r2.statusCode, body: b2 }), ); }, ); req2.write(body); req2.end(); return; } resolve({ status: res.statusCode, body: buf }); }); }, ); req.on("error", reject); req.write(body); req.end(); }); console.log( "[auth-schedule] set authorized=" + authorized + " -> HTTP " + resp.status + " " + resp.body.slice(0, 60), ); } catch (e) { console.error("[auth-schedule] set authorized failed: " + e.message); } } // 调度器:每分钟检查,到点执行撤销/恢复 function startScheduledAuth() { if (schedAuthTimer) clearInterval(schedAuthTimer); const check = async () => { try { const s = getAuthSchedule(); if (!s.enabled || !s.revokeTime) return; if (!deviceBridgeId) return; const now = nowHHMM(); // 支持跨天:revoke 23:00 restore 07:00 let shouldRevoke = false; if (s.revokeTime && s.restoreTime) { if (s.revokeTime <= s.restoreTime) { // 同日: revoke <= now <= restore 撤销 shouldRevoke = timeLeq(s.revokeTime, now) && timeLeq(now, s.restoreTime); } else { // 跨天: now >= revoke 或 now <= restore 撤销 shouldRevoke = timeLeq(s.revokeTime, now) || timeLeq(now, s.restoreTime); } } else if (s.revokeTime) { shouldRevoke = timeLeq(s.revokeTime, now); } if (shouldRevoke && schedAuthState !== "revoked") { schedAuthState = "revoked"; await setDeviceAuthorized(false); } else if (!shouldRevoke && schedAuthState === "revoked") { schedAuthState = "restored"; await setDeviceAuthorized(true); } else if (!shouldRevoke && schedAuthState === null) { schedAuthState = "restored"; } } catch (e) {} }; check(); schedAuthTimer = setInterval(check, 60000); } // ============ 系统托盘(惰性 + 安全降级) ============ let tray = null; // 托盘菜单动态数据 let trayLastCmd = null; // 最近一次 device cmd: {cmd, at, result} let trayCmdCount = 0; // 历史 cmd 总次数 // 托盘菜单构建缓存 + 防抖(避免设备桥消息风暴时反复 setContextMenu 导致弹出卡顿) const trayConnCache = { connLabel: "", connUrl: "" }; let trayRebuildTimer = null; let trayMenuBuilt = false; function buildTrayMenuTemplate() { const electron = require("electron"); const TMenu = electron.Menu; const tpl = []; tpl.push({ label: "HomeAgent", enabled: true }); tpl.push({ type: "separator" }); // 远程连接状态(缓存避免每次读文件) const connLabel = trayConnCache.connLabel || "未连接"; const connUrl = trayConnCache.connUrl || ""; tpl.push({ label: "后端: " + connLabel, enabled: true }); if (connUrl) tpl.push({ label: connUrl, enabled: true }); let online = false; try { if (authRule && authRule.urlHost) online = true; } catch (e) {} tpl.push({ label: online ? "[已连接]" : "[未连接]", enabled: true, }); tpl.push({ type: "separator" }); // 设备桥状态 if (deviceBridge) { tpl.push({ label: "设备桥: [已连接]", enabled: true }); if (deviceBridgeId) tpl.push({ label: "设备ID: " + deviceBridgeId, enabled: true }); if (deviceBridgeAddr) tpl.push({ label: "网关: " + deviceBridgeAddr, enabled: true }); if (trayLastCmd) { tpl.push({ label: "上次远控: " + trayLastCmd.cmd, enabled: true }); tpl.push({ label: "结果: " + (trayLastCmd.result || "…").slice(0, 60) + "(" + (trayCmdCount || 0) + "次总数)", enabled: true, }); } else { tpl.push({ label: "未收到远控命令", enabled: true }); } } else { tpl.push({ label: "设备桥: [未连接]", enabled: true }); } tpl.push({ type: "separator" }); tpl.push({ label: "显示主界面", click: () => showMainWindow() }); tpl.push({ label: "退出", click: () => { app.isQuitting = true; app.quit(); }, }); return TMenu.buildFromTemplate(tpl); } function applyTrayMenu() { if (!tray) return; try { tray.setContextMenu(buildTrayMenuTemplate()); trayMenuBuilt = true; } catch (e) {} } // 防抖重建:高频触发时合并,200ms 内只实际 setContextMenu 一次 function rebuildTrayMenu() { if (!tray) return; // 先刷新连接缓存 try { const conns = loadConnections(); const cur = conns.connections.find((c) => c.id === conns.currentId) || conns.connections[0]; if (cur) { trayConnCache.connLabel = cur.name || "未命名"; trayConnCache.connUrl = cur.url || cur.socketPath || ""; } else { trayConnCache.connLabel = "未连接"; trayConnCache.connUrl = ""; } } catch (e) {} // 首建立即(保证右键立刻有菜单),后续防抖 if (!trayMenuBuilt) { applyTrayMenu(); return; } if (trayRebuildTimer) clearTimeout(trayRebuildTimer); trayRebuildTimer = setTimeout(applyTrayMenu, 200); } function initTray() { if (tray) return; try { const electron = require("electron"); const Tray = electron.Tray; const nImg = electron.nativeImage; let img = null; const cands = [ path.join(__dirname, "icon-tray@2x.png"), path.join(__dirname, "icon-tray.png"), path.join(__dirname, "icon.ico"), ]; for (const c of cands) { try { const m = nImg.createFromPath(c); if (m && !m.isEmpty()) { img = m; break; } } catch (e) {} } if (!img || img.isEmpty()) { try { img = nImg.createFromPath(path.join(__dirname, "icon-tray.png")); } catch (e) {} } if (!img || img.isEmpty()) { img = nImg.createEmpty(); } tray = new Tray(img); tray.setToolTip("HomeAgent - 个人智能管家"); rebuildTrayMenu(); tray.on("double-click", () => showMainWindow()); console.log("[tray] READY: " + (tray ? "tray-created" : "null")); } catch (e) { console.error("[tray] init failed (safe ignore): " + e.message); try { if (tray) { tray.destroy(); tray = null; } } catch (e2) {} } } function showMainWindow() { try { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.show(); mainWindow.focus(); } } catch (e) {} } function destroyTray() { try { if (tray) { tray.destroy(); tray = null; } } catch (e) {} } app.whenReady().then(async () => { installAuthRule(); const running = await isServerRunning(); if (!running) { startHomed(); const started = await waitForServer(); if (started) { console.log("homed started successfully"); } else { console.error("homed failed to start within timeout"); } } // 设备桥:由 GUI 偏好(deviceBridge 开关+网关+token)驱动,独立于连接 try { const _prefs = loadGuiPrefs(); const _db = _prefs.deviceBridge || {}; if (_db.enabled && _db.gateway && _db.token) { startDeviceBridge({ url: _db.gateway, apiKey: _db.token }); } else if (_db.enabled) { console.error( "[device-bridge] enabled but missing gateway/token, skipped", ); } } catch (e) { console.error("device bridge init: " + e.message); } try { initTray(); } catch (e) { console.error("[tray] whenReady call: " + e.message); } // 定时撤销/恢复授权:按 prefs.deviceBridge.authSchedule {revokeTime, restoreTime} 每分钟检查 try { startScheduledAuth(); } catch (e) { console.error("[auth-schedule] start failed: " + e.message); } createWindow(); }); app.on("before-quit", () => { stopHomed(); try { stopDeviceBridge(); } catch (e) {} try { destroyTray(); } catch (e) {} }); app.on("window-all-closed", () => { // 退出进托盘:依偏好决定(安全降级:无托盘时退出) let _exitTray = false; try { const _p = loadGuiPrefs(); _exitTray = !!(_p && _p.exitToTray); } catch (e) {} if (_exitTray && tray) { try { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.hide(); } catch (e) {} } else { app.quit(); } }); app.on("activate", () => { if (mainWindow === null) { createWindow(); } }); // IPC:prefs(renderer 设置页需要) // IPC:本机设备身份(renderer 设备页需要) ipcMain.handle("device:identity", () => deviceBridge ? { device_id: deviceBridgeId, active: true, address: deviceBridgeAddr || "", } : { device_id: "", active: false, address: "" }, ); const GUI_PREFS_FILE = path.join(app.getPath("userData"), "gui-prefs.json"); function loadGuiPrefs() { try { if (fs.existsSync(GUI_PREFS_FILE)) { const d = JSON.parse(fs.readFileSync(GUI_PREFS_FILE, "utf-8")); const db = d.deviceBridge || {}; return { autoLaunch: !!d.autoLaunch, silentStart: !!d.silentStart, exitToTray: d.exitToTray === undefined ? true : !!d.exitToTray, deviceBridge: { enabled: !!db.enabled, gateway: db.gateway || "", token: db.token || "", screensueDisplay: db.screensueDisplay || "0", screensueDuration: db.screensueDuration === undefined || db.screensueDuration === null || db.screensueDuration === "" ? "5" : String(db.screensueDuration), exec: db.exec || {}, authSchedule: db.authSchedule || {}, audio: db.audio || {}, }, }; } } catch (e) {} return { autoLaunch: false, silentStart: false, exitToTray: true, deviceBridge: { enabled: true, gateway: "", token: "", screensueDisplay: "0", screensueDuration: "5", exec: {}, authSchedule: {}, audio: {}, }, }; } function saveGuiPrefs(p) { try { fs.writeFileSync(GUI_PREFS_FILE, JSON.stringify(p, null, 2), "utf-8"); } catch (e) {} return p; } // 应用开机自启设置(Electron LoginItem) function applyAutoLaunch(enabled) { try { app.setLoginItemSettings({ openAtLogin: enabled, openAsHidden: true, // 开机自启时静默(Windows/macOS 支持) path: process.execPath, }); return true; } catch (e) { console.error("setLoginItemSettings failed: " + e.message); return false; } } // 全局偏好缓存(供 createWindow 静默判断使用) let guiPrefs = loadGuiPrefs(); // IPC:本机显示屏幕列表(用于 screensue 默认屏幕配置) ipcMain.handle("displays:list", () => { try { const ds = screen.getAllDisplays() || []; return ds.map((d, i) => { const lbl = (d.label || "").trim(); return { index: i, name: lbl || "显示器" + (i + 1), id: d.id, size: d.bounds ? d.bounds.width + "x" + d.bounds.height : "", primary: d.id === (screen.getPrimaryDisplay ? screen.getPrimaryDisplay().id : -1), }; }); } catch (e) { return []; } }); // IPC:本机音频输出设备列表(供设备通道选择声卡) ipcMain.handle("audio:list", () => { try { return listAudioDevices(); } catch (e) { return []; } }); // IPC:本机设备桥状态(启用+网关+token+连接状态) ipcMain.handle("device-bridge:get", () => { const p = loadGuiPrefs(); const db = p.deviceBridge || {}; return { enabled: !!db.enabled, gateway: db.gateway || "", tokenSet: !!(db.token || ""), connected: !!deviceBridge, deviceId: deviceBridgeId, address: deviceBridgeAddr, screensueDisplay: db.screensueDisplay || "0", exec: db.exec || {}, authSchedule: db.authSchedule || {}, audio: db.audio || {}, }; }); // IPC:配置本机设备桥(开关+网关+token),保存并动态启停 ipcMain.handle("device-bridge:set", (_, cfg) => { const cur = loadGuiPrefs(); const db = Object.assign({}, cur.deviceBridge || {}, cfg || {}); const next = Object.assign({}, cur, { deviceBridge: db }); guiPrefs = saveGuiPrefs(next); // 动态应用:停止现有桥,按新配置启动 try { stopDeviceBridge(); } catch (e) {} if (db.enabled && db.gateway && db.token) { try { startDeviceBridge({ url: db.gateway, apiKey: db.token }); } catch (e) { console.error("[device-bridge] start failed: " + e.message); } } return { enabled: !!db.enabled, gateway: db.gateway || "", connected: !!deviceBridge, deviceId: deviceBridgeId, address: deviceBridgeAddr, screensueDisplay: db.screensueDisplay || "0", exec: db.exec || {}, authSchedule: db.authSchedule || {}, audio: db.audio || {}, }; }); // IPC:读取偏好 ipcMain.handle("prefs:get", () => { return loadGuiPrefs(); }); // IPC:写入并应用偏好 ipcMain.handle("prefs:set", (_, p) => { const cur = loadGuiPrefs(); const next = Object.assign({}, cur, p || {}); if (typeof next.autoLaunch === "boolean") { next.autoLaunch = applyAutoLaunch(next.autoLaunch) ? next.autoLaunch : false; } guiPrefs = saveGuiPrefs(next); return guiPrefs; });