diff --git a/docs/changelog/2026-06-10-rdp-display-mount-fix.md b/docs/changelog/2026-06-10-rdp-display-mount-fix.md new file mode 100644 index 00000000..1da2296c --- /dev/null +++ b/docs/changelog/2026-06-10-rdp-display-mount-fix.md @@ -0,0 +1,20 @@ +# 2026-06-10 — RDP 画布挂载与前端同步修复 + +## 摘要 + +修复 3389 页一直「正在连接」:Guacamole 画布需在 `connect()` 前挂入 DOM;连接层用独立 `canvasHost` + 遮罩,避免 Vue 重绘抹掉画布;生产同步 `web/app` 进容器。 + +## 动机 + +- 官方 Guacamole 用法:先 `appendChild(display.getElement())` 再 `client.connect()` +- 原实现等 `STATE_CONNECTED` 才挂载,sync/flush 可能无法完成,客户端停在 WAITING +- Docker 镜像内前端为旧 vite 缓存,未执行 `sync_webapp_to_container` + +## 涉及文件 + +- `frontend/src/composables/rdp/useRdpSession.ts` +- `frontend/src/pages/RdpPage.vue` + +## 验证 + +生产 `sync_webapp_to_container.sh` 后,3389 连接应进入「已连接」并显示桌面。 diff --git a/frontend/src/composables/rdp/useRdpSession.ts b/frontend/src/composables/rdp/useRdpSession.ts index e9217dce..16a6c3e4 100644 --- a/frontend/src/composables/rdp/useRdpSession.ts +++ b/frontend/src/composables/rdp/useRdpSession.ts @@ -30,6 +30,10 @@ function buildConnectString(info: RdpConnectInfo): string { return pairs.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&') } +function tunnelErrorMessage(st: { code?: number; message?: string }): string { + return st?.message || `RDP 隧道错误 (${st?.code ?? 'unknown'})` +} + export function useRdpSession() { const status = ref('idle') const errorMessage = ref('') @@ -71,6 +75,18 @@ export function useRdpSession() { client.sendSize(w, h) } + function mountDisplay(rdpClient: InstanceType) { + if (!mountTarget) return + mountTarget.innerHTML = '' + const el = rdpClient.getDisplay().getElement() + mountTarget.appendChild(el) + displayEl.value = el + resizeObserver?.disconnect() + resizeObserver = new ResizeObserver(() => sendDisplaySize()) + resizeObserver.observe(mountTarget) + sendDisplaySize() + } + async function connect(hostId: number, container: HTMLElement) { disconnect() mountTarget = container @@ -96,32 +112,27 @@ export function useRdpSession() { serverName.value = info.name status.value = 'connecting' - // Token in path: Guacamole Client.connect() appends ?hostname=… to tunnel URL. const wsUrl = buildWebSocketUrl( `/ws/rdp/hosts/${hostId}/tunnel/${encodeURIComponent(auth.token)}`, ) - tunnel = new Guacamole.WebSocketTunnel(wsUrl) - const rdpClient = new Guacamole.Client(tunnel) + const rdpTunnel = new Guacamole.WebSocketTunnel(wsUrl) + tunnel = rdpTunnel + const rdpClient = new Guacamole.Client(rdpTunnel) client = rdpClient - rdpClient.onerror = (st: { code?: number; message?: string }) => { + const fail = (message: string) => { status.value = 'error' - errorMessage.value = st?.message || `RDP 错误 (${st?.code ?? 'unknown'})` + errorMessage.value = message disconnect() } + rdpTunnel.onerror = (st) => fail(tunnelErrorMessage(st)) + rdpClient.onerror = (st) => fail(tunnelErrorMessage(st)) + rdpClient.onstatechange = (state: number) => { if (state === Guacamole.Client.STATE_CONNECTED) { status.value = 'connected' - const el = rdpClient.getDisplay().getElement() - if (el && mountTarget) { - mountTarget.innerHTML = '' - mountTarget.appendChild(el) - displayEl.value = el - sendDisplaySize() - resizeObserver = new ResizeObserver(() => sendDisplaySize()) - resizeObserver.observe(mountTarget) - } + sendDisplaySize() } else if (state === Guacamole.Client.STATE_DISCONNECTED) { if (status.value !== 'error') status.value = 'disconnected' } @@ -130,6 +141,8 @@ export function useRdpSession() { const display = rdpClient.getDisplay() display.onresize = () => sendDisplaySize() + // Display must be in DOM before connect so sync/flush can complete (Guacamole convention). + mountDisplay(rdpClient) rdpClient.connect(buildConnectString(info)) } diff --git a/frontend/src/pages/RdpPage.vue b/frontend/src/pages/RdpPage.vue index 58500fb9..8bca0bbc 100644 --- a/frontend/src/pages/RdpPage.vue +++ b/frontend/src/pages/RdpPage.vue @@ -84,16 +84,22 @@ -
- -

{{ status === 'loading' ? '加载连接参数…' : '正在连接远程桌面…' }}

-
-
- - {{ errorMessage }} - - 返回列表 -
+