Files
HomeAgent/cmd/waiter/device.go
JianFeeeee 7f5bf1670f feat(clients): 设备桥自动链接改用服务端发现 + 路径挂载(无 DNS 依赖)
配套 webui 反代改造:网关现在可由 HomeAgent 反代出去,客户端不能再靠
「门户地址同 host 拼 /api/v1/device/ws」猜地址——基域名与子域标签都是
**服务端配置**,客户端无从得知。

## 服务端:/api/v1/device/gateway 发现端点

客户端问「网关在哪」是唯一不会漂移的做法:子域标签可改(插件声明)、
基域名可改(webui.base_domain)、实例可换形态,客户端都不用跟着改。

⚠️ **不返回设备令牌**:本端点用门户凭证鉴权,而设备令牌能执行设备命令;
把令牌塞进来等于「门户只读凭证 → 设备执行权」的越权。令牌仍由客户端
自配。已有判据钉住「不得泄漏凭证字段」。

## ★ 实测发现:*.localhost 只有浏览器能解析

这是本轮最重要的发现,直接决定了设计:

| 环境 | devices.localhost 解析 |
|---|---|
| 浏览器 | ✓(RFC 6761 内置) |
| curl | ✓(内置特例) |
| getent / Go / Node | ✗(系统 nsswitch 是 files,dns,无 nss-myhostname) |

设备客户端(waiter / GUI 主进程 / 嵌入式固件)用的正是系统解析器。
实测 waiter 报「lookup devices.localhost on 192.168.2.1:53」。

因此**两处**设计变更:
1. 发现端点同时返回两种形态,并标 preferred:
   - url(子域)—— 浏览器用
   - url_portal(门户同源,同一 host、同一端口,走路径挂载)—— 非浏览器用,
     无任何 DNS 依赖
2. SDK 的 ProxyDecl 新增 **Path**(路径挂载前缀):让同一服务同时挂到
   门户自身 host 的路径下。remotedevice 声明 Path="/api/v1/device",
   设备客户端因此能沿用**它已硬编码的路径**,不需要知道反代存在。

路径挂载语义:请求路径**原样保留**(不剥前缀),上游按真实路径注册即可。
边界卡在路径分隔符上(/api/v1/device 不匹配 /api/v1/devicefoo)。

## 客户端

- **waiter**:新增 discoverGateway(),仅在用户配了门户地址时尝试,失败回退
  自配地址(老版本 HomeAgent 无该端点)。抽出 normalizeGateway() 纯函数,
  显式钉住「已带子域/完整端点的地址不得被改写」。
- **GUI**:renderer 新增 loadDiscoveredGateway(),renderDeviceChannel 优先用
  发现值、回退旧口径。顺带修掉此前插入函数时 anchor 不匹配导致调用点
  找不到定义的问题。
- **鸿蒙**:discoverGateway() + resolveGatewayUrl(),优先 url_portal。
- 三者都**优先 url_portal**(system resolver 的现实约束)。

## 遗留路由鉴权修正

`/api/v1/device/` 的旧路径反代原被 requireAPI 包裹 —— 但其调用方是设备
(带设备令牌而非门户凭证),套上门户鉴权会把它们全挡在 401(**真实实测**:
waiter 经此路径升级握手 401)。去掉这层包装,鉴权交给上游 remotedevice
自己的 requireToken,安全性不降级。

## 判据

webui +6 条、waiter +7 条。

★ 其中一条是**真实回归**:/api/v1/device/gateway 曾被 Path="/api/v1/device"
的路径挂载接走(那服务 auth=none),于是发现请求被转给上游、回 401,
客户端再也发现不到网关。修法是发现端点先于路径挂载判定,并补判据
(走完整生产链,同时确认同前缀的真实设备路径仍归反代)。

## 真实验收(隔离实例,命名 netns + 独立 data + 18080)

真 waiter 客户端 + 真 remotedevice 网关:
  device gateway discovered: ws://127.0.0.1:18080/api/v1/device/ws
  device bridge active: waiter-mainserver authorized=true
  hello_ack / bind_ack 均经反代往返成功

说明:`bind_ack device=<nil>` 与在线列表为空的现象,**直连 9890 绕开反代
完全一致复现**,属 remotedevice 与 waiter 之间既有的握手细节,与本次
反代改造无关(反代侧职责已证:连接建立 + 双向帧往返都通)。
2026-09-26 13:49:59 +08:00

777 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/devicebridge/client"
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
)
// ===== 设备桥管理 =====
var (
deviceBridge *client.Bridge
cmdRouter *client.CmdRouter
deviceBridgeID string
)
// startDeviceBridge 启动设备桥,连接 remotedevice 网关。
// 使用共享库 client.Bridge 替代手写 WS 协议。
func startDeviceBridge(addr, token string) error {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "local"
}
deviceID := "waiter-" + sanitizeID(hostname)
// 构建完整能力列表
caps := []string{
"status", "cmdrun", "deviceinfo", "cmdresult",
"computeruse", "screensee", "clipboardsee", "clipboardsue",
"camerasue", "speakeruse", "screensue",
}
info := map[string]interface{}{
"hostname": hostname,
"platform": runtime.GOOS,
"arch": runtime.GOARCH,
"cpus": runtime.NumCPU(),
// 客户端版本与内核同源(internal/meta),deviceinfo 回显的软件版本
// 因此与 homed 一致,不再是一个空缺字段。
"version": meta.Version,
}
// 网关地址规范化(含「已是完整端点」「只有 host:port」两种旧输入形态)。
gateway := normalizeGateway(addr)
bridge := client.New(gateway, token, deviceID, "HomeAgent CLI", caps, info)
cmdRouter = client.NewCmdRouter()
// cmd_type 是主路由信号;保留 homeagent-* 文本前缀兼容旧服务端。
cmdRouter.Handle("homeagent-", handleHomeagentCmd)
cmdRouter.HandleDefault(handleShellCmd)
bridge.OnCmd(func(reqID, command, cmdType string) {
if cmdType == "homeagent" {
handleHomeagentCmd(reqID, command)
return
}
cmdRouter.Dispatch(reqID, command)
})
// agent 主动投递(output_send__device/<id>)→ 终端显示。
// 设备侧参考实现:文本/结构化直接打出来;二进制负载走 OnData(TTS 音频等)。
bridge.OnPush(func(reqID, typ, payload, meta string) {
printlnC("cyan", fmt.Sprintf("[push:%s] %s", typ, payload))
})
if err := bridge.Start(); err != nil {
return fmt.Errorf("device bridge: %w", err)
}
deviceBridge = bridge
deviceBridgeID = deviceID
return nil
}
// stopDeviceBridge 停止设备桥。
func stopDeviceBridge() {
if deviceBridge != nil {
deviceBridge.Stop()
deviceBridge = nil
}
}
// ===== 命令分发 =====
// homeagent 能力白名单命令(与 remotedevice 插件对齐)
var homeagentAllowCmd = regexp.MustCompile(
"^(ls|pwd|whoami|uname|date|echo|uptime|hostname|cat|df|free|ps|ip|dir|node|python3?|npm|git|curl|wget|systeminfo|tasklist)\\b",
)
func handleShellCmd(reqID, command string) {
cmd := strings.TrimSpace(command)
if cmd == "" {
sendBridgeResult(reqID, "error", "", "empty command")
return
}
if !homeagentAllowCmd.MatchString(cmd) {
sendBridgeResult(reqID, "error", "", "command not in whitelist")
return
}
parts := strings.Fields(cmd)
if len(parts) == 0 {
sendBridgeResult(reqID, "error", "", "empty command")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
execCmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
out, err := execCmd.Output()
if err != nil {
if ctx.Err() != nil {
sendBridgeResult(reqID, "error", "", "timeout")
return
}
sendBridgeResult(reqID, "error", truncate8k(string(out)), err.Error())
return
}
sendBridgeResult(reqID, "ok", truncate8k(string(out)), "")
}
func handleHomeagentCmd(reqID, command string) {
capability, args := client.ParseHomeagentCmd(command)
switch capability {
case "camerasue":
execCamerasue(reqID, args)
case "screensue":
execScreensue(reqID, args)
case "screensee":
execScreensee(reqID, args)
case "speakeruse":
execSpeakeruse(reqID, args)
case "computeruse":
execComputeruse(reqID, args)
case "clipboardsee":
execClipboardsee(reqID)
case "clipboardsue":
execClipboardsue(reqID, args)
case "status":
execDeviceStatus(reqID)
case "deviceinfo":
execDeviceInfo(reqID)
default:
sendBridgeResult(reqID, "error", "", fmt.Sprintf("unknown capability: %s", capability))
}
}
// ===== 能力实现 =====
func execCamerasue(reqID, args string) {
// 摄像头:依赖于 ffmpeg/v4l2(Linux)或 ffmpeg/dshow(Windows)
args = strings.TrimSpace(args)
durMatch := 0
if args != "" {
if n, err := fmt.Sscanf(args, "%d", &durMatch); err != nil || n != 1 {
durMatch = 0
}
}
isVideo := durMatch > 0
tmpDir := os.TempDir()
outFile := filepath.Join(tmpDir, fmt.Sprintf("ha_cam_%d.jpg", time.Now().UnixNano()))
if isVideo {
outFile = filepath.Join(tmpDir, fmt.Sprintf("ha_cam_%d.mp4", time.Now().UnixNano()))
}
var cmd *exec.Cmd
if runtime.GOOS == "linux" {
if isVideo {
cmd = exec.Command("ffmpeg", "-f", "v4l2", "-i", "/dev/video0",
"-t", fmt.Sprintf("%d", durMatch),
"-pix_fmt", "yuv420p", "-c:v", "libx264",
"-f", "mp4", outFile)
} else {
cmd = exec.Command("ffmpeg", "-f", "v4l2", "-i", "/dev/video0",
"-frames:v", "1", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1")
}
} else if runtime.GOOS == "windows" {
if isVideo {
cmd = exec.Command("ffmpeg", "-f", "dshow", "-i", "video=USB Camera",
"-t", fmt.Sprintf("%d", durMatch),
"-pix_fmt", "yuv420p", "-c:v", "libx264",
"-f", "mp4", outFile)
} else {
cmd = exec.Command("ffmpeg", "-f", "dshow", "-i", "video=USB Camera",
"-frames:v", "1", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1")
}
} else {
sendBridgeResult(reqID, "error", "", "camerasue: unsupported platform")
return
}
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = nil
timeout := 15 * time.Second
if isVideo {
timeout = time.Duration(durMatch+15) * time.Second
}
cmdCtx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd = exec.CommandContext(cmdCtx, cmd.Path, cmd.Args[1:]...)
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
sendBridgeResult(reqID, "error", "", fmt.Sprintf("camerasue failed: %v", err))
return
}
if isVideo {
data, err := os.ReadFile(outFile)
if err != nil {
sendBridgeResult(reqID, "error", "", fmt.Sprintf("camerasue read failed: %v", err))
return
}
os.Remove(outFile)
if deviceBridge != nil {
deviceBridge.SendDataChunked(reqID, "camera_video", "video/mp4", data)
} else {
b64 := base64.StdEncoding.EncodeToString(data)
sendBridgeResult(reqID, "ok", "data:video/mp4;base64,"+b64, "")
}
} else {
b64 := base64.StdEncoding.EncodeToString(out.Bytes())
sendBridgeResult(reqID, "ok", "data:image/jpeg;base64,"+b64, "")
}
}
func execScreensue(reqID, args string) {
// CLI 版 screensue:TUI 风格显示
// 在终端中渲染 HTML 或启动新终端窗口
args = strings.TrimSpace(args)
duration := 5
content := args
// 解析参数:首个纯数字 token 作为时长
tokens := strings.Fields(args)
if len(tokens) > 1 && regexp.MustCompile(`^\d+$`).MatchString(tokens[0]) {
fmt.Sscanf(tokens[0], "%d", &duration)
content = strings.Join(tokens[1:], " ")
}
if content == "" {
content = "HomeAgent 远程屏幕提示"
}
// 检测 HTML 内容
isHTML := strings.Contains(content, "<") && strings.Contains(content, ">")
// 按平台选择 TUI 渲染方式
platform := runtime.GOOS
var err error
switch platform {
case "linux":
err = screensueLinux(content, isHTML, duration)
case "windows":
err = screensueWindows(content, isHTML, duration)
default:
err = screensueFallback(content, isHTML, duration)
}
if err != nil {
sendBridgeResult(reqID, "error", "", fmt.Sprintf("screensue failed: %v", err))
return
}
sendBridgeResult(reqID, "ok", "screensue shown", "")
}
func screensueLinux(content string, isHTML bool, duration int) error {
// 尝试多种方式显示
// 1. browsh(如果可用)
// 2. w3m(终端内渲染 HTML)
// 3. 写入临时文件 + notify-send
// 4. 启动新终端窗口
// 方式1: 写入临时 HTML 文件
tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("ha_screensue_%d.html", time.Now().UnixNano()))
var htmlContent string
if isHTML {
htmlContent = content
} else {
htmlContent = fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><style>
body{font-family:sans-serif;display:flex;flex-direction:column;justify-content:center;
align-items:center;height:100vh;margin:0;background:#0b1020;color:#eef1f8;}
h1{color:#ff7fac;margin-bottom:16px;}</style></head><body>
<h1>HomeAgent</h1><pre>%s</pre></body></html>`, content)
}
if err := os.WriteFile(tmpFile, []byte(htmlContent), 0644); err != nil {
return fmt.Errorf("write temp file: %w", err)
}
defer os.Remove(tmpFile)
// 尝试 browsh(Firefox headless 渲染)
if _, err := exec.LookPath("browsh"); err == nil {
cmd := exec.Command("browsh", "--url="+tmpFile, "--startup-url="+tmpFile)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Start()
go func() {
time.Sleep(time.Duration(duration) * time.Second)
_ = cmd.Process.Kill()
}()
return nil
}
// 尝试 w3m(终端内渲染)
if _, err := exec.LookPath("w3m"); err == nil {
cmd := exec.Command("w3m", tmpFile)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Start()
go func() {
time.Sleep(time.Duration(duration) * time.Second)
_ = cmd.Process.Kill()
}()
return nil
}
// 尝试 lynx
if _, err := exec.LookPath("lynx"); err == nil {
cmd := exec.Command("lynx", tmpFile)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Start()
go func() {
time.Sleep(time.Duration(duration) * time.Second)
_ = cmd.Process.Kill()
}()
return nil
}
// 尝试 notify-send(桌面通知)
if _, err := exec.LookPath("notify-send"); err == nil {
_ = exec.Command("notify-send", "HomeAgent", content).Run()
return nil
}
// 最后尝试:启动 xterm 显示
if _, err := exec.LookPath("xterm"); err == nil {
cmd := exec.Command("xterm", "-e", "cat", tmpFile)
_ = cmd.Start()
go func() {
time.Sleep(time.Duration(duration) * time.Second)
_ = cmd.Process.Kill()
}()
return nil
}
// 全部失败:打印到终端
fmt.Printf("\n=== HomeAgent Screensue ===\n%s\n===========================\n", content)
return nil
}
func screensueWindows(content string, isHTML bool, duration int) error {
// Windows 使用 PowerShell 弹窗或启动浏览器渲染 HTML
if isHTML {
// 方式1: 写入临时 HTML 文件并用默认浏览器打开
tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("ha_screensue_%d.html", time.Now().UnixNano()))
htmlContent := content
if !strings.Contains(strings.ToLower(content), "<html") {
htmlContent = fmt.Sprintf(`<!DOCTYPE html><html><meta charset="utf-8"><body>%s</body></html>`, content)
}
if err := os.WriteFile(tmpFile, []byte(htmlContent), 0644); err != nil {
return fmt.Errorf("write temp file: %w", err)
}
defer os.Remove(tmpFile)
// 用默认浏览器打开
cmd := exec.Command("cmd", "/c", "start", "", tmpFile)
_ = cmd.Start()
// 定时关闭(浏览器窗口无法自动关闭,但可以提示)
go func() {
time.Sleep(time.Duration(duration) * time.Second)
_ = exec.Command("powershell", "-Command",
`[Windows.Forms.MessageBox]::Show("screensue 展示结束","HomeAgent")`).Run()
}()
return nil
}
// 方式2: PowerShell 弹出消息框(纯文本)
psScript := fmt.Sprintf(`
Add-Type -AssemblyName System.Windows.Forms
$popup = New-Object Windows.Forms.Form
$popup.Text = "HomeAgent"
$popup.Size = New-Object Drawing.Size(600,400)
$popup.StartPosition = "CenterScreen"
$popup.TopMost = $true
$label = New-Object Windows.Forms.Label
$label.Text = "%s"
$label.AutoSize = $true
$label.TextAlign = "MiddleCenter"
$popup.Controls.Add($label)
$timer = New-Object Windows.Forms.Timer
$timer.Interval = %d
$timer.Add_Tick({ $popup.Close() })
$timer.Start()
[Windows.Forms.Application]::Run($popup)
`, strings.ReplaceAll(content, `"`, "`\""), duration*1000)
cmd := exec.Command("powershell", "-Command", psScript)
_ = cmd.Start()
go func() {
_ = cmd.Wait()
}()
return nil
}
func screensueFallback(content string, isHTML bool, duration int) error {
fmt.Printf("\n=== HomeAgent Screensue ===\n%s\n===========================\n", content)
return nil
}
func execScreensee(reqID, args string) {
// 屏幕截图:使用平台特定工具
platform := runtime.GOOS
tmpDir := os.TempDir()
outFile := filepath.Join(tmpDir, fmt.Sprintf("ha_screenshot_%d.png", time.Now().UnixNano()))
defer os.Remove(outFile)
var cmd *exec.Cmd
switch platform {
case "linux":
// 尝试多种截图工具
if _, err := exec.LookPath("import"); err == nil {
cmd = exec.Command("import", "-window", "root", outFile)
} else if _, err := exec.LookPath("scrot"); err == nil {
cmd = exec.Command("scrot", outFile)
} else if _, err := exec.LookPath("gnome-screenshot"); err == nil {
cmd = exec.Command("gnome-screenshot", "-f", outFile)
} else {
sendBridgeResult(reqID, "error", "", "screensee: no screenshot tool found (install scrot/import/gnome-screenshot)")
return
}
case "windows":
// Windows 使用 PowerShell
psScript := fmt.Sprintf(`
Add-Type -AssemblyName System.Windows.Forms
$screen = [Windows.Forms.Screen]::PrimaryScreen.Bounds
$bitmap = New-Object Drawing.Bitmap $screen.Width, $screen.Height
$graphics = [Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($screen.X, $screen.Y, 0, 0, $screen.Size)
$bitmap.Save('%s', [Drawing.Imaging.ImageFormat]::Png)
$graphics.Dispose()
$bitmap.Dispose()
`, strings.ReplaceAll(outFile, "\\", "\\\\"))
cmd = exec.Command("powershell", "-NoProfile", "-Command", psScript)
case "darwin":
cmd = exec.Command("screencapture", "-x", outFile)
default:
sendBridgeResult(reqID, "error", "", "screensee: unsupported platform")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
if out, err := cmd.CombinedOutput(); err != nil {
sendBridgeResult(reqID, "error", "", fmt.Sprintf("screensee failed: %v\n%s", err, string(out)))
return
}
data, err := os.ReadFile(outFile)
if err != nil {
sendBridgeResult(reqID, "error", "", fmt.Sprintf("screensee read failed: %v", err))
return
}
// 小图直接 base64,大图分块
if len(data) < 512*1024 {
b64 := base64.StdEncoding.EncodeToString(data)
sendBridgeResult(reqID, "ok", "data:image/png;base64,"+b64, "")
} else if deviceBridge != nil {
deviceBridge.SendDataChunked(reqID, "screenshot", "image/png", data)
} else {
b64 := base64.StdEncoding.EncodeToString(data)
sendBridgeResult(reqID, "ok", "data:image/png;base64,"+b64, "")
}
}
func execSpeakeruse(reqID, args string) {
args = strings.TrimSpace(args)
if args == "" {
sendBridgeResult(reqID, "error", "", "speakeruse: empty text")
return
}
platform := runtime.GOOS
var cmd *exec.Cmd
switch platform {
case "linux":
// 尝试多种 TTS 引擎
if _, err := exec.LookPath("espeak"); err == nil {
cmd = exec.Command("espeak", args)
} else if _, err := exec.LookPath("festival"); err == nil {
cmd = exec.Command("festival", "--tts", "--pipe")
stdin, _ := cmd.StdinPipe()
go func() {
defer stdin.Close()
stdin.Write([]byte(args))
}()
} else {
sendBridgeResult(reqID, "error", "", "speakeruse: no TTS engine (install espeak/festival)")
return
}
case "windows":
// Windows 使用 SAPI
psScript := fmt.Sprintf(`
Add-Type -AssemblyName System.Speech
$synthesizer = New-Object System.Speech.Synthesis.SpeechSynthesizer
$synthesizer.Speak('%s')
`, strings.ReplaceAll(args, "'", "''"))
cmd = exec.Command("powershell", "-NoProfile", "-Command", psScript)
case "darwin":
cmd = exec.Command("say", args)
default:
sendBridgeResult(reqID, "error", "", "speakeruse: unsupported platform")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
if err := cmd.Run(); err != nil {
sendBridgeResult(reqID, "error", "", fmt.Sprintf("speakeruse failed: %v", err))
return
}
sendBridgeResult(reqID, "ok", "speakeruse done", "")
}
func execComputeruse(reqID, args string) {
// 鼠标键盘操控:解析 JSON 参数
action, params, err := client.ParseJSONCmd(args)
if err != nil {
sendBridgeResult(reqID, "error", "", fmt.Sprintf("computeruse: %v", err))
return
}
platform := runtime.GOOS
if platform == "linux" {
if _, err := exec.LookPath("xdotool"); err != nil {
sendBridgeResult(reqID, "error", "", "computeruse: xdotool not installed")
return
}
execComputeruseLinux(reqID, action, params)
} else if platform == "windows" {
execComputeruseWindows(reqID, action, params)
} else {
sendBridgeResult(reqID, "error", "", "computeruse: unsupported platform")
}
}
func execComputeruseLinux(reqID, action string, params map[string]interface{}) {
switch action {
case "click":
btn := "1"
if b, ok := params["button"].(string); ok {
switch b {
case "right":
btn = "3"
case "middle":
btn = "2"
}
}
cmd := exec.Command("xdotool", "click", btn)
_ = cmd.Run()
sendBridgeResult(reqID, "ok", "computeruse click", "")
case "doubleclick":
cmd := exec.Command("xdotool", "click", "--repeat", "2", "1")
_ = cmd.Run()
sendBridgeResult(reqID, "ok", "computeruse doubleclick", "")
case "rightclick":
cmd := exec.Command("xdotool", "click", "3")
_ = cmd.Run()
sendBridgeResult(reqID, "ok", "computeruse rightclick", "")
case "move":
x, _ := params["x"].(float64)
y, _ := params["y"].(float64)
cmd := exec.Command("xdotool", "mousemove", fmt.Sprintf("%d", int(x)), fmt.Sprintf("%d", int(y)))
_ = cmd.Run()
sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse move (%d,%d)", int(x), int(y)), "")
case "scroll":
dy, _ := params["dy"].(float64)
cmd := exec.Command("xdotool", "click", "4")
if dy < 0 {
cmd = exec.Command("xdotool", "click", "5")
}
_ = cmd.Run()
sendBridgeResult(reqID, "ok", "computeruse scroll", "")
case "type":
text, _ := params["text"].(string)
cmd := exec.Command("xdotool", "type", text)
_ = cmd.Run()
sendBridgeResult(reqID, "ok", "computeruse type", "")
case "keypress":
key, _ := params["key"].(string)
cmd := exec.Command("xdotool", "key", key)
_ = cmd.Run()
sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse keypress %s", key), "")
default:
sendBridgeResult(reqID, "error", "", fmt.Sprintf("computeruse: unknown action %s", action))
}
}
func execComputeruseWindows(reqID, action string, params map[string]interface{}) {
switch action {
case "click":
ps := `[System.Windows.Forms.Cursor]::Position = [System.Drawing.Point]::new($x,$y); ` +
`[System.Windows.Forms.SendKeys]::SendWait("{ENTER}")`
cmd := exec.Command("powershell", "-NoProfile", "-Command", ps)
_ = cmd.Run()
sendBridgeResult(reqID, "ok", "computeruse click", "")
case "move":
x, _ := params["x"].(float64)
y, _ := params["y"].(float64)
ps := fmt.Sprintf(`[System.Windows.Forms.Cursor]::Position = [System.Drawing.Point]::new(%d,%d)`, int(x), int(y))
cmd := exec.Command("powershell", "-NoProfile", "-Command", ps)
_ = cmd.Run()
sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse move (%d,%d)", int(x), int(y)), "")
case "type":
text, _ := params["text"].(string)
ps := fmt.Sprintf(`$wshell = New-Object -ComObject wscript.shell; $wshell.SendKeys('%s')`, strings.ReplaceAll(text, "'", "''"))
cmd := exec.Command("powershell", "-NoProfile", "-Command", ps)
_ = cmd.Run()
sendBridgeResult(reqID, "ok", "computeruse type", "")
default:
sendBridgeResult(reqID, "error", "", fmt.Sprintf("computeruse: unknown action %s", action))
}
}
func execClipboardsee(reqID string) {
// 读取剪贴板
platform := runtime.GOOS
var text string
var err error
switch platform {
case "linux":
if _, e := exec.LookPath("xclip"); e == nil {
out, _ := exec.Command("xclip", "-o", "-selection", "clipboard").Output()
text = string(out)
} else if _, e := exec.LookPath("xsel"); e == nil {
out, _ := exec.Command("xsel", "-ob").Output()
text = string(out)
} else {
sendBridgeResult(reqID, "error", "", "clipboardsee: install xclip or xsel")
return
}
case "windows":
psScript := `Add-Type -AssemblyName System.Windows.Forms; [Windows.Forms.Clipboard]::GetText()`
out, _ := exec.Command("powershell", "-NoProfile", "-Command", psScript).Output()
text = strings.TrimSpace(string(out))
case "darwin":
out, _ := exec.Command("pbpaste").Output()
text = string(out)
default:
sendBridgeResult(reqID, "error", "", "clipboardsee: unsupported platform")
return
}
if err != nil {
sendBridgeResult(reqID, "error", "", fmt.Sprintf("clipboardsee failed: %v", err))
return
}
sendBridgeResult(reqID, "ok", text, "")
}
func execClipboardsue(reqID, text string) {
// 写入剪贴板
text = strings.TrimSpace(text)
if text == "" {
sendBridgeResult(reqID, "error", "", "clipboardsue: empty text")
return
}
platform := runtime.GOOS
switch platform {
case "linux":
if _, err := exec.LookPath("xclip"); err == nil {
cmd := exec.Command("xclip", "-i", "-selection", "clipboard")
cmd.Stdin = strings.NewReader(text)
_ = cmd.Run()
} else if _, err := exec.LookPath("xsel"); err == nil {
cmd := exec.Command("xsel", "-ib")
cmd.Stdin = strings.NewReader(text)
_ = cmd.Run()
} else {
sendBridgeResult(reqID, "error", "", "clipboardsue: install xclip or xsel")
return
}
case "windows":
psScript := fmt.Sprintf(`Add-Type -AssemblyName System.Windows.Forms; [Windows.Forms.Clipboard]::SetText('%s')`,
strings.ReplaceAll(text, "'", "''"))
_ = exec.Command("powershell", "-NoProfile", "-Command", psScript).Run()
case "darwin":
cmd := exec.Command("pbcopy")
cmd.Stdin = strings.NewReader(text)
_ = cmd.Run()
default:
sendBridgeResult(reqID, "error", "", "clipboardsue: unsupported platform")
return
}
sendBridgeResult(reqID, "ok", "clipboard written", "")
}
func execDeviceStatus(reqID string) {
hostname, _ := os.Hostname()
status := map[string]interface{}{
"device_id": deviceBridgeID,
"status": "online",
"hostname": hostname,
"platform": runtime.GOOS,
"arch": runtime.GOARCH,
"uptime": time.Now().Unix(),
}
b, _ := json.Marshal(status)
sendBridgeResult(reqID, "ok", string(b), "")
}
func execDeviceInfo(reqID string) {
hostname, _ := os.Hostname()
info := map[string]interface{}{
"device_id": deviceBridgeID,
"name": "HomeAgent CLI",
"kind": "computer",
"caps": []string{"status", "cmdrun", "deviceinfo", "cmdresult", "computeruse", "screensee", "clipboardsee", "clipboardsue", "camerasue", "speakeruse", "screensue"},
"info": map[string]interface{}{
"hostname": hostname,
"platform": runtime.GOOS,
"arch": runtime.GOARCH,
"cpus": runtime.NumCPU(),
},
}
b, _ := json.Marshal(info)
sendBridgeResult(reqID, "ok", string(b), "")
}
// ===== 辅助函数 =====
var sendBridgeResult = func(reqID, status, output, errMsg string) {
if deviceBridge != nil {
deviceBridge.SendResult(reqID, status, output, errMsg)
}
}
const devMaxOut = 8192
func truncate8k(s string) string {
if len(s) <= devMaxOut {
return s
}
return s[:devMaxOut]
}
func sanitizeID(s string) string {
var sb strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_' {
sb.WriteRune(r)
} else {
sb.WriteByte('_')
}
}
return sb.String()
}