Spaces:
Paused
Paused
opencode commited on
Commit ·
53d84c3
1
Parent(s): f31486b
桌面剪貼簿與本機系統雙向同步:autocutsel 對齊 X11 與 VNC
Browse files- README.md +5 -0
- app.py +213 -7
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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
|