opencode commited on
Commit
2f64e61
·
1 Parent(s): 70ccbbb

新增固定 LXDE 桌面分頁:Xvfb + x11vnc + noVNC,看門狗維持恰好一個

Browse files
Files changed (3) hide show
  1. README.md +20 -2
  2. app.py +718 -9
  3. packages.txt +5 -0
README.md CHANGED
@@ -19,8 +19,8 @@ OpenCode 在啟動時安裝,並重設使用者層級設定為預設值。
19
 
20
  ## 檔案
21
 
22
- - `app.py` — FastAPI + xterm.js 終端機、檔案瀏覽器與 `/ws` session 管理
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.term) setTimeout(() => { t.fit.fit(); t.term.focus(); }, 30);
 
 
 
 
 
 
 
 
 
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 (tabs.length <= 1) {
 
 
 
 
 
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) activate(tabs[0].id);
 
 
 
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 = () => fileInput.click();
 
 
 
 
 
 
 
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) || tabs[0];
 
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