diff --git a/backend/display.py b/backend/display.py
index e6a73c5..641de20 100644
--- a/backend/display.py
+++ b/backend/display.py
@@ -42,9 +42,15 @@ XVFB_BIN = _find_bin("Xvfb")
XDOTOOL_BIN = _find_bin("xdotool")
IMPORT_BIN = _find_bin("import") # ImageMagick
XCLIP_BIN = _find_bin("xclip")
+XSEL_BIN = _find_bin("xsel")
+CLIPBOARD_BIN = XCLIP_BIN or XSEL_BIN # prefer xclip, fallback to xsel
HAS_DISPLAY_DEPS = all((XVFB_BIN, XDOTOOL_BIN, IMPORT_BIN))
+if not CLIPBOARD_BIN:
+ log.warning("Neither xclip nor xsel found — clipboard sync will be disabled. "
+ "Install xclip: apt-get install xclip")
+
# Allowed command pattern — alphanumeric + hyphens only
_SAFE_CMD = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._\-]*$")
@@ -138,7 +144,7 @@ class ManagedGuiApp:
self._capture_task = asyncio.create_task(self._capture_loop())
if self._stderr_task is None or self._stderr_task.done():
self._stderr_task = asyncio.create_task(self._read_stderr())
- if XCLIP_BIN and (self._clipboard_task is None or self._clipboard_task.done()):
+ if CLIPBOARD_BIN and (self._clipboard_task is None or self._clipboard_task.done()):
self._clipboard_task = asyncio.create_task(self._clipboard_loop())
async def _read_stderr(self):
@@ -159,59 +165,126 @@ class ManagedGuiApp:
except Exception:
pass
+ async def _read_x11_selection(self, selection: str, env: dict) -> str | None:
+ """Read an X11 selection (clipboard or primary) using xclip or xsel."""
+ try:
+ if XCLIP_BIN:
+ proc = await asyncio.create_subprocess_exec(
+ XCLIP_BIN, "-selection", selection, "-o",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ env=env,
+ )
+ elif XSEL_BIN:
+ flag = "--clipboard" if selection == "clipboard" else "--primary"
+ proc = await asyncio.create_subprocess_exec(
+ XSEL_BIN, flag, "--output",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ env=env,
+ )
+ else:
+ return None
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=2)
+ if proc.returncode == 0 and stdout:
+ return stdout.decode(errors="replace")
+ # Log non-zero exit codes with stderr (but only once per selection to reduce noise)
+ if proc.returncode != 0 and stderr:
+ err = stderr.decode(errors="replace").strip()
+ if err and not hasattr(self, f"_logged_{selection}_err"):
+ setattr(self, f"_logged_{selection}_err", True)
+ log.warning("xclip %s read failed (rc=%d): %s", selection, proc.returncode, err)
+ except asyncio.TimeoutError:
+ log.warning("xclip %s read timed out for %s", selection, self.app_id)
+ except Exception as e:
+ log.warning("xclip %s read exception for %s: %s", selection, self.app_id, e)
+ return None
+
async def _clipboard_loop(self):
- """Poll X11 clipboard and push changes to connected browsers."""
- if not XCLIP_BIN:
+ """Poll X11 clipboard AND primary selection, push changes to browsers.
+
+ Polls both CLIPBOARD (Ctrl+C, explicit copy) and PRIMARY (selection-based,
+ button-click copies in Electron/GTK apps) every 500ms.
+ """
+ if not CLIPBOARD_BIN:
+ log.warning("Clipboard loop skipped for %s — no xclip/xsel available", self.app_id)
return
env = self._display_env()
+ log.info("Clipboard loop started for %s (using %s, DISPLAY=:%d)",
+ self.app_id, "xclip" if XCLIP_BIN else "xsel", self.display_num)
+
+ # Startup diagnostic — verify xclip can connect to the X display
+ test = await self._read_x11_selection("clipboard", env)
+ if test is not None:
+ log.info("Clipboard startup test OK for %s: got %d chars", self.app_id, len(test))
+ else:
+ log.info("Clipboard startup test for %s: empty/no owner (normal for fresh display)", self.app_id)
+
+ last_primary = ""
while self.alive:
try:
await asyncio.sleep(0.5)
if not self._websockets:
continue
- # Read clipboard from X11
- proc = await asyncio.create_subprocess_exec(
- XCLIP_BIN, "-selection", "clipboard", "-o",
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- env=env,
- )
- stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=2)
- if proc.returncode != 0 or not stdout:
- continue
- text = stdout.decode(errors="replace")
- if text and text != self._last_clipboard:
- self._last_clipboard = text
- log.debug("Clipboard changed for %s: %d chars", self.app_id, len(text))
+
+ # Poll both CLIPBOARD and PRIMARY selections
+ new_text = None
+ for sel in ("clipboard", "primary"):
+ text = await self._read_x11_selection(sel, env)
+ if not text:
+ continue
+
+ old = self._last_clipboard if sel == "clipboard" else last_primary
+ if text != old:
+ if sel == "clipboard":
+ self._last_clipboard = text
+ else:
+ last_primary = text
+ new_text = text
+ log.info("Clipboard change detected (%s selection) for %s: %d chars",
+ sel, self.app_id, len(text))
+ break # Send the first change found
+
+ if new_text:
dead = []
for ws in self._websockets:
try:
- await ws.send_json({"type": "clipboard", "data": text})
+ await ws.send_json({"type": "clipboard", "data": new_text})
except Exception:
dead.append(ws)
for ws in dead:
self.detach_ws(ws)
- except asyncio.TimeoutError:
- continue
except Exception:
+ log.warning("Clipboard loop error for %s", self.app_id, exc_info=True)
await asyncio.sleep(2)
+ log.info("Clipboard loop ended for %s", self.app_id)
async def set_clipboard(self, text: str):
"""Write text to the X11 clipboard from the browser."""
- if not XCLIP_BIN:
+ if not CLIPBOARD_BIN:
+ log.warning("Cannot set clipboard — no xclip/xsel available")
return
env = self._display_env()
try:
- proc = await asyncio.create_subprocess_exec(
- XCLIP_BIN, "-selection", "clipboard",
- stdin=asyncio.subprocess.PIPE,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- env=env,
- )
+ if XCLIP_BIN:
+ proc = await asyncio.create_subprocess_exec(
+ XCLIP_BIN, "-selection", "clipboard",
+ stdin=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ env=env,
+ )
+ else:
+ proc = await asyncio.create_subprocess_exec(
+ XSEL_BIN, "--clipboard", "--input",
+ stdin=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ env=env,
+ )
await asyncio.wait_for(proc.communicate(input=text.encode()), timeout=2)
self._last_clipboard = text # prevent echo back
- log.debug("Clipboard set from browser for %s: %d chars", self.app_id, len(text))
+ log.info("Clipboard set from browser for %s: %d chars", self.app_id, len(text))
except Exception as e:
log.warning("Failed to set clipboard for %s: %s", self.app_id, e)
diff --git a/frontend/css/apps/display.css b/frontend/css/apps/display.css
index e96886e..e2d2ec5 100644
--- a/frontend/css/apps/display.css
+++ b/frontend/css/apps/display.css
@@ -85,3 +85,69 @@
word-break: break-word;
white-space: pre-wrap;
}
+
+/* Clipboard toast notification */
+.gui-clipboard-toast {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background: var(--bg-titlebar);
+ border: 1px solid var(--border-structural);
+ border-radius: var(--radius-md);
+ box-shadow: 0 4px 16px rgba(0,0,0,0.4);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--text-primary);
+ animation: gui-toast-in 0.2s ease-out;
+ max-width: 400px;
+}
+
+.gui-clipboard-toast.copied {
+ border-color: var(--status-green);
+}
+
+.gui-clipboard-toast.fade-out {
+ opacity: 0;
+ transition: opacity 0.3s ease-out;
+}
+
+@keyframes gui-toast-in {
+ from { opacity: 0; transform: translateY(-8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.gui-clipboard-toast-icon {
+ flex-shrink: 0;
+ font-size: 14px;
+}
+
+.gui-clipboard-toast-text {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text-secondary);
+}
+
+.gui-clipboard-toast-btn {
+ flex-shrink: 0;
+ padding: 4px 10px;
+ background: var(--accent);
+ border: none;
+ border-radius: var(--radius-sm);
+ color: #fff;
+ font-family: var(--font-ui);
+ font-size: 11px;
+ font-weight: 500;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.gui-clipboard-toast-btn:hover {
+ opacity: 0.9;
+}
diff --git a/frontend/js/apps/display.js b/frontend/js/apps/display.js
index 7fa1be5..937d907 100644
--- a/frontend/js/apps/display.js
+++ b/frontend/js/apps/display.js
@@ -179,6 +179,98 @@
img.src = URL.createObjectURL(blob);
}
+ // ---- Clipboard toast notification ----
+ // On HTTP (non-HTTPS), navigator.clipboard.writeText() fails silently.
+ // We show a toast with "Click to copy" so the user gesture enables clipboard write.
+
+ let _clipboardToastEl = null;
+ let _clipboardToastTimer = null;
+ let _pendingClipboardText = '';
+
+ function _showClipboardToast(state, text) {
+ _pendingClipboardText = text;
+ const container = state.container;
+ if (!container) return;
+
+ // Remove existing toast
+ if (_clipboardToastEl && _clipboardToastEl.parentNode) {
+ _clipboardToastEl.parentNode.removeChild(_clipboardToastEl);
+ }
+ clearTimeout(_clipboardToastTimer);
+
+ const toast = document.createElement('div');
+ toast.className = 'gui-clipboard-toast';
+ const preview = text.length > 60 ? text.substring(0, 60) + '…' : text;
+ toast.innerHTML = `📋`
+ + `${_escHtml(preview)}`
+ + ``;
+
+ // Click the "Copy" button → user gesture → clipboard write guaranteed
+ const btn = toast.querySelector('.gui-clipboard-toast-btn');
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ _copyToClipboard(_pendingClipboardText);
+ toast.classList.add('copied');
+ toast.querySelector('.gui-clipboard-toast-text').textContent = 'Copied!';
+ btn.style.display = 'none';
+ setTimeout(() => _removeToast(), 1200);
+ });
+
+ container.appendChild(toast);
+ _clipboardToastEl = toast;
+
+ // Auto-dismiss after 8 seconds if not clicked
+ _clipboardToastTimer = setTimeout(() => _removeToast(), 8000);
+ }
+
+ function _removeToast() {
+ clearTimeout(_clipboardToastTimer);
+ if (_clipboardToastEl && _clipboardToastEl.parentNode) {
+ _clipboardToastEl.classList.add('fade-out');
+ setTimeout(() => {
+ if (_clipboardToastEl && _clipboardToastEl.parentNode) {
+ _clipboardToastEl.parentNode.removeChild(_clipboardToastEl);
+ }
+ _clipboardToastEl = null;
+ }, 300);
+ }
+ }
+
+ function _copyToClipboard(text) {
+ // Called from a user gesture (button click) so both APIs work
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).then(() => {
+ console.log('[display] Clipboard written via Clipboard API:', text.length, 'chars');
+ }).catch(err => {
+ console.warn('[display] Clipboard API failed even with gesture:', err.message);
+ _execCommandCopy(text);
+ });
+ } else {
+ _execCommandCopy(text);
+ }
+ }
+
+ function _execCommandCopy(text) {
+ try {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.position = 'fixed';
+ ta.style.left = '-9999px';
+ ta.style.top = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ console.log('[display] Clipboard written via execCommand fallback');
+ } catch (e) {
+ console.warn('[display] execCommand copy failed:', e.message);
+ }
+ }
+
+ function _escHtml(s) {
+ return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+ }
+
function handleMessage(state, msg) {
if (msg.type === 'closed') {
showStatus(state, 'exited', msg.data || 'Application exited');
@@ -187,18 +279,47 @@
} else if (msg.type === 'clipboard') {
// X11 clipboard → browser clipboard
const text = msg.data || '';
- if (text && navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(text).then(() => {
- console.log('[display] Clipboard synced to browser:', text.length, 'chars');
- }).catch(err => {
- console.warn('[display] Clipboard write failed:', err);
- });
+ if (text) {
+ console.log('[display] Clipboard received from server:', text.length, 'chars');
+ // First try automatic write (works on HTTPS or localhost)
+ let autoWriteOk = false;
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).then(() => {
+ console.log('[display] Clipboard auto-written via Clipboard API');
+ // Show brief success toast
+ _showClipboardSuccess(state);
+ }).catch(err => {
+ console.warn('[display] Auto clipboard write failed:', err.message);
+ // Show interactive toast — user must click to copy
+ _showClipboardToast(state, text);
+ });
+ } else {
+ // No Clipboard API (HTTP) — show interactive toast
+ _showClipboardToast(state, text);
+ }
}
} else if (msg.type === 'meta') {
// Could update title etc.
}
}
+ function _showClipboardSuccess(state) {
+ // Brief "Copied!" flash when auto-write succeeds
+ const container = state.container;
+ if (!container) return;
+ if (_clipboardToastEl && _clipboardToastEl.parentNode) {
+ _clipboardToastEl.parentNode.removeChild(_clipboardToastEl);
+ }
+ clearTimeout(_clipboardToastTimer);
+ const toast = document.createElement('div');
+ toast.className = 'gui-clipboard-toast copied';
+ toast.innerHTML = '✓'
+ + 'Copied to clipboard';
+ container.appendChild(toast);
+ _clipboardToastEl = toast;
+ _clipboardToastTimer = setTimeout(() => _removeToast(), 2000);
+ }
+
function showStatus(state, type, text) {
state.status = type;
state.wrap.style.display = 'none';
diff --git a/install.sh b/install.sh
index 234dddc..b410d54 100755
--- a/install.sh
+++ b/install.sh
@@ -51,6 +51,7 @@ install_deps() {
xvfb \
xdotool \
imagemagick \
+ xclip \
x11-utils \
libxcb-cursor0 \
iw \