Spaces:
Paused
Paused
opencode commited on
Commit ·
202df14
1
Parent(s): 4db130b
桌面剪貼簿固定開啟、移除複製貼上按鈕、檔案下載不限路徑且支援資料夾
Browse files
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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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">×</span>`;
|
| 351 |
el.querySelector('.tab-title').textContent = title || tabTitle(sessionId);
|
| 352 |
-
bar.insertBefore(el,
|
| 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
|
| 705 |
-
filePickerFilename.value =
|
| 706 |
-
filePickerDownload.disabled =
|
| 707 |
-
filePickerStatus.textContent =
|
| 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('/
|
| 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 |
-
|
| 791 |
-
|
|
|
|
| 792 |
}
|
| 793 |
|
| 794 |
function formatFileSize(size) {
|
|
@@ -864,11 +834,10 @@ async def index():
|
|
| 864 |
|
| 865 |
|
| 866 |
def _safe_path(path: str) -> str | None:
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 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
|
| 910 |
-
return
|
| 911 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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><
|
| 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 (!
|
| 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 (!
|
| 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 || !
|
| 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 |
-
|
| 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 (!
|
| 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 (!
|
| 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">×</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');
|