This commit is contained in:
KingKDot
2026-08-03 23:29:16 -05:00
parent 5693409c93
commit f2aae81792
17 changed files with 930 additions and 3 deletions
@@ -80,6 +80,13 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
const canvas = document.getElementById("frameCanvas");
const canvasContainer = document.getElementById("canvasContainer");
const renderSurface = document.getElementById("renderSurface");
const rdFileDropOverlay = document.getElementById("rdFileDropOverlay");
const rdFileDragGhost = document.getElementById("rdFileDragGhost");
const rdFileDragLabel = document.getElementById("rdFileDragLabel");
const rdUploadStatus = document.getElementById("rdUploadStatus");
const rdUploadStatusIcon = document.getElementById("rdUploadStatusIcon");
const rdUploadStatusText = document.getElementById("rdUploadStatusText");
const rdUploadProgressBar = document.getElementById("rdUploadProgressBar");
const remoteCursor = document.getElementById("remoteCursor");
const remoteCursorImage = document.getElementById("remoteCursorImage");
const ctx = canvas.getContext("2d");
@@ -222,6 +229,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
let elevationPending = false;
let clientOs = "";
let clientIsAdmin = false;
let desktopFileUploadSupported = true;
let firewallWarningAcked = false;
let firstFrameLogged = false;
@@ -538,6 +546,242 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
};
}
let fileTransferWs = null;
let fileTransferConnectPromise = null;
const pendingDesktopUploads = new Map();
let desktopUploadHideTimer = null;
function hasDraggedFiles(event) {
return Array.from(event.dataTransfer?.types || []).includes("Files");
}
function draggedFileLabel(dataTransfer) {
const files = Array.from(dataTransfer?.files || []);
if (files.length === 1) return files[0].name;
if (files.length > 1) return `${files.length} files`;
const count = Array.from(dataTransfer?.items || []).filter((item) => item.kind === "file").length;
return count === 1 ? "Copy file" : (count > 1 ? `Copy ${count} files` : "Copy files");
}
function setDesktopFileDragUi(active, event) {
if (!rdFileDropOverlay) return;
rdFileDropOverlay.hidden = !active;
rdFileDropOverlay.setAttribute("aria-hidden", active ? "false" : "true");
if (!active || !event || !rdFileDragGhost || !renderSurface) return;
if (rdFileDragLabel) rdFileDragLabel.textContent = draggedFileLabel(event.dataTransfer);
const rect = renderSurface.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width - 40, event.clientX - rect.left));
const y = Math.max(0, Math.min(rect.height - 40, event.clientY - rect.top));
rdFileDragGhost.style.left = `${x}px`;
rdFileDragGhost.style.top = `${y}px`;
}
function showDesktopUploadStatus(text, progress, state = "uploading") {
if (!rdUploadStatus) return;
if (desktopUploadHideTimer) {
clearTimeout(desktopUploadHideTimer);
desktopUploadHideTimer = null;
}
rdUploadStatus.hidden = false;
if (rdUploadStatusText) rdUploadStatusText.textContent = text;
if (rdUploadProgressBar) {
rdUploadProgressBar.style.width = `${Math.max(0, Math.min(100, progress))}%`;
rdUploadProgressBar.style.background = state === "error" ? "#fb7185" : (state === "success" ? "#34d399" : "#38bdf8");
}
if (rdUploadStatusIcon) {
rdUploadStatusIcon.className = state === "error"
? "fa-solid fa-circle-exclamation"
: (state === "success" ? "fa-solid fa-circle-check" : "fa-solid fa-cloud-arrow-up");
rdUploadStatusIcon.style.color = state === "error" ? "#fb7185" : (state === "success" ? "#34d399" : "#38bdf8");
}
if (state !== "uploading") {
desktopUploadHideTimer = setTimeout(() => { rdUploadStatus.hidden = true; }, state === "success" ? 3500 : 6500);
}
}
function rejectPendingDesktopUploads(error) {
for (const pending of pendingDesktopUploads.values()) {
clearTimeout(pending.timeout);
pending.reject(error);
}
pendingDesktopUploads.clear();
}
function handleFileTransferMessage(event) {
const msg = decodeMsgpack(event.data);
if (!msg) return;
if (msg.type === "command_error") {
rejectPendingDesktopUploads(new Error(msg.error || "Remote file transfer was rejected"));
return;
}
if (msg.type !== "command_result" || typeof msg.commandId !== "string") return;
const pending = pendingDesktopUploads.get(msg.commandId);
if (!pending) return;
pendingDesktopUploads.delete(msg.commandId);
clearTimeout(pending.timeout);
if (msg.ok) pending.resolve(msg);
else pending.reject(new Error(msg.message || "Remote file transfer failed"));
}
function ensureFileTransferSocket() {
if (fileTransferWs?.readyState === WebSocket.OPEN) return Promise.resolve(fileTransferWs);
if (fileTransferConnectPromise) return fileTransferConnectPromise;
fileTransferConnectPromise = new Promise((resolve, reject) => {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${location.host}/api/clients/${encodeURIComponent(clientId)}/files/ws`);
socket.binaryType = "arraybuffer";
const timeout = setTimeout(() => {
try { socket.close(); } catch {}
reject(new Error("File transfer connection timed out"));
}, 10_000);
socket.addEventListener("open", () => {
clearTimeout(timeout);
fileTransferWs = socket;
resolve(socket);
}, { once: true });
socket.addEventListener("message", handleFileTransferMessage);
socket.addEventListener("error", () => {
if (socket.readyState !== WebSocket.OPEN) {
clearTimeout(timeout);
reject(new Error("File upload permission is unavailable"));
}
});
socket.addEventListener("close", () => {
clearTimeout(timeout);
if (fileTransferWs === socket) fileTransferWs = null;
rejectPendingDesktopUploads(new Error("File transfer connection closed"));
});
}).finally(() => {
fileTransferConnectPromise = null;
});
return fileTransferConnectPromise;
}
function waitForDesktopUpload(commandId) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pendingDesktopUploads.delete(commandId);
reject(new Error("Timed out waiting for the remote file transfer"));
}, 30 * 60 * 1000);
pendingDesktopUploads.set(commandId, { resolve, reject, timeout });
});
}
async function stageDesktopUpload(file, progressBase, progressSpan) {
const request = await fetch("/api/file/upload/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ clientId, path: file.name, fileName: file.name }),
});
if (!request.ok) throw new Error((await request.text()) || "Upload request failed");
const intent = await request.json();
if (!intent?.uploadUrl) throw new Error("Upload request did not return a staging URL");
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", intent.uploadUrl, true);
xhr.withCredentials = true;
xhr.responseType = "text";
xhr.timeout = 30 * 60 * 1000;
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (uploadEvent) => {
const total = uploadEvent.total || file.size || 1;
const ratio = Math.max(0, Math.min(1, uploadEvent.loaded / total));
showDesktopUploadStatus(`Copying ${file.name} to the remote target…`, progressBase + ratio * progressSpan);
};
xhr.onerror = () => reject(new Error("Upload staging failed"));
xhr.ontimeout = () => reject(new Error("Upload staging timed out"));
xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(xhr.responseText || "Upload staging failed"));
return;
}
try {
const staged = xhr.responseText ? JSON.parse(xhr.responseText) : {};
if (!staged.pullUrl) throw new Error("Upload staging did not return a pull URL");
resolve(staged);
} catch (error) {
reject(error);
}
};
xhr.send(file);
});
}
async function uploadFilesToRemoteDesktop(files, dropPoint) {
if (!desktopFileUploadSupported) {
throw new Error("Update the remote agent to enable integrated desktop file drop");
}
const socket = await ensureFileTransferSocket();
const totalFiles = files.length;
for (let index = 0; index < totalFiles; index += 1) {
const file = files[index];
const fileBase = (index / totalFiles) * 100;
const fileSpan = 100 / totalFiles;
showDesktopUploadStatus(`Preparing ${file.name}…`, fileBase);
const staged = await stageDesktopUpload(file, fileBase, fileSpan * 0.72);
const commandId = `rd-desktop-upload-${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`;
const completed = waitForDesktopUpload(commandId);
socket.send(encodeMsgpack({
type: "command",
commandType: "file_upload_desktop",
id: commandId,
payload: {
fileName: file.name,
url: staged.pullUrl,
total: file.size,
display: Number(displaySelect?.value || 0),
x: dropPoint.x + index * 18,
y: dropPoint.y + index * 18,
},
}));
showDesktopUploadStatus(`Dropping ${file.name} at the remote pointer…`, fileBase + fileSpan * 0.78);
await completed;
showDesktopUploadStatus(`Copied ${file.name} to the remote target`, fileBase + fileSpan);
}
showDesktopUploadStatus(`${totalFiles} file${totalFiles === 1 ? "" : "s"} copied to the remote target`, 100, "success");
}
function setupRemoteDesktopFileDrop() {
if (!renderSurface) return;
renderSurface.addEventListener("dragenter", (event) => {
if (!hasDraggedFiles(event)) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
setDesktopFileDragUi(true, event);
});
renderSurface.addEventListener("dragover", (event) => {
if (!hasDraggedFiles(event)) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
setDesktopFileDragUi(true, event);
});
renderSurface.addEventListener("dragleave", (event) => {
if (!hasDraggedFiles(event)) return;
if (event.relatedTarget instanceof Node && renderSurface.contains(event.relatedTarget)) return;
setDesktopFileDragUi(false);
});
renderSurface.addEventListener("drop", async (event) => {
if (!hasDraggedFiles(event)) return;
event.preventDefault();
event.stopPropagation();
setDesktopFileDragUi(false);
const files = Array.from(event.dataTransfer?.files || []);
if (files.length === 0) return;
const dropPoint = getRenderPoint(event);
if (!dropPoint) return;
try {
await uploadFilesToRemoteDesktop(files, dropPoint);
} catch (error) {
console.error("rd: desktop file drop failed", error);
showDesktopUploadStatus(error instanceof Error ? error.message : "Remote desktop file transfer failed", 100, "error");
}
});
document.addEventListener("dragend", () => setDesktopFileDragUi(false));
}
applySharedSettings(await loadSharedUiSettings("remote_desktop"));
// Interactive input is intentionally session-scoped: opening any client's
// page must require the operator to opt in again, regardless of saved UI
@@ -1629,6 +1873,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
clientLabel.textContent = `${client.host || client.id} (${client.os || ""})`;
clientOs = (client.os || "").toLowerCase();
clientIsAdmin = !!client.isAdmin;
desktopFileUploadSupported = !client.commandVersions || !!client.commandVersions.file_upload_desktop;
}
if (client) {
populateDisplays(client.monitors, client.monitorInfo);
@@ -3247,10 +3492,15 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
}
disconnectAudio();
destroyVideoDecoder();
if (fileTransferWs) {
try { fileTransferWs.close(1000, "page hidden"); } catch {}
fileTransferWs = null;
}
}
window.addEventListener("beforeunload", stopOnExit);
window.addEventListener("pagehide", stopOnExit);
setupRemoteDesktopFileDrop();
fetchClientInfo();
})();
+87 -1
View File
@@ -348,6 +348,24 @@
height: auto;
"
></video>
<div id="rdFileDropOverlay" class="rd-file-drop-overlay" hidden aria-hidden="true">
<div id="rdFileDragGhost" class="rd-file-drag-ghost">
<i class="fa-solid fa-file-arrow-up"></i>
<span id="rdFileDragLabel">Copy file</span>
</div>
<div class="rd-file-drop-hint">
<i class="fa-solid fa-copy"></i>
<strong>Drop at the remote pointer</strong>
<span>Folders receive the file; Desktop drops keep this position</span>
</div>
</div>
<div id="rdUploadStatus" class="rd-upload-status" hidden role="status" aria-live="polite">
<div class="rd-upload-status-row">
<i id="rdUploadStatusIcon" class="fa-solid fa-cloud-arrow-up"></i>
<span id="rdUploadStatusText">Preparing upload…</span>
</div>
<div class="rd-upload-progress"><div id="rdUploadProgressBar"></div></div>
</div>
<div id="remoteCursor" hidden aria-hidden="true" class="absolute pointer-events-none" style="left: 0; top: 0; z-index: 30; will-change: transform">
<img id="remoteCursorImage" alt="" draggable="false" style="display: block; width: 100%; height: 100%" />
</div>
@@ -668,6 +686,74 @@
font-size: 12px;
}
.rd-diagnostics-hud.hidden { display: none; }
.rd-file-drop-overlay {
position: absolute;
inset: 0;
z-index: 45;
pointer-events: none;
border: 3px dashed rgba(56, 189, 248, .95);
border-radius: 12px;
background: rgba(2, 132, 199, .14);
box-shadow: inset 0 0 80px rgba(14, 165, 233, .16);
}
.rd-file-drop-overlay[hidden], .rd-upload-status[hidden] { display: none; }
.rd-file-drop-hint {
position: absolute;
left: 50%;
top: 50%;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
transform: translate(-50%, -50%);
padding: 18px 24px;
border: 1px solid rgba(125, 211, 252, .55);
border-radius: 14px;
color: #e0f2fe;
background: rgba(2, 6, 23, .9);
box-shadow: 0 18px 45px rgba(0, 0, 0, .45);
backdrop-filter: blur(10px);
}
.rd-file-drop-hint i { color: #7dd3fc; font-size: 24px; }
.rd-file-drop-hint span { color: #94a3b8; font-size: 11px; }
.rd-file-drag-ghost {
position: absolute;
left: 0;
top: 0;
display: flex;
align-items: center;
gap: 8px;
max-width: 240px;
padding: 8px 11px;
border: 1px solid rgba(125, 211, 252, .65);
border-radius: 9px;
color: #f0f9ff;
background: rgba(15, 23, 42, .94);
box-shadow: 0 10px 28px rgba(0, 0, 0, .5);
transform: translate(14px, 14px);
will-change: left, top;
}
.rd-file-drag-ghost i { color: #7dd3fc; }
.rd-file-drag-ghost span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rd-upload-status {
position: absolute;
left: 50%;
bottom: 20px;
z-index: 46;
width: min(420px, calc(100% - 32px));
padding: 11px 13px;
border: 1px solid rgba(71, 85, 105, .9);
border-radius: 11px;
color: #e2e8f0;
background: rgba(2, 6, 23, .92);
box-shadow: 0 14px 38px rgba(0, 0, 0, .48);
transform: translateX(-50%);
backdrop-filter: blur(10px);
}
.rd-upload-status-row { display: flex; align-items: center; gap: 9px; font-size: 12px; }
.rd-upload-status-row i { color: #38bdf8; }
.rd-upload-progress { height: 4px; margin-top: 9px; overflow: hidden; border-radius: 999px; background: #1e293b; }
.rd-upload-progress > div { width: 0; height: 100%; border-radius: inherit; background: #38bdf8; transition: width .15s ease; }
.rd-diagnostics-header, .rd-diagnostics-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.rd-diagnostics-title { color: #f8fafc; font-size: 14px; font-weight: 700; }
.rd-diagnostics-summary { margin-top: 2px; color: #67e8f9; }
@@ -688,6 +774,6 @@
<script defer src="/vendor/msgpackr/msgpackr.js"></script>
<script defer src="/assets/remotedesktop-settings-menu.js"></script>
<script type="module" src="/assets/nav.js"></script>
<script type="module" src="/assets/remotedesktop.js"></script>
<script type="module" src="/assets/remotedesktop.js?v=desktop-file-drop-20260803-2"></script>
</body>
</html>