diff --git a/root/app/backend/session_manager.py b/root/app/backend/session_manager.py index cb061cc..158ab0c 100644 --- a/root/app/backend/session_manager.py +++ b/root/app/backend/session_manager.py @@ -620,6 +620,12 @@ def send_keys(self, key_combination: str) -> Dict[str, Any]: def set_clipboard(self, text: str) -> Dict[str, Any]: """Put text on the X11 CLIPBOARD selection; the RDP/VNC client syncs it to the remote side.""" + with self.lock: + running = self.process is not None and self.process.poll() is None + if not running: + # Nothing would pick it up: reporting success here sent users looking for text + # that never reached the remote machine + return {"success": False, "message": "No session is running"} try: # xclip forks to own the selection, so its output must not be piped back to us subprocess.run( diff --git a/root/app/frontend/js/app.js b/root/app/frontend/js/app.js index d52f916..3640348 100644 --- a/root/app/frontend/js/app.js +++ b/root/app/frontend/js/app.js @@ -234,13 +234,15 @@ function setupEventListeners() { const text = document.getElementById("clip-text").value; if (!text) return; const results = []; + let sent = false; try { const res = await apiFetch("/api/session/clipboard", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }) }); - results.push(res.ok ? "Sent to Windows clipboard." : `Remote clipboard failed: ${await errorMessage(res, "unknown error")}`); + sent = res.ok; + results.push(res.ok ? "Sent to the remote session's clipboard." : `Not sent to the remote session: ${await errorMessage(res, "unknown error")}`); } catch (e) { results.push("Remote clipboard failed: network error."); } @@ -253,6 +255,7 @@ function setupEventListeners() { } } clipStatus.textContent = results.join(" "); + clipStatus.classList.toggle("alert-error", !sent); clipStatus.classList.remove("hidden"); }); diff --git a/tests/test_session_manager.py b/tests/test_session_manager.py index 3f040e8..19b9940 100644 --- a/tests/test_session_manager.py +++ b/tests/test_session_manager.py @@ -514,3 +514,27 @@ def test_connect_starts_the_idle_watcher_only_when_enabled(mgr, monkeypatch): mgr.connect({"protocol": "vnc", "host": "10.0.0.5"}) sm.time.sleep(0.05) # the watcher runs on its own thread assert started == [1200] + + +# ----------------- Clipboard helper ----------------- + +def test_clipboard_send_without_a_session_is_refused(mgr, monkeypatch): + calls = [] + monkeypatch.setattr(sm.subprocess, "run", lambda *a, **k: calls.append(a)) + result = mgr.set_clipboard("hello") + assert result == {"success": False, "message": "No session is running"} + assert calls == [] # nothing written to a clipboard nobody reads + + +def test_clipboard_send_after_the_client_exited_is_refused(mgr, monkeypatch): + mgr.process = ExitedProc() + monkeypatch.setattr(sm.subprocess, "run", lambda *a, **k: None) + assert mgr.set_clipboard("hello")["success"] is False + + +def test_clipboard_send_with_a_session_goes_to_xclip(mgr, monkeypatch): + calls = [] + mgr.process = FakeProc() + monkeypatch.setattr(sm.subprocess, "run", lambda cmd, **k: calls.append((cmd, k["input"]))) + assert mgr.set_clipboard("héllo")["success"] is True + assert calls == [(["xclip", "-selection", "clipboard", "-i"], "héllo".encode("utf-8"))]