diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 9338959..dfea0ec 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -581,16 +581,29 @@ async function refreshAll() { state.devices = []; } try { - if ( - state.currentConn && - state.currentConn.type === "device" && - window.homeagent && - window.homeagent.device - ) { - var idn = await window.homeagent.device.identity(); - if (idn) { - state.selfDeviceId = idn.device_id || ""; - state.selfGateway = idn.address || ""; + // 本机设备桥身份:设备桥由 gui-prefs 驱动,独立于当前连接类型 + if (window.homeagent && window.homeagent.deviceBridge) { + var dbinfo = await window.homeagent.deviceBridge.get(); + if (dbinfo && dbinfo.enabled) { + if (dbinfo.deviceId) { + state.selfDeviceId = dbinfo.deviceId; + state.selfGateway = dbinfo.address || state.selfGateway; + } + // 若尚未有设备列表且已启用设备桥但非 device 连接,尝试经设备桥网关拉取 + if ( + state.devices.length === 0 && + dbinfo.gateway && + window.homeagent && + window.homeagent.device + ) { + try { + var eb = await window.homeagent.device.identity(); + if (eb && eb.device_id) { + state.selfDeviceId = eb.device_id; + state.selfGateway = eb.address || dbinfo.gateway; + } + } catch (e2) {} + } } } } catch (e) {} @@ -4839,21 +4852,6 @@ function renderDevices() { var el = document.getElementById("view-devices"); if (!el) return; var conn = state.currentConn; - if (!conn || conn.type !== "device") { - el.innerHTML = - '

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

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

" + - '
"; - return; - } var devs = state.devices || []; var selfDev = null; if (state.selfDeviceId) { @@ -4864,6 +4862,8 @@ function renderDevices() { } } } + // 本机 GUI 设备卡片:始终显示(不依赖当前连接类型), + // 设备桥由 gui-prefs 的 deviceBridge 驱动,独立于连接。 var selfHtml = '

' + __("本机 GUI 设备", "Local GUI Device") + diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index 15593da..06dccb1 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -1,6 +1,7 @@ package webui import ( + "bufio" "context" "crypto/rand" "embed" @@ -1678,6 +1679,7 @@ var ( // handleDeviceGatewayProxy 将 /api/v1/device/* 反代到 remotedevice 内部 HTTP 服务。 // 鉴权:本端走 requireAPI(webui API key),转发时带 remotedevice 的 token(X-API-Key)。 +// WS 升级请求(Upgrade: websocket)走 hijack 双向字节透传(标准库 http.Client 不支持 101 升级)。 func (h *Handler) handleDeviceGatewayProxy(w http.ResponseWriter, r *http.Request) { if !deviceGatewayEnabled { http.NotFound(w, r) @@ -1692,6 +1694,13 @@ func (h *Handler) handleDeviceGatewayProxy(w http.ResponseWriter, r *http.Reques if r.URL.RawQuery != "" { url += "?" + r.URL.RawQuery } + + // WebSocket 升级:hijack 双向透传(支持 WS over 远程 homed) + if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + h.proxyWebSocket(w, r, addr, path) + return + } + req, err := http.NewRequestWithContext(r.Context(), r.Method, url, r.Body) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) @@ -1714,6 +1723,88 @@ func (h *Handler) handleDeviceGatewayProxy(w http.ResponseWriter, r *http.Reques io.Copy(w, resp.Body) } +// proxyWebSocket 用 TCP 直连 + hijack 将客户端 WS 连接双向透传到设备网关。 +func (h *Handler) proxyWebSocket(w http.ResponseWriter, r *http.Request, addr, path string) { + upstream := "ws://" + addr + path + if r.URL.RawQuery != "" { + upstream += "?" + r.URL.RawQuery + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + // 构造带 Upgrade 头的请求:http.Transport 对 Upgrade 请求保留连接字节流 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstream, nil) + if err != nil { + http.Error(w, "ws upstream: "+err.Error(), http.StatusBadGateway) + return + } + req.Header = r.Header.Clone() + if deviceGatewayToken != "" { + req.Header.Set("X-API-Key", deviceGatewayToken) + } + tr := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + } + resp, err := tr.RoundTrip(req) + if err != nil { + http.Error(w, "ws upstream dial: "+err.Error(), http.StatusBadGateway) + return + } + if resp.StatusCode != http.StatusSwitchingProtocols { + defer resp.Body.Close() + http.Error(w, "ws upstream status: "+resp.Status, http.StatusBadGateway) + return + } + + // 客户端 hijack:把 101 响应头写给客户端并接管双向连接 + hj, ok := w.(http.Hijacker) + if !ok { + resp.Body.Close() + http.Error(w, "hijack not supported", http.StatusInternalServerError) + return + } + clientConn, brw, err := hj.Hijack() + if err != nil { + resp.Body.Close() + return + } + defer clientConn.Close() + + // 向上游写回 101 响应头 + if err := resp.Write(brw); err != nil { + resp.Body.Close() + return + } + if err := brw.Flush(); err != nil { + resp.Body.Close() + return + } + + // 上游连接 + upConn, ok := resp.Body.(io.ReadWriteCloser) + if !ok { + clientConn.Close() + http.Error(w, "upstream conn not rw", http.StatusBadGateway) + return + } + defer upConn.Close() + + // 双向透传(WS 帧字节不动) + errCh := make(chan struct{}, 2) + go func() { + io.Copy(upConn, brw) + if tc, ok := upConn.(interface{ CloseWrite() error }); ok { + tc.CloseWrite() + } + errCh <- struct{}{} + }() + go func() { + io.Copy(bufio.NewWriter(clientConn), upConn) + errCh <- struct{}{} + }() + <-errCh + tr.CloseIdleConnections() +} + // ======== Plugin Management (proxied to pluginmgr HTTP API) ======== func (h *Handler) pluginmgrAddr() string {