Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions root/app/backend/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion root/app/frontend/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Expand All @@ -253,6 +255,7 @@ function setupEventListeners() {
}
}
clipStatus.textContent = results.join(" ");
clipStatus.classList.toggle("alert-error", !sent);
clipStatus.classList.remove("hidden");
});

Expand Down
24 changes: 24 additions & 0 deletions tests/test_session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))]
Loading