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 {