Spaces:
Paused
Paused
opencode commited on
Commit ·
2f64e61
1
Parent(s): 70ccbbb
新增固定 LXDE 桌面分頁:Xvfb + x11vnc + noVNC,看門狗維持恰好一個
Browse files- README.md +20 -2
- app.py +718 -9
- packages.txt +5 -0
README.md
CHANGED
|
@@ -19,8 +19,8 @@ OpenCode 在啟動時安裝,並重設使用者層級設定為預設值。
|
|
| 19 |
|
| 20 |
## 檔案
|
| 21 |
|
| 22 |
-
- `app.py` — FastAPI + xterm.js 終端機、檔案瀏覽器
|
| 23 |
-
- `packages.txt` — 系統套件(`tmux`)
|
| 24 |
- `requirements.txt` — Python 相依套件
|
| 25 |
|
| 26 |
## 終端機分頁(全部共用、全部長存)
|
|
@@ -44,6 +44,24 @@ OpenCode 在啟動時安裝,並重設使用者層級設定為預設值。
|
|
| 44 |
`installpi.sh` 已從 Space 流程中移除(不再自動執行),檔案本身保留在 repo 作為紀錄,
|
| 45 |
需要時手動執行。
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
## 記憶體守衛(memguard)
|
| 48 |
|
| 49 |
每次 Space 啟動時,`app.py` 的 `install_memguard()` 會自動:
|
|
|
|
| 19 |
|
| 20 |
## 檔案
|
| 21 |
|
| 22 |
+
- `app.py` — FastAPI + xterm.js 終端機、檔案瀏覽器、`/ws` session 管理與固定 LXDE 桌面
|
| 23 |
+
- `packages.txt` — 系統套件(`tmux`、`xvfb`、`x11vnc`、`lxde-core`、`novnc`、`x11-utils`)
|
| 24 |
- `requirements.txt` — Python 相依套件
|
| 25 |
|
| 26 |
## 終端機分頁(全部共用、全部長存)
|
|
|
|
| 44 |
`installpi.sh` 已從 Space 流程中移除(不再自動執行),檔案本身保留在 repo 作為紀錄,
|
| 45 |
需要時手動執行。
|
| 46 |
|
| 47 |
+
## 固定桌面分頁(Xvfb + LXDE + x11vnc + noVNC)
|
| 48 |
+
|
| 49 |
+
終端機分頁之外,最左側有一個 **固定** 的「桌面 1(LXDE,共用)」分頁:
|
| 50 |
+
|
| 51 |
+
- 伺服器永遠維持 **恰好一個** `Xvfb`(`:99`)+ 一個 LXDE session + 一個 `x11vnc`
|
| 52 |
+
(只監聽 `127.0.0.1:5900`)。看門狗每 3 秒檢查,任何一個程序死掉會自動補回,
|
| 53 |
+
重複的程序會被清掉。
|
| 54 |
+
- 瀏覽器是透過 `/vnc` WebSocket 橋接到同一個桌面,所有裝置看到完全相同、可同時操作的
|
| 55 |
+
LXDE 畫面;關掉瀏覽器分頁不會終止桌面,重新整理或重新連線都會接回原畫面。
|
| 56 |
+
- 這個分頁是固定的:按它的 `×` 不會刪除分頁,而是重新啟動一份新的桌面
|
| 57 |
+
(`POST /api/desktop/restart`),永遠維持一個。
|
| 58 |
+
- 舊的 tmux 終端機分頁、上傳/下載/複製/貼上等行為完全沒有改變。
|
| 59 |
+
- 除錯端點:`GET /api/desktop/status`、`POST /api/desktop/restart`(程序 pid、VNC port、
|
| 60 |
+
健康狀態);桌面 log 在 `/tmp/webterm-desktop.log`。
|
| 61 |
+
- 可用 Space 變數覆寫:`DESKTOP_DISPLAY`(預設 `:99`)、`DESKTOP_GEOMETRY`
|
| 62 |
+
(預設 `1440x900x24`)、`DESKTOP_VNC_PORT`(預設 `5900`)、`DESKTOP_NOVNC_ROOT`
|
| 63 |
+
(預設 `/usr/share/novnc`)。noVNC 靜態檔由 `/novnc/*` 提供,缺少時前端自動改用 CDN。
|
| 64 |
+
|
| 65 |
## 記憶體守衛(memguard)
|
| 66 |
|
| 67 |
每次 Space 啟動時,`app.py` 的 `install_memguard()` 會自動:
|
app.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import os
|
| 2 |
import pty
|
| 3 |
import shutil
|
|
|
|
| 4 |
import subprocess
|
| 5 |
import signal
|
| 6 |
import struct
|
|
@@ -10,6 +11,7 @@ import asyncio
|
|
| 10 |
import json
|
| 11 |
import base64
|
| 12 |
import logging
|
|
|
|
| 13 |
import time
|
| 14 |
import uuid
|
| 15 |
|
|
@@ -79,6 +81,10 @@ html,body{height:100%;overflow:hidden;background:#1e1e1e;color:#ccc;font-family:
|
|
| 79 |
#term-box{flex:1;overflow:hidden;padding:2px}
|
| 80 |
.tw{width:100%;height:100%;display:none}
|
| 81 |
.tw.active{display:block}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
body{display:flex;flex-direction:column}
|
| 83 |
#file-picker{width:min(1040px,96vw);height:min(700px,90vh);max-height:90vh;padding:0;margin:auto;border:1px solid #aeb7c2;border-radius:8px;background:#f3f5f8;color:#20242a;box-shadow:0 18px 60px #0005;overflow:hidden;font-family:"Segoe UI",Arial,sans-serif}
|
| 84 |
#file-picker::backdrop{background:#10182088;backdrop-filter:blur(2px)}
|
|
@@ -338,6 +344,66 @@ function createTab(sessionId = null, title = null, activateIt = true) {
|
|
| 338 |
return obj;
|
| 339 |
}
|
| 340 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
function activate(id) {
|
| 342 |
activeId = id;
|
| 343 |
saveActive();
|
|
@@ -345,7 +411,16 @@ function activate(id) {
|
|
| 345 |
const on = t.id === id;
|
| 346 |
t.el.classList.toggle('active', on);
|
| 347 |
t.wrap.classList.toggle('active', on);
|
| 348 |
-
if (on && t.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
});
|
| 350 |
}
|
| 351 |
|
|
@@ -354,6 +429,7 @@ function removeLocalTab(id) {
|
|
| 354 |
const i = tabs.findIndex(t => t.id === id);
|
| 355 |
if (i < 0) return;
|
| 356 |
const t = tabs[i];
|
|
|
|
| 357 |
t.closed = true;
|
| 358 |
if (t.ws) { try { t.ws.close(); } catch (e) {} }
|
| 359 |
if (t.term) t.term.dispose();
|
|
@@ -368,7 +444,12 @@ function removeLocalTab(id) {
|
|
| 368 |
async function closeTab(id) {
|
| 369 |
const t = tabs.find(t => t.id === id);
|
| 370 |
if (!t) return;
|
| 371 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
setAppStatus('至少要保留一個終端機分頁', 'warning');
|
| 373 |
return;
|
| 374 |
}
|
|
@@ -400,7 +481,7 @@ async function doSyncTabs(data = null) {
|
|
| 400 |
const res = data || await apiTabs();
|
| 401 |
const serverIds = res.tabs.map(t => t.id);
|
| 402 |
[...tabs].forEach(t => {
|
| 403 |
-
if (t.sessionId && !serverIds.includes(t.sessionId)) removeLocalTab(t.id);
|
| 404 |
});
|
| 405 |
res.tabs.forEach(t => {
|
| 406 |
const local = tabs.find(l => l.sessionId === t.id);
|
|
@@ -411,7 +492,10 @@ async function doSyncTabs(data = null) {
|
|
| 411 |
createTab(t.id, t.title, false);
|
| 412 |
}
|
| 413 |
});
|
| 414 |
-
if (!activeId && tabs.length)
|
|
|
|
|
|
|
|
|
|
| 415 |
} catch (err) {
|
| 416 |
setAppStatus('分頁同步失敗:' + err.message, 'warning');
|
| 417 |
}
|
|
@@ -432,6 +516,10 @@ addBtn.onclick = async () => {
|
|
| 432 |
copyBtn.onclick = async () => {
|
| 433 |
const t = activeTab();
|
| 434 |
if (!t) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
const text = t.term.getSelection();
|
| 436 |
if (text) {
|
| 437 |
try {
|
|
@@ -448,6 +536,10 @@ copyBtn.onclick = async () => {
|
|
| 448 |
pasteBtn.onclick = async () => {
|
| 449 |
const t = activeTab();
|
| 450 |
if (!t) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
try {
|
| 452 |
const text = await navigator.clipboard.readText();
|
| 453 |
if (t.ws && t.ws.readyState === WebSocket.OPEN) t.ws.send(JSON.stringify({type: 'input', data: text}));
|
|
@@ -456,7 +548,14 @@ pasteBtn.onclick = async () => {
|
|
| 456 |
}
|
| 457 |
};
|
| 458 |
|
| 459 |
-
uploadBtn.onclick = () =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
fileInput.onchange = (e) => {
|
| 461 |
const file = e.target.files[0];
|
| 462 |
if (!file) return;
|
|
@@ -661,16 +760,18 @@ document.addEventListener('keydown', e => {
|
|
| 661 |
try {
|
| 662 |
let res = await apiTabs();
|
| 663 |
if (!res.tabs.length) res = await apiNewTab();
|
|
|
|
| 664 |
await syncTabs(res);
|
| 665 |
const wanted = loadActive();
|
| 666 |
-
const local = tabs.find(t => String(t.sessionId) === wanted)
|
|
|
|
| 667 |
if (local) activate(local.id);
|
| 668 |
} catch (err) {
|
| 669 |
setAppStatus('初始化失敗:' + err.message, 'error');
|
| 670 |
}
|
| 671 |
-
setInterval(() => { if (!document.hidden) syncTabs(); }, 4000);
|
| 672 |
-
window.addEventListener('focus', () => syncTabs());
|
| 673 |
-
document.addEventListener('visibilitychange', () => { if (!document.hidden) syncTabs(); });
|
| 674 |
})();
|
| 675 |
</script>
|
| 676 |
</body>
|
|
@@ -731,6 +832,110 @@ async def download_route(path: str):
|
|
| 731 |
return FileResponse(safe, filename=os.path.basename(safe))
|
| 732 |
|
| 733 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
@app.get("/api/sessions")
|
| 735 |
async def api_sessions():
|
| 736 |
"""列出目前伺服器上的終端機工作階段(除錯/確認跨裝置是否共用)。"""
|
|
@@ -959,6 +1164,503 @@ async def run_installpi_script():
|
|
| 959 |
return False
|
| 960 |
|
| 961 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 962 |
@spaces.GPU(duration=1, queue=False)
|
| 963 |
def dummy_gpu():
|
| 964 |
"""空函數,僅用於讓平台識別 GPU 裝飾器(若無 GPU 可忽略)。"""
|
|
@@ -967,10 +1669,17 @@ def dummy_gpu():
|
|
| 967 |
|
| 968 |
@app.on_event("startup")
|
| 969 |
async def startup_event():
|
|
|
|
| 970 |
_load_tabs()
|
| 971 |
_write_shell_launcher()
|
| 972 |
await ensure_tmux()
|
| 973 |
_reconcile_tmux()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 974 |
dummy_gpu()
|
| 975 |
if SKIP_INSTALL:
|
| 976 |
log.info("WEBTERM_SKIP_INSTALL=1:跳過 opencode / 編輯器安裝")
|
|
|
|
| 1 |
import os
|
| 2 |
import pty
|
| 3 |
import shutil
|
| 4 |
+
import socket
|
| 5 |
import subprocess
|
| 6 |
import signal
|
| 7 |
import struct
|
|
|
|
| 11 |
import json
|
| 12 |
import base64
|
| 13 |
import logging
|
| 14 |
+
import mimetypes
|
| 15 |
import time
|
| 16 |
import uuid
|
| 17 |
|
|
|
|
| 81 |
#term-box{flex:1;overflow:hidden;padding:2px}
|
| 82 |
.tw{width:100%;height:100%;display:none}
|
| 83 |
.tw.active{display:block}
|
| 84 |
+
.desktop-wrap{background:#000}
|
| 85 |
+
.desktop-frame{width:100%;height:100%;border:0;display:block;background:#000}
|
| 86 |
+
.tab.fixed{border-color:#3a5f8a}
|
| 87 |
+
.tab.fixed .tab-title::before{content:"固定 · ";color:#7fb0e8;font-size:11px}
|
| 88 |
body{display:flex;flex-direction:column}
|
| 89 |
#file-picker{width:min(1040px,96vw);height:min(700px,90vh);max-height:90vh;padding:0;margin:auto;border:1px solid #aeb7c2;border-radius:8px;background:#f3f5f8;color:#20242a;box-shadow:0 18px 60px #0005;overflow:hidden;font-family:"Segoe UI",Arial,sans-serif}
|
| 90 |
#file-picker::backdrop{background:#10182088;backdrop-filter:blur(2px)}
|
|
|
|
| 344 |
return obj;
|
| 345 |
}
|
| 346 |
|
| 347 |
+
function ensureDesktopTab() {
|
| 348 |
+
if (tabs.some(t => t.type === 'desktop')) return;
|
| 349 |
+
const id = ++cnt;
|
| 350 |
+
const el = document.createElement('div');
|
| 351 |
+
el.className = 'tab fixed';
|
| 352 |
+
el.dataset.id = id;
|
| 353 |
+
el.innerHTML = `<span class="status-dot down"></span><span class="tab-title"></span><span class="x" title="重新啟動桌面">×</span>`;
|
| 354 |
+
el.querySelector('.tab-title').textContent = '桌面 1(LXDE,共用)';
|
| 355 |
+
bar.insertBefore(el, addBtn.nextSibling);
|
| 356 |
+
|
| 357 |
+
const wrap = document.createElement('div');
|
| 358 |
+
wrap.className = 'tw desktop-wrap';
|
| 359 |
+
wrap.dataset.id = id;
|
| 360 |
+
const frame = document.createElement('iframe');
|
| 361 |
+
frame.className = 'desktop-frame';
|
| 362 |
+
frame.title = 'LXDE 共用桌面';
|
| 363 |
+
frame.src = '/desktop?ts=' + Date.now();
|
| 364 |
+
frame.setAttribute('allow', 'clipboard-read; clipboard-write; fullscreen');
|
| 365 |
+
wrap.appendChild(frame);
|
| 366 |
+
box.appendChild(wrap);
|
| 367 |
+
|
| 368 |
+
const obj = {id, type: 'desktop', el, wrap, frame, ws: null, closed: false,
|
| 369 |
+
currentDir: '/home/user/app', sessionId: 'desktop'};
|
| 370 |
+
tabs.unshift(obj);
|
| 371 |
+
el.onclick = e => {
|
| 372 |
+
if (e.target.classList.contains('x')) closeTab(id);
|
| 373 |
+
else activate(id);
|
| 374 |
+
};
|
| 375 |
+
refreshDesktopStatus();
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
async function refreshDesktopStatus() {
|
| 379 |
+
const t = tabs.find(t => t.type === 'desktop');
|
| 380 |
+
if (!t) return;
|
| 381 |
+
try {
|
| 382 |
+
const r = await fetch('/api/desktop/status');
|
| 383 |
+
const s = await r.json();
|
| 384 |
+
const dot = t.el.querySelector('.status-dot');
|
| 385 |
+
if (dot) dot.classList.toggle('down', !s.alive);
|
| 386 |
+
t.alive = !!s.alive;
|
| 387 |
+
if (!s.alive && s.last_error) setAppStatus('桌面:' + s.last_error, 'warning');
|
| 388 |
+
} catch (e) { /* 狀態查詢失敗不影響終端機 */ }
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
async function restartDesktop() {
|
| 392 |
+
const t = tabs.find(t => t.type === 'desktop');
|
| 393 |
+
if (!t) return;
|
| 394 |
+
setAppStatus('正在重新啟動桌面(Xvfb + LXDE + x11vnc)…', 'info');
|
| 395 |
+
try {
|
| 396 |
+
const r = await fetch('/api/desktop/restart', {method: 'POST'});
|
| 397 |
+
const s = await r.json().catch(() => ({}));
|
| 398 |
+
if (!r.ok) throw new Error(s.error || '無法重新啟動桌面');
|
| 399 |
+
t.frame.src = '/desktop?ts=' + Date.now();
|
| 400 |
+
setAppStatus(s.alive ? '桌面已重新啟動' : '桌面已重新啟動,等待就緒', s.alive ? 'success' : 'warning');
|
| 401 |
+
refreshDesktopStatus();
|
| 402 |
+
} catch (err) {
|
| 403 |
+
setAppStatus(err.message, 'error');
|
| 404 |
+
}
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
function activate(id) {
|
| 408 |
activeId = id;
|
| 409 |
saveActive();
|
|
|
|
| 411 |
const on = t.id === id;
|
| 412 |
t.el.classList.toggle('active', on);
|
| 413 |
t.wrap.classList.toggle('active', on);
|
| 414 |
+
if (on && t.type === 'desktop') {
|
| 415 |
+
setTimeout(() => {
|
| 416 |
+
try {
|
| 417 |
+
t.frame.focus();
|
| 418 |
+
t.frame.contentWindow.dispatchEvent(new Event('resize'));
|
| 419 |
+
} catch (e) { /* ignore */ }
|
| 420 |
+
}, 30);
|
| 421 |
+
} else if (on && t.term) {
|
| 422 |
+
setTimeout(() => { t.fit.fit(); t.term.focus(); }, 30);
|
| 423 |
+
}
|
| 424 |
});
|
| 425 |
}
|
| 426 |
|
|
|
|
| 429 |
const i = tabs.findIndex(t => t.id === id);
|
| 430 |
if (i < 0) return;
|
| 431 |
const t = tabs[i];
|
| 432 |
+
if (t.type === 'desktop') return;
|
| 433 |
t.closed = true;
|
| 434 |
if (t.ws) { try { t.ws.close(); } catch (e) {} }
|
| 435 |
if (t.term) t.term.dispose();
|
|
|
|
| 444 |
async function closeTab(id) {
|
| 445 |
const t = tabs.find(t => t.id === id);
|
| 446 |
if (!t) return;
|
| 447 |
+
if (t.type === 'desktop') {
|
| 448 |
+
if (!confirm('重新啟動「桌面 1(LXDE,共用)」?\n\n這是固定分頁,關閉後會自動重開一個新的桌面,永遠維持恰好一個。')) return;
|
| 449 |
+
await restartDesktop();
|
| 450 |
+
return;
|
| 451 |
+
}
|
| 452 |
+
if (tabs.filter(t => t.type === 'terminal').length <= 1) {
|
| 453 |
setAppStatus('至少要保留一個終端機分頁', 'warning');
|
| 454 |
return;
|
| 455 |
}
|
|
|
|
| 481 |
const res = data || await apiTabs();
|
| 482 |
const serverIds = res.tabs.map(t => t.id);
|
| 483 |
[...tabs].forEach(t => {
|
| 484 |
+
if (t.type === 'terminal' && t.sessionId && !serverIds.includes(t.sessionId)) removeLocalTab(t.id);
|
| 485 |
});
|
| 486 |
res.tabs.forEach(t => {
|
| 487 |
const local = tabs.find(l => l.sessionId === t.id);
|
|
|
|
| 492 |
createTab(t.id, t.title, false);
|
| 493 |
}
|
| 494 |
});
|
| 495 |
+
if (!activeId && tabs.length) {
|
| 496 |
+
const firstTerminal = tabs.find(t => t.type === 'terminal') || tabs[0];
|
| 497 |
+
activate(firstTerminal.id);
|
| 498 |
+
}
|
| 499 |
} catch (err) {
|
| 500 |
setAppStatus('分頁同步失敗:' + err.message, 'warning');
|
| 501 |
}
|
|
|
|
| 516 |
copyBtn.onclick = async () => {
|
| 517 |
const t = activeTab();
|
| 518 |
if (!t) return;
|
| 519 |
+
if (t.type === 'desktop') {
|
| 520 |
+
setAppStatus('桌面分頁請直接在畫面內選取/複製,或使用桌面內應用程式的剪貼簿', 'info');
|
| 521 |
+
return;
|
| 522 |
+
}
|
| 523 |
const text = t.term.getSelection();
|
| 524 |
if (text) {
|
| 525 |
try {
|
|
|
|
| 536 |
pasteBtn.onclick = async () => {
|
| 537 |
const t = activeTab();
|
| 538 |
if (!t) return;
|
| 539 |
+
if (t.type === 'desktop') {
|
| 540 |
+
setAppStatus('桌面分頁請使用桌面內應用程式的貼上功能', 'info');
|
| 541 |
+
return;
|
| 542 |
+
}
|
| 543 |
try {
|
| 544 |
const text = await navigator.clipboard.readText();
|
| 545 |
if (t.ws && t.ws.readyState === WebSocket.OPEN) t.ws.send(JSON.stringify({type: 'input', data: text}));
|
|
|
|
| 548 |
}
|
| 549 |
};
|
| 550 |
|
| 551 |
+
uploadBtn.onclick = () => {
|
| 552 |
+
const t = activeTab();
|
| 553 |
+
if (t && t.type === 'desktop') {
|
| 554 |
+
setAppStatus('桌面分頁請直接在 LXDE 內使用檔案管理員或瀏覽器操作檔案', 'warning');
|
| 555 |
+
return;
|
| 556 |
+
}
|
| 557 |
+
fileInput.click();
|
| 558 |
+
};
|
| 559 |
fileInput.onchange = (e) => {
|
| 560 |
const file = e.target.files[0];
|
| 561 |
if (!file) return;
|
|
|
|
| 760 |
try {
|
| 761 |
let res = await apiTabs();
|
| 762 |
if (!res.tabs.length) res = await apiNewTab();
|
| 763 |
+
ensureDesktopTab();
|
| 764 |
await syncTabs(res);
|
| 765 |
const wanted = loadActive();
|
| 766 |
+
const local = tabs.find(t => t.type === 'terminal' && String(t.sessionId) === wanted)
|
| 767 |
+
|| tabs.find(t => t.type === 'terminal') || tabs[0];
|
| 768 |
if (local) activate(local.id);
|
| 769 |
} catch (err) {
|
| 770 |
setAppStatus('初始化失敗:' + err.message, 'error');
|
| 771 |
}
|
| 772 |
+
setInterval(() => { if (!document.hidden) { syncTabs(); refreshDesktopStatus(); } }, 4000);
|
| 773 |
+
window.addEventListener('focus', () => { syncTabs(); refreshDesktopStatus(); });
|
| 774 |
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) { syncTabs(); refreshDesktopStatus(); } });
|
| 775 |
})();
|
| 776 |
</script>
|
| 777 |
</body>
|
|
|
|
| 832 |
return FileResponse(safe, filename=os.path.basename(safe))
|
| 833 |
|
| 834 |
|
| 835 |
+
# ──────────────────────────── 固定桌面分頁路由 ──────────────────────────────
|
| 836 |
+
|
| 837 |
+
@app.get("/desktop")
|
| 838 |
+
async def desktop_page():
|
| 839 |
+
"""noVNC 用戶端;固定桌面分頁的 iframe 會載入這一頁。"""
|
| 840 |
+
return HTMLResponse(DESKTOP_PAGE)
|
| 841 |
+
|
| 842 |
+
|
| 843 |
+
@app.get("/api/desktop/status")
|
| 844 |
+
async def api_desktop_status():
|
| 845 |
+
return desktop_manager.status()
|
| 846 |
+
|
| 847 |
+
|
| 848 |
+
@app.post("/api/desktop/restart")
|
| 849 |
+
async def api_desktop_restart():
|
| 850 |
+
try:
|
| 851 |
+
ok = await desktop_manager.restart()
|
| 852 |
+
except Exception as exc:
|
| 853 |
+
log.exception("[desktop] 重新啟動失敗")
|
| 854 |
+
return JSONResponse({"error": f"無法重新啟動桌面:{exc}"}, status_code=500)
|
| 855 |
+
return {"ok": ok, **desktop_manager.status()}
|
| 856 |
+
|
| 857 |
+
|
| 858 |
+
@app.get("/novnc/{asset_path:path}")
|
| 859 |
+
async def novnc_asset(asset_path: str):
|
| 860 |
+
"""把系統的 noVNC 靜態檔(apt: novnc)提供給瀏覽器;缺少時前端會改用 CDN。"""
|
| 861 |
+
root = os.path.realpath(DESKTOP_NOVNC_ROOT)
|
| 862 |
+
target = os.path.realpath(os.path.join(root, asset_path))
|
| 863 |
+
if not (target == root or target.startswith(root + os.sep)) or not os.path.isfile(target):
|
| 864 |
+
return JSONResponse({"error": "not found"}, status_code=404)
|
| 865 |
+
media = mimetypes.guess_type(target)[0] or "application/octet-stream"
|
| 866 |
+
return FileResponse(target, media_type=media)
|
| 867 |
+
|
| 868 |
+
|
| 869 |
+
@app.websocket("/vnc")
|
| 870 |
+
async def vnc_proxy(websocket: WebSocket):
|
| 871 |
+
"""把 noVNC 的 WebSocket 流量橋接到本機 x11vnc(只監聽 127.0.0.1)。"""
|
| 872 |
+
offered = websocket.headers.get("sec-websocket-protocol", "")
|
| 873 |
+
protocols = [p.strip() for p in offered.split(",") if p.strip()]
|
| 874 |
+
subprotocol = "binary" if "binary" in protocols else (protocols[0] if protocols else None)
|
| 875 |
+
await websocket.accept(subprotocol=subprotocol)
|
| 876 |
+
|
| 877 |
+
if not await desktop_manager.ensure():
|
| 878 |
+
try:
|
| 879 |
+
await websocket.close(code=1011, reason="desktop unavailable")
|
| 880 |
+
except Exception:
|
| 881 |
+
pass
|
| 882 |
+
return
|
| 883 |
+
|
| 884 |
+
try:
|
| 885 |
+
reader, writer = await asyncio.open_connection("127.0.0.1", DESKTOP_VNC_PORT)
|
| 886 |
+
except OSError as exc:
|
| 887 |
+
log.warning("[desktop] VNC 連線失敗:%s", exc)
|
| 888 |
+
try:
|
| 889 |
+
await websocket.close(code=1011, reason="vnc unavailable")
|
| 890 |
+
except Exception:
|
| 891 |
+
pass
|
| 892 |
+
return
|
| 893 |
+
|
| 894 |
+
async def ws_to_tcp():
|
| 895 |
+
try:
|
| 896 |
+
while True:
|
| 897 |
+
message = await websocket.receive()
|
| 898 |
+
if message.get("type") == "websocket.disconnect":
|
| 899 |
+
break
|
| 900 |
+
data = message.get("bytes")
|
| 901 |
+
if data is None:
|
| 902 |
+
text = message.get("text")
|
| 903 |
+
data = text.encode("utf-8") if text is not None else b""
|
| 904 |
+
if data:
|
| 905 |
+
writer.write(data)
|
| 906 |
+
await writer.drain()
|
| 907 |
+
except (WebSocketDisconnect, RuntimeError, OSError):
|
| 908 |
+
pass
|
| 909 |
+
|
| 910 |
+
async def tcp_to_ws():
|
| 911 |
+
try:
|
| 912 |
+
while True:
|
| 913 |
+
data = await reader.read(65536)
|
| 914 |
+
if not data:
|
| 915 |
+
break
|
| 916 |
+
await websocket.send_bytes(data)
|
| 917 |
+
except (WebSocketDisconnect, RuntimeError, OSError):
|
| 918 |
+
pass
|
| 919 |
+
|
| 920 |
+
tasks = [asyncio.create_task(ws_to_tcp()), asyncio.create_task(tcp_to_ws())]
|
| 921 |
+
try:
|
| 922 |
+
_done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
| 923 |
+
for task in pending:
|
| 924 |
+
task.cancel()
|
| 925 |
+
finally:
|
| 926 |
+
for task in tasks:
|
| 927 |
+
task.cancel()
|
| 928 |
+
try:
|
| 929 |
+
writer.close()
|
| 930 |
+
await writer.wait_closed()
|
| 931 |
+
except Exception:
|
| 932 |
+
pass
|
| 933 |
+
try:
|
| 934 |
+
await websocket.close()
|
| 935 |
+
except Exception:
|
| 936 |
+
pass
|
| 937 |
+
|
| 938 |
+
|
| 939 |
@app.get("/api/sessions")
|
| 940 |
async def api_sessions():
|
| 941 |
"""列出目前伺服器上的終端機工作階段(除錯/確認跨裝置是否共用)。"""
|
|
|
|
| 1164 |
return False
|
| 1165 |
|
| 1166 |
|
| 1167 |
+
# ──────────────────────────── 固定桌面分頁(Xvfb + LXDE + x11vnc)─────────────────
|
| 1168 |
+
# 這是一個「固定」分頁,和 tmux 終端機完全獨立:
|
| 1169 |
+
# * 伺服器永遠維持恰好一個 Xvfb(:99)+ 一個 LXDE session + 一個 x11vnc
|
| 1170 |
+
# * 瀏覽器只是透過 /vnc 這條 WebSocket 橋接到同一個桌面(可多台裝置同時觀看)
|
| 1171 |
+
# * 關掉頁面、重新整理、或任何一個程序意外死亡,看門狗都會把桌面重新拉起來
|
| 1172 |
+
# * 所有邏輯都包在 try/except 裡,桌面壞掉不會影響原本的 tmux 終端機分頁
|
| 1173 |
+
DESKTOP_ID = "desktop"
|
| 1174 |
+
DESKTOP_TITLE = "桌面 1(LXDE,共用)"
|
| 1175 |
+
DESKTOP_DISPLAY = os.environ.get("DESKTOP_DISPLAY", ":99")
|
| 1176 |
+
DESKTOP_GEOMETRY = os.environ.get("DESKTOP_GEOMETRY", "1440x900x24")
|
| 1177 |
+
DESKTOP_VNC_PORT = int(os.environ.get("DESKTOP_VNC_PORT", "5900"))
|
| 1178 |
+
DESKTOP_NOVNC_ROOT = os.environ.get("DESKTOP_NOVNC_ROOT", "/usr/share/novnc")
|
| 1179 |
+
DESKTOP_LOG = "/tmp/webterm-desktop.log"
|
| 1180 |
+
DESKTOP_APT_PACKAGES = "xvfb x11vnc lxde-core novnc x11-utils"
|
| 1181 |
+
DESKTOP_WATCHDOG = None
|
| 1182 |
+
|
| 1183 |
+
DESKTOP_PAGE = r"""<!DOCTYPE html>
|
| 1184 |
+
<html lang="zh-TW">
|
| 1185 |
+
<head>
|
| 1186 |
+
<meta charset="utf-8"/>
|
| 1187 |
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
| 1188 |
+
<title>LXDE 桌面(共用)</title>
|
| 1189 |
+
<style>
|
| 1190 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 1191 |
+
html,body{width:100%;height:100%;overflow:hidden;background:#000;font-family:'Segoe UI',system-ui,sans-serif}
|
| 1192 |
+
#screen{width:100%;height:100%;outline:none;background:#000}
|
| 1193 |
+
#screen canvas{display:block;margin:auto}
|
| 1194 |
+
#bar{position:fixed;left:10px;top:10px;z-index:10;display:flex;align-items:center;gap:8px;
|
| 1195 |
+
padding:6px 10px;border-radius:6px;background:#111c;color:#ddd;font-size:12px;
|
| 1196 |
+
backdrop-filter:blur(4px);border:1px solid #ffffff22;pointer-events:none}
|
| 1197 |
+
#dot{width:8px;height:8px;border-radius:50%;background:#e0a800;flex:none}
|
| 1198 |
+
#dot.ok{background:#3fbf50}#dot.bad{background:#c0392b}
|
| 1199 |
+
#retry{pointer-events:auto;display:none;border:1px solid #ffffff33;background:#2d2d2d;color:#ddd;
|
| 1200 |
+
border-radius:4px;padding:4px 9px;cursor:pointer;font-size:12px}
|
| 1201 |
+
#retry:hover{background:#3c3c3c;color:#fff}
|
| 1202 |
+
</style>
|
| 1203 |
+
</head>
|
| 1204 |
+
<body>
|
| 1205 |
+
<div id="screen" tabindex="0"></div>
|
| 1206 |
+
<div id="bar"><span id="dot"></span><span id="msg">正在連線到共用桌面…</span><button id="retry" type="button">重新連線</button></div>
|
| 1207 |
+
<script type="module">
|
| 1208 |
+
const screenEl = document.getElementById('screen');
|
| 1209 |
+
const dot = document.getElementById('dot');
|
| 1210 |
+
const msg = document.getElementById('msg');
|
| 1211 |
+
const retry = document.getElementById('retry');
|
| 1212 |
+
let rfb = null, delay = 800, disposed = false, connecting = false;
|
| 1213 |
+
|
| 1214 |
+
function setState(state, text) {
|
| 1215 |
+
dot.className = state;
|
| 1216 |
+
msg.textContent = text;
|
| 1217 |
+
retry.style.display = state === 'bad' ? 'inline-block' : 'none';
|
| 1218 |
+
}
|
| 1219 |
+
|
| 1220 |
+
async function loadRFB() {
|
| 1221 |
+
try { return (await import('/novnc/core/rfb.js')).default; }
|
| 1222 |
+
catch (e) { return (await import('https://cdn.jsdelivr.net/gh/novnc/noVNC@v1.5.0/core/rfb.js')).default; }
|
| 1223 |
+
}
|
| 1224 |
+
|
| 1225 |
+
function scheduleReconnect() {
|
| 1226 |
+
if (disposed) return;
|
| 1227 |
+
setState('bad', `桌面連線中斷,${(delay / 1000).toFixed(1)} 秒後自動重連…`);
|
| 1228 |
+
setTimeout(connect, delay);
|
| 1229 |
+
delay = Math.min(delay * 1.6, 8000);
|
| 1230 |
+
}
|
| 1231 |
+
|
| 1232 |
+
async function connect() {
|
| 1233 |
+
if (disposed || connecting) return;
|
| 1234 |
+
connecting = true;
|
| 1235 |
+
setState('', '正在連線到共用桌面…');
|
| 1236 |
+
try {
|
| 1237 |
+
if (rfb) { try { rfb.disconnect(); } catch (e) { /* ignore */ } rfb = null; }
|
| 1238 |
+
const RFB = await loadRFB();
|
| 1239 |
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 1240 |
+
const url = `${proto}//${location.host}/vnc`;
|
| 1241 |
+
const client = new RFB(screenEl, url, {
|
| 1242 |
+
shared: true,
|
| 1243 |
+
scaleViewport: true,
|
| 1244 |
+
resizeSession: false,
|
| 1245 |
+
viewOnly: false,
|
| 1246 |
+
clipViewport: false,
|
| 1247 |
+
dragViewport: false,
|
| 1248 |
+
background: '#000',
|
| 1249 |
+
});
|
| 1250 |
+
rfb = client;
|
| 1251 |
+
client.addEventListener('connect', () => {
|
| 1252 |
+
delay = 800;
|
| 1253 |
+
connecting = false;
|
| 1254 |
+
setState('ok', `已連線(${client._fbWidth || ''}x${client._fbHeight || ''})`);
|
| 1255 |
+
client.focus();
|
| 1256 |
+
});
|
| 1257 |
+
client.addEventListener('disconnect', () => {
|
| 1258 |
+
if (rfb !== client) return;
|
| 1259 |
+
connecting = false;
|
| 1260 |
+
rfb = null;
|
| 1261 |
+
try { client.disconnect(); } catch (e) { /* ignore */ }
|
| 1262 |
+
scheduleReconnect();
|
| 1263 |
+
});
|
| 1264 |
+
client.addEventListener('securityfailure', e => {
|
| 1265 |
+
if (rfb !== client) return;
|
| 1266 |
+
connecting = false;
|
| 1267 |
+
setState('bad', '桌面驗證失敗:' + ((e.detail && e.detail.reason) || ''));
|
| 1268 |
+
});
|
| 1269 |
+
} catch (err) {
|
| 1270 |
+
connecting = false;
|
| 1271 |
+
setState('bad', '無法載入桌面用戶端:' + err.message);
|
| 1272 |
+
scheduleReconnect();
|
| 1273 |
+
}
|
| 1274 |
+
}
|
| 1275 |
+
|
| 1276 |
+
retry.onclick = () => { delay = 800; connect(); };
|
| 1277 |
+
screenEl.addEventListener('click', () => { if (rfb) rfb.focus(); });
|
| 1278 |
+
window.addEventListener('beforeunload', () => {
|
| 1279 |
+
disposed = true;
|
| 1280 |
+
try { if (rfb) rfb.disconnect(); } catch (e) { /* ignore */ }
|
| 1281 |
+
});
|
| 1282 |
+
connect();
|
| 1283 |
+
</script>
|
| 1284 |
+
</body>
|
| 1285 |
+
</html>"""
|
| 1286 |
+
|
| 1287 |
+
|
| 1288 |
+
def _desktop_port_open() -> bool:
|
| 1289 |
+
try:
|
| 1290 |
+
with socket.create_connection(("127.0.0.1", DESKTOP_VNC_PORT), timeout=0.4):
|
| 1291 |
+
return True
|
| 1292 |
+
except OSError:
|
| 1293 |
+
return False
|
| 1294 |
+
|
| 1295 |
+
|
| 1296 |
+
def _desktop_binaries_present() -> bool:
|
| 1297 |
+
if not (shutil.which("Xvfb") and shutil.which("x11vnc")):
|
| 1298 |
+
return False
|
| 1299 |
+
return any(shutil.which(x) for x in ("startlxde", "lxsession", "openbox"))
|
| 1300 |
+
|
| 1301 |
+
|
| 1302 |
+
class DesktopManager:
|
| 1303 |
+
"""維持唯一一個 Xvfb + LXDE + x11vnc;死了就補、重複就砍。"""
|
| 1304 |
+
|
| 1305 |
+
SESSION_NAMES = ("lxsession", "startlxde", "openbox", "lxpanel", "pcmanfm", "lxterminal")
|
| 1306 |
+
|
| 1307 |
+
def __init__(self):
|
| 1308 |
+
self._lock = asyncio.Lock()
|
| 1309 |
+
self.pids = {"xvfb": None, "session": None, "vnc": None}
|
| 1310 |
+
self.started_at = 0.0
|
| 1311 |
+
self.last_error = ""
|
| 1312 |
+
self._logf = None
|
| 1313 |
+
self._last_install = 0.0
|
| 1314 |
+
|
| 1315 |
+
# ── 基本工具 ──────────────────────────────────────────────
|
| 1316 |
+
def _log(self, message: str, level: int = logging.INFO):
|
| 1317 |
+
line = f"[desktop] {message}"
|
| 1318 |
+
log.log(level, "%s", line)
|
| 1319 |
+
try:
|
| 1320 |
+
f = self._logfd()
|
| 1321 |
+
if hasattr(f, "write"):
|
| 1322 |
+
f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + line + "\n")
|
| 1323 |
+
f.flush()
|
| 1324 |
+
except OSError:
|
| 1325 |
+
pass
|
| 1326 |
+
|
| 1327 |
+
def _logfd(self):
|
| 1328 |
+
if self._logf is None:
|
| 1329 |
+
try:
|
| 1330 |
+
self._logf = open(DESKTOP_LOG, "a", buffering=1)
|
| 1331 |
+
except OSError:
|
| 1332 |
+
self._logf = subprocess.DEVNULL
|
| 1333 |
+
return self._logf
|
| 1334 |
+
|
| 1335 |
+
@staticmethod
|
| 1336 |
+
def _pid_alive(pid) -> bool:
|
| 1337 |
+
if not pid:
|
| 1338 |
+
return False
|
| 1339 |
+
try:
|
| 1340 |
+
os.kill(int(pid), 0)
|
| 1341 |
+
return True
|
| 1342 |
+
except (ProcessLookupError, ValueError):
|
| 1343 |
+
return False
|
| 1344 |
+
except PermissionError:
|
| 1345 |
+
return True
|
| 1346 |
+
|
| 1347 |
+
@staticmethod
|
| 1348 |
+
def _cmdline(pid: int) -> str:
|
| 1349 |
+
try:
|
| 1350 |
+
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
| 1351 |
+
return f.read().replace(b"\0", b" ").decode("utf-8", "replace").strip()
|
| 1352 |
+
except OSError:
|
| 1353 |
+
return ""
|
| 1354 |
+
|
| 1355 |
+
def _has_display(self, pid: int) -> bool:
|
| 1356 |
+
wanted = ("DISPLAY=" + DESKTOP_DISPLAY).encode()
|
| 1357 |
+
try:
|
| 1358 |
+
with open(f"/proc/{pid}/environ", "rb") as f:
|
| 1359 |
+
return wanted in f.read().split(b"\0")
|
| 1360 |
+
except OSError:
|
| 1361 |
+
return False
|
| 1362 |
+
|
| 1363 |
+
def _env(self) -> dict:
|
| 1364 |
+
env = os.environ.copy()
|
| 1365 |
+
env["DISPLAY"] = DESKTOP_DISPLAY
|
| 1366 |
+
env["XDG_SESSION_TYPE"] = "x11"
|
| 1367 |
+
env["XDG_CURRENT_DESKTOP"] = "LXDE"
|
| 1368 |
+
env["DESKTOP_SESSION"] = "LXDE"
|
| 1369 |
+
env.pop("SESSION_MANAGER", None)
|
| 1370 |
+
runtime = os.path.join("/tmp", f"xdg-runtime-{os.getuid()}")
|
| 1371 |
+
try:
|
| 1372 |
+
os.makedirs(runtime, mode=0o700, exist_ok=True)
|
| 1373 |
+
os.chmod(runtime, 0o700)
|
| 1374 |
+
except OSError:
|
| 1375 |
+
pass
|
| 1376 |
+
env["XDG_RUNTIME_DIR"] = runtime
|
| 1377 |
+
return env
|
| 1378 |
+
|
| 1379 |
+
def _spawn(self, argv, env=None):
|
| 1380 |
+
self._log("spawn: " + " ".join(argv))
|
| 1381 |
+
return subprocess.Popen(
|
| 1382 |
+
argv, env=env or self._env(),
|
| 1383 |
+
stdin=subprocess.DEVNULL, stdout=self._logfd(), stderr=subprocess.STDOUT,
|
| 1384 |
+
start_new_session=True, close_fds=True,
|
| 1385 |
+
)
|
| 1386 |
+
|
| 1387 |
+
# ── 掃描/清理 ────────────────────────────────────────────
|
| 1388 |
+
def _scan(self):
|
| 1389 |
+
xvfb, vnc, session = [], [], []
|
| 1390 |
+
try:
|
| 1391 |
+
entries = os.listdir("/proc")
|
| 1392 |
+
except OSError:
|
| 1393 |
+
return xvfb, vnc, session
|
| 1394 |
+
for entry in entries:
|
| 1395 |
+
if not entry.isdigit():
|
| 1396 |
+
continue
|
| 1397 |
+
pid = int(entry)
|
| 1398 |
+
if pid == os.getpid():
|
| 1399 |
+
continue
|
| 1400 |
+
cmd = self._cmdline(pid)
|
| 1401 |
+
if not cmd:
|
| 1402 |
+
continue
|
| 1403 |
+
low = cmd.lower()
|
| 1404 |
+
if "xvfb" in low and DESKTOP_DISPLAY in cmd:
|
| 1405 |
+
xvfb.append(pid)
|
| 1406 |
+
elif "x11vnc" in low and DESKTOP_DISPLAY in cmd:
|
| 1407 |
+
vnc.append(pid)
|
| 1408 |
+
elif any(name in low for name in self.SESSION_NAMES) and (
|
| 1409 |
+
DESKTOP_DISPLAY in cmd or self._has_display(pid)):
|
| 1410 |
+
session.append(pid)
|
| 1411 |
+
return xvfb, vnc, session
|
| 1412 |
+
|
| 1413 |
+
def _kill_pids(self, pids, sig=signal.SIGTERM):
|
| 1414 |
+
for pid in pids:
|
| 1415 |
+
if not self._pid_alive(pid):
|
| 1416 |
+
continue
|
| 1417 |
+
try:
|
| 1418 |
+
pgid = os.getpgid(pid)
|
| 1419 |
+
if pgid > 1 and pgid != os.getpgid(0):
|
| 1420 |
+
os.killpg(pgid, sig)
|
| 1421 |
+
else:
|
| 1422 |
+
os.kill(pid, sig)
|
| 1423 |
+
except (ProcessLookupError, PermissionError, OSError):
|
| 1424 |
+
try:
|
| 1425 |
+
os.kill(pid, sig)
|
| 1426 |
+
except OSError:
|
| 1427 |
+
pass
|
| 1428 |
+
|
| 1429 |
+
def _stop_all(self):
|
| 1430 |
+
xvfb, vnc, session = self._scan()
|
| 1431 |
+
pids = set(xvfb) | set(vnc) | set(session)
|
| 1432 |
+
pids |= {p for p in self.pids.values() if p}
|
| 1433 |
+
if pids:
|
| 1434 |
+
self._log(f"停止桌面程序:{sorted(pids)}")
|
| 1435 |
+
self._kill_pids(pids, signal.SIGTERM)
|
| 1436 |
+
time.sleep(0.5)
|
| 1437 |
+
self._kill_pids(pids, signal.SIGKILL)
|
| 1438 |
+
self.pids = {"xvfb": None, "session": None, "vnc": None}
|
| 1439 |
+
|
| 1440 |
+
def _clean_display_files(self):
|
| 1441 |
+
num = DESKTOP_DISPLAY.lstrip(":") or "99"
|
| 1442 |
+
for path in (f"/tmp/.X{num}-lock", f"/tmp/.X11-unix/X{num}"):
|
| 1443 |
+
try:
|
| 1444 |
+
os.remove(path)
|
| 1445 |
+
except FileNotFoundError:
|
| 1446 |
+
pass
|
| 1447 |
+
except OSError as exc:
|
| 1448 |
+
self._log(f"無法移除 {path}: {exc}", logging.WARNING)
|
| 1449 |
+
|
| 1450 |
+
def _display_socket(self) -> str:
|
| 1451 |
+
num = DESKTOP_DISPLAY.lstrip(":") or "99"
|
| 1452 |
+
return f"/tmp/.X11-unix/X{num}"
|
| 1453 |
+
|
| 1454 |
+
def _healthy(self) -> bool:
|
| 1455 |
+
return (
|
| 1456 |
+
_desktop_port_open()
|
| 1457 |
+
and all(self._pid_alive(self.pids[k]) for k in ("xvfb", "session", "vnc"))
|
| 1458 |
+
and os.path.exists(self._display_socket())
|
| 1459 |
+
)
|
| 1460 |
+
|
| 1461 |
+
# ── 啟動各元件 ────────────────────────────────────────────
|
| 1462 |
+
async def _wait_for(self, predicate, timeout: float) -> bool:
|
| 1463 |
+
deadline = time.monotonic() + timeout
|
| 1464 |
+
while time.monotonic() < deadline:
|
| 1465 |
+
try:
|
| 1466 |
+
if predicate():
|
| 1467 |
+
return True
|
| 1468 |
+
except Exception:
|
| 1469 |
+
pass
|
| 1470 |
+
await asyncio.sleep(0.2)
|
| 1471 |
+
return False
|
| 1472 |
+
|
| 1473 |
+
async def _start_xvfb(self) -> bool:
|
| 1474 |
+
self._log(f"啟動 Xvfb {DESKTOP_DISPLAY}({DESKTOP_GEOMETRY})")
|
| 1475 |
+
try:
|
| 1476 |
+
proc = self._spawn([
|
| 1477 |
+
"Xvfb", DESKTOP_DISPLAY, "-screen", "0", DESKTOP_GEOMETRY,
|
| 1478 |
+
"-nolisten", "tcp", "-ac", "-noreset",
|
| 1479 |
+
])
|
| 1480 |
+
except OSError as exc:
|
| 1481 |
+
self.last_error = f"無法啟動 Xvfb:{exc}"
|
| 1482 |
+
self._log(self.last_error, logging.ERROR)
|
| 1483 |
+
return False
|
| 1484 |
+
self.pids["xvfb"] = proc.pid
|
| 1485 |
+
ok = await self._wait_for(
|
| 1486 |
+
lambda: os.path.exists(self._display_socket()) and self._pid_alive(proc.pid), 8)
|
| 1487 |
+
if not ok:
|
| 1488 |
+
self.last_error = f"Xvfb 啟動失敗(returncode={proc.poll()})"
|
| 1489 |
+
self._log(self.last_error, logging.ERROR)
|
| 1490 |
+
return False
|
| 1491 |
+
return True
|
| 1492 |
+
|
| 1493 |
+
async def _start_session(self) -> bool:
|
| 1494 |
+
last_error = "找不到 startlxde / lxsession / openbox"
|
| 1495 |
+
for launcher in ("startlxde", "lxsession", "openbox"):
|
| 1496 |
+
path = shutil.which(launcher)
|
| 1497 |
+
if not path:
|
| 1498 |
+
continue
|
| 1499 |
+
self._log(f"啟動 LXDE session:{launcher}")
|
| 1500 |
+
try:
|
| 1501 |
+
proc = self._spawn([path])
|
| 1502 |
+
except OSError as exc:
|
| 1503 |
+
last_error = f"{launcher} 啟動失敗:{exc}"
|
| 1504 |
+
continue
|
| 1505 |
+
await asyncio.sleep(1.2)
|
| 1506 |
+
pid = proc.pid if self._pid_alive(proc.pid) else None
|
| 1507 |
+
if not pid:
|
| 1508 |
+
_x, _v, found = self._scan()
|
| 1509 |
+
pid = found[0] if found else None
|
| 1510 |
+
if pid:
|
| 1511 |
+
self.pids["session"] = pid
|
| 1512 |
+
self._log(f"LXDE session 就緒({launcher},pid {pid})")
|
| 1513 |
+
return True
|
| 1514 |
+
last_error = f"{launcher} 啟動後立即結束(returncode={proc.poll()})"
|
| 1515 |
+
self._log(last_error, logging.WARNING)
|
| 1516 |
+
self.last_error = last_error
|
| 1517 |
+
return False
|
| 1518 |
+
|
| 1519 |
+
async def _start_vnc(self) -> bool:
|
| 1520 |
+
self._log(f"啟動 x11vnc(127.0.0.1:{DESKTOP_VNC_PORT})")
|
| 1521 |
+
try:
|
| 1522 |
+
proc = self._spawn([
|
| 1523 |
+
"x11vnc", "-display", DESKTOP_DISPLAY,
|
| 1524 |
+
"-rfbport", str(DESKTOP_VNC_PORT), "-localhost",
|
| 1525 |
+
"-forever", "-shared", "-nopw", "-noxdamage",
|
| 1526 |
+
"-repeat", "-wait", "10", "-defer", "10", "-no6",
|
| 1527 |
+
])
|
| 1528 |
+
except OSError as exc:
|
| 1529 |
+
self.last_error = f"無法啟動 x11vnc:{exc}"
|
| 1530 |
+
self._log(self.last_error, logging.ERROR)
|
| 1531 |
+
return False
|
| 1532 |
+
self.pids["vnc"] = proc.pid
|
| 1533 |
+
ok = await self._wait_for(
|
| 1534 |
+
lambda: _desktop_port_open() and self._pid_alive(proc.pid), 10)
|
| 1535 |
+
if not ok:
|
| 1536 |
+
self.last_error = f"x11vnc 啟動失敗(returncode={proc.poll()})"
|
| 1537 |
+
self._log(self.last_error, logging.ERROR)
|
| 1538 |
+
self.pids["vnc"] = None
|
| 1539 |
+
self._kill_pids([proc.pid], signal.SIGKILL)
|
| 1540 |
+
return False
|
| 1541 |
+
return True
|
| 1542 |
+
|
| 1543 |
+
async def _full_restart(self) -> bool:
|
| 1544 |
+
await asyncio.to_thread(self._stop_all)
|
| 1545 |
+
self._clean_display_files()
|
| 1546 |
+
if not await self._start_xvfb():
|
| 1547 |
+
return False
|
| 1548 |
+
if not await self._start_session():
|
| 1549 |
+
return False
|
| 1550 |
+
if not await self._start_vnc():
|
| 1551 |
+
return False
|
| 1552 |
+
self.started_at = time.time()
|
| 1553 |
+
self.last_error = ""
|
| 1554 |
+
self._log("桌面已就緒(Xvfb + LXDE + x11vnc)")
|
| 1555 |
+
return self._healthy()
|
| 1556 |
+
|
| 1557 |
+
async def _install_binaries(self) -> bool:
|
| 1558 |
+
now = time.time()
|
| 1559 |
+
if now - self._last_install < 300:
|
| 1560 |
+
return False
|
| 1561 |
+
self._last_install = now
|
| 1562 |
+
self._log("缺少桌面套件,嘗試安裝:" + DESKTOP_APT_PACKAGES, logging.WARNING)
|
| 1563 |
+
cmd = ("DEBIAN_FRONTEND=noninteractive apt-get update -y && "
|
| 1564 |
+
"DEBIAN_FRONTEND=noninteractive apt-get install -y "
|
| 1565 |
+
"--no-install-recommends " + DESKTOP_APT_PACKAGES)
|
| 1566 |
+
for prefix in ("", "sudo "):
|
| 1567 |
+
try:
|
| 1568 |
+
rc, _out, err = await _run(prefix + cmd)
|
| 1569 |
+
except Exception as exc:
|
| 1570 |
+
self._log(f"桌面套件安裝例外:{exc}", logging.WARNING)
|
| 1571 |
+
continue
|
| 1572 |
+
if rc == 0 and _desktop_binaries_present():
|
| 1573 |
+
self._log("桌面套件安裝完成")
|
| 1574 |
+
return True
|
| 1575 |
+
self._log(f"桌面套件安裝失敗(prefix={prefix!r}):{(err or '')[-300:]}", logging.WARNING)
|
| 1576 |
+
return False
|
| 1577 |
+
|
| 1578 |
+
async def ensure(self) -> bool:
|
| 1579 |
+
async with self._lock:
|
| 1580 |
+
if self._healthy():
|
| 1581 |
+
return True
|
| 1582 |
+
if not _desktop_binaries_present():
|
| 1583 |
+
ok = await self._install_binaries()
|
| 1584 |
+
if not ok:
|
| 1585 |
+
self.last_error = "缺少 Xvfb / x11vnc / LXDE 套件"
|
| 1586 |
+
return False
|
| 1587 |
+
self._log("桌面未就緒,開始檢查與修復")
|
| 1588 |
+
xvfb, vnc, session = await asyncio.to_thread(self._scan)
|
| 1589 |
+
|
| 1590 |
+
if len(xvfb) > 1:
|
| 1591 |
+
self._kill_pids(xvfb[1:], signal.SIGKILL)
|
| 1592 |
+
xvfb = xvfb[:1]
|
| 1593 |
+
if len(vnc) > 1:
|
| 1594 |
+
self._kill_pids(vnc[1:], signal.SIGKILL)
|
| 1595 |
+
vnc = vnc[:1]
|
| 1596 |
+
|
| 1597 |
+
if not xvfb or not self._pid_alive(xvfb[0]):
|
| 1598 |
+
self._log("找不到 Xvfb,重建整個桌面")
|
| 1599 |
+
return await self._full_restart()
|
| 1600 |
+
self.pids["xvfb"] = xvfb[0]
|
| 1601 |
+
if not os.path.exists(self._display_socket()):
|
| 1602 |
+
self._log("X11 socket 不存在,重建整個桌面")
|
| 1603 |
+
return await self._full_restart()
|
| 1604 |
+
|
| 1605 |
+
if session:
|
| 1606 |
+
self.pids["session"] = session[0]
|
| 1607 |
+
else:
|
| 1608 |
+
if not await self._start_session():
|
| 1609 |
+
return False
|
| 1610 |
+
|
| 1611 |
+
if vnc and not _desktop_port_open():
|
| 1612 |
+
self._kill_pids(vnc, signal.SIGKILL)
|
| 1613 |
+
vnc = []
|
| 1614 |
+
if vnc:
|
| 1615 |
+
self.pids["vnc"] = vnc[0]
|
| 1616 |
+
else:
|
| 1617 |
+
if not await self._start_vnc():
|
| 1618 |
+
return False
|
| 1619 |
+
|
| 1620 |
+
if not self._healthy():
|
| 1621 |
+
self.last_error = "桌面元件存在但健康檢查未通過"
|
| 1622 |
+
return False
|
| 1623 |
+
self.started_at = self.started_at or time.time()
|
| 1624 |
+
self.last_error = ""
|
| 1625 |
+
self._log("桌面已修復並就緒")
|
| 1626 |
+
return True
|
| 1627 |
+
|
| 1628 |
+
async def restart(self) -> bool:
|
| 1629 |
+
async with self._lock:
|
| 1630 |
+
self._log("收到重新啟動桌面要求")
|
| 1631 |
+
return await self._full_restart()
|
| 1632 |
+
|
| 1633 |
+
def status(self) -> dict:
|
| 1634 |
+
alive = self._healthy()
|
| 1635 |
+
return {
|
| 1636 |
+
"id": DESKTOP_ID,
|
| 1637 |
+
"title": DESKTOP_TITLE,
|
| 1638 |
+
"alive": alive,
|
| 1639 |
+
"display": DESKTOP_DISPLAY,
|
| 1640 |
+
"geometry": DESKTOP_GEOMETRY,
|
| 1641 |
+
"vnc_port": DESKTOP_VNC_PORT,
|
| 1642 |
+
"pids": dict(self.pids),
|
| 1643 |
+
"started_at": round(self.started_at, 3) if self.started_at else None,
|
| 1644 |
+
"last_error": self.last_error,
|
| 1645 |
+
"novnc": os.path.isdir(DESKTOP_NOVNC_ROOT),
|
| 1646 |
+
}
|
| 1647 |
+
|
| 1648 |
+
|
| 1649 |
+
desktop_manager = DesktopManager()
|
| 1650 |
+
|
| 1651 |
+
|
| 1652 |
+
async def desktop_watchdog():
|
| 1653 |
+
"""每 3 秒確認桌面還在,且永遠只有一份;不影響終端機流程。"""
|
| 1654 |
+
await asyncio.sleep(1)
|
| 1655 |
+
while True:
|
| 1656 |
+
ok = False
|
| 1657 |
+
try:
|
| 1658 |
+
ok = await desktop_manager.ensure()
|
| 1659 |
+
except Exception as exc:
|
| 1660 |
+
log.warning("[desktop] 看門狗例外(終端機不受影響):%s", exc)
|
| 1661 |
+
await asyncio.sleep(3 if ok else 5)
|
| 1662 |
+
|
| 1663 |
+
|
| 1664 |
@spaces.GPU(duration=1, queue=False)
|
| 1665 |
def dummy_gpu():
|
| 1666 |
"""空函數,僅用於讓平台識別 GPU 裝飾器(若無 GPU 可忽略)。"""
|
|
|
|
| 1669 |
|
| 1670 |
@app.on_event("startup")
|
| 1671 |
async def startup_event():
|
| 1672 |
+
global DESKTOP_WATCHDOG
|
| 1673 |
_load_tabs()
|
| 1674 |
_write_shell_launcher()
|
| 1675 |
await ensure_tmux()
|
| 1676 |
_reconcile_tmux()
|
| 1677 |
+
# 固定桌面分頁(Xvfb + LXDE + x11vnc)。獨立背景任務,失敗不影響終端機。
|
| 1678 |
+
try:
|
| 1679 |
+
if DESKTOP_WATCHDOG is None or DESKTOP_WATCHDOG.done():
|
| 1680 |
+
DESKTOP_WATCHDOG = asyncio.create_task(desktop_watchdog())
|
| 1681 |
+
except Exception as _desktop_err: # pragma: no cover
|
| 1682 |
+
log.warning("[desktop] 無法啟動看門狗(終端機照常運作):%r", _desktop_err)
|
| 1683 |
dummy_gpu()
|
| 1684 |
if SKIP_INSTALL:
|
| 1685 |
log.info("WEBTERM_SKIP_INSTALL=1:跳過 opencode / 編輯器安裝")
|
packages.txt
CHANGED
|
@@ -1 +1,6 @@
|
|
| 1 |
tmux
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
tmux
|
| 2 |
+
xvfb
|
| 3 |
+
x11vnc
|
| 4 |
+
lxde-core
|
| 5 |
+
novnc
|
| 6 |
+
x11-utils
|