HelloSun commited on
Commit
0c87290
·
verified ·
1 Parent(s): 68fe1ba

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +268 -66
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
- function wsUrl() {
 
88
  const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
89
- return `${p}//${location.host}/ws`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 === 'output') {
 
 
 
 
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) t.ws.close();
 
 
 
 
 
 
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
- createTab();
 
 
 
 
 
 
 
 
 
 
 
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 WebSocket ──────────────────────────────
396
-
397
- async def _reap_process(pid: int, loop: asyncio.AbstractEventLoop):
398
- """非阻塞地終止並回收子行程。"""
399
- for sig in (signal.SIGTERM, signal.SIGKILL):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  try:
401
- os.kill(pid, sig)
402
- except ProcessLookupError:
403
  return
404
- for _ in range(20):
405
- await asyncio.sleep(0.1)
 
 
 
 
 
 
 
 
 
406
  try:
407
- wpid, _status = os.waitpid(pid, os.WNOHANG)
408
- if wpid == pid:
409
- return
410
- except ChildProcessError:
411
- return
412
- try:
413
- await loop.run_in_executor(None, os.waitpid, pid, 0)
414
- except Exception:
415
- pass
416
 
417
 
418
- @app.websocket("/ws")
419
- async def terminal_ws(websocket: WebSocket):
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
- f'opencode 與 installpi.sh 已執行(請查看日誌)\\033[0m";'
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
- def set_winsize(cols: int, rows: int):
456
- fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
457
 
458
- set_winsize(120, 30)
 
459
 
 
460
  fl = fcntl.fcntl(fd, fcntl.F_GETFL)
461
  fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
462
 
463
- queue: asyncio.Queue = asyncio.Queue()
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
- queue.put_nowait(None)
 
 
 
 
 
 
 
 
 
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
- if msg["type"] == "input":
 
 
497
  try:
498
- os.write(fd, msg["data"].encode("utf-8"))
499
  except OSError:
500
  break
501
 
502
- elif msg["type"] == "resize":
503
  try:
504
- set_winsize(msg["cols"], msg["rows"])
 
505
  except Exception:
506
  pass
507
 
508
- elif msg["type"] == "upload":
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
- await _reap_process(pid, loop)
542
- try:
543
- os.close(fd)
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__":