Feature(Go): Web terminal relay with PTY mode and graceful close (Phase 6)

This commit is contained in:
yuanyuanxiang
2026-05-18 15:03:42 +02:00
committed by yuanyuanxiang
parent 6485e800d6
commit d7f38ecfdb
7 changed files with 696 additions and 96 deletions

View File

@@ -46,11 +46,12 @@ type wsClient struct {
once sync.Once
// Mutated under wsHub.mu (or only by the read loop owning this client).
nonce string // outstanding challenge — cleared after a successful login
token string // set once authenticated
role string // mirrors session role after login
addr string // client address for logs
watching string // device ID this browser is currently streaming, "" when on the list
nonce string // outstanding challenge — cleared after a successful login
token string // set once authenticated
role string // mirrors session role after login
addr string // client address for logs
watching string // device ID this browser is currently streaming, "" when on the list
termWatching string // device ID for an open web terminal session, "" otherwise
}
// queue writes a JSON text frame onto the send buffer. Drops silently if the
@@ -176,6 +177,61 @@ func (h *wsHub) OnScreenFrame(deviceID string, packet []byte, _ bool) {
}
}
// OnTerminalReady notifies the requesting browser that its term_open
// handshake completed. mode is "pty" or "legacy" — xterm.js disables the
// resize callback in legacy mode (no PTY behind the cmd pipe).
func (h *wsHub) OnTerminalReady(deviceID string, isPTY bool) {
mode := "legacy"
if isPTY {
mode = "pty"
}
msg := mustJSON(map[string]any{
"cmd": "term_ready",
"id": deviceID,
"mode": mode,
})
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.termWatching == deviceID && c.token != "" {
c.queue(msg)
}
}
}
// OnTerminalData ships one chunk of raw shell output (already wrapped in
// the "TRM1" magic header) over the binary WS frame. Single-viewer is
// enforced upstream so at most one client matches per device.
func (h *wsHub) OnTerminalData(deviceID string, packet []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients {
if c.termWatching == deviceID && c.token != "" {
c.queueBinary(packet)
}
}
}
// OnTerminalClosed fires when the device's shell exits or the sub-conn
// drops. The browser closes its xterm panel. We also clear termWatching
// so a subsequent term_open from the same browser isn't rejected as
// "already open" by stale state.
func (h *wsHub) OnTerminalClosed(deviceID string, reason string) {
msg := mustJSON(map[string]any{
"cmd": "term_closed",
"ok": true,
"reason": reason,
})
h.mu.Lock()
defer h.mu.Unlock()
for c := range h.clients {
if c.termWatching == deviceID && c.token != "" {
c.termWatching = ""
c.queue(msg)
}
}
}
// OnDeviceUpdate forwards heartbeat-derived liveness data so the device-list
// rows can refresh RTT and active-window labels without re-fetching.
func (h *wsHub) OnDeviceUpdate(id string, rtt int, activeWindow string) {
@@ -221,6 +277,13 @@ func (h *wsHub) unregister(c *wsClient) {
if c.watching != "" && h.countWatchers(c.watching) == 0 {
h.devices.CloseScreen(c.watching)
}
// Terminal sessions are single-viewer by design, so any open session
// belongs to this client. Tear it down so the next viewer doesn't
// hit ErrTerminalBusy from an abandoned session.
if c.termWatching != "" {
h.devices.CloseTerminalSession(c.termWatching)
c.termWatching = ""
}
// Do NOT revoke the token: tokens are session-scoped, not WS-scoped.
// Frontend may close+reopen the WS at any time (visibilitychange handler,
// brief network blip, reload) and must be able to resume with the same