Spaces:
Paused
Paused
Update app.py
Browse files
app.py
CHANGED
|
@@ -8,6 +8,8 @@ import asyncio
|
|
| 8 |
import json
|
| 9 |
import base64
|
| 10 |
import logging
|
|
|
|
|
|
|
| 11 |
import shutil
|
| 12 |
import subprocess
|
| 13 |
|
|
@@ -28,6 +30,11 @@ ALLOWED_BASES = [os.path.abspath(p) for p in (
|
|
| 28 |
"/home/user", "/workspace", "/home/app", HOME
|
| 29 |
)]
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
# ──────────────────────────── Frontend ────────────────────────────
|
| 32 |
HTML_PAGE = r"""<!DOCTYPE html>
|
| 33 |
<html lang="zh-TW">
|
|
@@ -84,13 +91,36 @@ const fileInput = document.getElementById('file-input');
|
|
| 84 |
let tabs = [], activeId = null, cnt = 0;
|
| 85 |
|
| 86 |
function activeTab() { return tabs.find(t => t.id === activeId); }
|
| 87 |
-
|
|
|
|
| 88 |
const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
|
| 92 |
function connect(tabObj) {
|
| 93 |
-
const ws = new WebSocket(wsUrl());
|
| 94 |
tabObj.ws = ws;
|
| 95 |
tabObj.reconnectDelay = tabObj.reconnectDelay || 500;
|
| 96 |
|
|
@@ -103,7 +133,11 @@ function connect(tabObj) {
|
|
| 103 |
ws.onmessage = e => {
|
| 104 |
try {
|
| 105 |
const m = JSON.parse(e.data);
|
| 106 |
-
if (m.type === '
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
const pwdRegex = /\x1b\]0;PWD:(.*?)\x07/g;
|
| 108 |
let match;
|
| 109 |
while ((match = pwdRegex.exec(m.data)) !== null) {
|
|
@@ -122,7 +156,7 @@ function connect(tabObj) {
|
|
| 122 |
ws.onclose = () => {
|
| 123 |
if (tabObj.closed) return;
|
| 124 |
tabObj.el.querySelector('.status-dot').classList.add('down');
|
| 125 |
-
tabObj.term.write(`\r\n\x1b[31m[連線中斷,${(tabObj.reconnectDelay/1000).toFixed(1)}s 後自動重連...]\x1b[0m\r\n`);
|
| 126 |
setTimeout(() => {
|
| 127 |
if (!tabObj.closed) connect(tabObj);
|
| 128 |
}, tabObj.reconnectDelay);
|
|
@@ -131,7 +165,7 @@ function connect(tabObj) {
|
|
| 131 |
ws.onerror = () => ws.close();
|
| 132 |
}
|
| 133 |
|
| 134 |
-
function createTab() {
|
| 135 |
const id = ++cnt;
|
| 136 |
|
| 137 |
const el = document.createElement('div');
|
|
@@ -159,9 +193,10 @@ function createTab() {
|
|
| 159 |
term.open(wrap);
|
| 160 |
fit.fit();
|
| 161 |
|
| 162 |
-
const obj = {id, el, wrap, term, fit, ws: null, closed: false, currentDir: '~'};
|
| 163 |
tabs.push(obj);
|
| 164 |
connect(obj);
|
|
|
|
| 165 |
|
| 166 |
term.onData(d => {
|
| 167 |
if (obj.ws && obj.ws.readyState === WebSocket.OPEN) obj.ws.send(JSON.stringify({type: 'input', data: d}));
|
|
@@ -194,16 +229,23 @@ function closeTab(id) {
|
|
| 194 |
if (i < 0) return;
|
| 195 |
const t = tabs[i];
|
| 196 |
t.closed = true;
|
| 197 |
-
if (t.ws)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
t.term.dispose();
|
| 199 |
t.el.remove();
|
| 200 |
t.wrap.remove();
|
| 201 |
tabs.splice(i, 1);
|
|
|
|
| 202 |
if (!tabs.length) { createTab(); return; }
|
| 203 |
if (activeId === id) activate(tabs[Math.min(i, tabs.length - 1)].id);
|
| 204 |
}
|
| 205 |
|
| 206 |
-
addBtn.onclick = createTab;
|
| 207 |
|
| 208 |
copyBtn.onclick = async () => {
|
| 209 |
const t = activeTab();
|
|
@@ -278,7 +320,18 @@ document.addEventListener('keydown', e => {
|
|
| 278 |
if (e.ctrlKey && e.shiftKey && e.key === 'W') { e.preventDefault(); if (activeId) closeTab(activeId); }
|
| 279 |
});
|
| 280 |
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
</script>
|
| 283 |
</body>
|
| 284 |
</html>"""
|
|
@@ -347,20 +400,20 @@ async def install_editors():
|
|
| 347 |
async def run_installpi_script():
|
| 348 |
"""執行 ~/app/installpi.sh(若存在),並自動設定執行權限。"""
|
| 349 |
installpi_path = os.path.expanduser("~/app/installpi.sh")
|
| 350 |
-
|
| 351 |
if not os.path.isfile(installpi_path):
|
| 352 |
log.warning("⚠️ installpi.sh 不存在於 ~/app/installpi.sh,跳過執行")
|
| 353 |
return False
|
| 354 |
-
|
| 355 |
try:
|
| 356 |
# 設定執行權限
|
| 357 |
os.chmod(installpi_path, 0o755)
|
| 358 |
log.info("✅ 已設定 installpi.sh 執行權限 (755)")
|
| 359 |
-
|
| 360 |
# 執行腳本
|
| 361 |
log.info("正在執行 ~/app/installpi.sh ...")
|
| 362 |
rc, out, err = await _run(installpi_path)
|
| 363 |
-
|
| 364 |
if rc == 0:
|
| 365 |
log.info("✅ installpi.sh 執行成功")
|
| 366 |
if out:
|
|
@@ -392,43 +445,72 @@ async def startup_event():
|
|
| 392 |
asyncio.create_task(install_editors())
|
| 393 |
|
| 394 |
|
| 395 |
-
# ──────────────────────────── Terminal
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
try:
|
| 401 |
-
|
| 402 |
-
except
|
| 403 |
return
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
try:
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
try:
|
| 413 |
-
await loop.run_in_executor(None, os.waitpid, pid, 0)
|
| 414 |
-
except Exception:
|
| 415 |
-
pass
|
| 416 |
|
| 417 |
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
await websocket.accept()
|
| 421 |
|
| 422 |
-
try:
|
| 423 |
-
pid, fd = pty.fork()
|
| 424 |
-
except OSError as e:
|
| 425 |
-
await websocket.send_text(json.dumps({
|
| 426 |
-
"type": "system",
|
| 427 |
-
"data": f"\r\n\x1b[31m[❌ 無法建立新終端機 (pty.fork 失敗: {e}),請稍後再試或關閉其他分頁]\x1b[0m\r\n",
|
| 428 |
-
}))
|
| 429 |
-
await websocket.close()
|
| 430 |
-
return
|
| 431 |
|
|
|
|
|
|
|
| 432 |
if pid == 0:
|
| 433 |
os.environ["TERM"] = "xterm-256color"
|
| 434 |
os.environ["COLORTERM"] = "truecolor"
|
|
@@ -441,7 +523,7 @@ async def terminal_ws(websocket: WebSocket):
|
|
| 441 |
|
| 442 |
banner = (
|
| 443 |
'echo -e "\\033[36m歡迎使用 Web Terminal|vim/nvim 已安裝|'
|
| 444 |
-
|
| 445 |
)
|
| 446 |
bash_cmd = (
|
| 447 |
f"{banner} "
|
|
@@ -449,28 +531,146 @@ async def terminal_ws(websocket: WebSocket):
|
|
| 449 |
"exec bash --login"
|
| 450 |
)
|
| 451 |
os.execvp("bash", ["bash", "-c", bash_cmd])
|
|
|
|
| 452 |
|
| 453 |
-
loop = asyncio.get_running_loop()
|
| 454 |
|
| 455 |
-
|
| 456 |
-
|
| 457 |
|
| 458 |
-
|
|
|
|
| 459 |
|
|
|
|
| 460 |
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
|
| 461 |
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
|
| 462 |
|
| 463 |
-
|
| 464 |
|
| 465 |
def _on_read():
|
| 466 |
try:
|
| 467 |
data = os.read(fd, 65536)
|
| 468 |
-
queue.put_nowait(data if data else None)
|
| 469 |
except (BlockingIOError, OSError):
|
| 470 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
|
| 472 |
loop.add_reader(fd, _on_read)
|
| 473 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
async def _pty_to_ws():
|
| 475 |
while True:
|
| 476 |
chunk = await queue.get()
|
|
@@ -493,19 +693,22 @@ async def terminal_ws(websocket: WebSocket):
|
|
| 493 |
except json.JSONDecodeError:
|
| 494 |
continue
|
| 495 |
|
| 496 |
-
|
|
|
|
|
|
|
| 497 |
try:
|
| 498 |
-
os.write(fd, msg["data"].encode("utf-8"))
|
| 499 |
except OSError:
|
| 500 |
break
|
| 501 |
|
| 502 |
-
elif
|
| 503 |
try:
|
| 504 |
-
|
|
|
|
| 505 |
except Exception:
|
| 506 |
pass
|
| 507 |
|
| 508 |
-
elif
|
| 509 |
filename = os.path.basename(msg.get("filename", "uploaded_file"))
|
| 510 |
target_dir = msg.get("targetDir", "/home/user")
|
| 511 |
safe_dir = _safe_path(target_dir) or "/home/user"
|
|
@@ -528,21 +731,20 @@ async def terminal_ws(websocket: WebSocket):
|
|
| 528 |
"data": f"\r\n\x1b[31m[❌ 上傳失敗: {e}]\x1b[0m\r\n",
|
| 529 |
}))
|
| 530 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
except (WebSocketDisconnect, RuntimeError):
|
| 532 |
pass
|
| 533 |
except Exception:
|
| 534 |
log.exception("terminal_ws 循環發生未預期錯誤")
|
| 535 |
finally:
|
| 536 |
-
try:
|
| 537 |
-
loop.remove_reader(fd)
|
| 538 |
-
except Exception:
|
| 539 |
-
pass
|
| 540 |
sender.cancel()
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
except Exception:
|
| 545 |
-
pass
|
| 546 |
|
| 547 |
|
| 548 |
if __name__ == "__main__":
|
|
|
|
| 8 |
import json
|
| 9 |
import base64
|
| 10 |
import logging
|
| 11 |
+
import time
|
| 12 |
+
import uuid
|
| 13 |
import shutil
|
| 14 |
import subprocess
|
| 15 |
|
|
|
|
| 30 |
"/home/user", "/workspace", "/home/app", HOME
|
| 31 |
)]
|
| 32 |
|
| 33 |
+
# 斷線後 session 保留的閒置時間(沒有任何分頁接回就會被清除)
|
| 34 |
+
SESSION_IDLE_TIMEOUT = 30 * 60 # 30 分鐘
|
| 35 |
+
# 每個 session 保留的畫面回放緩衝區大小上限(bytes)
|
| 36 |
+
SESSION_BUFFER_MAX = 300_000
|
| 37 |
+
|
| 38 |
# ──────────────────────────── Frontend ────────────────────────────
|
| 39 |
HTML_PAGE = r"""<!DOCTYPE html>
|
| 40 |
<html lang="zh-TW">
|
|
|
|
| 91 |
let tabs = [], activeId = null, cnt = 0;
|
| 92 |
|
| 93 |
function activeTab() { return tabs.find(t => t.id === activeId); }
|
| 94 |
+
|
| 95 |
+
function wsUrl(sessionId) {
|
| 96 |
const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 97 |
+
let url = `${p}//${location.host}/ws`;
|
| 98 |
+
if (sessionId) url += `?session_id=${encodeURIComponent(sessionId)}`;
|
| 99 |
+
return url;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// ── 分頁狀態持久化:把「分頁 id + 對應的伺服器 session_id」存進 localStorage,
|
| 103 |
+
// 這樣重新整理整個網頁,甚至瀏覽器關掉重開,都能接回原本還在跑的終端機。
|
| 104 |
+
function saveTabs() {
|
| 105 |
+
try {
|
| 106 |
+
const data = tabs.map(t => ({ id: t.id, sessionId: t.sessionId || null }));
|
| 107 |
+
localStorage.setItem('webterm_tabs', JSON.stringify(data));
|
| 108 |
+
localStorage.setItem('webterm_cnt', String(cnt));
|
| 109 |
+
} catch (e) { /* localStorage 不可用時忽略 */ }
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function loadTabs() {
|
| 113 |
+
try {
|
| 114 |
+
const raw = localStorage.getItem('webterm_tabs');
|
| 115 |
+
if (!raw) return null;
|
| 116 |
+
const data = JSON.parse(raw);
|
| 117 |
+
if (Array.isArray(data) && data.length) return data;
|
| 118 |
+
} catch (e) { /* ignore */ }
|
| 119 |
+
return null;
|
| 120 |
}
|
| 121 |
|
| 122 |
function connect(tabObj) {
|
| 123 |
+
const ws = new WebSocket(wsUrl(tabObj.sessionId));
|
| 124 |
tabObj.ws = ws;
|
| 125 |
tabObj.reconnectDelay = tabObj.reconnectDelay || 500;
|
| 126 |
|
|
|
|
| 133 |
ws.onmessage = e => {
|
| 134 |
try {
|
| 135 |
const m = JSON.parse(e.data);
|
| 136 |
+
if (m.type === 'session') {
|
| 137 |
+
// 伺服器告訴我們這條連線對應的 session id:記住它,下次斷線/重整才能接回同一個終端機
|
| 138 |
+
tabObj.sessionId = m.id;
|
| 139 |
+
saveTabs();
|
| 140 |
+
} else if (m.type === 'output') {
|
| 141 |
const pwdRegex = /\x1b\]0;PWD:(.*?)\x07/g;
|
| 142 |
let match;
|
| 143 |
while ((match = pwdRegex.exec(m.data)) !== null) {
|
|
|
|
| 156 |
ws.onclose = () => {
|
| 157 |
if (tabObj.closed) return;
|
| 158 |
tabObj.el.querySelector('.status-dot').classList.add('down');
|
| 159 |
+
tabObj.term.write(`\r\n\x1b[31m[連線中斷,${(tabObj.reconnectDelay/1000).toFixed(1)}s 後自動重連(將接回原本畫面)...]\x1b[0m\r\n`);
|
| 160 |
setTimeout(() => {
|
| 161 |
if (!tabObj.closed) connect(tabObj);
|
| 162 |
}, tabObj.reconnectDelay);
|
|
|
|
| 165 |
ws.onerror = () => ws.close();
|
| 166 |
}
|
| 167 |
|
| 168 |
+
function createTab(sessionId) {
|
| 169 |
const id = ++cnt;
|
| 170 |
|
| 171 |
const el = document.createElement('div');
|
|
|
|
| 193 |
term.open(wrap);
|
| 194 |
fit.fit();
|
| 195 |
|
| 196 |
+
const obj = {id, el, wrap, term, fit, ws: null, closed: false, currentDir: '~', sessionId: sessionId || null};
|
| 197 |
tabs.push(obj);
|
| 198 |
connect(obj);
|
| 199 |
+
saveTabs();
|
| 200 |
|
| 201 |
term.onData(d => {
|
| 202 |
if (obj.ws && obj.ws.readyState === WebSocket.OPEN) obj.ws.send(JSON.stringify({type: 'input', data: d}));
|
|
|
|
| 229 |
if (i < 0) return;
|
| 230 |
const t = tabs[i];
|
| 231 |
t.closed = true;
|
| 232 |
+
if (t.ws) {
|
| 233 |
+
// 使用者主動關閉分頁才真正通知伺服器砍掉該 session(區別於單純網路斷線)
|
| 234 |
+
if (t.ws.readyState === WebSocket.OPEN) {
|
| 235 |
+
try { t.ws.send(JSON.stringify({type: 'kill'})); } catch (e) {}
|
| 236 |
+
}
|
| 237 |
+
t.ws.close();
|
| 238 |
+
}
|
| 239 |
t.term.dispose();
|
| 240 |
t.el.remove();
|
| 241 |
t.wrap.remove();
|
| 242 |
tabs.splice(i, 1);
|
| 243 |
+
saveTabs();
|
| 244 |
if (!tabs.length) { createTab(); return; }
|
| 245 |
if (activeId === id) activate(tabs[Math.min(i, tabs.length - 1)].id);
|
| 246 |
}
|
| 247 |
|
| 248 |
+
addBtn.onclick = () => createTab();
|
| 249 |
|
| 250 |
copyBtn.onclick = async () => {
|
| 251 |
const t = activeTab();
|
|
|
|
| 320 |
if (e.ctrlKey && e.shiftKey && e.key === 'W') { e.preventDefault(); if (activeId) closeTab(activeId); }
|
| 321 |
});
|
| 322 |
|
| 323 |
+
// ── 頁面載入時:如果 localStorage 有先前留下的分頁紀錄,就依序用相同 session_id 接回,
|
| 324 |
+
// 而不是每次重新整理都開全新的終端機。
|
| 325 |
+
(function initTabs() {
|
| 326 |
+
const saved = loadTabs();
|
| 327 |
+
const savedCnt = parseInt(localStorage.getItem('webterm_cnt') || '0', 10);
|
| 328 |
+
if (savedCnt) cnt = savedCnt;
|
| 329 |
+
if (saved && saved.length) {
|
| 330 |
+
saved.forEach(t => createTab(t.sessionId));
|
| 331 |
+
} else {
|
| 332 |
+
createTab();
|
| 333 |
+
}
|
| 334 |
+
})();
|
| 335 |
</script>
|
| 336 |
</body>
|
| 337 |
</html>"""
|
|
|
|
| 400 |
async def run_installpi_script():
|
| 401 |
"""執行 ~/app/installpi.sh(若存在),並自動設定執行權限。"""
|
| 402 |
installpi_path = os.path.expanduser("~/app/installpi.sh")
|
| 403 |
+
|
| 404 |
if not os.path.isfile(installpi_path):
|
| 405 |
log.warning("⚠️ installpi.sh 不存在於 ~/app/installpi.sh,跳過執行")
|
| 406 |
return False
|
| 407 |
+
|
| 408 |
try:
|
| 409 |
# 設定執行權限
|
| 410 |
os.chmod(installpi_path, 0o755)
|
| 411 |
log.info("✅ 已設定 installpi.sh 執行權限 (755)")
|
| 412 |
+
|
| 413 |
# 執行腳本
|
| 414 |
log.info("正在執行 ~/app/installpi.sh ...")
|
| 415 |
rc, out, err = await _run(installpi_path)
|
| 416 |
+
|
| 417 |
if rc == 0:
|
| 418 |
log.info("✅ installpi.sh 執行成功")
|
| 419 |
if out:
|
|
|
|
| 445 |
asyncio.create_task(install_editors())
|
| 446 |
|
| 447 |
|
| 448 |
+
# ──────────────────────────── Terminal Session 管理 ──────────────────────────────
|
| 449 |
+
# 核心概念:pty(bash 進程)的生命週期跟 WebSocket 連線脫鉤。
|
| 450 |
+
# WebSocket 斷線/頁面重新整理時,只是少了一個「訂閱者」,背後的 bash 依然在跑;
|
| 451 |
+
# 只有使用者主動關閉分頁(送出 kill 訊息),或閒置太久沒人接回,才會真正砍掉該 session。
|
| 452 |
+
|
| 453 |
+
SESSIONS: dict[str, "TerminalSession"] = {}
|
| 454 |
+
SESSIONS_LOCK = asyncio.Lock()
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
class TerminalSession:
|
| 458 |
+
def __init__(self, session_id: str, pid: int, fd: int, cols: int = 120, rows: int = 30):
|
| 459 |
+
self.id = session_id
|
| 460 |
+
self.pid = pid
|
| 461 |
+
self.fd = fd
|
| 462 |
+
self.cols = cols
|
| 463 |
+
self.rows = rows
|
| 464 |
+
self.buffer = bytearray() # 畫面回放緩衝區,供重連時還原終端機畫面
|
| 465 |
+
self.subscribers: dict[int, asyncio.Queue] = {}
|
| 466 |
+
self._next_sub_id = 0
|
| 467 |
+
self.cleanup_task: asyncio.Task | None = None
|
| 468 |
+
|
| 469 |
+
def add_subscriber(self):
|
| 470 |
+
sid = self._next_sub_id
|
| 471 |
+
self._next_sub_id += 1
|
| 472 |
+
q: asyncio.Queue = asyncio.Queue()
|
| 473 |
+
self.subscribers[sid] = q
|
| 474 |
+
if self.cleanup_task:
|
| 475 |
+
self.cleanup_task.cancel()
|
| 476 |
+
self.cleanup_task = None
|
| 477 |
+
return sid, q
|
| 478 |
+
|
| 479 |
+
def remove_subscriber(self, sid: int, loop: asyncio.AbstractEventLoop):
|
| 480 |
+
self.subscribers.pop(sid, None)
|
| 481 |
+
if not self.subscribers:
|
| 482 |
+
self.cleanup_task = loop.create_task(self._schedule_cleanup(loop))
|
| 483 |
+
|
| 484 |
+
async def _schedule_cleanup(self, loop: asyncio.AbstractEventLoop):
|
| 485 |
try:
|
| 486 |
+
await asyncio.sleep(SESSION_IDLE_TIMEOUT)
|
| 487 |
+
except asyncio.CancelledError:
|
| 488 |
return
|
| 489 |
+
if not self.subscribers:
|
| 490 |
+
log.info("session %s 閒置超過 %ds 無人接回,清除", self.id, SESSION_IDLE_TIMEOUT)
|
| 491 |
+
await kill_session(self.id, loop)
|
| 492 |
+
|
| 493 |
+
def feed(self, data: bytes):
|
| 494 |
+
self.buffer.extend(data)
|
| 495 |
+
overflow = len(self.buffer) - SESSION_BUFFER_MAX
|
| 496 |
+
if overflow > 0:
|
| 497 |
+
del self.buffer[:overflow]
|
| 498 |
+
dead = []
|
| 499 |
+
for sid, q in self.subscribers.items():
|
| 500 |
try:
|
| 501 |
+
q.put_nowait(data)
|
| 502 |
+
except Exception:
|
| 503 |
+
dead.append(sid)
|
| 504 |
+
for sid in dead:
|
| 505 |
+
self.subscribers.pop(sid, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
|
| 507 |
|
| 508 |
+
def _set_winsize(fd: int, cols: int, rows: int):
|
| 509 |
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
|
|
|
| 510 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
|
| 512 |
+
def _fork_shell():
|
| 513 |
+
pid, fd = pty.fork()
|
| 514 |
if pid == 0:
|
| 515 |
os.environ["TERM"] = "xterm-256color"
|
| 516 |
os.environ["COLORTERM"] = "truecolor"
|
|
|
|
| 523 |
|
| 524 |
banner = (
|
| 525 |
'echo -e "\\033[36m歡迎使用 Web Terminal|vim/nvim 已安裝|'
|
| 526 |
+
'opencode 與 installpi.sh 已執行(請查看日誌)\\033[0m";'
|
| 527 |
)
|
| 528 |
bash_cmd = (
|
| 529 |
f"{banner} "
|
|
|
|
| 531 |
"exec bash --login"
|
| 532 |
)
|
| 533 |
os.execvp("bash", ["bash", "-c", bash_cmd])
|
| 534 |
+
return pid, fd
|
| 535 |
|
|
|
|
| 536 |
|
| 537 |
+
async def create_session(cols: int = 120, rows: int = 30) -> TerminalSession:
|
| 538 |
+
pid, fd = _fork_shell()
|
| 539 |
|
| 540 |
+
session_id = uuid.uuid4().hex[:12]
|
| 541 |
+
sess = TerminalSession(session_id, pid, fd, cols, rows)
|
| 542 |
|
| 543 |
+
_set_winsize(fd, cols, rows)
|
| 544 |
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
|
| 545 |
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
|
| 546 |
|
| 547 |
+
loop = asyncio.get_running_loop()
|
| 548 |
|
| 549 |
def _on_read():
|
| 550 |
try:
|
| 551 |
data = os.read(fd, 65536)
|
|
|
|
| 552 |
except (BlockingIOError, OSError):
|
| 553 |
+
return
|
| 554 |
+
if not data:
|
| 555 |
+
# bash 行程已結束(例如使用者在終端機內打了 exit)
|
| 556 |
+
try:
|
| 557 |
+
loop.remove_reader(fd)
|
| 558 |
+
except Exception:
|
| 559 |
+
pass
|
| 560 |
+
asyncio.create_task(kill_session(session_id, loop, already_dead=True))
|
| 561 |
+
return
|
| 562 |
+
sess.feed(data)
|
| 563 |
|
| 564 |
loop.add_reader(fd, _on_read)
|
| 565 |
|
| 566 |
+
async with SESSIONS_LOCK:
|
| 567 |
+
SESSIONS[session_id] = sess
|
| 568 |
+
return sess
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
async def _reap_process(pid: int, loop: asyncio.AbstractEventLoop):
|
| 572 |
+
"""非阻塞地終止並回收子行程。"""
|
| 573 |
+
for sig in (signal.SIGTERM, signal.SIGKILL):
|
| 574 |
+
try:
|
| 575 |
+
os.kill(pid, sig)
|
| 576 |
+
except ProcessLookupError:
|
| 577 |
+
return
|
| 578 |
+
for _ in range(20):
|
| 579 |
+
await asyncio.sleep(0.1)
|
| 580 |
+
try:
|
| 581 |
+
wpid, _status = os.waitpid(pid, os.WNOHANG)
|
| 582 |
+
if wpid == pid:
|
| 583 |
+
return
|
| 584 |
+
except ChildProcessError:
|
| 585 |
+
return
|
| 586 |
+
try:
|
| 587 |
+
await loop.run_in_executor(None, os.waitpid, pid, 0)
|
| 588 |
+
except Exception:
|
| 589 |
+
pass
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
async def kill_session(session_id: str, loop: asyncio.AbstractEventLoop, already_dead: bool = False):
|
| 593 |
+
async with SESSIONS_LOCK:
|
| 594 |
+
sess = SESSIONS.pop(session_id, None)
|
| 595 |
+
if not sess:
|
| 596 |
+
return
|
| 597 |
+
try:
|
| 598 |
+
loop.remove_reader(sess.fd)
|
| 599 |
+
except Exception:
|
| 600 |
+
pass
|
| 601 |
+
if not already_dead:
|
| 602 |
+
await _reap_process(sess.pid, loop)
|
| 603 |
+
try:
|
| 604 |
+
os.close(sess.fd)
|
| 605 |
+
except Exception:
|
| 606 |
+
pass
|
| 607 |
+
for q in list(sess.subscribers.values()):
|
| 608 |
+
try:
|
| 609 |
+
q.put_nowait(None)
|
| 610 |
+
except Exception:
|
| 611 |
+
pass
|
| 612 |
+
log.info("session %s 已清除", session_id)
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
# ──────────────────────────── Terminal WebSocket ──────────────────────────────
|
| 616 |
+
|
| 617 |
+
@app.websocket("/ws")
|
| 618 |
+
async def terminal_ws(websocket: WebSocket):
|
| 619 |
+
await websocket.accept()
|
| 620 |
+
loop = asyncio.get_running_loop()
|
| 621 |
+
|
| 622 |
+
requested_id = websocket.query_params.get("session_id")
|
| 623 |
+
try:
|
| 624 |
+
cols = int(websocket.query_params.get("cols", 120))
|
| 625 |
+
rows = int(websocket.query_params.get("rows", 30))
|
| 626 |
+
except ValueError:
|
| 627 |
+
cols, rows = 120, 30
|
| 628 |
+
|
| 629 |
+
sess = SESSIONS.get(requested_id) if requested_id else None
|
| 630 |
+
reattached = sess is not None
|
| 631 |
+
|
| 632 |
+
if sess is None:
|
| 633 |
+
try:
|
| 634 |
+
sess = await create_session(cols, rows)
|
| 635 |
+
except OSError as e:
|
| 636 |
+
await websocket.send_text(json.dumps({
|
| 637 |
+
"type": "system",
|
| 638 |
+
"data": f"\r\n\x1b[31m[❌ 無法建立新終端機 (pty.fork 失敗: {e}),請稍後再試或關閉其他分頁]\x1b[0m\r\n",
|
| 639 |
+
}))
|
| 640 |
+
await websocket.close()
|
| 641 |
+
return
|
| 642 |
+
|
| 643 |
+
# 告知前端這次連線對應的 session id,讓前端存起來,日後斷線重連 / 重新整理都能接回同一個
|
| 644 |
+
await websocket.send_text(json.dumps({"type": "session", "id": sess.id, "reattached": reattached}))
|
| 645 |
+
|
| 646 |
+
if reattached:
|
| 647 |
+
if sess.buffer:
|
| 648 |
+
try:
|
| 649 |
+
await websocket.send_text(json.dumps({
|
| 650 |
+
"type": "output",
|
| 651 |
+
"data": bytes(sess.buffer).decode("utf-8", errors="replace"),
|
| 652 |
+
}))
|
| 653 |
+
except Exception:
|
| 654 |
+
pass
|
| 655 |
+
try:
|
| 656 |
+
await websocket.send_text(json.dumps({
|
| 657 |
+
"type": "system",
|
| 658 |
+
"data": "\r\n\x1b[32m[✅ 已��新連線,畫面已還原]\x1b[0m\r\n",
|
| 659 |
+
}))
|
| 660 |
+
except Exception:
|
| 661 |
+
pass
|
| 662 |
+
elif requested_id:
|
| 663 |
+
# 前端要求接回某個 session,但伺服器已找不到(逾時被清除或伺服器重啟過)
|
| 664 |
+
try:
|
| 665 |
+
await websocket.send_text(json.dumps({
|
| 666 |
+
"type": "system",
|
| 667 |
+
"data": "\r\n\x1b[33m[⚠️ 找不到先前的工作階段(可能已逾時或伺服器重新啟動),已建立新的終端機]\x1b[0m\r\n",
|
| 668 |
+
}))
|
| 669 |
+
except Exception:
|
| 670 |
+
pass
|
| 671 |
+
|
| 672 |
+
sub_id, queue = sess.add_subscriber()
|
| 673 |
+
|
| 674 |
async def _pty_to_ws():
|
| 675 |
while True:
|
| 676 |
chunk = await queue.get()
|
|
|
|
| 693 |
except json.JSONDecodeError:
|
| 694 |
continue
|
| 695 |
|
| 696 |
+
mtype = msg.get("type")
|
| 697 |
+
|
| 698 |
+
if mtype == "input":
|
| 699 |
try:
|
| 700 |
+
os.write(sess.fd, msg["data"].encode("utf-8"))
|
| 701 |
except OSError:
|
| 702 |
break
|
| 703 |
|
| 704 |
+
elif mtype == "resize":
|
| 705 |
try:
|
| 706 |
+
sess.cols, sess.rows = msg["cols"], msg["rows"]
|
| 707 |
+
_set_winsize(sess.fd, msg["cols"], msg["rows"])
|
| 708 |
except Exception:
|
| 709 |
pass
|
| 710 |
|
| 711 |
+
elif mtype == "upload":
|
| 712 |
filename = os.path.basename(msg.get("filename", "uploaded_file"))
|
| 713 |
target_dir = msg.get("targetDir", "/home/user")
|
| 714 |
safe_dir = _safe_path(target_dir) or "/home/user"
|
|
|
|
| 731 |
"data": f"\r\n\x1b[31m[❌ 上傳失敗: {e}]\x1b[0m\r\n",
|
| 732 |
}))
|
| 733 |
|
| 734 |
+
elif mtype == "kill":
|
| 735 |
+
# 使用者主動關閉這個分頁:真正終止此 session(不再保留供重連)
|
| 736 |
+
await kill_session(sess.id, loop)
|
| 737 |
+
break
|
| 738 |
+
|
| 739 |
except (WebSocketDisconnect, RuntimeError):
|
| 740 |
pass
|
| 741 |
except Exception:
|
| 742 |
log.exception("terminal_ws 循環發生未預期錯誤")
|
| 743 |
finally:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 744 |
sender.cancel()
|
| 745 |
+
# 注意:這裡「不」殺掉 pty/bash,只是移除這個訂閱者,
|
| 746 |
+
# 讓 session 保留在 SESSIONS 裡,等待同一個分頁重連接回。
|
| 747 |
+
sess.remove_subscriber(sub_id, loop)
|
|
|
|
|
|
|
| 748 |
|
| 749 |
|
| 750 |
if __name__ == "__main__":
|