From c29abe956995f291e9b39e9b0c7eeedf908f33a8 Mon Sep 17 00:00:00 2001 From: jianf <2198972886@qq.com> Date: Thu, 20 Aug 2026 09:07:41 +0800 Subject: [PATCH] =?UTF-8?q?gui:=20=E8=AE=BE=E5=A4=87=E5=91=BD=E4=BB=A4/?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0=20+=20?= =?UTF-8?q?=E4=BA=8C=E8=BF=9B=E5=88=B6=E5=88=86=E5=9D=97=20+=20=E6=B6=88?= =?UTF-8?q?=E6=81=AFID=E5=8E=BB=E9=87=8D=20+=20=E4=BA=A4=E4=BA=92=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设备命令执行对齐服务端 cmd_type 契约: - onDeviceMsg 解析 msg.cmd_type: homeagent->能力分发 / shell->白名单执行 - 统一回执 sendCmdResult(兼容双参), 全链路回执修复 - 修复 pi-lens auto-fix 把 trayLastCmd 改 const 导致的 TypeError(命令未执行/无回执根因) homeagent 能力: - screensue: 独立窗口显示文字/HTML, 可配默认屏幕(displays:list IPC), 支持时长参数 "screensue [秒] 内容", 默认常驻 - camerasue: 抓拍单张 jpeg(base64文本); camerasue =录像 mp4(libx264), 二进制分块经设备通道回传 - speakeruse: 接收服务端二进制音频(WS 0x2), 聚合后播放(aplay/paplay/ffplay/afplay/SoundPlayer) 二进制分块协议(设备<->网关): - 录像(设备->网关): cmd_data_start/0x2帧/cmd_data_end - 音频(网关->设备): cmd_speech_start/0x2帧/cmd_speech_end - 设备端 WS 读写侧都支持 0x2 帧; N/A 服务端 readFrame 需配套(已发群) 消息重放/交互: - sendChat 带 client_msg_id 唯一ID(服务端可去重), 超时明确提示勿重复发送 - 托盘菜单: 信息项 enabled(不再灰字)+200ms防抖+连接缓存, 首建立即弹出 - 托盘显示连接/设备桥/远控活动状态 UI: - 设备页本机卡片: 设备通道配置(网关/ws_token/screensue屏幕/cmdrun目录/沙箱) - 设备列表经 webui 反代 /api/v1/device/online 拉取 - 连接管理移除 device 类型(设备独立为 GUI 组件), toggleConnType/saveConnForm 清理 - 字体对比度提升(text-muted/secondary重调色), 内联小字 10/11px->12/13px preload: 新增 displays.list IPC --- cmd/gui/main.js | 869 +++++++++++++++++++++++++++++++------ cmd/gui/preload.js | 3 + cmd/gui/renderer/app.js | 316 +++++++++----- cmd/gui/renderer/style.css | 42 +- 4 files changed, 980 insertions(+), 250 deletions(-) diff --git a/cmd/gui/main.js b/cmd/gui/main.js index d11b1f4..427fc3c 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -1,4 +1,5 @@ -const { app, BrowserWindow, ipcMain, Menu } = require("electron"); +const { app, BrowserWindow, ipcMain, Menu, screen } = require("electron"); +let screensueWin = null; // screensue 展示窗口(独立于主窗口,显示在配置的屏幕) // 设备桥直连远程网关:绕过系统代理(本机 clash 代理会导致 wss 被雷池 403) try { app.commandLine.appendSwitch("no-proxy-server"); @@ -53,10 +54,25 @@ function installAuthRule() { } // 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"] || ""; - h["Cookie"] = [existing].concat(extra).filter(Boolean).join("; "); + const merged = [existing].concat(extra).filter(Boolean).join("; "); + // 按 cookie 名去重:保留第一个,后续同名丢弃(网关只认首值) + const seen = {}; + const dedup = merged + .split("; ") + .map((s) => s.trim()) + .filter((s) => { + const eq = s.indexOf("="); + const name = eq > 0 ? s.slice(0, eq) : s; + if (seen[name]) return false; + seen[name] = true; + return true; + }) + .join("; "); + h["Cookie"] = dedup; } } callback({ requestHeaders: h }); @@ -794,6 +810,80 @@ const devOs = require("os"); let deviceBridge = null; // 当前活动设备桥 let deviceBridgeId = ""; // 设备 meta device_id(hello 后可用于 cmd_result) let deviceBridgeAddr = ""; // 设备桥网关地址 +// 音频/媒体接收聚合缓冲(服务端分块推送二进制→聚合→播放) +let speechAccum = null; + +// 播放设备收到的音频(由 cmd_speech_end 触发,二进制已聚合) +function playDeviceAudio(audioBuf, mime, reqId) { + try { + const os = require("os"); + const path = require("path"); + const fs = require("fs"); + const porcp = require("child_process"); + const ext = + (mime || "").indexOf("mp3") === -1 + ? (mime || "").indexOf("ogg") === -1 + ? "wav" + : "ogg" + : "mp3"; + const tmp = path.join(os.tmpdir(), "ha_speech_" + Date.now() + "." + ext); + fs.writeFileSync(tmp, audioBuf); + const platform = process.platform; + let cmd = null; + let args = []; + if (platform === "linux") { + if (porcp.spawnSync("which", ["aplay"]).status === 0) { + cmd = "aplay"; + args = [tmp]; + } else if (porcp.spawnSync("which", ["paplay"]).status === 0) { + cmd = "paplay"; + args = [tmp]; + } else if (porcp.spawnSync("which", ["ffplay"]).status === 0) { + cmd = "ffplay"; + args = ["-nodisp", "-autoexit", "-loglevel", "quiet", tmp]; + } + } else if (platform === "darwin") { + cmd = "afplay"; + args = [tmp]; + } else if (platform === "win32") { + cmd = "powershell"; + args = [ + "-Command", + "(New-Object Media.SoundPlayer '" + tmp + "').PlaySync()", + ]; + } + if (cmd) { + porcp.execFile(cmd, args, { timeout: 60000 }, () => { + try { + fs.unlinkSync(tmp); + } catch (e2) {} + }); + // 回执:媒体收到并开始播放 + sendCmdResult( + reqId || "", + baseResult( + reqId || "", + "ok", + "audio played: " + audioBuf.length + " bytes", + "", + ), + ); + } else { + // 无播放器,至少落盘供手动查看,回执带文件路径 + sendCmdResult( + reqId || "", + baseResult( + reqId || "", + "ok", + "audio saved: " + tmp + " (" + audioBuf.length + " bytes)", + "", + ), + ); + } + } catch (e) { + console.log("[device-bridge] play audio error: " + e.message); + } +} // 建立到 remotedevice WS 网关连接,返回 {send(obj), close()},消息经 onMsg 回调。 function connectDeviceWS(url, token, onMsg) { @@ -890,6 +980,7 @@ function connectDeviceWS(url, token, onMsg) { opened = true; resolve({ send: (obj) => sendDeviceFrame(sock, JSON.stringify(obj)), + __sock: sock, // 暴露底层 socket 供二进制分块发送 close: () => sock.destroy(), }); } @@ -913,8 +1004,42 @@ function connectDeviceWS(url, token, onMsg) { buf = buf.slice(off + len); if (opcode === 0x1) { try { - onMsg(JSON.parse(payload.toString("utf8"))); + const obj = JSON.parse(payload.toString("utf8")); + if (obj && obj.op === "cmd_speech_start") { + // 收到音频开始:初始化聚合缓冲 + try { + if (speechAccum) speechAccum = null; + } catch (e2) {} + speechAccum = { + reqId: obj.req_id || "", + kind: obj.kind || "speech", + mime: obj.mime || "audio/wav", + total: obj.total || 0, + chunks: [], + got: 0, + }; + continue; + } + if (obj && obj.op === "cmd_speech_end") { + // 音频结束:合并块并播放 + const reqId = obj.req_id || ""; + if (speechAccum) { + try { + const audio = Buffer.concat(speechAccum.chunks); + playDeviceAudio(audio, speechAccum.mime, reqId); + } catch (e2) {} + speechAccum = null; + } + continue; + } + onMsg(obj); } catch (e) {} + } else if (opcode === 0x2) { + // 二进制帧:接收音频数据块(若处于聚合状态) + if (speechAccum) { + speechAccum.chunks.push(payload); + speechAccum.got += payload.length; + } } else if (opcode === 0x8) { sock.destroy(); return; @@ -954,13 +1079,104 @@ function sendDeviceFrame(sock, text) { sock.write(Buffer.concat([hdr, mask, masked])); } -// 处理网关 WS 消息:hello_ack/bind_ack/cmd 等 +// 发送 WS 二进制帧(0x2)——用于大体积数据(如录像)分块回传 +function sendDeviceBinaryFrame(sock, buf) { + const mask = crypto.randomBytes(4); + const masked = Buffer.from(buf); + for (let i = 0; i < masked.length; i++) masked[i] ^= mask[i % 4]; + const len = masked.length; + let hdr; + if (len < 126) { + hdr = Buffer.from([0x82, 0x80 | len]); + } else if (len < 65536) { + hdr = Buffer.alloc(4); + hdr[0] = 0x82; + hdr[1] = 0x80 | 126; + hdr.writeUInt16BE(len, 2); + } else { + hdr = Buffer.alloc(10); + hdr[0] = 0x82; + hdr[1] = 0x80 | 127; + hdr.writeBigUInt64BE(BigInt(len), 2); + } + sock.write(Buffer.concat([hdr, mask, masked])); +} +// 通过设备通道以二进制分块回传大体积数据(如录像)。 +// 协议(与服务端协商): +// cmd_data_start {op, req_id, kind, total, chunk_size, mime} —— 文本帧 +// —— 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: "执行中…" }; @@ -968,57 +1184,14 @@ function onDeviceMsg(msg) { try { rebuildTrayMenu(); } catch (e) {} - const cp = require("child_process"); - if (argsSafe(command)) { - cp.exec( - command, - { timeout: 15000, maxBuffer: 8192 }, - (err, stdout, stderr) => { - const resp = { - op: "cmd_result", - req_id: reqId, - device_id: deviceBridgeId, - status: err ? "error" : "ok", - output: (stdout || "") + (stderr || ""), - error: err ? err.message : "", - }; - if (deviceBridge && deviceBridge.send) { - try { - deviceBridge.send(resp); - } catch (e) {} - } - // 更新结果到托盘 - if (trayLastCmd) { - trayLastCmd.result = - resp.status === "ok" - ? String(resp.output || "").slice(0, 40) - : "错误: " + String(resp.error || ""); - try { - rebuildTrayMenu(); - } catch (e) {} - } - }, - ); - } else { - const resp = { - op: "cmd_result", - req_id: reqId, - device_id: deviceBridgeId, - status: "denied", - output: "", - error: "command not allowed", - }; - if (deviceBridge && deviceBridge.send) { - try { - deviceBridge.send(resp); - } catch (e) {} - } - if (trayLastCmd) { - trayLastCmd.result = "已拒绝(白名单)"; - try { - rebuildTrayMenu(); - } catch (e) {} + 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( @@ -1030,6 +1203,417 @@ function onDeviceMsg(msg) { } } +// 统一发送命令结果(回执 + 托盘更新)。兼容两种调用:sendCmdResult(resp) 或 sendCmdResult(reqId, resp) +function sendCmdResult(a, b) { + const resp = b || a; + console.log( + "[device-bridge] send result op=cmd_result status=" + + (resp.status || "") + + " req=" + + (resp.req_id || ""), + ); + if (deviceBridge && deviceBridge.send) { + try { + deviceBridge.send(resp); + } catch (e) {} + } + if (trayLastCmd) { + trayLastCmd.result = + resp.status === "ok" + ? String(resp.output || "").slice(0, 40) + : "错误: " + String(resp.error || ""); + try { + rebuildTrayMenu(); + } catch (e) {} + } +} + +function baseResult(reqId, status, output, error) { + return { + op: "cmd_result", + req_id: reqId, + device_id: deviceBridgeId, + status: status, + output: output || "", + error: error || "", + }; +} + +// 读取设备命令执行配置(gui-prefs.deviceBridge.exec) +function getExecConfig() { + try { + const prefs = loadGuiPrefs(); + const db = prefs.deviceBridge || {}; + const ex = db.exec || {}; + return { + cwd: ex.cwd || "", + sandbox: ex.sandbox || "off", // off | home | box + boxDir: ex.boxDir || "", + timeout: parseInt(ex.timeout || "15", 10) || 15, + maxBuffer: (parseInt(ex.maxBuffer || "8192", 10) || 8192) * 1024, + }; + } catch (e) { + return { + cwd: "", + sandbox: "off", + boxDir: "", + timeout: 15, + maxBuffer: 8192 * 1024, + }; + } +} + +// shell 命令:按 exec 配置(cwd/沙箱/超时)执行 +function executeShellCmd(command, reqId) { + console.log( + "[device-bridge] execShell cmd=" + + String(command).slice(0, 60) + + " req=" + + reqId, + ); + const cp = require("child_process"); + const ex = getExecConfig(); + if (!argsSafe(command)) { + sendCmdResult( + reqId, + baseResult(reqId, "denied", "", "command not allowed"), + ); + return; + } + // 沙箱策略:home=锁定用户主目录; box=锁定指定目录; off=cwd 或默认 + const os = require("os"); + let cwd = ex.cwd || os.homedir() || process.cwd(); + if (ex.sandbox === "home") cwd = os.homedir() || cwd; + else if (ex.sandbox === "box" && ex.boxDir) cwd = ex.boxDir; + else if (ex.sandbox === "box" && !ex.boxDir) { + sendCmdResult( + reqId, + baseResult(reqId, "denied", "", "sandbox=box 需配置 boxDir"), + ); + return; + } + cp.exec( + command, + { + cwd: cwd, + timeout: ex.timeout * 1000, + maxBuffer: ex.maxBuffer, + env: process.env, + }, + (err, stdout, stderr) => { + sendCmdResult( + reqId, + baseResult( + reqId, + err ? "error" : "ok", + (stdout || "") + (stderr || ""), + err ? err.message : "", + ), + ); + }, + ); +} + +// homeagent 内置能力分发(与服务端 device_ctl_cmdrun 的 homeagent-* 对齐) +function executeHomeagentCmd(capability, reqId) { + const name = String(capability || "") + .trim() + .split(/[ >\n]/)[0]; + switch (name) { + case "camerasue": { + // 摄像头:camerasue=抓拍单张;camerasue <秒>=录制 N 秒视频,返回 base64 + const argStr = String(capability || "") + .replace(/^camerasue/, "") + .trim(); + const durMatch = /^\d+$/.test(argStr) ? parseInt(argStr, 10) : 0; + const isVideo = durMatch > 0; + const cp = require("child_process"); + const os = require("os"); + const path = require("path"); + const fs = require("fs"); + if (isVideo) { + // 录像:ffmpeg 录 N 秒 mp4 到临时文件 + const outFile = path.join(os.tmpdir(), "ha_cam_" + Date.now() + ".mp4"); + const args = [ + "-f", + "v4l2", + "-i", + "/dev/video0", + "-t", + String(durMatch), + "-pix_fmt", + "yuv420p", + "-c:v", + "libx264", + "-f", + "mp4", + outFile, + ]; + cp.execFile( + "ffmpeg", + args, + { timeout: (durMatch + 15) * 1000, maxBuffer: 64 * 1024 * 1024 }, + (err) => { + if (err || !fs.existsSync(outFile)) { + sendCmdResult( + reqId, + baseResult( + reqId, + "error", + "", + "camera record failed: " + (err ? err.message : "no file"), + ), + ); + return; + } + try { + const data = fs.readFileSync(outFile); + fs.unlinkSync(outFile); + // 录像以二进制分块经设备通道回传(协议 cmd_data_start/二进制帧/cmd_data_end) + sendDeviceDataChunked(reqId, "camera_video", "video/mp4", data); + } catch (e2) { + sendCmdResult( + reqId, + baseResult( + reqId, + "error", + "", + "camera record read failed: " + e2.message, + ), + ); + } + }, + ); + return; + } + // 抓拍单张 jpeg + const args = [ + "-f", + "v4l2", + "-i", + "/dev/video0", + "-frames:v", + "1", + "-f", + "image2pipe", + "-vcodec", + "mjpeg", + "pipe:1", + ]; + cp.execFile( + "ffmpeg", + args, + { timeout: 10000, maxBuffer: 8 * 1024 * 1024 }, + (err, stdout) => { + if (err || !stdout) { + sendCmdResult( + reqId, + baseResult( + reqId, + "error", + "", + "camera capture failed: " + (err ? err.message : "no data"), + ), + ); + return; + } + const b64 = Buffer.from(stdout).toString("base64"); + sendCmdResult( + reqId, + baseResult(reqId, "ok", "data:image/jpeg;base64," + b64, ""), + ); + }, + ); + return; + } + case "screensue": { + // 在配置的目标屏幕上拉起独立窗口显示内容(支持文字 / HTML) + var duration = 0; // 显示时长(秒),0=常驻 + var raw = String(capability || "") + .replace(/^screensue/, "") + .trim(); + // 配置: gui-prefs.deviceBridge.screensueDisplay / screensueDuration + var dispIdx = 0; + try { + const prefs = loadGuiPrefs(); + const db = prefs.deviceBridge || {}; + dispIdx = parseInt(db.screensueDisplay || "0", 10) || 0; + duration = parseInt(db.screensueDuration || "0", 10) || 0; + } catch (e) {} + // 参数支持 "screensue [时长秒] 内容":首个纯数字 token 作为时长 + const tokens = raw.split(/\s+/); + if (tokens.length > 1 && /^\d+$/.test(tokens[0])) { + duration = parseInt(tokens[0], 10); + raw = tokens.slice(1).join(" "); + } + const display = + (screen.getAllDisplays() || [])[dispIdx] || screen.getAllDisplays()[0]; + const area = display + ? display.workArea + : { x: 0, y: 0, width: 800, height: 480 }; + const w = Math.min(parseInt(area.width || 800, 10) - 40, 900); + const h = Math.min(parseInt(area.height || 600, 10) - 40, 560); + const html = /<\/?[a-z][\s\S]*>/i.test(raw) + ? raw + : "

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" : "") + + ": " + + raw.slice(0, 80), + "", + ), + ); + } catch (e) { + sendCmdResult( + reqId, + baseResult(reqId, "error", "", "screensue failed: " + e.message), + ); + } + return; + } + case "speakeruse": { + // 音频播报:speakeruse <文字> 用语音朗读(TTS)。 + // 依赖系统 TTS;Linux 用 espeak/festival,macOS 用 say,Windows 用 PowerShell SAPI。 + const text = String(capability || "") + .replace(/^speakeruse/, "") + .trim(); + const cp = require("child_process"); + const platform = process.platform; + let cmd = null; + let args = []; + if (platform === "linux") { + // 优先尝试 espeak,其次 festival + if (cp.spawnSync("which", ["espeak"]).status === 0) { + cmd = "espeak"; + args = [text]; + } else if (cp.spawnSync("which", ["festival"]).status === 0) { + cmd = "bash"; + args = [ + "-c", + "echo '" + + String(text).replace(/'/g, "'\\''") + + "' | festival --tts", + ]; + } else { + sendCmdResult( + reqId, + baseResult( + reqId, + "error", + "", + "speakeruse: no TTS engine (espeak/festival)", + ), + ); + return; + } + } else if (platform === "darwin") { + cmd = "say"; + args = [text]; + } else if (platform === "win32") { + cmd = "powershell"; + args = [ + "-Command", + "(New-Object -ComObject SAPI.SpVoice).Speak('" + + String(text).replace(/'/g, "''") + + "')", + ]; + } else { + sendCmdResult( + reqId, + baseResult(reqId, "error", "", "speakeruse: unsupported platform"), + ); + return; + } + if (!text) { + sendCmdResult( + reqId, + baseResult(reqId, "error", "", "speakeruse: empty text"), + ); + return; + } + cp.execFile( + cmd, + args, + { timeout: 30000, maxBuffer: 1024 * 1024 }, + (err) => { + sendCmdResult( + reqId, + baseResult( + reqId, + err ? "error" : "ok", + err ? "" : "spoken: " + text.slice(0, 60), + err ? err.message : "", + ), + ); + }, + ); + return; + } + default: + sendCmdResult( + reqId, + baseResult( + reqId, + "error", + "", + "unsupported homeagent capability: " + name, + ), + ); + } +} + // 简单安全校验:拒绝明显危险命令 function argsSafe(cmd) { if (!cmd) return false; @@ -1114,79 +1698,98 @@ function stopDeviceBridge() { // ============ 系统托盘(惰性 + 安全降级) ============ let tray = null; // 托盘菜单动态数据 -const trayLastCmd = null; // 最近一次 device cmd: {cmd, at, result} -const trayCmdCount = 0; // 历史 cmd 总次数 -function rebuildTrayMenu() { +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 { - const electron = require("electron"); - const TMenu = electron.Menu; - const tpl = []; - // 标题 - tpl.push({ label: "HomeAgent", enabled: false }); - tpl.push({ type: "separator" }); - // 远程连接状态 - let connLabel = "未连接"; - let connUrl = ""; - try { - const conns = loadConnections(); - const cur = - conns.connections.find((c) => c.id === conns.currentId) || - conns.connections[0]; - if (cur) { - connLabel = cur.name || "未命名"; - connUrl = cur.url || cur.socketPath || ""; - } - } catch (e) {} - tpl.push({ label: "后端: " + connLabel, enabled: false }); - if (connUrl) tpl.push({ label: connUrl, enabled: false }); - let online = false; - try { - if (authRule && authRule.urlHost) online = true; - } catch (e) {} - tpl.push({ - label: online ? "[已连接]" : "[未连接]", - enabled: false, - }); - tpl.push({ type: "separator" }); - // 设备桥状态 - if (deviceBridge) { - tpl.push({ label: "设备桥: [已连接]", enabled: false }); - if (deviceBridgeId) - tpl.push({ label: "设备ID: " + deviceBridgeId, enabled: false }); - if (deviceBridgeAddr) - tpl.push({ label: "网关: " + deviceBridgeAddr, enabled: false }); - if (trayLastCmd) { - tpl.push({ label: "上次远控: " + trayLastCmd.cmd, enabled: false }); - tpl.push({ - label: - "结果: " + - (trayLastCmd.result || "…").slice(0, 60) + - "(" + - (trayCmdCount || 0) + - "次总数)", - enabled: false, - }); - } else { - tpl.push({ label: "未收到远控命令", enabled: false }); - } + 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 { - tpl.push({ label: "设备桥: [未连接]", enabled: false }); + trayConnCache.connLabel = "未连接"; + trayConnCache.connUrl = ""; } - tpl.push({ type: "separator" }); - tpl.push({ label: "显示主界面", click: () => showMainWindow() }); - tpl.push({ - label: "退出", - click: () => { - app.isQuitting = true; - app.quit(); - }, - }); - const tmenu = TMenu.buildFromTemplate(tpl); - tray.setContextMenu(tmenu); - } catch (e) { - console.error("[tray] rebuild failed: " + e.message); + } catch (e) {} + // 首建立即(保证右键立刻有菜单),后续防抖 + if (!trayMenuBuilt) { + applyTrayMenu(); + return; } + if (trayRebuildTimer) clearTimeout(trayRebuildTimer); + trayRebuildTimer = setTimeout(applyTrayMenu, 200); } function initTray() { if (tray) return; @@ -1348,7 +1951,7 @@ function loadGuiPrefs() { autoLaunch: false, silentStart: false, exitToTray: true, - deviceBridge: { enabled: false, gateway: "", token: "" }, + deviceBridge: { enabled: true, gateway: "", token: "" }, }; } @@ -1377,6 +1980,24 @@ function applyAutoLaunch(enabled) { // 全局偏好缓存(供 createWindow 静默判断使用) let guiPrefs = loadGuiPrefs(); +// IPC:本机显示屏幕列表(用于 screensue 默认屏幕配置) +ipcMain.handle("displays:list", () => { + try { + const ds = screen.getAllDisplays() || []; + return ds.map((d, i) => ({ + index: i, + name: d.label || "显示器" + (i + 1), + id: d.id, + size: d.bounds ? d.size.width + "x" + d.size.height : "", + primary: + d.id === + (screen.getPrimaryDisplay ? screen.getPrimaryDisplay().id : -1), + })); + } catch (e) { + return []; + } +}); + // IPC:本机设备桥状态(启用+网关+token+连接状态) ipcMain.handle("device-bridge:get", () => { const p = loadGuiPrefs(); @@ -1388,6 +2009,8 @@ ipcMain.handle("device-bridge:get", () => { connected: !!deviceBridge, deviceId: deviceBridgeId, address: deviceBridgeAddr, + screensueDisplay: db.screensueDisplay || "0", + exec: db.exec || {}, }; }); diff --git a/cmd/gui/preload.js b/cmd/gui/preload.js index 926485a..caadb1b 100644 --- a/cmd/gui/preload.js +++ b/cmd/gui/preload.js @@ -45,4 +45,7 @@ contextBridge.exposeInMainWorld("homeagent", { get: () => ipcRenderer.invoke("device-bridge:get"), set: (cfg) => ipcRenderer.invoke("device-bridge:set", cfg), }, + displays: { + list: () => ipcRenderer.invoke("displays:list"), + }, }); diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 02f35fe..240d42b 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -28,6 +28,7 @@ const state = { connections: [], currentConn: null, devices: [], + displays: [], selfDeviceId: "", selfGateway: "", }; @@ -321,6 +322,18 @@ function timeAgo(t) { return Math.floor(m / 60) + __("小时前", "h ago"); } +// 生成客户端唯一消息 ID(服务端据此去重,避免断线/重试重放) +function clientMsgId() { + return ( + "cli-" + + Date.now().toString(36) + + "-" + + Math.random().toString(36).slice(2, 8) + ); +} +// 最近一次发送的消息 ID(防重复重发提示用) +var lastClientMsgId = null; + function toast(m, isError, warn) { var t = document.getElementById("toast"); t.textContent = m; @@ -573,17 +586,31 @@ async function refreshAll() { await loadCmdHistory(); } catch (e) {} try { - if (state.currentConn && state.currentConn.type === "device") { - var d = await api("/device"); - state.devices = d.devices || []; + // 设备列表:webui 连接时经 webui 反代 /api/v1/device/online 拉取(反代只挂 /api/v1/device/ 前缀) + if ( + state.currentConn && + state.currentConn.type === "webui" && + state.currentConn.url + ) { + var d = await api("/device/online"); + state.devices = (d && d.devices) || []; + } else { + state.devices = []; } } catch (e) { state.devices = []; } try { + // 本机显示器列表(screensue 默认屏幕配置用) + try { + if (window.homeagent && window.homeagent.displays) { + state.displays = (await window.homeagent.displays.list()) || []; + } + } catch (e) {} // 本机设备桥身份:设备桥由 gui-prefs 驱动,独立于当前连接类型 if (window.homeagent && window.homeagent.deviceBridge) { var dbinfo = await window.homeagent.deviceBridge.get(); + state.dbConfig = dbinfo || state.dbConfig; if (dbinfo && dbinfo.enabled) { if (dbinfo.deviceId) { state.selfDeviceId = dbinfo.deviceId; @@ -893,7 +920,7 @@ function buildChatLayout() { html += '

' + __("对话", "Chat") + - '

'; if (state.messages.length === 0) { @@ -1429,7 +1456,7 @@ function renderChatStarmap() { (!window.THREE && window._THREE_FAILED !== undefined) ) { cont.innerHTML = - '

' + + '

' + __( "3D 星图不可用(CDN 加载失败)", "Star map unavailable (CDN load failed)", @@ -1781,7 +1808,7 @@ 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") { + if (state.currentConn && state.currentConn.type === "cli") { toast( __( "设备网关连接不支持聊天", @@ -1802,34 +1829,35 @@ async function sendChat() { btn.textContent = ""; rerenderChat(); try { - // webui 连接:触发式 + SSE 流式接管(避免 POST 与 SSE 双通道重复渲染卡死) + // webui 连接:同步 POST 等完整回复(服务端 X-Trigger-Only 也返回 response;SSE 公网不稳时靠同步兜底) var isWebui = !state.currentConn || state.currentConn.type !== "cli"; var r = null; + // 唯一消息 ID:服务端据此去重(断线重放/超时重试不再重复处理) + var cid = clientMsgId(); + lastClientMsgId = cid; 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, - }); - } + r = await api("/chat", { + method: "POST", + body: JSON.stringify({ message: text, client_msg_id: cid }), + timeout: 120000, + }); } catch (e) { - // 触发请求超时/失败:不阻塞 UI,等 SSE 兜底;若 SSE 也无响应则报错 + // 同步超时/失败:不阻塞 UI,等 SSE 兜底;若 SSE 也无响应则报错。 + // 明确提示"可能已发送",避免用户在超时后重复点击导致服务端收到多条相同消息 console.warn("[sendChat] trigger failed: " + e.message); + toast( + __( + "请求超时(可能已发送,请稍候或在收到回复前勿重复发送)", + "Request timeout (may have been sent; wait for reply before resending)", + ), + true, + ); r = null; } state.chatStage = __("AI 回复中...", "AI replying..."); var last = state.messages[state.messages.length - 1]; - // CLI/device 无 SSE:POST 完整结果直接填充 - if (!isWebui && r) { + // 无论 webui/cli:同步 POST 拿到完整回复就直接填充(SSE 公网不稳靠此兜底) + if (r && r.response) { if (last && last.role === "assistant" && last._streaming) { last.content = r.response || __("(无响应)", "(no response)"); last._grow = true; @@ -1852,7 +1880,7 @@ async function sendChat() { } state.chatFinalIdx = state.messages.length - 1; } - if (!isWebui) rerenderChat(); + rerenderChat(); } catch (e) { state.messages.push({ role: "assistant", @@ -1878,7 +1906,7 @@ async function queryMemoryChat() { try { var data = await api("/memory?q=" + encodeURIComponent(q) + "&depth=2"); r.innerHTML = - '

' +
+      '
' +
       escHtml(JSON.stringify(data, null, 2)) +
       "
"; } catch (e) { @@ -1901,7 +1929,7 @@ async function queryMemoryContext() { var summary = data?.summary || ""; var entities = data?.entities || []; var tk = data?.token_estimate || 0; - var html = '
'; + var html = '
'; if (summary) html += '
' + @@ -1924,7 +1952,7 @@ async function queryMemoryContext() { "
"; } html += - '
' +
+      '
' +
       escHtml(ctx) +
       "
"; r.innerHTML = html; @@ -1945,7 +1973,7 @@ async function searchKnowledgeChat() { try { var data = await api("/knowledge?q=" + encodeURIComponent(q)); r.innerHTML = - '
' +
+      '
' +
       escHtml(JSON.stringify(data, null, 2)) +
       "
"; } catch (e) { @@ -2067,7 +2095,7 @@ function renderTerminals() { if (cnt) cnt.textContent = list.length; if (list.length === 0) { r.innerHTML = - '

' + + '

' + __("暂无终端会话", "No terminal sessions") + "

"; return; @@ -2087,13 +2115,13 @@ function renderTerminals() { ""; } html += - '
'; + '
'; html += '
"; html += - '' + + '' + escHtml(t.id || "-") + ""; html += @@ -2107,7 +2135,7 @@ function renderTerminals() { (running ? __("运行中", "Running") : __("已关闭", "Closed")) + ""; html += - '' + + '' + escHtml(t.created_at || "") + ""; html += "
"; @@ -2147,13 +2175,13 @@ function renderCmdHistory() { if (cnt) cnt.textContent = running.length; if (running.length === 0) { r.innerHTML = - '

' + + '

' + __("暂无运行中的命令", "No running commands") + "

"; return; } var html = - '
' + + '"; if (out) { html += - '"; } @@ -2650,7 +2678,7 @@ function renderHealthResult(r) { '">' + (c.status || "unknown") + "" + - '' + + '' + escHtml(c.detail || "") + ""; }); @@ -3208,7 +3236,7 @@ function renderOneSettings() { }); } var descHtml = desc - ? '

' + + ? '

' + escHtml(desc) + "

" : ""; @@ -3343,7 +3371,7 @@ function renderOneSettings() { html += '

' + __("MCP 服务器", "MCP Servers") + - '

' + + '

' + __( "配置 Model Context Protocol 服务端连接", "Configure Model Context Protocol server connections", @@ -3852,7 +3880,7 @@ function renderGuiPrefs() { label + "

" + (desc - ? '
' + + ? '
' + desc + "
" : "") + @@ -3954,8 +3982,7 @@ function renderConnSection() { __("连接类型", "Type") + '' + + '' + '
' + @@ -4032,11 +4059,6 @@ 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(); } @@ -4365,38 +4387,6 @@ 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) { @@ -4497,31 +4487,19 @@ async function saveConnForm() { testBtn.textContent = __("保存", "Save"); testBtn.disabled = false; var connData = - ctype === "device" - ? { + ctype === "cli" + ? { name: name, type: "cli", socketPath: sock, url: "", apiKey: apiKey } + : { name: name, - type: "device", + type: "webui", url: url, apiKey: apiKey, - 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, - }; + username: username, + password: password, + cookie: cookie, + headers: headers, + gateway: gwEnabled, + }; var data; if (editingConnId) { data = await window.homeagent.connections.update(editingConnId, connData); @@ -4572,8 +4550,7 @@ connectSSE = () => { } if (!state.currentConn) return; // CLI/device 连接无 SSE 通道,聊天走同步 - if (state.currentConn.type === "cli" || state.currentConn.type === "device") - return; + if (state.currentConn.type === "cli") return; connectFetchSSE(state.currentConn.url + "/api/v1/chat/events"); }; @@ -4910,6 +4887,97 @@ function renderDevices() { ) + "

"; } + // 设备通道配置(独立于连接类型:devicced 是 GUI 组件,默认走 webui 反代端口) + var dbc = state.dbConfig || {}; + var webuiUrl = ""; + if ( + state.currentConn && + state.currentConn.type === "webui" && + state.currentConn.url + ) { + webuiUrl = state.currentConn.url.replace(/\/+$/, "") + "/api/v1/device/ws"; + } + var curGateway = dbc.gateway || webuiUrl || ""; + var dbExec = dbc.exec || {}; + var dispIdx = dbc.screensueDisplay || "0"; + var dispOpts = (state.displays || []) + .map( + (d) => + '", + ) + .join(""); + selfHtml += + '
' + + __("设备通道", "Device Channel") + + '' + + '
' + + '' + + (dbc.connected + ? __("设备桥已连接", "Bridge connected") + : __("设备桥未连接", "Bridge not connected")) + + (dbc.deviceId ? " · " + escHtml(dbc.deviceId) : "") + + "
" + + '' + + '' + + '
' + + "" + + "
" + + '
' + + "' + + "
" + + '
' + + "" + + (dbExec.sandbox === "box" + ? '' + : '') + + "
" + + '
"; + selfHtml += ""; var html = selfHtml + @@ -4956,7 +5024,7 @@ function renderDevices() { html += "
' + __("命令", "Command") + "" + __("状态", "Status") + @@ -2177,7 +2205,7 @@ function renderCmdHistory() { "
' +
+        '
' +
         escHtml(out.substring(0, 2000)) +
         "
" + escHtml(d.name || d.device_id) + - '
' + + '
' + escHtml(d.device_id) + "
" + escHtml(d.kind || "-") + @@ -4986,9 +5054,45 @@ function renderDevices() { el.innerHTML = html; } +// 保存设备通道配置(网关 + token + 启用),调主进程 deviceBridge:set +async function saveBridgeChannel() { + try { + if (!window.homeagent || !window.homeagent.deviceBridge) { + toast(__("设备桥不可用", "Device bridge unavailable"), true); + return; + } + var gw = (document.getElementById("dev-bridge-gw").value || "").trim(); + var tok = (document.getElementById("dev-bridge-token").value || "").trim(); + if (!gw) { + toast(__("请填设备通道地址", "Set device channel URL first"), true); + return; + } + var cfg = { enabled: true, gateway: gw }; + if (tok) cfg.token = tok; + // 能力配置:screensue 屏幕 / cmdrun 目录 / 沙箱 + var disp = document.getElementById("dev-bridge-display"); + if (disp) cfg.screensueDisplay = disp.value || "0"; + var cwd = document.getElementById("dev-bridge-cwd"); + var sandbox = document.getElementById("dev-bridge-sandbox"); + var boxdir = document.getElementById("dev-bridge-boxdir"); + var ex = {}; + if (cwd) ex.cwd = cwd.value.trim(); + if (sandbox) ex.sandbox = sandbox.value || "off"; + if (boxdir) ex.boxDir = boxdir.value.trim(); + cfg.exec = ex; + var r = await window.homeagent.deviceBridge.set(cfg); + state.dbConfig = r || state.dbConfig; + toast(__("设备通道已保存并应用", "Device channel saved & applied")); + refreshAll(); + renderDevices(); + } catch (e) { + toast(__("保存失败: ", "Save failed: ") + e.message, true); + } +} + async function deviceRefresh() { try { - var d = await api("/device"); + var d = await api("/device/online"); state.devices = d.devices || []; renderDevices(); } catch (e) { diff --git a/cmd/gui/renderer/style.css b/cmd/gui/renderer/style.css index f8e6496..0d83af4 100644 --- a/cmd/gui/renderer/style.css +++ b/cmd/gui/renderer/style.css @@ -45,8 +45,8 @@ --bg-input: rgba(13, 18, 34, 0.75); --bg-hover: rgba(255, 255, 255, 0.06); --text-primary: #eef1f8; - --text-secondary: #a7b0c4; - --text-muted: #77809a; + --text-secondary: #b8c1d6; + --text-muted: #93a0b8; --border-color: rgba(255, 255, 255, 0.09); --accent: #ff7fac; --accent-bg: rgba(255, 127, 172, 0.14); @@ -98,8 +98,8 @@ --bg-input: rgba(255, 224, 233, 0.55); --bg-hover: rgba(255, 127, 172, 0.08); --text-primary: #3b2030; - --text-secondary: #7a5c6b; - --text-muted: #a48a96; + --text-secondary: #6b4b5c; + --text-muted: #8f6f7d; --border-color: rgba(201, 36, 98, 0.14); --accent: #c92462; --accent-bg: #ffe4e9; @@ -290,7 +290,7 @@ body { transition: background 0.2s, color 0.2s; - font-size: 14px; + font-size: 15px; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; user-select: none; @@ -707,13 +707,13 @@ body.maximized .tb-max svg { transform: translateY(-1px); } .card h2 { - font-size: 15px; + font-size: 16px; font-weight: 600; margin-bottom: 12px; color: var(--text-primary); } .card h3 { - font-size: 13px; + font-size: 14px; font-weight: 600; color: var(--text-secondary); margin: 16px 0 8px; @@ -901,7 +901,7 @@ select { border-radius: var(--radius-sm); padding: 8px 12px; color: var(--text-primary); - font-size: 13px; + font-size: 14px; width: 100%; margin-bottom: 10px; outline: none; @@ -923,9 +923,9 @@ textarea { } label { display: block; - font-size: 11px; + font-size: 13px; color: var(--text-secondary); - margin-bottom: 3px; + margin-bottom: 4px; font-weight: 500; } pre { @@ -1597,10 +1597,10 @@ code { margin-bottom: 16px; } .settings-tabs span { - padding: 6px 14px; - font-size: 13px; + padding: 7px 16px; + font-size: 14px; cursor: pointer; - color: var(--text-muted); + color: var(--text-secondary); border-radius: var(--radius-pill); border: 1px solid transparent; transition: all 0.15s; @@ -1619,18 +1619,18 @@ code { } .settings-key { font-family: var(--font-mono); - font-size: 11px; - color: var(--text-muted); - margin-bottom: 2px; + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 3px; } .kv-row { display: flex; - padding: 6px 0; + padding: 7px 0; border-bottom: 1px solid var(--kv-border); - font-size: 13px; + font-size: 14px; } .kv-row .key { - color: var(--text-muted); + color: var(--text-secondary); width: 180px; flex-shrink: 0; } @@ -1716,8 +1716,8 @@ code { vertical-align: 1px; } .conn-item .conn-url { - font-size: 11px; - color: var(--text-muted); + font-size: 12px; + color: var(--text-secondary); margin-top: 2px; } .conn-item .conn-actions {