From 22b87a3427efe13ca3b13a34ebfcab7b52faea19 Mon Sep 17 00:00:00 2001 From: roberts Date: Sun, 15 Mar 2026 01:50:13 -0500 Subject: [PATCH] Fix clipboard sync: install xclip, poll both X11 selections, add toast UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add xclip to install.sh dependencies (was missing entirely — root cause) - Poll both CLIPBOARD and PRIMARY X11 selections (Electron button-click copies often use PRIMARY) - Add xsel as fallback if xclip unavailable - Log xclip errors with stderr instead of silently swallowing - Add startup diagnostic test for clipboard connectivity - Replace silent clipboard write with visible toast notification: on HTTPS auto-copies and shows brief "Copied" confirmation, on HTTP shows "Click to copy" button (user gesture enables clipboard) - Toast auto-dismisses after 8s, styled to match Atlus theme Co-Authored-By: Claude Opus 4.6 --- backend/display.py | 131 +++++++++++++++++++++++++-------- frontend/css/apps/display.css | 66 +++++++++++++++++ frontend/js/apps/display.js | 133 ++++++++++++++++++++++++++++++++-- install.sh | 1 + 4 files changed, 296 insertions(+), 35 deletions(-) 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 \