opencode commited on
Commit
53d84c3
·
1 Parent(s): f31486b

桌面剪貼簿與本機系統雙向同步:autocutsel 對齊 X11 與 VNC

Browse files
Files changed (3) hide show
  1. README.md +5 -0
  2. app.py +213 -7
  3. packages.txt +1 -0
README.md CHANGED
@@ -56,6 +56,11 @@ OpenCode 在啟動時安裝,並重設使用者層級設定為預設值。
56
  所以固定分頁裡永遠有 1 個可用的圖形終端機視窗(使用者自己另開的终端機不受影響)。
57
  - 容器內沒有 systemd-logind session,因此會自動停用 `lxpolkit` 與 `light-locker`
58
  autostart,避免桌面啟動時跳出「No session for pid …」的 Error 對話框。
 
 
 
 
 
59
  - 瀏覽器是透過 `/vnc` WebSocket 橋接到同一個桌面,所有裝置看到完全相同、可同時操作的
60
  LXDE 畫面;關掉瀏覽器分頁不會終止桌面,重新整理或重新連線都會接回原畫面。
61
  - 這個分頁是固定的:按它的 `×` 不會刪除分頁,而是重新啟動一份新的桌面
 
56
  所以固定分頁裡永遠有 1 個可用的圖形終端機視窗(使用者自己另開的终端機不受影響)。
57
  - 容器內沒有 systemd-logind session,因此會自動停用 `lxpolkit` 與 `light-locker`
58
  autostart,避免桌面啟動時跳出「No session for pid …」的 Error 對話框。
59
+ - **剪貼簿同步**:桌面右上角有「剪貼簿同步:開/關」按鈕。開啟時,桌面裡的
60
+ CLIPBOARD/PRIMARY 會與瀏覽器所在系統的剪貼簿雙向同步;在桌面內按 Ctrl+V
61
+ (或 Cmd+V)會先把本機剪貼簿送進 X11 再貼上。底層由 `autocutsel` 將 x11vnc
62
+ 使用的 CUT_BUFFER0 與 X11 selection 對齊,xterm 也設定為選取即同步 CLIPBOARD。
63
+ 瀏覽器基於安全限制,頁面需要保持在前景;若系統阻擋寫入,點一下桌面會重試。
64
  - 瀏覽器是透過 `/vnc` WebSocket 橋接到同一個桌面,所有裝置看到完全相同、可同時操作的
65
  LXDE 畫面;關掉瀏覽器分頁不會終止桌面,重新整理或重新連線都會接回原畫面。
66
  - 這個分頁是固定的:按它的 `×` 不會刪除分頁,而是重新啟動一份新的桌面
app.py CHANGED
@@ -1179,7 +1179,7 @@ DESKTOP_GEOMETRY = os.environ.get("DESKTOP_GEOMETRY", "1440x900x24")
1179
  DESKTOP_VNC_PORT = int(os.environ.get("DESKTOP_VNC_PORT", "5900"))
1180
  DESKTOP_NOVNC_ROOT = os.environ.get("DESKTOP_NOVNC_ROOT", "/usr/share/novnc")
1181
  DESKTOP_LOG = "/tmp/webterm-desktop.log"
1182
- DESKTOP_APT_PACKAGES = "xvfb x11vnc lxde-core xterm novnc x11-utils"
1183
  DESKTOP_TERM_TITLE = "共用終端機"
1184
  DESKTOP_WATCHDOG = None
1185
 
@@ -1215,17 +1215,106 @@ html,body{width:100%;height:100%;overflow:hidden;background:#000;font-family:'Se
1215
  #retry{pointer-events:auto;display:none;border:1px solid #ffffff33;background:#2d2d2d;color:#ddd;
1216
  border-radius:4px;padding:4px 9px;cursor:pointer;font-size:12px}
1217
  #retry:hover{background:#3c3c3c;color:#fff}
 
 
 
 
1218
  </style>
1219
  </head>
1220
  <body>
1221
  <div id="screen" tabindex="0"></div>
1222
- <div id="bar"><span id="dot"></span><span id="msg">正在連線到共用桌面…</span><button id="retry" type="button">重新連線</button></div>
1223
  <script type="module">
1224
  const screenEl = document.getElementById('screen');
1225
  const dot = document.getElementById('dot');
1226
  const msg = document.getElementById('msg');
1227
  const retry = document.getElementById('retry');
 
1228
  let rfb = null, delay = 800, disposed = false, connecting = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1229
 
1230
  function setState(state, text) {
1231
  dot.className = state;
@@ -1282,6 +1371,14 @@ async function connect() {
1282
  connecting = false;
1283
  setState('bad', '桌面驗證失敗:' + ((e.detail && e.detail.reason) || ''));
1284
  });
 
 
 
 
 
 
 
 
1285
  } catch (err) {
1286
  connecting = false;
1287
  setState('bad', '無法載入桌面用戶端:' + err.message);
@@ -1289,6 +1386,44 @@ async function connect() {
1289
  }
1290
  }
1291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1292
  retry.onclick = () => { delay = 800; connect(); };
1293
  screenEl.addEventListener('click', () => { if (rfb) rfb.focus(); });
1294
  window.addEventListener('beforeunload', () => {
@@ -1323,7 +1458,7 @@ class DesktopManager:
1323
 
1324
  def __init__(self):
1325
  self._lock = asyncio.Lock()
1326
- self.pids = {"xvfb": None, "session": None, "vnc": None, "term": None}
1327
  self.started_at = 0.0
1328
  self.last_error = ""
1329
  self._logf = None
@@ -1472,6 +1607,26 @@ class DesktopManager:
1472
  pids.append(pid)
1473
  return pids
1474
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1475
  def _term_binary(self):
1476
  return shutil.which("xterm") or shutil.which("lxterminal")
1477
 
@@ -1566,14 +1721,18 @@ class DesktopManager:
1566
 
1567
  def _stop_all(self):
1568
  xvfb, vnc, session = self._scan()
1569
- pids = set(xvfb) | set(vnc) | set(session) | set(self._scan_terms())
1570
- pids |= {p for p in self.pids.values() if p}
 
 
 
 
1571
  if pids:
1572
  self._log(f"停止桌面程序:{sorted(pids)}")
1573
  self._kill_pids(pids, signal.SIGTERM)
1574
  time.sleep(0.5)
1575
  self._kill_pids(pids, signal.SIGKILL)
1576
- self.pids = {"xvfb": None, "session": None, "vnc": None, "term": None}
1577
 
1578
  def _clean_display_files(self):
1579
  num = DESKTOP_DISPLAY.lstrip(":") or "99"
@@ -1593,9 +1752,17 @@ class DesktopManager:
1593
  required = ["xvfb", "session", "vnc"]
1594
  if self._term_binary():
1595
  required.append("term")
 
 
 
 
 
 
 
 
1596
  return (
1597
  _desktop_port_open()
1598
- and all(self._pid_alive(self.pids[k]) for k in required)
1599
  and os.path.exists(self._display_socket())
1600
  )
1601
 
@@ -1694,6 +1861,7 @@ class DesktopManager:
1694
  if name == "xterm":
1695
  argv = [binary, "-u8", "-fa", "DejaVu Sans Mono", "-fs", "11",
1696
  "-geometry", "110x32+50+50",
 
1697
  "-T", self.TERM_TITLE, "-e", "bash", "--login"]
1698
  else:
1699
  argv = [binary, "--title=" + self.TERM_TITLE, "-e", "bash --login"]
@@ -1715,6 +1883,32 @@ class DesktopManager:
1715
  self._log(self.last_error, logging.WARNING)
1716
  return False
1717
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1718
  async def _full_restart(self) -> bool:
1719
  await asyncio.to_thread(self._stop_all)
1720
  self._clean_display_files()
@@ -1724,6 +1918,7 @@ class DesktopManager:
1724
  return False
1725
  if not await self._start_vnc():
1726
  return False
 
1727
  await self._start_term()
1728
  await asyncio.to_thread(self._suppress_session_popups)
1729
  self.started_at = time.time()
@@ -1807,6 +2002,17 @@ class DesktopManager:
1807
  else:
1808
  self.pids["term"] = None
1809
 
 
 
 
 
 
 
 
 
 
 
 
1810
  if not self._healthy():
1811
  self.last_error = "桌面元件存在但健康檢查未通過"
1812
  return False
 
1179
  DESKTOP_VNC_PORT = int(os.environ.get("DESKTOP_VNC_PORT", "5900"))
1180
  DESKTOP_NOVNC_ROOT = os.environ.get("DESKTOP_NOVNC_ROOT", "/usr/share/novnc")
1181
  DESKTOP_LOG = "/tmp/webterm-desktop.log"
1182
+ DESKTOP_APT_PACKAGES = "xvfb x11vnc lxde-core xterm novnc x11-utils autocutsel"
1183
  DESKTOP_TERM_TITLE = "共用終端機"
1184
  DESKTOP_WATCHDOG = None
1185
 
 
1215
  #retry{pointer-events:auto;display:none;border:1px solid #ffffff33;background:#2d2d2d;color:#ddd;
1216
  border-radius:4px;padding:4px 9px;cursor:pointer;font-size:12px}
1217
  #retry:hover{background:#3c3c3c;color:#fff}
1218
+ #clip{pointer-events:auto;border:1px solid #ffffff33;background:#2d2d2d;color:#ddd;
1219
+ border-radius:4px;padding:4px 9px;cursor:pointer;font-size:12px;white-space:nowrap}
1220
+ #clip:hover{background:#3c3c3c;color:#fff}
1221
+ #clip.off{opacity:.55}
1222
  </style>
1223
  </head>
1224
  <body>
1225
  <div id="screen" tabindex="0"></div>
1226
+ <div id="bar"><span id="dot"></span><span id="msg">正在連線到共用桌面…</span><button id="retry" type="button">重新連線</button><button id="clip" type="button" title="與瀏覽器所在系統的剪貼簿雙向同步(頁面需保持在前景)">剪貼簿同步</button></div>
1227
  <script type="module">
1228
  const screenEl = document.getElementById('screen');
1229
  const dot = document.getElementById('dot');
1230
  const msg = document.getElementById('msg');
1231
  const retry = document.getElementById('retry');
1232
+ const clipBtn = document.getElementById('clip');
1233
  let rfb = null, delay = 800, disposed = false, connecting = false;
1234
+ let clipSync = localStorage.getItem('webtermClipboardSync') !== 'off';
1235
+ let lastRemoteClip = '';
1236
+ let pendingRemoteClip = '';
1237
+
1238
+ function updateClipBtn() {
1239
+ clipBtn.textContent = clipSync ? '剪貼簿同步:開' : '剪貼簿同步:關';
1240
+ clipBtn.classList.toggle('off', !clipSync);
1241
+ clipBtn.title = clipSync
1242
+ ? '已與本機系統剪貼簿雙向同步;關閉請按一下(頁面需保持在前景)'
1243
+ : '目前不同步;按一下開啟(會把本機剪貼簿帶進桌面)';
1244
+ }
1245
+ updateClipBtn();
1246
+
1247
+ function writeLocalClipboard(text) {
1248
+ if (!clipSync || !text) return Promise.resolve(false);
1249
+ const done = ok => {
1250
+ if (ok) {
1251
+ clipBtn.title = '已同步到本機剪貼簿;關閉同步請按一下';
1252
+ } else {
1253
+ clipBtn.title = '瀏覽器暫不允許寫入剪貼簿;點一下桌面或按 Ctrl+V 再試';
1254
+ }
1255
+ return ok;
1256
+ };
1257
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1258
+ return navigator.clipboard.writeText(text).then(() => done(true)).catch(() => {
1259
+ pendingRemoteClip = text;
1260
+ return done(false);
1261
+ });
1262
+ }
1263
+ try {
1264
+ const ta = document.createElement('textarea');
1265
+ ta.value = text;
1266
+ ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';
1267
+ document.body.appendChild(ta);
1268
+ ta.select();
1269
+ const ok = document.execCommand('copy');
1270
+ ta.remove();
1271
+ if (!ok) pendingRemoteClip = text;
1272
+ return Promise.resolve(done(ok));
1273
+ } catch (e) {
1274
+ pendingRemoteClip = text;
1275
+ return Promise.resolve(false);
1276
+ }
1277
+ }
1278
+
1279
+ async function flushPendingClipboard() {
1280
+ if (!clipSync || !pendingRemoteClip) return;
1281
+ const text = pendingRemoteClip;
1282
+ if (await writeLocalClipboard(text) && pendingRemoteClip === text) {
1283
+ pendingRemoteClip = '';
1284
+ }
1285
+ }
1286
+
1287
+ async function pullLocalClipboard() {
1288
+ if (!rfb || !clipSync || !navigator.clipboard || !navigator.clipboard.readText) return;
1289
+ try {
1290
+ const text = await navigator.clipboard.readText();
1291
+ if (text) {
1292
+ rfb.clipboardPasteFrom(text);
1293
+ lastRemoteClip = text;
1294
+ }
1295
+ } catch (e) { /* 使用者未授權讀取剪貼簿,維持原內容 */ }
1296
+ }
1297
+
1298
+ async function pasteIntoDesktop(withShift) {
1299
+ if (!rfb) return;
1300
+ try {
1301
+ if (navigator.clipboard && navigator.clipboard.readText) {
1302
+ const text = await navigator.clipboard.readText();
1303
+ if (text) {
1304
+ rfb.clipboardPasteFrom(text);
1305
+ lastRemoteClip = text;
1306
+ await new Promise(resolve => setTimeout(resolve, 60));
1307
+ }
1308
+ }
1309
+ } catch (e) { /* 讀不到本機剪貼簿就沿用桌面原本的內容 */ }
1310
+ if (!rfb) return;
1311
+ rfb.sendKey(0xffe3, "ControlLeft", true);
1312
+ if (withShift) rfb.sendKey(0xffe1, "ShiftLeft", true);
1313
+ rfb.sendKey(0x76, "KeyV", true);
1314
+ rfb.sendKey(0x76, "KeyV", false);
1315
+ if (withShift) rfb.sendKey(0xffe1, "ShiftLeft", false);
1316
+ rfb.sendKey(0xffe3, "ControlLeft", false);
1317
+ }
1318
 
1319
  function setState(state, text) {
1320
  dot.className = state;
 
1371
  connecting = false;
1372
  setState('bad', '桌面驗證失敗:' + ((e.detail && e.detail.reason) || ''));
1373
  });
1374
+ client.addEventListener('clipboard', e => {
1375
+ if (rfb !== client) return;
1376
+ const text = String((e.detail && e.detail.text) || '');
1377
+ if (!text) return;
1378
+ lastRemoteClip = text;
1379
+ pendingRemoteClip = text;
1380
+ if (clipSync) writeLocalClipboard(text);
1381
+ });
1382
  } catch (err) {
1383
  connecting = false;
1384
  setState('bad', '無法載入桌面用戶端:' + err.message);
 
1386
  }
1387
  }
1388
 
1389
+ clipBtn.onclick = async e => {
1390
+ e.stopPropagation();
1391
+ clipSync = !clipSync;
1392
+ localStorage.setItem('webtermClipboardSync', clipSync ? 'on' : 'off');
1393
+ updateClipBtn();
1394
+ if (clipSync) {
1395
+ if (rfb) rfb.focus();
1396
+ await pullLocalClipboard();
1397
+ await flushPendingClipboard();
1398
+ }
1399
+ };
1400
+
1401
+ screenEl.addEventListener('keydown', e => {
1402
+ if (!clipSync || !rfb) return;
1403
+ const key = (e.key || '').toLowerCase();
1404
+ if (key !== 'v' || e.altKey || !(e.ctrlKey || e.metaKey)) return;
1405
+ e.preventDefault();
1406
+ e.stopImmediatePropagation();
1407
+ pasteIntoDesktop(e.shiftKey);
1408
+ }, true);
1409
+
1410
+ document.addEventListener('paste', e => {
1411
+ if (!clipSync || !rfb) return;
1412
+ const target = e.target;
1413
+ if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
1414
+ const text = e.clipboardData && e.clipboardData.getData('text/plain');
1415
+ if (!text) return;
1416
+ e.preventDefault();
1417
+ rfb.clipboardPasteFrom(text);
1418
+ });
1419
+
1420
+ document.addEventListener('pointerdown', () => { flushPendingClipboard(); }, true);
1421
+ document.addEventListener('keydown', () => { flushPendingClipboard(); }, true);
1422
+ window.addEventListener('focus', () => { flushPendingClipboard(); });
1423
+ document.addEventListener('visibilitychange', () => {
1424
+ if (!document.hidden) flushPendingClipboard();
1425
+ });
1426
+
1427
  retry.onclick = () => { delay = 800; connect(); };
1428
  screenEl.addEventListener('click', () => { if (rfb) rfb.focus(); });
1429
  window.addEventListener('beforeunload', () => {
 
1458
 
1459
  def __init__(self):
1460
  self._lock = asyncio.Lock()
1461
+ self.pids = {"xvfb": None, "session": None, "vnc": None, "term": None, "clip": []}
1462
  self.started_at = 0.0
1463
  self.last_error = ""
1464
  self._logf = None
 
1607
  pids.append(pid)
1608
  return pids
1609
 
1610
+ def _scan_clip(self):
1611
+ """找 autocutsel:把 x11vnc 使用的 CUT_BUFFER0 與 CLIPBOARD/PRIMARY 雙向同步。"""
1612
+ pids = []
1613
+ try:
1614
+ entries = os.listdir("/proc")
1615
+ except OSError:
1616
+ return pids
1617
+ for entry in entries:
1618
+ if not entry.isdigit():
1619
+ continue
1620
+ pid = int(entry)
1621
+ if pid == os.getpid():
1622
+ continue
1623
+ cmd = self._cmdline(pid)
1624
+ if not cmd or "autocutsel" not in cmd.lower():
1625
+ continue
1626
+ if DESKTOP_DISPLAY in cmd or self._has_display(pid):
1627
+ pids.append(pid)
1628
+ return pids
1629
+
1630
  def _term_binary(self):
1631
  return shutil.which("xterm") or shutil.which("lxterminal")
1632
 
 
1721
 
1722
  def _stop_all(self):
1723
  xvfb, vnc, session = self._scan()
1724
+ pids = set(xvfb) | set(vnc) | set(session) | set(self._scan_terms()) | set(self._scan_clip())
1725
+ for value in self.pids.values():
1726
+ if isinstance(value, (list, tuple, set)):
1727
+ pids |= set(value)
1728
+ elif value:
1729
+ pids.add(value)
1730
  if pids:
1731
  self._log(f"停止桌面程序:{sorted(pids)}")
1732
  self._kill_pids(pids, signal.SIGTERM)
1733
  time.sleep(0.5)
1734
  self._kill_pids(pids, signal.SIGKILL)
1735
+ self.pids = {"xvfb": None, "session": None, "vnc": None, "term": None, "clip": []}
1736
 
1737
  def _clean_display_files(self):
1738
  num = DESKTOP_DISPLAY.lstrip(":") or "99"
 
1752
  required = ["xvfb", "session", "vnc"]
1753
  if self._term_binary():
1754
  required.append("term")
1755
+ if shutil.which("autocutsel"):
1756
+ required.append("clip")
1757
+
1758
+ def alive(value):
1759
+ if isinstance(value, (list, tuple, set)):
1760
+ return bool(value) and all(self._pid_alive(p) for p in value)
1761
+ return self._pid_alive(value)
1762
+
1763
  return (
1764
  _desktop_port_open()
1765
+ and all(alive(self.pids[k]) for k in required)
1766
  and os.path.exists(self._display_socket())
1767
  )
1768
 
 
1861
  if name == "xterm":
1862
  argv = [binary, "-u8", "-fa", "DejaVu Sans Mono", "-fs", "11",
1863
  "-geometry", "110x32+50+50",
1864
+ "-xrm", "XTerm*selectToClipboard: true",
1865
  "-T", self.TERM_TITLE, "-e", "bash", "--login"]
1866
  else:
1867
  argv = [binary, "--title=" + self.TERM_TITLE, "-e", "bash --login"]
 
1883
  self._log(self.last_error, logging.WARNING)
1884
  return False
1885
 
1886
+ async def _start_clipboard(self) -> bool:
1887
+ """啟動 autocutsel,讓 VNC 的 CUT_BUFFER0 與 X11 的 CLIPBOARD/PRIMARY 同步。"""
1888
+ binary = shutil.which("autocutsel")
1889
+ if not binary:
1890
+ self.pids["clip"] = []
1891
+ self._log("找不到 autocutsel,X11 剪貼簿同步停用", logging.WARNING)
1892
+ return True
1893
+ pids = []
1894
+ for selection in ("CLIPBOARD", "PRIMARY"):
1895
+ try:
1896
+ proc = self._spawn(
1897
+ [binary, "-selection", selection, "-cutbuffer", "0"],
1898
+ env=self._env(),
1899
+ )
1900
+ pids.append(proc.pid)
1901
+ except OSError as exc:
1902
+ self._log(f"autocutsel {selection} 啟動失敗:{exc}", logging.WARNING)
1903
+ await asyncio.sleep(0.5)
1904
+ alive = [pid for pid in pids if self._pid_alive(pid)]
1905
+ self.pids["clip"] = alive
1906
+ if alive:
1907
+ self._log("X11 剪貼簿同步就緒(CLIPBOARD + PRIMARY ↔ CUT_BUFFER0)")
1908
+ else:
1909
+ self._log("autocutsel 未能啟動,X11 剪貼簿同步停用", logging.WARNING)
1910
+ return True
1911
+
1912
  async def _full_restart(self) -> bool:
1913
  await asyncio.to_thread(self._stop_all)
1914
  self._clean_display_files()
 
1918
  return False
1919
  if not await self._start_vnc():
1920
  return False
1921
+ await self._start_clipboard()
1922
  await self._start_term()
1923
  await asyncio.to_thread(self._suppress_session_popups)
1924
  self.started_at = time.time()
 
2002
  else:
2003
  self.pids["term"] = None
2004
 
2005
+ clips = await asyncio.to_thread(self._scan_clip)
2006
+ if len(clips) > 2:
2007
+ self._kill_pids(clips[2:], signal.SIGKILL)
2008
+ clips = clips[:2]
2009
+ if len(clips) == 2 and all(self._pid_alive(p) for p in clips):
2010
+ self.pids["clip"] = clips
2011
+ else:
2012
+ if clips:
2013
+ self._kill_pids(clips, signal.SIGKILL)
2014
+ await self._start_clipboard()
2015
+
2016
  if not self._healthy():
2017
  self.last_error = "桌面元件存在但健康檢查未通過"
2018
  return False
packages.txt CHANGED
@@ -36,3 +36,4 @@ fonts-ipafont-gothic
36
  fonts-wqy-zenhei
37
  fonts-tlwg-loma-otf
38
  fonts-freefont-ttf
 
 
36
  fonts-wqy-zenhei
37
  fonts-tlwg-loma-otf
38
  fonts-freefont-ttf
39
+ autocutsel