diff --git a/server/2015Remote/WebService.cpp b/server/2015Remote/WebService.cpp index 870d96a..58e1af7 100644 --- a/server/2015Remote/WebService.cpp +++ b/server/2015Remote/WebService.cpp @@ -1814,6 +1814,16 @@ bool CWebService::StartRemoteDesktop(uint64_t device_id) { context* ctx = m_pParentDlg->FindHost(device_id); if (!ctx) return false; + // Cancel any pending grace-period close so the existing session stays alive. + { + std::lock_guard lk(m_PendingCloseMutex); + auto it = m_PendingCloseTimers.find(device_id); + if (it != m_PendingCloseTimers.end()) { + it->second->store(true); + m_PendingCloseTimers.erase(it); + } + } + // Check if there's already a Web session for this device // Only reuse if Web has already triggered AND a Web dialog exists // This ensures MFC and Web have independent dialogs @@ -1854,11 +1864,36 @@ void CWebService::StopRemoteDesktop(uint64_t device_id) { } } - // If no more web clients watching, close only the Web session dialog - // MFC dialogs remain open + // If no more web clients watching, defer close by 30 s so a quick + // mobile reconnect (e.g. switching to a TOTP app) reuses the existing + // CScreenSpyDlg without a full cold-start. if (watchingCount == 0) { - ClearWebTriggered(device_id); - m_pParentDlg->CloseWebRemoteDesktopByClientID(device_id); + auto cancelled = std::make_shared>(false); + { + std::lock_guard lk(m_PendingCloseMutex); + auto it = m_PendingCloseTimers.find(device_id); + if (it != m_PendingCloseTimers.end()) + it->second->store(true); + m_PendingCloseTimers[device_id] = cancelled; + } + std::thread([this, device_id, cancelled]() { + std::this_thread::sleep_for(std::chrono::seconds(30)); + if (cancelled->load()) return; + { + std::lock_guard lk(m_PendingCloseMutex); + m_PendingCloseTimers.erase(device_id); + } + int count = 0; + { + std::lock_guard lock(m_ClientsMutex); + for (const auto& [ws, client] : m_Clients) + if (client.watch_device_id == device_id) count++; + } + if (count == 0 && m_pParentDlg) { + ClearWebTriggered(device_id); + m_pParentDlg->CloseWebRemoteDesktopByClientID(device_id); + } + }).detach(); } } diff --git a/server/2015Remote/WebService.h b/server/2015Remote/WebService.h index 4e664cf..41b80be 100644 --- a/server/2015Remote/WebService.h +++ b/server/2015Remote/WebService.h @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include #include @@ -231,6 +233,13 @@ private: std::set m_OfflineDevices; // Devices that went offline since last flush std::mutex m_DirtyDevicesMutex; + // Grace-period timers: delay CloseWebRemoteDesktopByClientID by 30 s after + // the last web viewer disconnects to allow quick mobile reconnects (e.g. + // switching to a TOTP app briefly). Cancelled by StartRemoteDesktop when a + // viewer reconnects within the window. + std::map>> m_PendingCloseTimers; + std::mutex m_PendingCloseMutex; + public: // Check if a device session was triggered by web (should be hidden) bool IsWebTriggered(uint64_t device_id); diff --git a/server/go/web/ws.go b/server/go/web/ws.go index bd2c6ad..5c2dedc 100644 --- a/server/go/web/ws.go +++ b/server/go/web/ws.go @@ -95,6 +95,12 @@ type wsHub struct { mu sync.RWMutex clients map[*wsClient]struct{} + // screenCloseTimers delays CloseScreen by screenCloseGrace after the last + // viewer disconnects. A reconnect within that window cancels the timer and + // reuses the live screen sub-conn (hot path), avoiding a full cold-start. + // Guarded by mu. + screenCloseTimers map[string]*time.Timer + unsub func() // Hardening knobs wired from server.Config. Nil/empty values mean @@ -111,7 +117,8 @@ func newWSHub(auth *wsauth.Authenticator, devices *hub.Hub, log *logger.Logger) auth: auth, devices: devices, log: log, - clients: make(map[*wsClient]struct{}), + clients: make(map[*wsClient]struct{}), + screenCloseTimers: make(map[string]*time.Timer), } h.unsub = devices.Subscribe(h) return h @@ -405,7 +412,7 @@ func (h *wsHub) unregister(c *wsClient) { // session so the device stops encoding. Done OUTSIDE the lock so the // hub's mutators can take their own locks without risk of recursion. if c.watching != "" && h.countWatchers(c.watching) == 0 { - h.devices.CloseScreen(c.watching) + h.deferredCloseScreen(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 @@ -421,6 +428,30 @@ func (h *wsHub) unregister(c *wsClient) { c.close() } +// screenCloseGrace is how long we keep a device's screen sub-conn alive after +// the last browser viewer disconnects. A reconnect within this window (e.g. +// the user briefly switched to a TOTP app on mobile) cancels the timer and +// resumes on the hot path — no cold-start, no waiting for a new IDR. +const screenCloseGrace = 30 * time.Second + +// deferredCloseScreen schedules CloseScreen after screenCloseGrace unless a +// viewer reconnects first. Must be called without h.mu held. +func (h *wsHub) deferredCloseScreen(deviceID string) { + h.mu.Lock() + if t, ok := h.screenCloseTimers[deviceID]; ok { + t.Stop() + } + h.screenCloseTimers[deviceID] = time.AfterFunc(screenCloseGrace, func() { + h.mu.Lock() + delete(h.screenCloseTimers, deviceID) + h.mu.Unlock() + if h.countWatchers(deviceID) == 0 { + h.devices.CloseScreen(deviceID) + } + }) + h.mu.Unlock() +} + // ----- HTTP handler ------------------------------------------------------- func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) { diff --git a/server/go/web/ws_handlers.go b/server/go/web/ws_handlers.go index b84d1e6..08697cd 100644 --- a/server/go/web/ws_handlers.go +++ b/server/go/web/ws_handlers.go @@ -244,7 +244,7 @@ func (h *wsHub) handleDisconnect(c *wsClient, _ []byte) { h.mu.Unlock() c.queue([]byte(`{"cmd":"disconnect_result","ok":true}`)) if prev != "" && h.countWatchers(prev) == 0 { - h.devices.CloseScreen(prev) + h.deferredCloseScreen(prev) } } @@ -288,6 +288,11 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) { c.queueBinary(cache.Keyframe) } h.mu.Lock() + // Cancel any pending deferred close so the relay stays alive. + if t, ok := h.screenCloseTimers[in.ID]; ok { + t.Stop() + delete(h.screenCloseTimers, in.ID) + } c.watching = in.ID h.mu.Unlock() return diff --git a/server/web/index.html b/server/web/index.html index 07da027..a121bf5 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -4185,11 +4185,33 @@ clearTimeout(backgroundDisconnectTimer); backgroundDisconnectTimer = null; } - // Reconnect or send immediate ping + const screenPage = document.getElementById('screen-page'); + const onScreenPage = screenPage && screenPage.classList.contains('active') && currentDevice; + if (!ws || ws.readyState !== WebSocket.OPEN) { + // WS was dropped — reset decoder before reconnect. + if (onScreenPage && decoder) { + try { decoder.close(); } catch(e) {} + decoder = null; + needKeyframe = true; + } + connectWebSocket(); + } else if (onScreenPage && isTouchDevice) { + // iOS suspends the tab and silently invalidates the VideoDecoder + // GPU context (or quietly closes the WS while readyState stays OPEN). + // Always force a clean WS reconnect on mobile so the server delivers + // a fresh stream start; the server no longer sends a stale cached + // keyframe (cleared on disconnect), so the client waits for the next + // natural IDR — no mosaic, no freeze. + if (decoder) { + try { decoder.close(); } catch(e) {} + decoder = null; + needKeyframe = true; + } + ws.onclose = null; + ws.close(); connectWebSocket(); } else if (token) { - // Connection still open - send immediate ping to refresh server heartbeat ws.send(JSON.stringify({ cmd: 'ping', token })); } }