opencode commited on
Commit
202df14
·
1 Parent(s): 4db130b

桌面剪貼簿固定開啟、移除複製貼上按鈕、檔案下載不限路徑且支援資料夾

Browse files
Files changed (2) hide show
  1. README.md +7 -5
  2. app.py +63 -99
README.md CHANGED
@@ -56,8 +56,8 @@ OpenCode 在啟動時安裝,並重設使用者層級設定為預設值。
56
  所以固定分頁裡永遠有 1 個可用的圖形終端機視窗(使用者自己另開的终端機不受影響)。
57
  - 容器內沒有 systemd-logind session,因此會自動停用 `lxpolkit` 與 `light-locker`
58
  autostart,避免桌面啟動時跳出「No session for pid …」的 Error 對話框。
59
- - **剪貼簿同步**:桌面右上角有「剪貼簿同步:開/關」按鈕。開啟時,桌面裡的
60
- CLIPBOARD/PRIMARY 會與瀏覽器所在系統的剪貼簿雙向同步;在桌面內按 Ctrl+V
61
  (或 Cmd+V)會先把本機剪貼簿送進 X11 再貼上。底層由 `autocutsel` 將 x11vnc
62
  使用的 CUT_BUFFER0 與 X11 selection 對齊,xterm 也設定為選取即同步 CLIPBOARD。
63
  瀏覽器基於安全限制,頁面需要保持在前景;若系統阻擋寫入,點一下桌面會重試。
@@ -66,9 +66,11 @@ OpenCode 在啟動時安裝,並重設使用者層級設定為預設值。
66
  - 這個分頁是固定的:按它的 `×` 不會刪除分頁,而是重新啟動一份新的桌面
67
  (`POST /api/desktop/restart`),永遠維持一個。
68
  - 終端機分頁也支援系統剪貼簿:選取後按 **Ctrl+Shift+C**(或 Ctrl+Insert)複製,
69
- **Ctrl+V**、**Ctrl+Shift+V**(或 Shift+Insert)直接貼上;「📋 複製」/「📌 貼上」
70
- 按鈕行為相同。貼上走 xterm.js 的 bracketed paste,與一般終端機一致。
71
- - 上傳/下載等其餘行為完全沒有改變。
 
 
72
  - 除錯端點:`GET /api/desktop/status`、`POST /api/desktop/restart`(程序 pid、VNC port、
73
  健康狀態);桌面 log 在 `/tmp/webterm-desktop.log`。
74
  - 可用 Space 變數覆寫:`DESKTOP_DISPLAY`(預設 `:99`)、`DESKTOP_GEOMETRY`
 
56
  所以固定分頁裡永遠有 1 個可用的圖形終端機視窗(使用者自己另開的终端機不受影響)。
57
  - 容器內沒有 systemd-logind session,因此會自動停用 `lxpolkit` 與 `light-locker`
58
  autostart,避免桌面啟動時跳出「No session for pid …」的 Error 對話框。
59
+ - **剪貼簿同步(固定開啟)**:桌面裡的 CLIPBOARD/PRIMARY 永遠與瀏覽器所在系統的
60
+ 剪貼簿雙向同步;在桌面內按 Ctrl+V
61
  (或 Cmd+V)會先把本機剪貼簿送進 X11 再貼上。底層由 `autocutsel` 將 x11vnc
62
  使用的 CUT_BUFFER0 與 X11 selection 對齊,xterm 也設定為選取即同步 CLIPBOARD。
63
  瀏覽器基於安全限制,頁面需要保持在前景;若系統阻擋寫入,點一下桌面會重試。
 
66
  - 這個分頁是固定的:按它的 `×` 不會刪除分頁,而是重新啟動一份新的桌面
67
  (`POST /api/desktop/restart`),永遠維持一個。
68
  - 終端機分頁也支援系統剪貼簿:選取後按 **Ctrl+Shift+C**(或 Ctrl+Insert)複製,
69
+ **Ctrl+V**、**Ctrl+Shift+V**(或 Shift+Insert)直接貼上。貼上走 xterm.js 的
70
+ bracketed paste,與一般終端機一致。
71
+ - 檔案瀏覽器可瀏覽/下載伺服器上的**任何路徑**(含 `/tmp`、`/`),不再有白名單限制;
72
+ 選取資料夾後下載會即時串流打包成 `.tar.gz`,不會一次載入記憶體。
73
+ - 桌面分頁不提供上傳(按鈕會停用),請改用終端機分頁或桌面內的檔案管理員。
74
  - 除錯端點:`GET /api/desktop/status`、`POST /api/desktop/restart`(程序 pid、VNC port、
75
  健康狀態);桌面 log 在 `/tmp/webterm-desktop.log`。
76
  - 可用 Space 變數覆寫:`DESKTOP_DISPLAY`(預設 `:99`)、`DESKTOP_GEOMETRY`
app.py CHANGED
@@ -16,6 +16,7 @@ import logging
16
  import mimetypes
17
  import time
18
  import uuid
 
19
 
20
  import spaces
21
  import httpx
@@ -41,9 +42,7 @@ except Exception as _relay_err: # pragma: no cover
41
 
42
  # ──────────────────────────── 全域設定 ────────────────────────────
43
  HOME = os.path.expanduser("~")
44
- ALLOWED_BASES = [os.path.abspath(p) for p in (
45
- "/app", "/home/user", "/workspace", "/home/app", HOME
46
- )]
47
 
48
  # 共用分頁:所有裝置/瀏覽器看到同一組分頁,而且每一頁都是「共用+長存」的 PTY。
49
  # 分頁清單由伺服器統一管理並落地到 TABS_FILE,所以:
@@ -77,6 +76,7 @@ html,body{height:100%;overflow:hidden;background:#1e1e1e;color:#ccc;font-family:
77
  .tab .x:hover{color:#f55}
78
  #add-btn{background:none;border:1px dashed #555;color:#777;padding:3px 12px;border-radius:4px;cursor:pointer;font-size:18px;margin-bottom:4px;margin-left:4px}
79
  #add-btn:hover{color:#fff;border-color:#aaa}
 
80
  .action-btn{background:#2d2d2d;color:#969696;border:1px solid #3c3c3c;border-radius:4px;padding:4px 10px;cursor:pointer;font-size:13px;margin-bottom:4px;margin-left:4px;transition:all 0.2s;white-space:nowrap}
81
  .action-btn:hover{color:#fff;border-color:#aaa;background:#3c3c3c}
82
  #app-status{flex:0 1 320px;min-width:150px;max-width:360px;height:28px;margin:0 0 4px 2cm;padding:0 10px;display:flex;align-items:center;overflow:hidden;border:1px solid #3c3c3c;border-radius:4px;background:#202020;color:#aaa;font-size:12px;white-space:nowrap;text-overflow:ellipsis}#app-status[data-kind="success"]{color:#7fd18b;border-color:#35613d}#app-status[data-kind="error"]{color:#ff8f8f;border-color:#703c3c}#app-status[data-kind="warning"]{color:#f0cb75;border-color:#705f32}#app-status[data-kind="info"]{color:#9cc9ff;border-color:#365577}
@@ -124,8 +124,6 @@ body{display:flex;flex-direction:column}
124
  <body>
125
  <div id="tab-bar">
126
  <button id="add-btn" title="New Terminal (Ctrl+Shift+T)">+</button>
127
- <button id="copy-btn" class="action-btn" title="複製選取文字(Ctrl+Shift+C)">📋 複製</button>
128
- <button id="paste-btn" class="action-btn" title="從系統剪貼簿貼上(Ctrl+V、Ctrl+Shift+V、Shift+Insert)">📌 貼上</button>
129
  <button id="upload-btn" class="action-btn" title="Upload to current directory">📤 上傳</button>
130
  <button id="download-btn" class="action-btn" title="Browse and download server files">📥 下載</button>
131
  <div id="app-status" role="status" aria-live="polite" title="應用程式訊息">就緒</div>
@@ -144,6 +142,8 @@ body{display:flex;flex-direction:column}
144
  <div class="explorer-nav-heading">快速存取</div>
145
  <button class="explorer-nav active" data-path="/home/user/app"><span class="nav-icon">📁</span>工作目錄</button>
146
  <button class="explorer-nav" data-path="/home/user"><span class="nav-icon">🏠</span>使用者資料夾</button>
 
 
147
  </aside>
148
  <main class="explorer-main">
149
  <div class="explorer-list-heading"><span>名稱</span><span class="explorer-modified">修改日期</span><span>大小</span></div>
@@ -151,7 +151,7 @@ body{display:flex;flex-direction:column}
151
  <div id="file-picker-status" class="explorer-status"></div>
152
  </main>
153
  </div>
154
- <div class="explorer-footer"><label class="explorer-filename-label" for="file-picker-filename">檔案名稱:</label><input id="file-picker-filename" class="explorer-filename" type="text" readonly placeholder="選取要下載的檔案"/><button id="file-picker-download" class="primary" type="button" disabled>下載</button><button id="file-picker-cancel" type="button">取消</button></div>
155
  </dialog>
156
  <div id="term-box"></div>
157
  <input type="file" id="file-input" style="display:none;" />
@@ -163,8 +163,6 @@ body{display:flex;flex-direction:column}
163
  const bar = document.getElementById('tab-bar');
164
  const box = document.getElementById('term-box');
165
  const addBtn = document.getElementById('add-btn');
166
- const copyBtn = document.getElementById('copy-btn');
167
- const pasteBtn = document.getElementById('paste-btn');
168
  const uploadBtn = document.getElementById('upload-btn');
169
  const downloadBtn = document.getElementById('download-btn');
170
  const appStatus = document.getElementById('app-status');
@@ -349,7 +347,7 @@ function createTab(sessionId = null, title = null, activateIt = true) {
349
  el.dataset.id = id;
350
  el.innerHTML = `<span class="status-dot"></span><span class="tab-title"></span><span class="x">&times;</span>`;
351
  el.querySelector('.tab-title').textContent = title || tabTitle(sessionId);
352
- bar.insertBefore(el, copyBtn);
353
 
354
  const wrap = document.createElement('div');
355
  wrap.className = 'tw';
@@ -489,6 +487,9 @@ async function restartDesktop() {
489
  function activate(id) {
490
  activeId = id;
491
  saveActive();
 
 
 
492
  tabs.forEach(t => {
493
  const on = t.id === id;
494
  t.el.classList.toggle('active', on);
@@ -595,38 +596,6 @@ addBtn.onclick = async () => {
595
  }
596
  };
597
 
598
- copyBtn.onclick = async () => {
599
- const t = activeTab();
600
- if (!t) return;
601
- if (t.type === 'desktop') {
602
- setAppStatus('桌面分頁請直接在畫面內選取/複製,或使用桌面內應用程式的剪貼簿', 'info');
603
- return;
604
- }
605
- const text = t.term.getSelection();
606
- if (text) {
607
- const ok = await copyTextToClipboard(text);
608
- setAppStatus(ok ? '已複製選取文字到系統剪貼簿' : '複製失敗,請檢查瀏覽器權限', ok ? 'success' : 'error');
609
- } else {
610
- setAppStatus('請先在終端機中選取要複製的文字', 'warning');
611
- }
612
- };
613
-
614
- pasteBtn.onclick = async () => {
615
- const t = activeTab();
616
- if (!t) return;
617
- if (t.type === 'desktop') {
618
- setAppStatus('桌面分頁請使用桌面內應用程式的貼上功能', 'info');
619
- return;
620
- }
621
- const text = await readClipboardText();
622
- if (text === null) {
623
- setAppStatus('貼上失敗,請允許瀏覽器讀取剪貼簿權限', 'error');
624
- return;
625
- }
626
- if (pasteToTerm(t, text)) setAppStatus('已從系統剪貼簿貼上', 'success');
627
- else setAppStatus('貼上失敗,終端機尚未連線', 'error');
628
- };
629
-
630
  uploadBtn.onclick = () => {
631
  const t = activeTab();
632
  if (t && t.type === 'desktop') {
@@ -701,10 +670,10 @@ function renderFilePickerEntries() {
701
  });
702
  row.classList.add('selected');
703
  row.setAttribute('aria-selected', 'true');
704
- filePickerSelected = entry.is_dir ? null : entry;
705
- filePickerFilename.value = filePickerSelected ? entry.name : '';
706
- filePickerDownload.disabled = !filePickerSelected;
707
- filePickerStatus.textContent = filePickerSelected ? `已選取 ${entry.name}` : `資料夾:${entry.name}`;
708
  };
709
  row.ondblclick = () => entry.is_dir ? navigateFilePicker(entry.path) : downloadSelectedFile();
710
  filePickerList.appendChild(row);
@@ -727,8 +696,8 @@ function renderFilePickerPath(directory) {
727
  const parts = directory.split('/').filter(Boolean);
728
  const root = document.createElement('button');
729
  root.type = 'button';
730
- root.textContent = '此電腦';
731
- root.onclick = () => navigateFilePicker('/home/user');
732
  filePickerPath.appendChild(root);
733
  let accumulated = '';
734
  parts.forEach(part => {
@@ -787,8 +756,9 @@ function downloadSelectedFile() {
787
  if (!filePickerSelected) return;
788
  const url = '/download?path=' + encodeURIComponent(filePickerSelected.path);
789
  window.location.href = url;
790
- const t = activeTab();
791
- setAppStatus(`正在下載 ${filePickerSelected.path}`, 'info');
 
792
  }
793
 
794
  function formatFileSize(size) {
@@ -864,11 +834,10 @@ async def index():
864
 
865
 
866
  def _safe_path(path: str) -> str | None:
867
- abs_path = os.path.abspath(path)
868
- for base in ALLOWED_BASES:
869
- if abs_path == base or abs_path.startswith(base + os.sep):
870
- return abs_path
871
- return None
872
 
873
 
874
  @app.get("/files")
@@ -906,9 +875,36 @@ async def download_route(path: str):
906
  safe = _safe_path(path)
907
  if safe is None:
908
  return JSONResponse({"error": "path not allowed"}, status_code=403)
909
- if not os.path.isfile(safe):
910
- return JSONResponse({"error": "file not found"}, status_code=404)
911
- return FileResponse(safe, filename=os.path.basename(safe))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
912
 
913
 
914
  # ──────────────────────────── 固定桌面分頁路由 ──────────────────────────────
@@ -1292,45 +1288,23 @@ html,body{width:100%;height:100%;overflow:hidden;background:#000;font-family:'Se
1292
  #retry{pointer-events:auto;display:none;border:1px solid #ffffff33;background:#2d2d2d;color:#ddd;
1293
  border-radius:4px;padding:4px 9px;cursor:pointer;font-size:12px}
1294
  #retry:hover{background:#3c3c3c;color:#fff}
1295
- #clip{pointer-events:auto;border:1px solid #ffffff33;background:#2d2d2d;color:#ddd;
1296
- border-radius:4px;padding:4px 9px;cursor:pointer;font-size:12px;white-space:nowrap}
1297
- #clip:hover{background:#3c3c3c;color:#fff}
1298
- #clip.off{opacity:.55}
1299
  </style>
1300
  </head>
1301
  <body>
1302
  <div id="screen" tabindex="0"></div>
1303
- <div id="bar"><span id="dot"></span><span id="msg">正在連線到共用桌面…</span><button id="retry" type="button">重新連線</button><button id="clip" type="button" title="與瀏覽器所在系統的剪貼簿雙向同步(頁面需保持在前景)">剪貼簿同步</button></div>
1304
  <script type="module">
1305
  const screenEl = document.getElementById('screen');
1306
  const dot = document.getElementById('dot');
1307
  const msg = document.getElementById('msg');
1308
  const retry = document.getElementById('retry');
1309
- const clipBtn = document.getElementById('clip');
1310
  let rfb = null, delay = 800, disposed = false, connecting = false;
1311
- let clipSync = localStorage.getItem('webtermClipboardSync') !== 'off';
1312
  let lastRemoteClip = '';
1313
  let pendingRemoteClip = '';
1314
 
1315
- function updateClipBtn() {
1316
- clipBtn.textContent = clipSync ? '剪貼簿同步:開' : '剪貼簿同步:關';
1317
- clipBtn.classList.toggle('off', !clipSync);
1318
- clipBtn.title = clipSync
1319
- ? '已與本機系統剪貼簿雙向同步;關閉請按一下(頁面需保持在前景)'
1320
- : '目前不同步;按一下開啟(會把本機剪貼簿帶進桌面)';
1321
- }
1322
- updateClipBtn();
1323
-
1324
  function writeLocalClipboard(text) {
1325
- if (!clipSync || !text) return Promise.resolve(false);
1326
- const done = ok => {
1327
- if (ok) {
1328
- clipBtn.title = '已同步到本機剪貼簿;關閉同步請按一下';
1329
- } else {
1330
- clipBtn.title = '瀏覽器暫不允許寫入剪貼簿;點一下桌面或按 Ctrl+V 再試';
1331
- }
1332
- return ok;
1333
- };
1334
  if (navigator.clipboard && navigator.clipboard.writeText) {
1335
  return navigator.clipboard.writeText(text).then(() => done(true)).catch(() => {
1336
  pendingRemoteClip = text;
@@ -1354,7 +1328,7 @@ function writeLocalClipboard(text) {
1354
  }
1355
 
1356
  async function flushPendingClipboard() {
1357
- if (!clipSync || !pendingRemoteClip) return;
1358
  const text = pendingRemoteClip;
1359
  if (await writeLocalClipboard(text) && pendingRemoteClip === text) {
1360
  pendingRemoteClip = '';
@@ -1362,7 +1336,7 @@ async function flushPendingClipboard() {
1362
  }
1363
 
1364
  async function pullLocalClipboard() {
1365
- if (!rfb || !clipSync || !navigator.clipboard || !navigator.clipboard.readText) return;
1366
  try {
1367
  const text = await navigator.clipboard.readText();
1368
  if (text) {
@@ -1435,6 +1409,8 @@ async function connect() {
1435
  connecting = false;
1436
  setState('ok', `已連線(${client._fbWidth || ''}x${client._fbHeight || ''})`);
1437
  client.focus();
 
 
1438
  });
1439
  client.addEventListener('disconnect', () => {
1440
  if (rfb !== client) return;
@@ -1454,7 +1430,7 @@ async function connect() {
1454
  if (!text) return;
1455
  lastRemoteClip = text;
1456
  pendingRemoteClip = text;
1457
- if (clipSync) writeLocalClipboard(text);
1458
  });
1459
  } catch (err) {
1460
  connecting = false;
@@ -1463,20 +1439,8 @@ async function connect() {
1463
  }
1464
  }
1465
 
1466
- clipBtn.onclick = async e => {
1467
- e.stopPropagation();
1468
- clipSync = !clipSync;
1469
- localStorage.setItem('webtermClipboardSync', clipSync ? 'on' : 'off');
1470
- updateClipBtn();
1471
- if (clipSync) {
1472
- if (rfb) rfb.focus();
1473
- await pullLocalClipboard();
1474
- await flushPendingClipboard();
1475
- }
1476
- };
1477
-
1478
  screenEl.addEventListener('keydown', e => {
1479
- if (!clipSync || !rfb) return;
1480
  const key = (e.key || '').toLowerCase();
1481
  if (key !== 'v' || e.altKey || !(e.ctrlKey || e.metaKey)) return;
1482
  e.preventDefault();
@@ -1485,7 +1449,7 @@ screenEl.addEventListener('keydown', e => {
1485
  }, true);
1486
 
1487
  document.addEventListener('paste', e => {
1488
- if (!clipSync || !rfb) return;
1489
  const target = e.target;
1490
  if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
1491
  const text = e.clipboardData && e.clipboardData.getData('text/plain');
 
16
  import mimetypes
17
  import time
18
  import uuid
19
+ from urllib.parse import quote
20
 
21
  import spaces
22
  import httpx
 
42
 
43
  # ──────────────────────────── 全域設定 ────────────────────────────
44
  HOME = os.path.expanduser("~")
45
+ # 檔案瀏覽/下載不設路徑白名單(此服務本身已提供完整終端機權限)。
 
 
46
 
47
  # 共用分頁:所有裝置/瀏覽器看到同一組分頁,而且每一頁都是「共用+長存」的 PTY。
48
  # 分頁清單由伺服器統一管理並落地到 TABS_FILE,所以:
 
76
  .tab .x:hover{color:#f55}
77
  #add-btn{background:none;border:1px dashed #555;color:#777;padding:3px 12px;border-radius:4px;cursor:pointer;font-size:18px;margin-bottom:4px;margin-left:4px}
78
  #add-btn:hover{color:#fff;border-color:#aaa}
79
+ .action-btn:disabled{opacity:.45;cursor:not-allowed}
80
  .action-btn{background:#2d2d2d;color:#969696;border:1px solid #3c3c3c;border-radius:4px;padding:4px 10px;cursor:pointer;font-size:13px;margin-bottom:4px;margin-left:4px;transition:all 0.2s;white-space:nowrap}
81
  .action-btn:hover{color:#fff;border-color:#aaa;background:#3c3c3c}
82
  #app-status{flex:0 1 320px;min-width:150px;max-width:360px;height:28px;margin:0 0 4px 2cm;padding:0 10px;display:flex;align-items:center;overflow:hidden;border:1px solid #3c3c3c;border-radius:4px;background:#202020;color:#aaa;font-size:12px;white-space:nowrap;text-overflow:ellipsis}#app-status[data-kind="success"]{color:#7fd18b;border-color:#35613d}#app-status[data-kind="error"]{color:#ff8f8f;border-color:#703c3c}#app-status[data-kind="warning"]{color:#f0cb75;border-color:#705f32}#app-status[data-kind="info"]{color:#9cc9ff;border-color:#365577}
 
124
  <body>
125
  <div id="tab-bar">
126
  <button id="add-btn" title="New Terminal (Ctrl+Shift+T)">+</button>
 
 
127
  <button id="upload-btn" class="action-btn" title="Upload to current directory">📤 上傳</button>
128
  <button id="download-btn" class="action-btn" title="Browse and download server files">📥 下載</button>
129
  <div id="app-status" role="status" aria-live="polite" title="應用程式訊息">就緒</div>
 
142
  <div class="explorer-nav-heading">快速存取</div>
143
  <button class="explorer-nav active" data-path="/home/user/app"><span class="nav-icon">📁</span>工作目錄</button>
144
  <button class="explorer-nav" data-path="/home/user"><span class="nav-icon">🏠</span>使用者資料夾</button>
145
+ <button class="explorer-nav" data-path="/tmp"><span class="nav-icon">⏱️</span>暫存資料夾</button>
146
+ <button class="explorer-nav" data-path="/"><span class="nav-icon">💽</span>根目錄</button>
147
  </aside>
148
  <main class="explorer-main">
149
  <div class="explorer-list-heading"><span>名稱</span><span class="explorer-modified">修改日期</span><span>大小</span></div>
 
151
  <div id="file-picker-status" class="explorer-status"></div>
152
  </main>
153
  </div>
154
+ <div class="explorer-footer"><label class="explorer-filename-label" for="file-picker-filename">檔案名稱:</label><input id="file-picker-filename" class="explorer-filename" type="text" readonly placeholder="選取要下載的檔案或資料夾"/><button id="file-picker-download" class="primary" type="button" disabled>下載</button><button id="file-picker-cancel" type="button">取消</button></div>
155
  </dialog>
156
  <div id="term-box"></div>
157
  <input type="file" id="file-input" style="display:none;" />
 
163
  const bar = document.getElementById('tab-bar');
164
  const box = document.getElementById('term-box');
165
  const addBtn = document.getElementById('add-btn');
 
 
166
  const uploadBtn = document.getElementById('upload-btn');
167
  const downloadBtn = document.getElementById('download-btn');
168
  const appStatus = document.getElementById('app-status');
 
347
  el.dataset.id = id;
348
  el.innerHTML = `<span class="status-dot"></span><span class="tab-title"></span><span class="x">&times;</span>`;
349
  el.querySelector('.tab-title').textContent = title || tabTitle(sessionId);
350
+ bar.insertBefore(el, uploadBtn);
351
 
352
  const wrap = document.createElement('div');
353
  wrap.className = 'tw';
 
487
  function activate(id) {
488
  activeId = id;
489
  saveActive();
490
+ const current = tabs.find(t => t.id === id);
491
+ uploadBtn.disabled = !!current && current.type === 'desktop';
492
+ uploadBtn.title = uploadBtn.disabled ? '桌面分頁不支援上傳' : 'Upload to current directory';
493
  tabs.forEach(t => {
494
  const on = t.id === id;
495
  t.el.classList.toggle('active', on);
 
596
  }
597
  };
598
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  uploadBtn.onclick = () => {
600
  const t = activeTab();
601
  if (t && t.type === 'desktop') {
 
670
  });
671
  row.classList.add('selected');
672
  row.setAttribute('aria-selected', 'true');
673
+ filePickerSelected = entry;
674
+ filePickerFilename.value = entry.is_dir ? entry.name + '/' : entry.name;
675
+ filePickerDownload.disabled = false;
676
+ filePickerStatus.textContent = entry.is_dir ? `已選取資料夾 ${entry.name}(下載為 .tar.gz)` : `已選取 ${entry.name}`;
677
  };
678
  row.ondblclick = () => entry.is_dir ? navigateFilePicker(entry.path) : downloadSelectedFile();
679
  filePickerList.appendChild(row);
 
696
  const parts = directory.split('/').filter(Boolean);
697
  const root = document.createElement('button');
698
  root.type = 'button';
699
+ root.textContent = '根目錄 /';
700
+ root.onclick = () => navigateFilePicker('/');
701
  filePickerPath.appendChild(root);
702
  let accumulated = '';
703
  parts.forEach(part => {
 
756
  if (!filePickerSelected) return;
757
  const url = '/download?path=' + encodeURIComponent(filePickerSelected.path);
758
  window.location.href = url;
759
+ setAppStatus(filePickerSelected.is_dir
760
+ ? `正在打包下載資料夾 ${filePickerSelected.path}(.tar.gz)`
761
+ : `正在下載 ${filePickerSelected.path}`, 'info');
762
  }
763
 
764
  function formatFileSize(size) {
 
834
 
835
 
836
  def _safe_path(path: str) -> str | None:
837
+ path = os.path.expanduser(os.path.expandvars(path or HOME))
838
+ if "\x00" in path:
839
+ return None
840
+ return os.path.abspath(path)
 
841
 
842
 
843
  @app.get("/files")
 
875
  safe = _safe_path(path)
876
  if safe is None:
877
  return JSONResponse({"error": "path not allowed"}, status_code=403)
878
+ if os.path.isfile(safe):
879
+ return FileResponse(safe, filename=os.path.basename(safe))
880
+ if not os.path.isdir(safe):
881
+ return JSONResponse({"error": "path not found"}, status_code=404)
882
+
883
+ # 資料夾串流打包成 .tar.gz,避免一次載入記憶體。
884
+ normalized = safe.rstrip(os.sep) or os.sep
885
+ name = os.path.basename(normalized) or "root"
886
+ parent = os.path.dirname(normalized) or os.sep
887
+ member = name if normalized != os.sep else "."
888
+ archive = f"{name}.tar.gz"
889
+ proc = subprocess.Popen(
890
+ ["tar", "-czf", "-", "-C", parent, member],
891
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0,
892
+ )
893
+
894
+ async def stream_archive():
895
+ try:
896
+ while True:
897
+ chunk = await asyncio.to_thread(proc.stdout.read, 1 << 20)
898
+ if not chunk:
899
+ break
900
+ yield chunk
901
+ finally:
902
+ if proc.poll() is None:
903
+ proc.kill()
904
+ await asyncio.to_thread(proc.wait)
905
+
906
+ headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(archive)}"}
907
+ return StreamingResponse(stream_archive(), media_type="application/gzip", headers=headers)
908
 
909
 
910
  # ──────────────────────────── 固定桌面分頁路由 ──────────────────────────────
 
1288
  #retry{pointer-events:auto;display:none;border:1px solid #ffffff33;background:#2d2d2d;color:#ddd;
1289
  border-radius:4px;padding:4px 9px;cursor:pointer;font-size:12px}
1290
  #retry:hover{background:#3c3c3c;color:#fff}
 
 
 
 
1291
  </style>
1292
  </head>
1293
  <body>
1294
  <div id="screen" tabindex="0"></div>
1295
+ <div id="bar"><span id="dot"></span><span id="msg">正在連線到共用桌面…</span><button id="retry" type="button">重新連線</button></div>
1296
  <script type="module">
1297
  const screenEl = document.getElementById('screen');
1298
  const dot = document.getElementById('dot');
1299
  const msg = document.getElementById('msg');
1300
  const retry = document.getElementById('retry');
 
1301
  let rfb = null, delay = 800, disposed = false, connecting = false;
 
1302
  let lastRemoteClip = '';
1303
  let pendingRemoteClip = '';
1304
 
 
 
 
 
 
 
 
 
 
1305
  function writeLocalClipboard(text) {
1306
+ if (!text) return Promise.resolve(false);
1307
+ const done = ok => ok;
 
 
 
 
 
 
 
1308
  if (navigator.clipboard && navigator.clipboard.writeText) {
1309
  return navigator.clipboard.writeText(text).then(() => done(true)).catch(() => {
1310
  pendingRemoteClip = text;
 
1328
  }
1329
 
1330
  async function flushPendingClipboard() {
1331
+ if (!pendingRemoteClip) return;
1332
  const text = pendingRemoteClip;
1333
  if (await writeLocalClipboard(text) && pendingRemoteClip === text) {
1334
  pendingRemoteClip = '';
 
1336
  }
1337
 
1338
  async function pullLocalClipboard() {
1339
+ if (!rfb || !navigator.clipboard || !navigator.clipboard.readText) return;
1340
  try {
1341
  const text = await navigator.clipboard.readText();
1342
  if (text) {
 
1409
  connecting = false;
1410
  setState('ok', `已連線(${client._fbWidth || ''}x${client._fbHeight || ''})`);
1411
  client.focus();
1412
+ pullLocalClipboard();
1413
+ flushPendingClipboard();
1414
  });
1415
  client.addEventListener('disconnect', () => {
1416
  if (rfb !== client) return;
 
1430
  if (!text) return;
1431
  lastRemoteClip = text;
1432
  pendingRemoteClip = text;
1433
+ writeLocalClipboard(text);
1434
  });
1435
  } catch (err) {
1436
  connecting = false;
 
1439
  }
1440
  }
1441
 
 
 
 
 
 
 
 
 
 
 
 
 
1442
  screenEl.addEventListener('keydown', e => {
1443
+ if (!rfb) return;
1444
  const key = (e.key || '').toLowerCase();
1445
  if (key !== 'v' || e.altKey || !(e.ctrlKey || e.metaKey)) return;
1446
  e.preventDefault();
 
1449
  }, true);
1450
 
1451
  document.addEventListener('paste', e => {
1452
+ if (!rfb) return;
1453
  const target = e.target;
1454
  if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
1455
  const text = e.clipboardData && e.clipboardData.getData('text/plain');