test
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package capture
|
||||||
|
|
||||||
|
type ShellDropHit struct {
|
||||||
|
Directory string
|
||||||
|
ItemName string
|
||||||
|
Desktop bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveShellDropHit(x, y int32) ShellDropHit { return ShellDropHit{} }
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package capture
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ShellDropHit struct {
|
||||||
|
Directory string
|
||||||
|
ItemName string
|
||||||
|
Desktop bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveShellDropHit(x, y int32) ShellDropHit {
|
||||||
|
result := make(chan ShellDropHit, 1)
|
||||||
|
go func() {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
|
||||||
|
out := ShellDropHit{}
|
||||||
|
hr, _, _ := procCoInitializeEx.Call(0, COINIT_MULTITHREADED)
|
||||||
|
if hr != 0 && hr != 1 {
|
||||||
|
result <- out
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer procCoUninitialize.Call()
|
||||||
|
|
||||||
|
var automation *iuiAutomation
|
||||||
|
hr, _, _ = procCoCreateInstance.Call(
|
||||||
|
uintptr(unsafe.Pointer(&CLSID_CUIAutomation)), 0, CLSCTX_INPROC_SERVER,
|
||||||
|
uintptr(unsafe.Pointer(&IID_IUIAutomation)), uintptr(unsafe.Pointer(&automation)),
|
||||||
|
)
|
||||||
|
if hr != S_OK || automation == nil {
|
||||||
|
result <- out
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer automation.Release()
|
||||||
|
walker := automation.GetControlViewWalker()
|
||||||
|
if walker == nil {
|
||||||
|
result <- out
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer walker.Release()
|
||||||
|
|
||||||
|
elem := automation.ElementFromPoint(point{x: x, y: y})
|
||||||
|
if elem == nil {
|
||||||
|
result <- out
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.ItemName = strings.TrimSpace(elem.GetCurrentName())
|
||||||
|
sawCabinet := false
|
||||||
|
sawShellList := false
|
||||||
|
for current, depth := elem, 0; current != nil && depth < 32; depth++ {
|
||||||
|
className := current.GetCurrentClassName()
|
||||||
|
if className == "CabinetWClass" {
|
||||||
|
sawCabinet = true
|
||||||
|
out.Directory = explorerDirectoryFromUIA(walker, current)
|
||||||
|
}
|
||||||
|
if className == "SysListView32" || className == "SHELLDLL_DefView" {
|
||||||
|
sawShellList = true
|
||||||
|
}
|
||||||
|
parent := walker.GetParentElement(current)
|
||||||
|
current.Release()
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
if sawCabinet {
|
||||||
|
out.Desktop = false
|
||||||
|
} else if sawShellList {
|
||||||
|
out.Desktop = true
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
out.Directory = filepath.Join(home, "Desktop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result <- out
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case hit := <-result:
|
||||||
|
return hit
|
||||||
|
case <-time.After(4 * time.Second):
|
||||||
|
return ShellDropHit{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func explorerDirectoryFromUIA(walker *iuiAutomationTreeWalker, root *iuiAutomationElement) string {
|
||||||
|
visited := 0
|
||||||
|
var walk func(*iuiAutomationElement) string
|
||||||
|
walk = func(parent *iuiAutomationElement) string {
|
||||||
|
for child := shellDropFirstChild(walker, parent); child != nil && visited < 2500; {
|
||||||
|
visited++
|
||||||
|
next := shellDropNextSibling(walker, child)
|
||||||
|
name := strings.TrimSpace(child.GetCurrentName())
|
||||||
|
if strings.HasPrefix(strings.ToLower(name), "address:") {
|
||||||
|
candidate := shellDirectoryCandidate(name)
|
||||||
|
if candidate == "" {
|
||||||
|
// Continue into the element; localized or virtual shell paths
|
||||||
|
// may not be filesystem destinations.
|
||||||
|
} else {
|
||||||
|
child.Release()
|
||||||
|
if next != nil {
|
||||||
|
next.Release()
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pattern := child.GetCurrentPattern(uiaValuePatternID); pattern != nil {
|
||||||
|
valuePattern := (*iuiAutomationValuePattern)(unsafe.Pointer(pattern))
|
||||||
|
candidate := shellDirectoryCandidate(valuePattern.GetCurrentValue())
|
||||||
|
valuePattern.Release()
|
||||||
|
if candidate != "" {
|
||||||
|
child.Release()
|
||||||
|
if next != nil {
|
||||||
|
next.Release()
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if candidate := walk(child); candidate != "" {
|
||||||
|
child.Release()
|
||||||
|
if next != nil {
|
||||||
|
next.Release()
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
child.Release()
|
||||||
|
child = next
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return walk(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellDropFirstChild(walker *iuiAutomationTreeWalker, elem *iuiAutomationElement) *iuiAutomationElement {
|
||||||
|
var child *iuiAutomationElement
|
||||||
|
hr := callSyscallN(walker.lpVtbl.GetFirstChildElement,
|
||||||
|
uintptr(unsafe.Pointer(walker)),
|
||||||
|
uintptr(unsafe.Pointer(elem)),
|
||||||
|
uintptr(unsafe.Pointer(&child)),
|
||||||
|
)
|
||||||
|
if hr != S_OK || child == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellDropNextSibling(walker *iuiAutomationTreeWalker, elem *iuiAutomationElement) *iuiAutomationElement {
|
||||||
|
var sibling *iuiAutomationElement
|
||||||
|
hr := callSyscallN(walker.lpVtbl.GetNextSiblingElement,
|
||||||
|
uintptr(unsafe.Pointer(walker)),
|
||||||
|
uintptr(unsafe.Pointer(elem)),
|
||||||
|
uintptr(unsafe.Pointer(&sibling)),
|
||||||
|
)
|
||||||
|
if hr != S_OK || sibling == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return sibling
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellDirectoryCandidate(value string) string {
|
||||||
|
value = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(value), "Address:"))
|
||||||
|
if strings.HasPrefix(strings.ToLower(value), "file:") {
|
||||||
|
if parsed, err := url.Parse(value); err == nil {
|
||||||
|
value = parsed.Path
|
||||||
|
if len(value) >= 3 && value[0] == '/' && value[2] == ':' {
|
||||||
|
value = value[1:]
|
||||||
|
}
|
||||||
|
value, _ = url.PathUnescape(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(value) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(value); err == nil && info.IsDir() {
|
||||||
|
return filepath.Clean(value)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -2689,6 +2689,20 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
|||||||
sourceURL, _ := payload["url"].(string)
|
sourceURL, _ := payload["url"].(string)
|
||||||
total := payloadNumberToInt64(payload["total"])
|
total := payloadNumberToInt64(payload["total"])
|
||||||
return HandleFileUploadHTTP(ctx, env, cmdID, path, sourceURL, total)
|
return HandleFileUploadHTTP(ctx, env, cmdID, path, sourceURL, total)
|
||||||
|
case "file_upload_desktop":
|
||||||
|
payload, _ := envelope["payload"].(map[string]interface{})
|
||||||
|
fileName, _ := payload["fileName"].(string)
|
||||||
|
sourceURL, _ := payload["url"].(string)
|
||||||
|
total := payloadNumberToInt64(payload["total"])
|
||||||
|
_, hasX := payload["x"]
|
||||||
|
_, hasY := payload["y"]
|
||||||
|
x := int32(payloadNumberToInt64(payload["x"]))
|
||||||
|
y := int32(payloadNumberToInt64(payload["y"]))
|
||||||
|
display := env.SelectedDisplay
|
||||||
|
if _, ok := payload["display"]; ok {
|
||||||
|
display = int(payloadNumberToInt64(payload["display"]))
|
||||||
|
}
|
||||||
|
return HandleFileUploadDesktop(ctx, env, cmdID, fileName, sourceURL, total, display, x, y, hasX && hasY)
|
||||||
case "file_delete":
|
case "file_delete":
|
||||||
path, _ := envelopePayloadString(envelope, "path")
|
path, _ := envelopePayloadString(envelope, "path")
|
||||||
return HandleFileDelete(ctx, env, cmdID, path)
|
return HandleFileDelete(ctx, env, cmdID, path)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
func positionDesktopUpload(fileName string, x, y int32) {}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ddUser32 = windows.NewLazySystemDLL("user32.dll")
|
||||||
|
ddKernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||||
|
ddFindWindowW = ddUser32.NewProc("FindWindowW")
|
||||||
|
ddFindWindowExW = ddUser32.NewProc("FindWindowExW")
|
||||||
|
ddSendMessageW = ddUser32.NewProc("SendMessageW")
|
||||||
|
ddScreenToClient = ddUser32.NewProc("ScreenToClient")
|
||||||
|
ddGetWindowThreadProcessID = ddUser32.NewProc("GetWindowThreadProcessId")
|
||||||
|
ddOpenProcess = ddKernel32.NewProc("OpenProcess")
|
||||||
|
ddVirtualAllocEx = ddKernel32.NewProc("VirtualAllocEx")
|
||||||
|
ddVirtualFreeEx = ddKernel32.NewProc("VirtualFreeEx")
|
||||||
|
ddWriteProcessMemory = ddKernel32.NewProc("WriteProcessMemory")
|
||||||
|
ddReadProcessMemory = ddKernel32.NewProc("ReadProcessMemory")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ddProcessVMOperation = 0x0008
|
||||||
|
ddProcessVMRead = 0x0010
|
||||||
|
ddProcessVMWrite = 0x0020
|
||||||
|
ddProcessQueryInfo = 0x0400
|
||||||
|
ddMemCommit = 0x1000
|
||||||
|
ddMemReserve = 0x2000
|
||||||
|
ddMemRelease = 0x8000
|
||||||
|
ddPageReadWrite = 0x04
|
||||||
|
ddLVMGetItemCount = 0x1004
|
||||||
|
ddLVMSetItemPosition = 0x100f
|
||||||
|
ddLVMGetItemTextW = 0x1073
|
||||||
|
)
|
||||||
|
|
||||||
|
type desktopDropPoint struct{ X, Y int32 }
|
||||||
|
|
||||||
|
type desktopDropLVItem struct {
|
||||||
|
Mask uint32
|
||||||
|
IItem int32
|
||||||
|
ISubItem int32
|
||||||
|
State uint32
|
||||||
|
StateMask uint32
|
||||||
|
PszText uintptr
|
||||||
|
CchTextMax int32
|
||||||
|
IImage int32
|
||||||
|
LParam uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
func positionDesktopUpload(fileName string, x, y int32) {
|
||||||
|
listView := desktopListView()
|
||||||
|
if listView == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
point := desktopDropPoint{X: x, Y: y}
|
||||||
|
ddScreenToClient.Call(listView, uintptr(unsafe.Pointer(&point)))
|
||||||
|
|
||||||
|
// Explorer may need a moment to observe the newly created file.
|
||||||
|
for attempt := 0; attempt < 20; attempt++ {
|
||||||
|
if index := desktopListViewItemIndex(listView, fileName); index >= 0 {
|
||||||
|
packed := uintptr(uint32(uint16(point.X)) | uint32(uint16(point.Y))<<16)
|
||||||
|
ddSendMessageW.Call(listView, ddLVMSetItemPosition, uintptr(index), packed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func desktopListView() uintptr {
|
||||||
|
progman, _, _ := ddFindWindowW.Call(uintptr(unsafe.Pointer(mustUTF16Ptr("Progman"))), 0)
|
||||||
|
if view := findShellDefView(progman); view != 0 {
|
||||||
|
list, _, _ := ddFindWindowExW.Call(view, 0, uintptr(unsafe.Pointer(mustUTF16Ptr("SysListView32"))), 0)
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
var after uintptr
|
||||||
|
for {
|
||||||
|
worker, _, _ := ddFindWindowExW.Call(0, after, uintptr(unsafe.Pointer(mustUTF16Ptr("WorkerW"))), 0)
|
||||||
|
if worker == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if view := findShellDefView(worker); view != 0 {
|
||||||
|
list, _, _ := ddFindWindowExW.Call(view, 0, uintptr(unsafe.Pointer(mustUTF16Ptr("SysListView32"))), 0)
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
after = worker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findShellDefView(parent uintptr) uintptr {
|
||||||
|
if parent == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
view, _, _ := ddFindWindowExW.Call(parent, 0, uintptr(unsafe.Pointer(mustUTF16Ptr("SHELLDLL_DefView"))), 0)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func desktopListViewItemIndex(listView uintptr, fileName string) int32 {
|
||||||
|
var pid uint32
|
||||||
|
ddGetWindowThreadProcessID.Call(listView, uintptr(unsafe.Pointer(&pid)))
|
||||||
|
process, _, _ := ddOpenProcess.Call(ddProcessVMOperation|ddProcessVMRead|ddProcessVMWrite|ddProcessQueryInfo, 0, uintptr(pid))
|
||||||
|
if process == 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(windows.Handle(process))
|
||||||
|
|
||||||
|
const textChars = 520
|
||||||
|
itemSize := unsafe.Sizeof(desktopDropLVItem{})
|
||||||
|
remote, _, _ := ddVirtualAllocEx.Call(process, 0, itemSize+textChars*2, ddMemCommit|ddMemReserve, ddPageReadWrite)
|
||||||
|
if remote == 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
defer ddVirtualFreeEx.Call(process, remote, 0, ddMemRelease)
|
||||||
|
item := desktopDropLVItem{ISubItem: 0, PszText: remote + itemSize, CchTextMax: textChars}
|
||||||
|
ddWriteProcessMemory.Call(process, remote, uintptr(unsafe.Pointer(&item)), itemSize, 0)
|
||||||
|
|
||||||
|
count, _, _ := ddSendMessageW.Call(listView, ddLVMGetItemCount, 0, 0)
|
||||||
|
want := strings.EqualFold
|
||||||
|
stem := strings.TrimSuffix(fileName, filepath.Ext(fileName))
|
||||||
|
buf := make([]uint16, textChars)
|
||||||
|
for index := int32(0); index < int32(count); index++ {
|
||||||
|
ddSendMessageW.Call(listView, ddLVMGetItemTextW, uintptr(index), remote)
|
||||||
|
ddReadProcessMemory.Call(process, remote+itemSize, uintptr(unsafe.Pointer(&buf[0])), textChars*2, 0)
|
||||||
|
name := syscall.UTF16ToString(buf)
|
||||||
|
if want(name, fileName) || (stem != "" && want(name, stem)) {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustUTF16Ptr(value string) *uint16 {
|
||||||
|
pointer, _ := windows.UTF16PtrFromString(value)
|
||||||
|
return pointer
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"overlord-client/cmd/agent/capture"
|
||||||
agentRuntime "overlord-client/cmd/agent/runtime"
|
agentRuntime "overlord-client/cmd/agent/runtime"
|
||||||
agentTLS "overlord-client/cmd/agent/tlsconfig"
|
agentTLS "overlord-client/cmd/agent/tlsconfig"
|
||||||
"overlord-client/cmd/agent/wire"
|
"overlord-client/cmd/agent/wire"
|
||||||
@@ -948,6 +949,89 @@ func HandleFileUploadHTTP(ctx context.Context, env *agentRuntime.Env, cmdID stri
|
|||||||
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true})
|
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func desktopUploadPath(fileName string) (string, error) {
|
||||||
|
fileName = strings.TrimSpace(fileName)
|
||||||
|
if fileName == "" || fileName == "." || fileName == ".." || filepath.Base(fileName) != fileName || strings.ContainsAny(fileName, `/\\`) {
|
||||||
|
return "", errors.New("invalid desktop upload filename")
|
||||||
|
}
|
||||||
|
if strings.IndexFunc(fileName, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||||
|
return "", errors.New("invalid desktop upload filename")
|
||||||
|
}
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil || strings.TrimSpace(homeDir) == "" {
|
||||||
|
return "", errors.New("unable to resolve the current user's home directory")
|
||||||
|
}
|
||||||
|
return filepath.Join(homeDir, "Desktop", fileName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func desktopUploadTarget(fileName string, display int, x, y int32, hasDropPoint bool) (string, bool, int32, int32, error) {
|
||||||
|
destPath, err := desktopUploadPath(fileName)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, 0, 0, err
|
||||||
|
}
|
||||||
|
if !hasDropPoint {
|
||||||
|
return destPath, false, 0, 0, nil
|
||||||
|
}
|
||||||
|
absX, absY := resolveDesktopPoint(display, x, y)
|
||||||
|
hit := capture.ResolveShellDropHit(absX, absY)
|
||||||
|
resolved, placeOnDesktop := desktopUploadTargetFromHit(destPath, fileName, hit)
|
||||||
|
return resolved, placeOnDesktop, absX, absY, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func desktopUploadTargetFromHit(destPath, fileName string, hit capture.ShellDropHit) (string, bool) {
|
||||||
|
directory := strings.TrimSpace(hit.Directory)
|
||||||
|
if directory == "" {
|
||||||
|
return destPath, hit.Desktop
|
||||||
|
}
|
||||||
|
if hit.ItemName != "" {
|
||||||
|
hovered := filepath.Join(directory, hit.ItemName)
|
||||||
|
if info, statErr := os.Stat(hovered); statErr == nil && info.IsDir() {
|
||||||
|
directory = hovered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Join(directory, fileName), hit.Desktop && directory == filepath.Dir(destPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleFileUploadDesktop(ctx context.Context, env *agentRuntime.Env, cmdID string, fileName string, sourceURL string, expectedSize int64, display int, x, y int32, hasDropPoint bool) error {
|
||||||
|
if remoteDesktopDropBlocked(env) {
|
||||||
|
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{
|
||||||
|
Type: "command_result",
|
||||||
|
CommandID: cmdID,
|
||||||
|
OK: false,
|
||||||
|
Message: "remote desktop file drop is unavailable while backstage mode is active",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
destPath, placeOnDesktop, absX, absY, err := desktopUploadTarget(fileName, display, x, y, hasDropPoint)
|
||||||
|
if err != nil {
|
||||||
|
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
|
||||||
|
}
|
||||||
|
before, _ := os.Stat(destPath)
|
||||||
|
resultErr := HandleFileUploadHTTP(ctx, env, cmdID, destPath, sourceURL, expectedSize)
|
||||||
|
if placeOnDesktop {
|
||||||
|
after, statErr := os.Stat(destPath)
|
||||||
|
if statErr == nil && (before == nil || after.ModTime() != before.ModTime() || after.Size() != before.Size()) {
|
||||||
|
positionDesktopUpload(fileName, absX, absY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resultErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteDesktopDropBlocked(env *agentRuntime.Env) bool {
|
||||||
|
if env == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
env.BackstageMu.Lock()
|
||||||
|
backstageActive := env.BackstageCancel != nil
|
||||||
|
env.BackstageMu.Unlock()
|
||||||
|
if backstageActive {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
env.VirtualMu.Lock()
|
||||||
|
virtualBackstageActive := env.VirtualCancel != nil
|
||||||
|
env.VirtualMu.Unlock()
|
||||||
|
return virtualBackstageActive
|
||||||
|
}
|
||||||
|
|
||||||
func sleepBackoff(ctx context.Context, d time.Duration) {
|
func sleepBackoff(ctx context.Context, d time.Duration) {
|
||||||
t := time.NewTimer(d)
|
t := time.NewTimer(d)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"overlord-client/cmd/agent/capture"
|
||||||
"overlord-client/cmd/agent/config"
|
"overlord-client/cmd/agent/config"
|
||||||
rt "overlord-client/cmd/agent/runtime"
|
rt "overlord-client/cmd/agent/runtime"
|
||||||
"overlord-client/cmd/agent/wire"
|
"overlord-client/cmd/agent/wire"
|
||||||
@@ -523,6 +524,88 @@ func TestHandleFileUploadHTTP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDesktopUploadPath(t *testing.T) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve home directory: %v", err)
|
||||||
|
}
|
||||||
|
got, err := desktopUploadPath("report.pdf")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("desktopUploadPath failed: %v", err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(homeDir, "Desktop", "report.pdf")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("desktop upload path = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{"", ".", "..", "../report.pdf", `folder\\report.pdf`, "bad\x00name"} {
|
||||||
|
if _, err := desktopUploadPath(invalid); err == nil {
|
||||||
|
t.Fatalf("desktopUploadPath(%q) should fail", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDesktopUploadTargetFromHitUsesHoveredFolder(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
hovered := filepath.Join(base, "Reports")
|
||||||
|
if err := os.Mkdir(hovered, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fallback := filepath.Join(base, "Desktop", "report.pdf")
|
||||||
|
|
||||||
|
got, place := desktopUploadTargetFromHit(fallback, "report.pdf", capture.ShellDropHit{
|
||||||
|
Directory: base,
|
||||||
|
ItemName: "Reports",
|
||||||
|
})
|
||||||
|
if got != filepath.Join(hovered, "report.pdf") || place {
|
||||||
|
t.Fatalf("desktopUploadTargetFromHit() = %q, %v", got, place)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDesktopUploadTargetFromHitPositionsDesktopBackground(t *testing.T) {
|
||||||
|
desktop := t.TempDir()
|
||||||
|
fallback := filepath.Join(desktop, "report.pdf")
|
||||||
|
got, place := desktopUploadTargetFromHit(fallback, "report.pdf", capture.ShellDropHit{
|
||||||
|
Directory: desktop,
|
||||||
|
Desktop: true,
|
||||||
|
})
|
||||||
|
if got != fallback || !place {
|
||||||
|
t.Fatalf("desktopUploadTargetFromHit() = %q, %v", got, place)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleFileUploadDesktopRejectsBackstageModes(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
activate func(*rt.Env)
|
||||||
|
}{
|
||||||
|
{name: "hidden desktop", activate: func(env *rt.Env) {
|
||||||
|
_, env.BackstageCancel = context.WithCancel(context.Background())
|
||||||
|
}},
|
||||||
|
{name: "virtual backstage", activate: func(env *rt.Env) {
|
||||||
|
_, env.VirtualCancel = context.WithCancel(context.Background())
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
writer := &testWriter{}
|
||||||
|
env := &rt.Env{Conn: writer}
|
||||||
|
test.activate(env)
|
||||||
|
if err := HandleFileUploadDesktop(context.Background(), env, "blocked-drop", "report.pdf", "/unused", 10, 0, 10, 10, true); err != nil {
|
||||||
|
t.Fatalf("HandleFileUploadDesktop failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(writer.msgs) != 1 {
|
||||||
|
t.Fatalf("expected one rejection result, got %d", len(writer.msgs))
|
||||||
|
}
|
||||||
|
var result wire.CommandResult
|
||||||
|
if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.OK || !strings.Contains(result.Message, "backstage mode") {
|
||||||
|
t.Fatalf("unexpected result: %+v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleFileUploadHTTP_RewritesUploadPullURLToAgentServer(t *testing.T) {
|
func TestHandleFileUploadHTTP_RewritesUploadPullURLToAgentServer(t *testing.T) {
|
||||||
data := []byte("wan-origin-upload-payload")
|
data := []byte("wan-origin-upload-payload")
|
||||||
var gotHost string
|
var gotHost string
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ const (
|
|||||||
CommandFileSearch CommandType = "file_search"
|
CommandFileSearch CommandType = "file_search"
|
||||||
CommandFileThumb CommandType = "file_thumb"
|
CommandFileThumb CommandType = "file_thumb"
|
||||||
CommandFileUpload CommandType = "file_upload"
|
CommandFileUpload CommandType = "file_upload"
|
||||||
|
CommandFileUploadDesktop CommandType = "file_upload_desktop"
|
||||||
CommandFileUploadHttp CommandType = "file_upload_http"
|
CommandFileUploadHttp CommandType = "file_upload_http"
|
||||||
CommandFileWrite CommandType = "file_write"
|
CommandFileWrite CommandType = "file_write"
|
||||||
CommandFileZip CommandType = "file_zip"
|
CommandFileZip CommandType = "file_zip"
|
||||||
@@ -361,6 +362,7 @@ var CommandTypes = [...]CommandType{
|
|||||||
CommandFileSearch,
|
CommandFileSearch,
|
||||||
CommandFileThumb,
|
CommandFileThumb,
|
||||||
CommandFileUpload,
|
CommandFileUpload,
|
||||||
|
CommandFileUploadDesktop,
|
||||||
CommandFileUploadHttp,
|
CommandFileUploadHttp,
|
||||||
CommandFileWrite,
|
CommandFileWrite,
|
||||||
CommandFileZip,
|
CommandFileZip,
|
||||||
@@ -511,6 +513,7 @@ var CommandVersionSupport = map[CommandType]CommandVersionRange{
|
|||||||
CommandFileSearch: {Min: 1, Max: 1},
|
CommandFileSearch: {Min: 1, Max: 1},
|
||||||
CommandFileThumb: {Min: 1, Max: 1},
|
CommandFileThumb: {Min: 1, Max: 1},
|
||||||
CommandFileUpload: {Min: 1, Max: 1},
|
CommandFileUpload: {Min: 1, Max: 1},
|
||||||
|
CommandFileUploadDesktop: {Min: 1, Max: 1},
|
||||||
CommandFileUploadHttp: {Min: 1, Max: 1},
|
CommandFileUploadHttp: {Min: 1, Max: 1},
|
||||||
CommandFileWrite: {Min: 1, Max: 1},
|
CommandFileWrite: {Min: 1, Max: 1},
|
||||||
CommandFileZip: {Min: 1, Max: 1},
|
CommandFileZip: {Min: 1, Max: 1},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ func TestGeneratedWireContract(t *testing.T) {
|
|||||||
if WireProtocolVersion != 1 {
|
if WireProtocolVersion != 1 {
|
||||||
t.Fatalf("unexpected wire protocol version: %d", WireProtocolVersion)
|
t.Fatalf("unexpected wire protocol version: %d", WireProtocolVersion)
|
||||||
}
|
}
|
||||||
if len(CommandTypes) != 142 {
|
if len(CommandTypes) != 143 {
|
||||||
t.Fatalf("unexpected command count: %d", len(CommandTypes))
|
t.Fatalf("unexpected command count: %d", len(CommandTypes))
|
||||||
}
|
}
|
||||||
if len(CommandVersionSupport) != len(CommandTypes) {
|
if len(CommandVersionSupport) != len(CommandTypes) {
|
||||||
@@ -33,6 +33,9 @@ func TestGeneratedWireContract(t *testing.T) {
|
|||||||
if !IsCommandType("virtual_window_list") {
|
if !IsCommandType("virtual_window_list") {
|
||||||
t.Fatal("virtual_window_list must be a known command")
|
t.Fatal("virtual_window_list must be a known command")
|
||||||
}
|
}
|
||||||
|
if !IsCommandType("file_upload_desktop") {
|
||||||
|
t.Fatal("file_upload_desktop must be a known command")
|
||||||
|
}
|
||||||
if IsCommandType("not_a_command") {
|
if IsCommandType("not_a_command") {
|
||||||
t.Fatal("unknown command was accepted")
|
t.Fatal("unknown command was accepted")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
|||||||
const canvas = document.getElementById("frameCanvas");
|
const canvas = document.getElementById("frameCanvas");
|
||||||
const canvasContainer = document.getElementById("canvasContainer");
|
const canvasContainer = document.getElementById("canvasContainer");
|
||||||
const renderSurface = document.getElementById("renderSurface");
|
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 remoteCursor = document.getElementById("remoteCursor");
|
||||||
const remoteCursorImage = document.getElementById("remoteCursorImage");
|
const remoteCursorImage = document.getElementById("remoteCursorImage");
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
@@ -222,6 +229,7 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
|||||||
let elevationPending = false;
|
let elevationPending = false;
|
||||||
let clientOs = "";
|
let clientOs = "";
|
||||||
let clientIsAdmin = false;
|
let clientIsAdmin = false;
|
||||||
|
let desktopFileUploadSupported = true;
|
||||||
let firewallWarningAcked = false;
|
let firewallWarningAcked = false;
|
||||||
let firstFrameLogged = 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"));
|
applySharedSettings(await loadSharedUiSettings("remote_desktop"));
|
||||||
// Interactive input is intentionally session-scoped: opening any client's
|
// Interactive input is intentionally session-scoped: opening any client's
|
||||||
// page must require the operator to opt in again, regardless of saved UI
|
// 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 || ""})`;
|
clientLabel.textContent = `${client.host || client.id} (${client.os || ""})`;
|
||||||
clientOs = (client.os || "").toLowerCase();
|
clientOs = (client.os || "").toLowerCase();
|
||||||
clientIsAdmin = !!client.isAdmin;
|
clientIsAdmin = !!client.isAdmin;
|
||||||
|
desktopFileUploadSupported = !client.commandVersions || !!client.commandVersions.file_upload_desktop;
|
||||||
}
|
}
|
||||||
if (client) {
|
if (client) {
|
||||||
populateDisplays(client.monitors, client.monitorInfo);
|
populateDisplays(client.monitors, client.monitorInfo);
|
||||||
@@ -3247,10 +3492,15 @@ import { createSharedUiSettingsSaver, loadSharedUiSettings } from "./generated/s
|
|||||||
}
|
}
|
||||||
disconnectAudio();
|
disconnectAudio();
|
||||||
destroyVideoDecoder();
|
destroyVideoDecoder();
|
||||||
|
if (fileTransferWs) {
|
||||||
|
try { fileTransferWs.close(1000, "page hidden"); } catch {}
|
||||||
|
fileTransferWs = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("beforeunload", stopOnExit);
|
window.addEventListener("beforeunload", stopOnExit);
|
||||||
window.addEventListener("pagehide", stopOnExit);
|
window.addEventListener("pagehide", stopOnExit);
|
||||||
|
|
||||||
|
setupRemoteDesktopFileDrop();
|
||||||
fetchClientInfo();
|
fetchClientInfo();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -348,6 +348,24 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
"
|
"
|
||||||
></video>
|
></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">
|
<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%" />
|
<img id="remoteCursorImage" alt="" draggable="false" style="display: block; width: 100%; height: 100%" />
|
||||||
</div>
|
</div>
|
||||||
@@ -668,6 +686,74 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.rd-diagnostics-hud.hidden { display: none; }
|
.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-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-title { color: #f8fafc; font-size: 14px; font-weight: 700; }
|
||||||
.rd-diagnostics-summary { margin-top: 2px; color: #67e8f9; }
|
.rd-diagnostics-summary { margin-top: 2px; color: #67e8f9; }
|
||||||
@@ -688,6 +774,6 @@
|
|||||||
<script defer src="/vendor/msgpackr/msgpackr.js"></script>
|
<script defer src="/vendor/msgpackr/msgpackr.js"></script>
|
||||||
<script defer src="/assets/remotedesktop-settings-menu.js"></script>
|
<script defer src="/assets/remotedesktop-settings-menu.js"></script>
|
||||||
<script type="module" src="/assets/nav.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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export const COMMAND_TYPES = [
|
|||||||
"file_search",
|
"file_search",
|
||||||
"file_thumb",
|
"file_thumb",
|
||||||
"file_upload",
|
"file_upload",
|
||||||
|
"file_upload_desktop",
|
||||||
"file_upload_http",
|
"file_upload_http",
|
||||||
"file_write",
|
"file_write",
|
||||||
"file_zip",
|
"file_zip",
|
||||||
@@ -602,6 +603,10 @@ export const COMMAND_VERSION_SUPPORT = {
|
|||||||
"min": 1,
|
"min": 1,
|
||||||
"max": 1
|
"max": 1
|
||||||
},
|
},
|
||||||
|
"file_upload_desktop": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 1
|
||||||
|
},
|
||||||
"file_upload_http": {
|
"file_upload_http": {
|
||||||
"min": 1,
|
"min": 1,
|
||||||
"max": 1
|
"max": 1
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ import {
|
|||||||
describe("generated wire protocol contract", () => {
|
describe("generated wire protocol contract", () => {
|
||||||
test("publishes the complete command catalog", () => {
|
test("publishes the complete command catalog", () => {
|
||||||
expect(WIRE_PROTOCOL_VERSION).toBe(1);
|
expect(WIRE_PROTOCOL_VERSION).toBe(1);
|
||||||
expect(COMMAND_TYPES.length).toBe(142);
|
expect(COMMAND_TYPES.length).toBe(143);
|
||||||
expect(new Set(COMMAND_TYPES).size).toBe(COMMAND_TYPES.length);
|
expect(new Set(COMMAND_TYPES).size).toBe(COMMAND_TYPES.length);
|
||||||
expect(isCommandType("desktop_start")).toBe(true);
|
expect(isCommandType("desktop_start")).toBe(true);
|
||||||
|
expect(isCommandType("file_upload_desktop")).toBe(true);
|
||||||
expect(isCommandType("virtual_window_list")).toBe(true);
|
expect(isCommandType("virtual_window_list")).toBe(true);
|
||||||
expect(isCommandType("not_a_command")).toBe(false);
|
expect(isCommandType("not_a_command")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,4 +35,19 @@ describe("file browser command payload validation", () => {
|
|||||||
path: "C:\\ok.bin", url: "https://attacker.invalid/payload", total: 10,
|
path: "C:\\ok.bin", url: "https://attacker.invalid/payload", total: 10,
|
||||||
})).toBeNull();
|
})).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("validates remote desktop uploads as safe basenames", () => {
|
||||||
|
const valid = {
|
||||||
|
fileName: "report.pdf",
|
||||||
|
url: "/api/file/upload/pull/123e4567-e89b-42d3-a456-426614174000",
|
||||||
|
total: 10,
|
||||||
|
};
|
||||||
|
expect(validateFileBrowserCommandPayload("file_upload_desktop", valid)).not.toBeNull();
|
||||||
|
expect(validateFileBrowserCommandPayload("file_upload_desktop", { ...valid, display: 1, x: 640, y: 360 })).not.toBeNull();
|
||||||
|
expect(validateFileBrowserCommandPayload("file_upload_desktop", { ...valid, x: 640 })).toBeNull();
|
||||||
|
expect(validateFileBrowserCommandPayload("file_upload_desktop", { ...valid, x: 640.5, y: 360 })).toBeNull();
|
||||||
|
expect(validateFileBrowserCommandPayload("file_upload_desktop", { ...valid, fileName: "../report.pdf" })).toBeNull();
|
||||||
|
expect(validateFileBrowserCommandPayload("file_upload_desktop", { ...valid, fileName: "folder/report.pdf" })).toBeNull();
|
||||||
|
expect(validateFileBrowserCommandPayload("file_upload_desktop", { ...valid, url: "https://attacker.invalid/file" })).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ export function validateFileBrowserCommandPayload(
|
|||||||
|| !/^\/api\/file\/upload\/pull\/[0-9a-f-]{36}$/i.test(payload.url)) return null;
|
|| !/^\/api\/file\/upload\/pull\/[0-9a-f-]{36}$/i.test(payload.url)) return null;
|
||||||
if (!Number.isSafeInteger(Number(payload.total)) || Number(payload.total) < 0) return null;
|
if (!Number.isSafeInteger(Number(payload.total)) || Number(payload.total) < 0) return null;
|
||||||
return payload;
|
return payload;
|
||||||
|
case "file_upload_desktop":
|
||||||
|
if (!safeString(payload.fileName, 255) || /[\\/]/.test(payload.fileName as string)
|
||||||
|
|| payload.fileName === "." || payload.fileName === ".."
|
||||||
|
|| typeof payload.url !== "string"
|
||||||
|
|| !/^\/api\/file\/upload\/pull\/[0-9a-f-]{36}$/i.test(payload.url)) return null;
|
||||||
|
if (!Number.isSafeInteger(Number(payload.total)) || Number(payload.total) < 0) return null;
|
||||||
|
if ((payload.x === undefined) !== (payload.y === undefined)) return null;
|
||||||
|
if (payload.x !== undefined && (!Number.isSafeInteger(Number(payload.x)) || Math.abs(Number(payload.x)) > 1_000_000)) return null;
|
||||||
|
if (payload.y !== undefined && (!Number.isSafeInteger(Number(payload.y)) || Math.abs(Number(payload.y)) > 1_000_000)) return null;
|
||||||
|
if (payload.display !== undefined && (!Number.isSafeInteger(Number(payload.display)) || Number(payload.display) < 0 || Number(payload.display) > 128)) return null;
|
||||||
|
return payload;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AuditAction, logAudit } from "../auditLog";
|
|||||||
import * as clientManager from "../clientManager";
|
import * as clientManager from "../clientManager";
|
||||||
import { logger } from "../logger";
|
import { logger } from "../logger";
|
||||||
import { metrics } from "../metrics";
|
import { metrics } from "../metrics";
|
||||||
|
import { versionCommandForClient } from "../command-compatibility";
|
||||||
import { encodeMessage } from "../protocol";
|
import { encodeMessage } from "../protocol";
|
||||||
import { getSessionByTokenHash } from "../db";
|
import { getSessionByTokenHash } from "../db";
|
||||||
import * as sessionManager from "../sessions/sessionManager";
|
import * as sessionManager from "../sessions/sessionManager";
|
||||||
@@ -251,6 +252,35 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
|
|||||||
success: true,
|
success: true,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case "file_upload_desktop":
|
||||||
|
try {
|
||||||
|
target.ws.send(encodeMessage(versionCommandForClient(target, {
|
||||||
|
type: "command",
|
||||||
|
commandType: "file_upload_desktop",
|
||||||
|
id: routedId,
|
||||||
|
payload: actualPayload,
|
||||||
|
} as any)));
|
||||||
|
} catch (error) {
|
||||||
|
finishFileBrowserCommand(routedId);
|
||||||
|
safeSendViewer(ws, {
|
||||||
|
type: "command_result",
|
||||||
|
commandId: routedId,
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : "Desktop upload is unsupported by this client",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
metrics.recordCommand("file_upload_desktop");
|
||||||
|
logAudit({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
username: (ws.data as any).username || "unknown",
|
||||||
|
ip: ws.data.ip || "unknown",
|
||||||
|
action: AuditAction.FILE_UPLOAD,
|
||||||
|
targetClientId: clientId,
|
||||||
|
details: JSON.stringify({ fileName: actualPayload.fileName || "", destination: "remote_pointer", display: actualPayload.display, x: actualPayload.x, y: actualPayload.y, mode: "http_pull" }),
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,7 @@
|
|||||||
"file_search",
|
"file_search",
|
||||||
"file_thumb",
|
"file_thumb",
|
||||||
"file_upload",
|
"file_upload",
|
||||||
|
"file_upload_desktop",
|
||||||
"file_upload_http",
|
"file_upload_http",
|
||||||
"file_write",
|
"file_write",
|
||||||
"file_zip",
|
"file_zip",
|
||||||
|
|||||||
Reference in New Issue
Block a user