big update
This commit is contained in:
@@ -42,7 +42,7 @@ func Loop(ctx context.Context, env *rt.Env) {
|
|||||||
restore := BypassResolutionCap()
|
restore := BypassResolutionCap()
|
||||||
defer restore()
|
defer restore()
|
||||||
var err error
|
var err error
|
||||||
if goruntime.GOOS == "windows" && activeDisplays() > 1 {
|
if goruntime.GOOS == "windows" {
|
||||||
err = captureAllDisplaysAndSend(ctx, env)
|
err = captureAllDisplaysAndSend(ctx, env)
|
||||||
} else {
|
} else {
|
||||||
err = CaptureAndSend(ctx, env)
|
err = CaptureAndSend(ctx, env)
|
||||||
|
|||||||
@@ -2124,6 +2124,29 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
return nil
|
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":
|
case "agent_update":
|
||||||
payload, _ := envelope["payload"].(map[string]interface{})
|
payload, _ := envelope["payload"].(map[string]interface{})
|
||||||
path, _ := payload["path"].(string)
|
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)
|
return HandleAgentUpdate(ctx, env, cmdID, path, hash, hideWindow)
|
||||||
case "process_list":
|
case "process_list":
|
||||||
return HandleProcessList(ctx, env, cmdID)
|
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":
|
case "process_kill":
|
||||||
payload, _ := envelope["payload"].(map[string]interface{})
|
payload, _ := envelope["payload"].(map[string]interface{})
|
||||||
if payload == nil {
|
if payload == nil {
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"hash/crc32"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -25,7 +31,7 @@ import (
|
|||||||
"overlord-client/cmd/agent/wire"
|
"overlord-client/cmd/agent/wire"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxChunkSize = 1024 * 1024
|
const maxChunkSize = 4 * 1024 * 1024
|
||||||
|
|
||||||
type pendingUpload struct {
|
type pendingUpload struct {
|
||||||
file *os.File
|
file *os.File
|
||||||
@@ -134,14 +140,19 @@ func listWindowsDrives(ctx context.Context, env *agentRuntime.Env, cmdID string)
|
|||||||
for drive := 'A'; drive <= 'Z'; drive++ {
|
for drive := 'A'; drive <= 'Z'; drive++ {
|
||||||
drivePath := string(drive) + ":\\"
|
drivePath := string(drive) + ":\\"
|
||||||
if _, err := os.Stat(drivePath); err == nil {
|
if _, err := os.Stat(drivePath); err == nil {
|
||||||
|
entry := wire.FileEntry{
|
||||||
entries = append(entries, wire.FileEntry{
|
|
||||||
Name: string(drive) + ":",
|
Name: string(drive) + ":",
|
||||||
Path: drivePath,
|
Path: drivePath,
|
||||||
IsDir: true,
|
IsDir: true,
|
||||||
Size: 0,
|
Size: 0,
|
||||||
ModTime: time.Now().Unix(),
|
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)
|
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 {
|
func HandleFileDownload(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error {
|
||||||
//garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
|
//garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
|
||||||
log.Printf("file_download: %s", path)
|
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)
|
return wire.WriteMsg(ctx, env.Conn, result)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
stat, err := file.Stat()
|
stat, err := file.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = file.Close()
|
||||||
result := wire.FileDownload{
|
result := wire.FileDownload{
|
||||||
Type: "file_download",
|
Type: "file_download",
|
||||||
CommandID: cmdID,
|
CommandID: cmdID,
|
||||||
@@ -184,55 +354,84 @@ func HandleFileDownload(ctx context.Context, env *agentRuntime.Env, cmdID string
|
|||||||
}
|
}
|
||||||
|
|
||||||
total := stat.Size()
|
total := stat.Size()
|
||||||
offset := int64(0)
|
|
||||||
buffer := make([]byte, maxChunkSize)
|
|
||||||
chunksTotal := 0
|
chunksTotal := 0
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
chunksTotal = int((total + int64(maxChunkSize) - 1) / int64(maxChunkSize))
|
chunksTotal = int((total + int64(maxChunkSize) - 1) / int64(maxChunkSize))
|
||||||
}
|
}
|
||||||
chunkIndex := 0
|
|
||||||
reader := bufio.NewReader(file)
|
|
||||||
|
|
||||||
for {
|
type readChunk struct {
|
||||||
n, err := io.ReadFull(reader, buffer)
|
data []byte
|
||||||
if err == io.EOF {
|
off int64
|
||||||
break
|
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",
|
Type: "file_download",
|
||||||
CommandID: cmdID,
|
CommandID: cmdID,
|
||||||
Path: path,
|
Path: path,
|
||||||
Error: err.Error(),
|
Error: chunk.err.Error(),
|
||||||
Offset: offset,
|
Offset: chunk.off,
|
||||||
Total: total,
|
Total: total,
|
||||||
ChunkIndex: chunkIndex,
|
ChunkIndex: chunk.idx,
|
||||||
ChunksTotal: chunksTotal,
|
ChunksTotal: chunksTotal,
|
||||||
}
|
})
|
||||||
return wire.WriteMsg(ctx, env.Conn, result)
|
|
||||||
}
|
}
|
||||||
|
msg := wire.FileDownload{
|
||||||
if n > 0 {
|
Type: "file_download",
|
||||||
chunk := wire.FileDownload{
|
CommandID: cmdID,
|
||||||
Type: "file_download",
|
Path: path,
|
||||||
CommandID: cmdID,
|
Data: chunk.data,
|
||||||
Path: path,
|
Offset: chunk.off,
|
||||||
Data: buffer[:n],
|
Total: total,
|
||||||
Offset: offset,
|
ChunkIndex: chunk.idx,
|
||||||
Total: total,
|
ChunksTotal: chunksTotal,
|
||||||
ChunkIndex: chunkIndex,
|
|
||||||
ChunksTotal: chunksTotal,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := wire.WriteMsg(ctx, env.Conn, chunk); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
offset += int64(n)
|
|
||||||
chunkIndex++
|
|
||||||
}
|
}
|
||||||
|
if err := wire.WriteMsg(ctx, env.Conn, msg); err != nil {
|
||||||
if err == io.ErrUnexpectedEOF {
|
return err
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ func getFilePermissions(info os.FileInfo) (mode, owner, group string) {
|
|||||||
return
|
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) {
|
func enrichFileEntry(entry *wire.FileEntry, info os.FileInfo) {
|
||||||
mode, owner, group := getFilePermissions(info)
|
mode, owner, group := getFilePermissions(info)
|
||||||
entry.Mode = mode
|
entry.Mode = mode
|
||||||
|
|||||||
@@ -8,10 +8,46 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
"overlord-client/cmd/agent/wire"
|
"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) {
|
func enrichFileEntry(entry *wire.FileEntry, info os.FileInfo) {
|
||||||
if d, ok := info.Sys().(*syscall.Win32FileAttributeData); ok {
|
if d, ok := info.Sys().(*syscall.Win32FileAttributeData); ok {
|
||||||
entry.Attrs = d.FileAttributes
|
entry.Attrs = d.FileAttributes
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"overlord-client/cmd/agent/runtime"
|
agentRuntime "overlord-client/cmd/agent/runtime"
|
||||||
"overlord-client/cmd/agent/wire"
|
"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")
|
log.Printf("process_list: listing all processes")
|
||||||
|
|
||||||
processes := []wire.ProcessInfo{}
|
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)
|
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
|
//garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
|
||||||
log.Printf("process_kill: %d", pid)
|
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)
|
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)
|
log.Printf("process_suspend: %d", pid)
|
||||||
|
|
||||||
err := suspendProcess(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)
|
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)
|
log.Printf("process_resume: %d", pid)
|
||||||
|
|
||||||
err := resumeProcess(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)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ var (
|
|||||||
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||||
modPsapi = windows.NewLazySystemDLL("psapi.dll")
|
modPsapi = windows.NewLazySystemDLL("psapi.dll")
|
||||||
modNtdll = windows.NewLazySystemDLL("ntdll.dll")
|
modNtdll = windows.NewLazySystemDLL("ntdll.dll")
|
||||||
procEnumProcesses = modPsapi.NewProc("EnumProcesses")
|
procEnumProcesses = modPsapi.NewProc("EnumProcesses")
|
||||||
procGetProcessMemoryInfo = modPsapi.NewProc("GetProcessMemoryInfo")
|
procGetProcessMemoryInfo = modPsapi.NewProc("GetProcessMemoryInfo")
|
||||||
procOpenProcess = modKernel32.NewProc("OpenProcess")
|
procOpenProcess = modKernel32.NewProc("OpenProcess")
|
||||||
procGetProcessImageFileNameW = modPsapi.NewProc("GetProcessImageFileNameW")
|
procGetProcessImageFileNameW = modPsapi.NewProc("GetProcessImageFileNameW")
|
||||||
procGetProcessTimes = modKernel32.NewProc("GetProcessTimes")
|
procGetProcessTimes = modKernel32.NewProc("GetProcessTimes")
|
||||||
|
procQueryFullProcessImageNameW = modKernel32.NewProc("QueryFullProcessImageNameW")
|
||||||
)
|
)
|
||||||
|
|
||||||
type cpuSample struct {
|
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
|
var memCounters PROCESS_MEMORY_COUNTERS
|
||||||
memCounters.CB = uint32(unsafe.Sizeof(memCounters))
|
memCounters.CB = uint32(unsafe.Sizeof(memCounters))
|
||||||
memory := uint64(0)
|
memory := uint64(0)
|
||||||
@@ -224,6 +237,7 @@ func queryProcess(pid, selfPID int32, numCPU int) *wire.ProcessInfo {
|
|||||||
PID: pid,
|
PID: pid,
|
||||||
PPID: ppid,
|
PPID: ppid,
|
||||||
Name: name,
|
Name: name,
|
||||||
|
ExePath: exePath,
|
||||||
CPU: cpu,
|
CPU: cpu,
|
||||||
Memory: memory,
|
Memory: memory,
|
||||||
Username: username,
|
Username: username,
|
||||||
|
|||||||
@@ -214,11 +214,15 @@ func captureScreenshotImageWindows(allDisplays bool) (*image.RGBA, int, image.Re
|
|||||||
sp := make([]scaledPart, len(parts))
|
sp := make([]scaledPart, len(parts))
|
||||||
for i, part := range parts {
|
for i, part := range parts {
|
||||||
sx, sy := 1.0, 1.0
|
sx, sy := 1.0, 1.0
|
||||||
if bw := part.bounds.Dx(); bw > 0 {
|
bw := part.bounds.Dx()
|
||||||
sx = float64(part.img.Rect.Dx()) / float64(bw)
|
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 {
|
if bh > 0 && ih > 0 {
|
||||||
sy = float64(part.img.Rect.Dy()) / float64(bh)
|
sy = float64(ih) / float64(bh)
|
||||||
}
|
}
|
||||||
sp[i] = scaledPart{
|
sp[i] = scaledPart{
|
||||||
img: part.img,
|
img: part.img,
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ type FileEntry struct {
|
|||||||
Group string `msgpack:"group,omitempty"`
|
Group string `msgpack:"group,omitempty"`
|
||||||
// windows file attributes
|
// windows file attributes
|
||||||
Attrs uint32 `msgpack:"attrs,omitempty"`
|
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 {
|
type FileListResult struct {
|
||||||
@@ -151,6 +155,7 @@ type ProcessInfo struct {
|
|||||||
PID int32 `msgpack:"pid"`
|
PID int32 `msgpack:"pid"`
|
||||||
PPID int32 `msgpack:"ppid"`
|
PPID int32 `msgpack:"ppid"`
|
||||||
Name string `msgpack:"name"`
|
Name string `msgpack:"name"`
|
||||||
|
ExePath string `msgpack:"exePath,omitempty"`
|
||||||
CPU float64 `msgpack:"cpu"`
|
CPU float64 `msgpack:"cpu"`
|
||||||
Memory uint64 `msgpack:"memory"`
|
Memory uint64 `msgpack:"memory"`
|
||||||
Username string `msgpack:"username,omitempty"`
|
Username string `msgpack:"username,omitempty"`
|
||||||
@@ -227,6 +232,26 @@ type FileThumbnailResult struct {
|
|||||||
Thumbs []FileThumbnailResultItem `msgpack:"thumbs"`
|
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 FolderSizeResult struct {
|
||||||
Type string `msgpack:"type"`
|
Type string `msgpack:"type"`
|
||||||
CommandID string `msgpack:"commandId,omitempty"`
|
CommandID string `msgpack:"commandId,omitempty"`
|
||||||
|
|||||||
@@ -275,6 +275,12 @@ function handleMessage(msg) {
|
|||||||
case "file_dirsize_result":
|
case "file_dirsize_result":
|
||||||
handleFileDirsizeResult(msg);
|
handleFileDirsizeResult(msg);
|
||||||
break;
|
break;
|
||||||
|
case "file_peek_result":
|
||||||
|
handleFilePeekResult(msg);
|
||||||
|
break;
|
||||||
|
case "file_hash_result":
|
||||||
|
handleFileHashResult(msg);
|
||||||
|
break;
|
||||||
case "command_result":
|
case "command_result":
|
||||||
console.log("[DEBUG] Command result:", msg);
|
console.log("[DEBUG] Command result:", msg);
|
||||||
handleCommandResult(msg);
|
handleCommandResult(msg);
|
||||||
@@ -1243,6 +1249,12 @@ function handleFileList(msg) {
|
|||||||
updateSidebarDrives([]);
|
updateSidebarDrives([]);
|
||||||
}
|
}
|
||||||
highlightSidebarActive();
|
highlightSidebarActive();
|
||||||
|
updatePinnedSidebar();
|
||||||
|
|
||||||
|
// If the previewed item was from another directory, clear it.
|
||||||
|
if (currentPreviewEntry && !directoryEntries.some((e) => e.path === currentPreviewEntry.path)) {
|
||||||
|
clearPreviewPane();
|
||||||
|
}
|
||||||
|
|
||||||
selectedFiles.clear();
|
selectedFiles.clear();
|
||||||
updateSelectionUI();
|
updateSelectionUI();
|
||||||
@@ -1321,11 +1333,20 @@ function createFileRow(entry) {
|
|||||||
const size = entry.isDir ? "-" : formatBytes(entry.size);
|
const size = entry.isDir ? "-" : formatBytes(entry.size);
|
||||||
const modTime = new Date(entry.modTime * 1000).toLocaleString();
|
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
|
||||||
|
? `<button class="${pinClass}" data-pin-toggle="${escapeHtml(entry.path)}" title="${pinTitle}" type="button"><i class="fa-solid ${pinIcon}"></i></button>`
|
||||||
|
: "";
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<input type="checkbox" class="file-checkbox" data-path="${escapeHtml(entry.path)}">
|
<input type="checkbox" class="file-checkbox" data-path="${escapeHtml(entry.path)}">
|
||||||
<div class="col-span-6 flex items-center gap-2 truncate pl-3">
|
<div class="col-span-6 flex items-center gap-2 truncate pl-3">
|
||||||
${icon}
|
${icon}
|
||||||
<span class="truncate">${escapeHtml(entry.name)}</span>
|
<span class="truncate">${escapeHtml(entry.name)}</span>
|
||||||
|
${pinBtn}
|
||||||
${badges}
|
${badges}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-2 text-sm text-slate-400 file-size-col">${size}</div>
|
<div class="col-span-2 text-sm text-slate-400 file-size-col">${size}</div>
|
||||||
@@ -1344,16 +1365,28 @@ function createFileRow(entry) {
|
|||||||
nameDiv.appendChild(mobileMetaDiv);
|
nameDiv.appendChild(mobileMetaDiv);
|
||||||
|
|
||||||
row.onclick = (e) => {
|
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;
|
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) {
|
if (entry.isDir) {
|
||||||
listFiles(entry.path);
|
listFiles(entry.path);
|
||||||
} else if (isPreviewable(entry.name)) {
|
} else if (isPreviewable(entry.name)) {
|
||||||
openFilePreview(entry.path, entry.size);
|
openFilePreview(entry.path, entry.size);
|
||||||
} else if (KNOWN_BINARY_EXTS.has(getFileExt(entry.name))) {
|
} else if (KNOWN_BINARY_EXTS.has(getFileExt(entry.name))) {
|
||||||
notifyToast("Binary file — use Download to save it", "info", 3000);
|
openHexViewer(entry.path);
|
||||||
} else {
|
} else {
|
||||||
openFileInEditor(entry.path);
|
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) => {
|
row.oncontextmenu = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showContextMenu(e.clientX, e.clientY, entry);
|
showContextMenu(e.clientX, e.clientY, entry);
|
||||||
@@ -1484,6 +1534,20 @@ function handleFileAction(action, entry) {
|
|||||||
case "dirsize":
|
case "dirsize":
|
||||||
requestFolderSize(entry);
|
requestFolderSize(entry);
|
||||||
break;
|
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;
|
transfer.total = total;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunks = [];
|
|
||||||
let received = 0;
|
let received = 0;
|
||||||
let lastLoggedBytes = 0;
|
let lastLoggedBytes = 0;
|
||||||
if (res.body) {
|
const trackChunk = (size) => {
|
||||||
const reader = res.body.getReader();
|
received += size;
|
||||||
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;
|
|
||||||
transfer.received = received;
|
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) {
|
if (transfer.cancelled) {
|
||||||
@@ -1644,22 +1771,12 @@ function downloadFile(path) {
|
|||||||
updateTransferProgress(transferId, transfer.progress, received, transfer.total);
|
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.log("Download complete:", path, `${received} bytes`);
|
||||||
console.debug("[filebrowser] download complete", {
|
console.debug("[filebrowser] download complete", {
|
||||||
path,
|
path,
|
||||||
received,
|
received,
|
||||||
total: transfer.total,
|
total: transfer.total,
|
||||||
chunks: chunks.length,
|
streamedToDisk: usedStreamToDisk,
|
||||||
});
|
});
|
||||||
removeTransfer(transferId);
|
removeTransfer(transferId);
|
||||||
fileDownloads.delete(path);
|
fileDownloads.delete(path);
|
||||||
@@ -2058,7 +2175,7 @@ function handleFileUploadResult(msg) {
|
|||||||
|
|
||||||
if (Number.isFinite(msg.received)) {
|
if (Number.isFinite(msg.received)) {
|
||||||
transfer.receivedBytes = Math.min(Number(msg.received), transfer.total);
|
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) {
|
if (transfer.total > 0) {
|
||||||
@@ -2164,6 +2281,10 @@ function showContextMenu(x, y, entry) {
|
|||||||
const executeItem = contextMenu.querySelector('[data-action="execute"]');
|
const executeItem = contextMenu.querySelector('[data-action="execute"]');
|
||||||
const silentExecuteItem = contextMenu.querySelector('[data-action="silent_execute"]');
|
const silentExecuteItem = contextMenu.querySelector('[data-action="silent_execute"]');
|
||||||
const dirsizeItem = contextMenu.querySelector('[data-action="dirsize"]');
|
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 (editItem) editItem.style.display = entry.isDir ? "none" : "block";
|
||||||
if (zipItem) zipItem.style.display = entry.isDir ? "block" : "none";
|
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 (executeItem) executeItem.style.display = entry.isDir ? "none" : "block";
|
||||||
if (silentExecuteItem) silentExecuteItem.style.display = entry.isDir ? "none" : "block";
|
if (silentExecuteItem) silentExecuteItem.style.display = entry.isDir ? "none" : "block";
|
||||||
if (dirsizeItem) dirsizeItem.style.display = entry.isDir ? "block" : "none";
|
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() {
|
function hideContextMenu() {
|
||||||
@@ -2229,17 +2356,50 @@ function hasFileDrag(event) {
|
|||||||
return types.includes("Files");
|
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 = `
|
||||||
|
<div class="text-center px-6">
|
||||||
|
<i class="fa-solid fa-cloud-arrow-up text-5xl text-blue-300 mb-3"></i>
|
||||||
|
<div class="text-base font-semibold text-blue-100 drop-target-label">Drop files to upload</div>
|
||||||
|
<div class="text-xs text-blue-200/80 mt-1 drop-target-path"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
fileListPanel.appendChild(overlay);
|
||||||
|
dropOverlayEl = overlay;
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
function setDropTargetActive(active) {
|
function setDropTargetActive(active) {
|
||||||
if (!fileListPanel) return;
|
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-2", active);
|
||||||
fileListPanel.classList.toggle("ring-blue-500", active);
|
fileListPanel.classList.toggle("ring-blue-500", active);
|
||||||
fileListPanel.classList.toggle("ring-offset-2", active);
|
fileListPanel.classList.toggle("ring-offset-2", active);
|
||||||
fileListPanel.classList.toggle("ring-offset-slate-950", active);
|
fileListPanel.classList.toggle("ring-offset-slate-950", active);
|
||||||
fileListPanel.classList.toggle("bg-blue-500/5", active);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupDragAndDropUpload() {
|
function setupDragAndDropUpload() {
|
||||||
if (!fileListPanel) return;
|
if (!fileListPanel) return;
|
||||||
|
ensureDropOverlay();
|
||||||
|
|
||||||
fileListPanel.addEventListener("dragenter", (e) => {
|
fileListPanel.addEventListener("dragenter", (e) => {
|
||||||
if (!hasFileDrag(e)) return;
|
if (!hasFileDrag(e)) return;
|
||||||
@@ -2429,20 +2589,28 @@ async function uploadFileViaHttpPull(file, path, transfer) {
|
|||||||
console.debug("[filebrowser] upload staged", {
|
console.debug("[filebrowser] upload staged", {
|
||||||
pullUrl: uploadData.pullUrl,
|
pullUrl: uploadData.pullUrl,
|
||||||
size: uploadData.size,
|
size: uploadData.size,
|
||||||
|
agentNotified: !!uploadData.agentNotified,
|
||||||
});
|
});
|
||||||
|
|
||||||
const commandId = `upload-http-${Date.now()}-${Math.random()}`;
|
let commandId;
|
||||||
const waitResult = waitForCommandResult(commandId, 12 * 60 * 1000);
|
let waitResult;
|
||||||
send({
|
if (uploadData.agentNotified && typeof uploadData.agentCommandId === "string") {
|
||||||
type: "command",
|
commandId = uploadData.agentCommandId;
|
||||||
commandType: "file_upload_http",
|
waitResult = waitForCommandResult(commandId, 12 * 60 * 1000);
|
||||||
id: commandId,
|
} else {
|
||||||
payload: {
|
commandId = `upload-http-${Date.now()}-${Math.random()}`;
|
||||||
path,
|
waitResult = waitForCommandResult(commandId, 12 * 60 * 1000);
|
||||||
url: uploadData.pullUrl,
|
send({
|
||||||
total: file.size,
|
type: "command",
|
||||||
},
|
commandType: "file_upload_http",
|
||||||
});
|
id: commandId,
|
||||||
|
payload: {
|
||||||
|
path,
|
||||||
|
url: uploadData.pullUrl,
|
||||||
|
total: file.size,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await waitResult;
|
await waitResult;
|
||||||
|
|
||||||
@@ -2459,6 +2627,80 @@ async function uploadFileViaHttpPull(file, path, transfer) {
|
|||||||
transfer.uploadXhr = null;
|
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) {
|
async function uploadFile(file) {
|
||||||
const path = currentPath ? `${currentPath}/${file.name}` : file.name;
|
const path = currentPath ? `${currentPath}/${file.name}` : file.name;
|
||||||
const transferId = `upload-${Date.now()}-${Math.random()}`;
|
const transferId = `upload-${Date.now()}-${Math.random()}`;
|
||||||
@@ -2490,8 +2732,14 @@ async function uploadFile(file) {
|
|||||||
activeTransfers.set(transferId, transfer);
|
activeTransfers.set(transferId, transfer);
|
||||||
addTransferToUI(transfer);
|
addTransferToUI(transfer);
|
||||||
|
|
||||||
|
const useWs = file.size <= WS_UPLOAD_MAX_TOTAL;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await uploadFileViaHttpPull(file, path, transfer);
|
if (useWs) {
|
||||||
|
await uploadFileViaWsChunks(file, path, transfer);
|
||||||
|
} else {
|
||||||
|
await uploadFileViaHttpPull(file, path, transfer);
|
||||||
|
}
|
||||||
finishUpload(transfer);
|
finishUpload(transfer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Upload error:", err);
|
console.error("Upload error:", err);
|
||||||
@@ -2548,6 +2796,448 @@ setupDragAndDropUpload();
|
|||||||
updateStatus("connecting", "Connecting...");
|
updateStatus("connecting", "Connecting...");
|
||||||
updateBackButton();
|
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 =
|
||||||
|
'<div class="text-xs text-slate-500 text-center py-3">No pinned paths.<br><span class="text-slate-600">Right-click any folder.</span></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = "";
|
||||||
|
list.forEach((p) => {
|
||||||
|
const active = currentPath && (currentPath === p.path || currentPath.startsWith(p.path + "/") || currentPath.startsWith(p.path + "\\"));
|
||||||
|
html += `
|
||||||
|
<div class="sidebar-item w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm text-slate-300 hover:text-white transition-colors text-left${active ? " active" : ""}" data-path="${escapeHtml(p.path)}">
|
||||||
|
<i class="fa-solid fa-star text-yellow-400 w-3 text-center text-[10px]"></i>
|
||||||
|
<span class="truncate flex-1" title="${escapeHtml(p.path)}">${escapeHtml(p.name)}</span>
|
||||||
|
<button class="text-[10px] text-slate-600 hover:text-red-400 pin-remove-btn" title="Unpin" data-path="${escapeHtml(p.path)}" type="button">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
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]) =>
|
||||||
|
`<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(String(v))}</dd>`
|
||||||
|
).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 = `<div class="text-4xl text-slate-500">${getFileIcon(entry)}</div>`;
|
||||||
|
}
|
||||||
|
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 = '<div class="text-slate-400"><i class="fa-solid fa-circle-notch fa-spin mr-2"></i>Loading...</div>';
|
||||||
|
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(`<span>${escapeHtml(String.fromCharCode(b))}</span>`);
|
||||||
|
} else {
|
||||||
|
asciiParts.push('<span class="np">.</span>');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hexParts.push(" ");
|
||||||
|
asciiParts.push(" ");
|
||||||
|
}
|
||||||
|
if (i === 7) hexParts.push("");
|
||||||
|
}
|
||||||
|
rows.push(
|
||||||
|
`<div class="hex-row">` +
|
||||||
|
`<span class="hex-off">${off.toString(16).padStart(8, "0")}</span>` +
|
||||||
|
`<span class="hex-bytes">${hexParts.join(" ")}</span>` +
|
||||||
|
`<span class="hex-ascii">${asciiParts.join("")}</span>` +
|
||||||
|
`</div>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 = `<div class="text-red-400 p-3"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(msg.error)}</div>`;
|
||||||
|
}
|
||||||
|
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 ──
|
// ── Sidebar Quick Access ──
|
||||||
let detectedHomePath = "";
|
let detectedHomePath = "";
|
||||||
let detectedOS = "";
|
let detectedOS = "";
|
||||||
@@ -2631,6 +3321,36 @@ function updateSidebar() {
|
|||||||
bindSidebarClicks(sidebarContent);
|
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 = `
|
||||||
|
<div class="drive-usage-bar"><div class="drive-usage-fill ${cls}" style="width:${pct}%"></div></div>
|
||||||
|
<div class="flex justify-between text-[10px] text-slate-500 mt-0.5">
|
||||||
|
<span>${formatBytes(free)} free</span>
|
||||||
|
<span>${formatBytes(total)}</span>
|
||||||
|
</div>`;
|
||||||
|
titleAttr = ` title="${escapeHtml(e.fsType || "")}${e.fsType ? " · " : ""}${formatBytes(free)} free of ${formatBytes(total)}"`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<div class="sidebar-item w-full flex flex-col gap-0 px-3 py-1.5 rounded-md text-sm text-slate-300 hover:text-white transition-colors text-left" data-path="${escapeHtml(path)}"${titleAttr}>
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<i class="fa-solid fa-hard-drive text-slate-400 w-4 text-center text-xs"></i>
|
||||||
|
<span class="truncate flex-1">${escapeHtml(label)}</span>
|
||||||
|
${e.fsType ? `<span class="text-[9px] text-slate-500 uppercase">${escapeHtml(e.fsType)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
${usageHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function updateSidebarDrives(entries) {
|
function updateSidebarDrives(entries) {
|
||||||
if (!sidebarDrives) return;
|
if (!sidebarDrives) return;
|
||||||
if (!entries || entries.length === 0) {
|
if (!entries || entries.length === 0) {
|
||||||
@@ -2645,7 +3365,7 @@ function updateSidebarDrives(entries) {
|
|||||||
let html = "";
|
let html = "";
|
||||||
entries.forEach((e) => {
|
entries.forEach((e) => {
|
||||||
if (e.isDir && e.name.match(/^[A-Za-z]:$/)) {
|
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) {
|
if (!html) {
|
||||||
@@ -2669,6 +3389,7 @@ function highlightSidebarActive() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatePinnedSidebar();
|
||||||
checkFeatureAccess("file_browser", clientId).then(ok => ok && connect());
|
checkFeatureAccess("file_browser", clientId).then(ok => ok && connect());
|
||||||
|
|
||||||
function addTransferToUI(transfer) {
|
function addTransferToUI(transfer) {
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ let sortField = "cpu";
|
|||||||
let sortDirection = "desc";
|
let sortDirection = "desc";
|
||||||
let searchTerm = "";
|
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 statusEl = document.getElementById("status-indicator");
|
||||||
const processCountEl = document.getElementById("process-count");
|
const processCountEl = document.getElementById("process-count");
|
||||||
const processListEl = document.getElementById("process-list");
|
const processListEl = document.getElementById("process-list");
|
||||||
@@ -107,6 +113,9 @@ function handleMessage(msg) {
|
|||||||
case "process_list_result":
|
case "process_list_result":
|
||||||
handleProcessList(msg);
|
handleProcessList(msg);
|
||||||
break;
|
break;
|
||||||
|
case "process_icon_result":
|
||||||
|
handleProcessIconResult(msg);
|
||||||
|
break;
|
||||||
case "command_result":
|
case "command_result":
|
||||||
handleCommandResult(msg);
|
handleCommandResult(msg);
|
||||||
break;
|
break;
|
||||||
@@ -148,6 +157,7 @@ function handleProcessList(msg) {
|
|||||||
updateStatus("connected", "Connected");
|
updateStatus("connected", "Connected");
|
||||||
buildProcessTree();
|
buildProcessTree();
|
||||||
renderProcesses();
|
renderProcesses();
|
||||||
|
requestProcessIcons(processes);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProcessTree() {
|
function buildProcessTree() {
|
||||||
@@ -314,22 +324,31 @@ function rowInnerHtml(proc, depth) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let nameColor = "text-slate-200";
|
let nameColor = "text-slate-200";
|
||||||
let iconClass = "fa-microchip";
|
let fallbackIcon = "fa-microchip";
|
||||||
let iconColor = "text-blue-400";
|
let fallbackColor = "text-blue-400";
|
||||||
if (proc.type === "system") {
|
if (proc.type === "system") {
|
||||||
nameColor = "text-purple-400";
|
nameColor = "text-purple-400";
|
||||||
iconColor = "text-purple-400";
|
fallbackColor = "text-purple-400";
|
||||||
} else if (proc.type === "service") {
|
} else if (proc.type === "service") {
|
||||||
nameColor = "text-cyan-400";
|
nameColor = "text-cyan-400";
|
||||||
iconColor = "text-cyan-400";
|
fallbackColor = "text-cyan-400";
|
||||||
} else if (proc.type === "own") {
|
} else if (proc.type === "own") {
|
||||||
nameColor = "text-green-300";
|
nameColor = "text-green-300";
|
||||||
iconColor = "text-green-400";
|
fallbackColor = "text-green-400";
|
||||||
}
|
}
|
||||||
if (proc.self) {
|
if (proc.self) {
|
||||||
nameColor = "text-yellow-300 font-semibold";
|
nameColor = "text-yellow-300 font-semibold";
|
||||||
iconClass = "fa-crosshairs";
|
fallbackIcon = "fa-crosshairs";
|
||||||
iconColor = "text-yellow-400";
|
fallbackColor = "text-yellow-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconKey = procIconKey(proc);
|
||||||
|
const cached = iconKey ? procIconCache.get(iconKey) : null;
|
||||||
|
let procIcon;
|
||||||
|
if (cached && cached.blobUrl) {
|
||||||
|
procIcon = `<span class="inline-flex w-4 h-4 items-center justify-center shrink-0" data-proc-icon-key="${escapeHtml(iconKey)}"><img src="${cached.blobUrl}" class="w-4 h-4 object-contain pointer-events-none" alt="" draggable="false"></span>`;
|
||||||
|
} else {
|
||||||
|
procIcon = `<span class="inline-flex w-4 h-4 items-center justify-center shrink-0"${iconKey ? ` data-proc-icon-key="${escapeHtml(iconKey)}"` : ""}><i class="fa-solid ${fallbackIcon} ${fallbackColor}"></i></span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selfBadge = proc.self
|
const selfBadge = proc.self
|
||||||
@@ -339,7 +358,7 @@ function rowInnerHtml(proc, depth) {
|
|||||||
return `
|
return `
|
||||||
<div class="col-span-1 text-sm font-mono text-slate-400">${proc.pid}</div>
|
<div class="col-span-1 text-sm font-mono text-slate-400">${proc.pid}</div>
|
||||||
<div class="col-span-4 flex items-center gap-1 truncate">
|
<div class="col-span-4 flex items-center gap-1 truncate">
|
||||||
${indent}${treeIcon}<i class="fa-solid ${iconClass} ${iconColor}"></i>
|
${indent}${treeIcon}${procIcon}
|
||||||
<span class="truncate ${nameColor}">${escapeHtml(proc.name)}</span>${selfBadge}
|
<span class="truncate ${nameColor}">${escapeHtml(proc.name)}</span>${selfBadge}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-2 text-sm ${cpuColor} font-semibold">${displayCpu.toFixed(1)}%</div>
|
<div class="col-span-2 text-sm ${cpuColor} font-semibold">${displayCpu.toFixed(1)}%</div>
|
||||||
@@ -615,6 +634,61 @@ setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, 3000);
|
}, 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 = `<img src="${entry.blobUrl}" class="w-4 h-4 object-contain pointer-events-none" alt="" draggable="false">`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
updateStatus("connecting", "Connecting...");
|
updateStatus("connecting", "Connecting...");
|
||||||
checkFeatureAccess("processes", clientId).then(ok => ok && connect());
|
checkFeatureAccess("processes", clientId).then(ok => ok && connect());
|
||||||
updateSortIndicators();
|
updateSortIndicators();
|
||||||
|
|||||||
@@ -52,11 +52,14 @@ function buildThumbnailUrl(clientId, version) {
|
|||||||
export function applyImageSrcSmooth(img, url) {
|
export function applyImageSrcSmooth(img, url) {
|
||||||
if (!img || !url) return;
|
if (!img || !url) return;
|
||||||
if (img.dataset.thumbUrl === 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);
|
const newImg = img.cloneNode(false);
|
||||||
newImg.dataset.thumbUrl = url;
|
newImg.dataset.thumbUrl = url;
|
||||||
newImg.style.display = "block";
|
newImg.style.display = "block";
|
||||||
const swap = () => {
|
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.addEventListener("error", () => {}, { once: true });
|
||||||
newImg.src = url;
|
newImg.src = url;
|
||||||
@@ -103,7 +106,7 @@ export class ThumbnailLoader {
|
|||||||
if (!element || !clientId) return;
|
if (!element || !clientId) return;
|
||||||
const prior = this.records.get(element);
|
const prior = this.records.get(element);
|
||||||
if (prior && prior.clientId === clientId) {
|
if (prior && prior.clientId === clientId) {
|
||||||
prior.version = initialVersion || prior.version;
|
prior.version = Math.max(initialVersion, prior.version);
|
||||||
this.applyVersion(element, prior);
|
this.applyVersion(element, prior);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -164,8 +167,7 @@ export class ThumbnailLoader {
|
|||||||
if (!element || !rec) return;
|
if (!element || !rec) return;
|
||||||
const img = element.querySelector("img[data-thumb-img]");
|
const img = element.querySelector("img[data-thumb-img]");
|
||||||
if (!img) return;
|
if (!img) return;
|
||||||
const url = buildThumbnailUrl(rec.clientId, rec.version);
|
applyImageSrcSmooth(img, buildThumbnailUrl(rec.clientId, rec.version));
|
||||||
applyImageSrcSmooth(img, url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleIntersection(entries) {
|
handleIntersection(entries) {
|
||||||
|
|||||||
@@ -322,7 +322,133 @@
|
|||||||
}
|
}
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
#sidebar { display: none !important; }
|
#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); }
|
||||||
</style>
|
</style>
|
||||||
<script defer src="/assets/toast.js"></script>
|
<script defer src="/assets/toast.js"></script>
|
||||||
<script defer src="/assets/ripple.js"></script>
|
<script defer src="/assets/ripple.js"></script>
|
||||||
@@ -334,7 +460,26 @@
|
|||||||
<div class="max-w-[1600px] mx-auto flex gap-4">
|
<div class="max-w-[1600px] mx-auto flex gap-4">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside id="sidebar" class="w-52 flex-shrink-0 hidden lg:block">
|
<aside id="sidebar" class="w-52 flex-shrink-0 hidden lg:block">
|
||||||
<div class="sticky top-6 flex flex-col gap-3">
|
<div class="sticky flex flex-col gap-3 sidebar-scroll" style="top: 5rem; max-height: calc(100vh - 6rem); overflow-y: auto;">
|
||||||
|
<div class="bg-slate-900/70 border border-slate-800 rounded-lg overflow-hidden">
|
||||||
|
<div class="px-3 py-2.5 border-b border-slate-700/50 flex items-center justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-star text-yellow-400 text-xs"></i>
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wider text-slate-400">Pinned</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="sidebar-pin-current-btn"
|
||||||
|
class="text-[10px] text-slate-400 hover:text-blue-300 transition-colors"
|
||||||
|
title="Pin current path"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="sidebar-pinned" class="p-1.5">
|
||||||
|
<div class="text-xs text-slate-500 text-center py-3">No pinned paths.<br><span class="text-slate-600">Right-click any folder.</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="bg-slate-900/70 border border-slate-800 rounded-lg overflow-hidden">
|
<div class="bg-slate-900/70 border border-slate-800 rounded-lg overflow-hidden">
|
||||||
<div class="px-3 py-2.5 border-b border-slate-700/50 flex items-center gap-2">
|
<div class="px-3 py-2.5 border-b border-slate-700/50 flex items-center gap-2">
|
||||||
<i class="fa-solid fa-bookmark text-blue-400 text-xs"></i>
|
<i class="fa-solid fa-bookmark text-blue-400 text-xs"></i>
|
||||||
@@ -599,6 +744,60 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview pane -->
|
||||||
|
<aside id="preview-pane-host" class="hidden lg:block">
|
||||||
|
<div id="preview-pane" class="bg-slate-900/70 border border-slate-800 rounded-lg empty">
|
||||||
|
<div class="px-3 py-2.5 border-b border-slate-700/50 flex items-center justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<i class="fa-solid fa-eye text-blue-400 text-xs flex-shrink-0"></i>
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wider text-slate-400">Preview</span>
|
||||||
|
</div>
|
||||||
|
<button id="preview-pane-toggle" class="text-[10px] text-slate-400 hover:text-blue-300 transition-colors" title="Hide preview pane" type="button">
|
||||||
|
<i class="fa-solid fa-eye-slash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 space-y-3">
|
||||||
|
<div class="preview-empty flex-col items-center justify-center text-center text-xs text-slate-500 py-10">
|
||||||
|
<i class="fa-solid fa-mouse-pointer text-2xl mb-2 text-slate-600"></i>
|
||||||
|
<div>Click a file to preview.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="preview-active" class="hidden space-y-3">
|
||||||
|
<div id="preview-thumb-box" class="bg-slate-950/50 border border-slate-800 rounded p-2 min-h-[80px] flex items-center justify-center"></div>
|
||||||
|
|
||||||
|
<div class="text-xs font-medium text-slate-200 truncate" id="preview-name">--</div>
|
||||||
|
<div class="text-[10px] text-slate-500 break-all" id="preview-path">--</div>
|
||||||
|
|
||||||
|
<dl id="preview-meta-list" class="grid grid-cols-2 gap-x-2"></dl>
|
||||||
|
|
||||||
|
<div id="preview-text-head-host" class="hidden">
|
||||||
|
<div class="text-[10px] uppercase text-slate-500 mb-1 tracking-wide">First 4 KB</div>
|
||||||
|
<div id="preview-text-head"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<button id="preview-hex-btn" class="inline-flex items-center justify-center gap-2 px-2 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-xs text-slate-100" type="button">
|
||||||
|
<i class="fa-solid fa-magnifying-glass-chart"></i> Hex peek
|
||||||
|
</button>
|
||||||
|
<button id="preview-hash-btn" class="inline-flex items-center justify-center gap-2 px-2 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-xs text-slate-100" type="button">
|
||||||
|
<i class="fa-solid fa-fingerprint"></i> SHA-256
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="preview-hash-result" class="hidden">
|
||||||
|
<div class="text-[10px] uppercase text-slate-500 mb-1 tracking-wide">SHA-256</div>
|
||||||
|
<div id="preview-hash-value" class="cursor-pointer" title="Click to copy"></div>
|
||||||
|
<div id="preview-hash-known" class="mt-1 text-[10px] text-emerald-400 hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex justify-center hidden" id="preview-pane-show-btn-host">
|
||||||
|
<button id="preview-pane-show-btn" class="inline-flex items-center gap-2 px-3 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-xs text-slate-200" type="button">
|
||||||
|
<i class="fa-solid fa-eye"></i> Show preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -633,11 +832,46 @@
|
|||||||
<i class="fa-solid fa-ghost mr-2"></i> Silent Execute
|
<i class="fa-solid fa-ghost mr-2"></i> Silent Execute
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-separator" style="height:1px;background:#334155;margin:0.25rem 0;"></div>
|
<div class="context-menu-separator" style="height:1px;background:#334155;margin:0.25rem 0;"></div>
|
||||||
|
<div class="context-menu-item" data-action="pin">
|
||||||
|
<i class="fa-solid fa-star mr-2"></i> Pin to sidebar
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="unpin">
|
||||||
|
<i class="fa-solid fa-star-half-stroke mr-2"></i> Unpin
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="hex_peek">
|
||||||
|
<i class="fa-solid fa-magnifying-glass-chart mr-2"></i> Hex peek (4 KB)
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="hash">
|
||||||
|
<i class="fa-solid fa-fingerprint mr-2"></i> SHA-256
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-separator" style="height:1px;background:#334155;margin:0.25rem 0;"></div>
|
||||||
<div class="context-menu-item text-red-400" data-action="delete">
|
<div class="context-menu-item text-red-400" data-action="delete">
|
||||||
<i class="fa-solid fa-trash mr-2"></i> Delete
|
<i class="fa-solid fa-trash mr-2"></i> Delete
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hex viewer modal -->
|
||||||
|
<div id="hex-viewer-modal" class="hex-viewer-modal">
|
||||||
|
<div class="hex-viewer-content">
|
||||||
|
<div class="px-4 py-3 bg-slate-800 border-b border-slate-700 flex items-center justify-between flex-shrink-0">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<i class="fa-solid fa-magnifying-glass-chart text-blue-400 flex-shrink-0"></i>
|
||||||
|
<span class="font-semibold truncate" id="hex-viewer-file">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||||
|
<span id="hex-viewer-info" class="text-xs text-slate-400"></span>
|
||||||
|
<button id="hex-viewer-copy-btn" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-white" type="button">
|
||||||
|
<i class="fa-solid fa-copy"></i> Copy hex
|
||||||
|
</button>
|
||||||
|
<button id="hex-viewer-close-btn" class="px-2 py-1 rounded hover:text-red-400 text-slate-400 transition-colors" type="button">
|
||||||
|
<i class="fa-solid fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="hex-viewer-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- File Editor Modal -->
|
<!-- File Editor Modal -->
|
||||||
<div id="file-editor-modal" class="file-editor-modal">
|
<div id="file-editor-modal" class="file-editor-modal">
|
||||||
<div class="file-editor-content">
|
<div class="file-editor-content">
|
||||||
@@ -768,6 +1002,6 @@
|
|||||||
<input type="file" id="file-input" style="display: none" multiple />
|
<input type="file" id="file-input" style="display: none" multiple />
|
||||||
|
|
||||||
<script type="module" src="/assets/nav.js"></script>
|
<script type="module" src="/assets/nav.js"></script>
|
||||||
<script type="module" src="/assets/filebrowser.js?v=iconsthumbs-20260517-1"></script>
|
<script type="module" src="/assets/filebrowser.js?v=pinpreviewhex-20260520-3"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ function parseMaxHttpBodyBytes(): number {
|
|||||||
}
|
}
|
||||||
logger.warn(`[HTTP] Invalid OVERLORD_MAX_HTTP_BODY_BYTES=${raw}; using default`);
|
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();
|
const MAX_HTTP_BODY_BYTES = parseMaxHttpBodyBytes();
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ export type ProcessInfo = {
|
|||||||
pid: number;
|
pid: number;
|
||||||
ppid: number;
|
ppid: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
exePath?: string;
|
||||||
cpu: number;
|
cpu: number;
|
||||||
memory: number;
|
memory: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import fs from "fs/promises";
|
|||||||
import type { FileHandle } from "fs/promises";
|
import type { FileHandle } from "fs/promises";
|
||||||
import { logger } from "../logger";
|
import { logger } from "../logger";
|
||||||
|
|
||||||
|
export const STREAM_REORDER_BUFFER_LIMIT = 16;
|
||||||
|
|
||||||
export type PendingHttpDownload = {
|
export type PendingHttpDownload = {
|
||||||
commandId: string;
|
commandId: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -20,8 +22,78 @@ export type PendingHttpDownload = {
|
|||||||
resolve: (entry: PendingHttpDownload) => void;
|
resolve: (entry: PendingHttpDownload) => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
timeout: ReturnType<typeof setTimeout>;
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
|
streamController?: ReadableStreamDefaultController<Uint8Array>;
|
||||||
|
reorderBuffer?: Map<number, Uint8Array>;
|
||||||
|
nextExpectedOffset?: number;
|
||||||
|
streamErrored?: boolean;
|
||||||
|
onFirstChunk?: () => void;
|
||||||
|
firstChunkSignaled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isStreamMode(pending: PendingHttpDownload): boolean {
|
||||||
|
return !!pending.streamController;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function failStream(pending: PendingHttpDownload, err: Error): Promise<void> {
|
||||||
|
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<string, PendingHttpDownload>): Promise<void> {
|
||||||
|
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(
|
export async function consumeHttpDownloadPayload(
|
||||||
payload: any,
|
payload: any,
|
||||||
pendingHttpDownloads: Map<string, PendingHttpDownload>,
|
pendingHttpDownloads: Map<string, PendingHttpDownload>,
|
||||||
@@ -33,15 +105,16 @@ export async function consumeHttpDownloadPayload(
|
|||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
|
|
||||||
if (payload?.error) {
|
if (payload?.error) {
|
||||||
clearTimeout(pending.timeout);
|
const err = new Error(String(payload.error));
|
||||||
pendingHttpDownloads.delete(commandId);
|
if (isStreamMode(pending)) {
|
||||||
try {
|
await failStream(pending, err);
|
||||||
await pending.fileHandle.close();
|
clearTimeout(pending.timeout);
|
||||||
} catch {}
|
pendingHttpDownloads.delete(commandId);
|
||||||
try {
|
pending.resolve(pending);
|
||||||
await fs.unlink(pending.tmpPath);
|
} else {
|
||||||
} catch {}
|
await teardownPending(pending, pendingHttpDownloads);
|
||||||
pending.reject(new Error(String(payload.error)));
|
pending.reject(err);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +140,7 @@ export async function consumeHttpDownloadPayload(
|
|||||||
commandId,
|
commandId,
|
||||||
total: pending.total,
|
total: pending.total,
|
||||||
rawTotalType: typeof rawTotal,
|
rawTotalType: typeof rawTotal,
|
||||||
|
mode: isStreamMode(pending) ? "stream" : "buffer",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,34 +186,44 @@ export async function consumeHttpDownloadPayload(
|
|||||||
chunkIndex,
|
chunkIndex,
|
||||||
chunksTotal,
|
chunksTotal,
|
||||||
expectedChunks: pending.expectedChunks,
|
expectedChunks: pending.expectedChunks,
|
||||||
|
mode: isStreamMode(pending) ? "stream" : "buffer",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveOffset = offset ?? 0;
|
||||||
const shouldWrite = chunkIndex !== null
|
const shouldWrite = chunkIndex !== null
|
||||||
? !pending.receivedChunks.has(chunkIndex)
|
? !pending.receivedChunks.has(chunkIndex)
|
||||||
: !pending.receivedOffsets.has(offset ?? 0);
|
: !pending.receivedOffsets.has(effectiveOffset);
|
||||||
|
|
||||||
if (shouldWrite) {
|
if (shouldWrite) {
|
||||||
try {
|
if (isStreamMode(pending)) {
|
||||||
await pending.fileHandle.write(data, 0, data.length, offset ?? 0);
|
const accepted = enqueueOrdered(pending, effectiveOffset, data);
|
||||||
} catch (err) {
|
if (!accepted) {
|
||||||
clearTimeout(pending.timeout);
|
clearTimeout(pending.timeout);
|
||||||
pendingHttpDownloads.delete(commandId);
|
pendingHttpDownloads.delete(commandId);
|
||||||
|
pending.resolve(pending);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
await pending.fileHandle.close();
|
await pending.fileHandle.write(data, 0, data.length, effectiveOffset);
|
||||||
} catch {}
|
} catch (err) {
|
||||||
try {
|
await teardownPending(pending, pendingHttpDownloads);
|
||||||
await fs.unlink(pending.tmpPath);
|
pending.reject(err as Error);
|
||||||
} catch {}
|
return;
|
||||||
pending.reject(err as Error);
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (chunkIndex !== null) {
|
if (chunkIndex !== null) {
|
||||||
pending.receivedChunks.add(chunkIndex);
|
pending.receivedChunks.add(chunkIndex);
|
||||||
} else {
|
} else {
|
||||||
pending.receivedOffsets.add(offset ?? 0);
|
pending.receivedOffsets.add(effectiveOffset);
|
||||||
}
|
}
|
||||||
pending.receivedBytes += data.length;
|
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) {
|
if ((pending.total > 0 ? pending.receivedBytes >= pending.total : hasAllChunks && pending.receivedBytes > 0) && hasAllChunks) {
|
||||||
clearTimeout(pending.timeout);
|
clearTimeout(pending.timeout);
|
||||||
pendingHttpDownloads.delete(commandId);
|
pendingHttpDownloads.delete(commandId);
|
||||||
try {
|
if (isStreamMode(pending)) {
|
||||||
await pending.fileHandle.close();
|
if (pending.reorderBuffer && pending.reorderBuffer.size > 0 && !pending.streamErrored) {
|
||||||
} catch {}
|
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);
|
pending.resolve(pending);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,17 +306,12 @@ export async function handleClientRoutes(
|
|||||||
if (!record) {
|
if (!record) {
|
||||||
return new Response("Not Found", { status: 404 });
|
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, {
|
return new Response(record.bytes as unknown as BodyInit, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": record.contentType,
|
"Content-Type": record.contentType,
|
||||||
"Content-Length": String(record.bytes.byteLength),
|
"Content-Length": String(record.bytes.byteLength),
|
||||||
ETag: etag,
|
"Cache-Control": "no-store",
|
||||||
"Cache-Control": "private, max-age=300",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
|
import type { FileHandle } from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
@@ -32,6 +33,12 @@ type PendingHttpDownload = {
|
|||||||
resolve: (entry: PendingHttpDownload) => void;
|
resolve: (entry: PendingHttpDownload) => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
timeout: ReturnType<typeof setTimeout>;
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
|
streamController?: ReadableStreamDefaultController<Uint8Array>;
|
||||||
|
reorderBuffer?: Map<number, Uint8Array>;
|
||||||
|
nextExpectedOffset?: number;
|
||||||
|
streamErrored?: boolean;
|
||||||
|
onFirstChunk?: () => void;
|
||||||
|
firstChunkSignaled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DownloadIntent = {
|
type DownloadIntent = {
|
||||||
@@ -53,6 +60,13 @@ type UploadIntent = {
|
|||||||
timeout: ReturnType<typeof setTimeout>;
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StreamingPullState = {
|
||||||
|
size: number;
|
||||||
|
done: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
waiters: Array<() => void>;
|
||||||
|
};
|
||||||
|
|
||||||
type UploadPull = {
|
type UploadPull = {
|
||||||
id: string;
|
id: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -63,8 +77,76 @@ type UploadPull = {
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
timeout: ReturnType<typeof setTimeout>;
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
deleteFile: boolean;
|
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<void> {
|
||||||
|
if (state.done) return Promise.resolve();
|
||||||
|
return new Promise<void>((resolve) => state.waiters.push(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeIncrementalPullStream(pull: UploadPull): ReadableStream<Uint8Array> {
|
||||||
|
let cancelled = false;
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
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<void>((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 = {
|
type FileDownloadRouteDeps = {
|
||||||
DATA_DIR: string;
|
DATA_DIR: string;
|
||||||
secureHeaders: (contentType?: string) => Record<string, string>;
|
secureHeaders: (contentType?: string) => Record<string, string>;
|
||||||
@@ -207,9 +289,6 @@ async function serveDownloadById(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const commandId = uuidv4();
|
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";
|
let fileName = path.basename(downloadPath) || "download.bin";
|
||||||
try {
|
try {
|
||||||
@@ -218,20 +297,47 @@ async function serveDownloadById(
|
|||||||
fileName = "download.bin";
|
fileName = "download.bin";
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileHandle = await fs.open(tmpPath, "w+");
|
|
||||||
|
|
||||||
logger.debug("[filebrowser] http download start", {
|
logger.debug("[filebrowser] http download start", {
|
||||||
commandId,
|
commandId,
|
||||||
clientId,
|
clientId,
|
||||||
path: downloadPath,
|
path: downloadPath,
|
||||||
tmpPath,
|
mode: "stream",
|
||||||
|
});
|
||||||
|
|
||||||
|
let streamController!: ReadableStreamDefaultController<Uint8Array>;
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
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<void>((resolve, reject) => {
|
||||||
|
firstChunkResolve = resolve;
|
||||||
|
firstChunkReject = reject;
|
||||||
});
|
});
|
||||||
|
|
||||||
const downloadPromise = new Promise<PendingHttpDownload>((resolve, reject) => {
|
const downloadPromise = new Promise<PendingHttpDownload>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
const p = deps.pendingHttpDownloads.get(commandId);
|
||||||
|
if (!p) return;
|
||||||
deps.pendingHttpDownloads.delete(commandId);
|
deps.pendingHttpDownloads.delete(commandId);
|
||||||
void fileHandle.close();
|
if (p.streamController && !p.streamErrored) {
|
||||||
void fs.unlink(tmpPath).catch(() => {});
|
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"));
|
reject(new Error("Download timed out"));
|
||||||
}, 5 * 60_000);
|
}, 5 * 60_000);
|
||||||
|
|
||||||
@@ -248,14 +354,40 @@ async function serveDownloadById(
|
|||||||
expectedChunks: 0,
|
expectedChunks: 0,
|
||||||
loggedTotal: false,
|
loggedTotal: false,
|
||||||
loggedFirstChunk: false,
|
loggedFirstChunk: false,
|
||||||
tmpPath,
|
tmpPath: "",
|
||||||
fileHandle,
|
fileHandle: null,
|
||||||
resolve,
|
resolve,
|
||||||
reject,
|
reject,
|
||||||
timeout,
|
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(
|
target.ws.send(
|
||||||
encodeMessage({
|
encodeMessage({
|
||||||
type: "command",
|
type: "command",
|
||||||
@@ -281,36 +413,28 @@ async function serveDownloadById(
|
|||||||
success: true,
|
success: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let completed: PendingHttpDownload;
|
|
||||||
try {
|
try {
|
||||||
completed = await downloadPromise;
|
await firstChunkPromise;
|
||||||
} catch (err) {
|
} 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 });
|
return new Response((err as Error).message || "Download failed", { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("[filebrowser] http download complete", {
|
const installed = deps.pendingHttpDownloads.get(commandId);
|
||||||
commandId,
|
if (installed?.streamErrored) {
|
||||||
clientId,
|
return new Response("Download failed", { status: 500 });
|
||||||
path: downloadPath,
|
}
|
||||||
total: completed.total,
|
|
||||||
receivedBytes: completed.receivedBytes,
|
|
||||||
expectedChunks: completed.expectedChunks,
|
|
||||||
receivedChunks: completed.receivedChunks.size,
|
|
||||||
});
|
|
||||||
|
|
||||||
const headers = {
|
const headers: Record<string, string> = {
|
||||||
...deps.secureHeaders("application/octet-stream"),
|
...deps.secureHeaders("application/octet-stream"),
|
||||||
"Content-Disposition": `attachment; filename="${completed.fileName}"`,
|
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||||
"Cache-Control": "no-store, private",
|
"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: {
|
export function createUploadPull(opts: {
|
||||||
@@ -459,70 +583,140 @@ export async function handleFileDownloadRoutes(
|
|||||||
|
|
||||||
const uploadDir = path.join(deps.DATA_DIR, "uploads");
|
const uploadDir = path.join(deps.DATA_DIR, "uploads");
|
||||||
await fs.mkdir(uploadDir, { recursive: true });
|
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 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 pullExpiresAt = Date.now() + FILE_UPLOAD_PULL_TTL_MS;
|
||||||
const pullTimeout = setTimeout(() => {
|
const pullTimeout = setTimeout(() => {
|
||||||
const pull = uploadPulls.get(pullId);
|
const stale = uploadPulls.get(pullId);
|
||||||
uploadPulls.delete(pullId);
|
uploadPulls.delete(pullId);
|
||||||
if (pull && pull.deleteFile) {
|
if (stale && stale.deleteFile) {
|
||||||
void fs.unlink(pull.tmpPath).catch(() => {});
|
void fs.unlink(stale.tmpPath).catch(() => {});
|
||||||
}
|
}
|
||||||
}, FILE_UPLOAD_PULL_TTL_MS);
|
}, FILE_UPLOAD_PULL_TTL_MS);
|
||||||
|
|
||||||
|
const state: StreamingPullState | undefined = canStream
|
||||||
|
? { size: 0, done: false, error: null, waiters: [] }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
uploadPulls.set(pullId, {
|
uploadPulls.set(pullId, {
|
||||||
id: pullId,
|
id: pullId,
|
||||||
clientId: intent.clientId,
|
clientId: intent.clientId,
|
||||||
path: intent.path,
|
path: intent.path,
|
||||||
fileName: intent.fileName,
|
fileName: intent.fileName,
|
||||||
tmpPath,
|
tmpPath,
|
||||||
size: stagedSize,
|
size: expectedTotal,
|
||||||
expiresAt: pullExpiresAt,
|
expiresAt: pullExpiresAt,
|
||||||
timeout: pullTimeout,
|
timeout: pullTimeout,
|
||||||
deleteFile: true,
|
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({
|
return Response.json({
|
||||||
@@ -530,6 +724,8 @@ export async function handleFileDownloadRoutes(
|
|||||||
size: stagedSize,
|
size: stagedSize,
|
||||||
path: intent.path,
|
path: intent.path,
|
||||||
pullUrl: `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`,
|
pullUrl: `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`,
|
||||||
|
agentCommandId,
|
||||||
|
agentNotified: !!agentCommandId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,6 +777,29 @@ export async function handleFileDownloadRoutes(
|
|||||||
if (pull.deleteFile) {
|
if (pull.deleteFile) {
|
||||||
uploadPulls.delete(pullId);
|
uploadPulls.delete(pullId);
|
||||||
clearTimeout(pull.timeout);
|
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), {
|
return new Response(streamFileAndDelete(pull.tmpPath), {
|
||||||
headers: { ...baseHeaders, "Content-Length": String(pull.size) },
|
headers: { ...baseHeaders, "Content-Length": String(pull.size) },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { clearClientSyncState, handleFrame, handleHello, handlePing, handlePong
|
|||||||
import { getMaxPayloadLimit, getMessageByteLength, isAllowedClientMessageType } from "../../wsValidation";
|
import { getMaxPayloadLimit, getMessageByteLength, isAllowedClientMessageType } from "../../wsValidation";
|
||||||
import { stopAllProxiesForClient } from "../socks5-proxy-manager";
|
import { stopAllProxiesForClient } from "../socks5-proxy-manager";
|
||||||
import { verifyBuildToken, isBuildBanned } from "../build-signing";
|
import { verifyBuildToken, isBuildBanned } from "../build-signing";
|
||||||
|
import { clearThumbnail } from "../../thumbnails";
|
||||||
|
|
||||||
const OFFLINE_GRACE_MS = (() => {
|
const OFFLINE_GRACE_MS = (() => {
|
||||||
const raw = process.env.OVERLORD_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);
|
await handleHello(infoObj, payload as Hello, ws, ip);
|
||||||
clientManager.addClient(infoObj.id, infoObj);
|
clientManager.addClient(infoObj.id, infoObj);
|
||||||
|
|
||||||
|
clearThumbnail(resolvedId);
|
||||||
|
|
||||||
const reconnectedWithinGrace = cancelPendingOffline(infoObj.id);
|
const reconnectedWithinGrace = cancelPendingOffline(infoObj.id);
|
||||||
|
|
||||||
deps.dispatchAutoScriptsForConnection(infoObj, ws);
|
deps.dispatchAutoScriptsForConnection(infoObj, ws);
|
||||||
@@ -643,6 +646,8 @@ export async function handleWebSocketMessage(
|
|||||||
case "file_icon_result":
|
case "file_icon_result":
|
||||||
case "file_thumb_result":
|
case "file_thumb_result":
|
||||||
case "file_dirsize_result":
|
case "file_dirsize_result":
|
||||||
|
case "file_peek_result":
|
||||||
|
case "file_hash_result":
|
||||||
case "command_result":
|
case "command_result":
|
||||||
if (payloadType === "command_result" && typeof (payload as any).commandId === "string") {
|
if (payloadType === "command_result" && typeof (payload as any).commandId === "string") {
|
||||||
const pending = deps.pendingCommandReplies.get((payload as any).commandId);
|
const pending = deps.pendingCommandReplies.get((payload as any).commandId);
|
||||||
@@ -676,6 +681,7 @@ export async function handleWebSocketMessage(
|
|||||||
deps.handleFileBrowserMessage(client.id, payload);
|
deps.handleFileBrowserMessage(client.id, payload);
|
||||||
break;
|
break;
|
||||||
case "process_list_result":
|
case "process_list_result":
|
||||||
|
case "process_icon_result":
|
||||||
deps.handleProcessMessage(client.id, payload);
|
deps.handleProcessMessage(client.id, payload);
|
||||||
break;
|
break;
|
||||||
case "keylog_file_list":
|
case "keylog_file_list":
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ type WsViewerClusterDeps = {
|
|||||||
consumeHttpDownloadPayload: (payload: any) => Promise<void> | void;
|
consumeHttpDownloadPayload: (payload: any) => Promise<void> | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WS_UPLOAD_MAX_TOTAL = 8 * 1024 * 1024;
|
||||||
|
|
||||||
const fileBrowserCommandSessions = new Map<string, string>();
|
const fileBrowserCommandSessions = new Map<string, string>();
|
||||||
|
|
||||||
function trackFileBrowserCommand(commandId: string, sessionId: string): void {
|
function trackFileBrowserCommand(commandId: string, sessionId: string): void {
|
||||||
@@ -142,6 +144,23 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
|
|||||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_dirsize", id: routedId, payload: actualPayload } as any));
|
target.ws.send(encodeMessage({ type: "command", commandType: "file_dirsize", id: routedId, payload: actualPayload } as any));
|
||||||
metrics.recordCommand("file_dirsize");
|
metrics.recordCommand("file_dirsize");
|
||||||
break;
|
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":
|
case "silent_exec":
|
||||||
logger.debug(`[DEBUG] Forwarding silent_exec to client ${clientId}:`, actualPayload.command);
|
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));
|
target.ws.send(encodeMessage({ type: "command", commandType: "silent_exec", id: routedId, payload: actualPayload } as any));
|
||||||
@@ -198,14 +217,43 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
|
|||||||
case "file_upload": {
|
case "file_upload": {
|
||||||
const upload = normalizeFileUploadPayload(payload);
|
const upload = normalizeFileUploadPayload(payload);
|
||||||
if (!upload) return;
|
if (!upload) return;
|
||||||
safeSendViewer(ws, {
|
if (upload.total > WS_UPLOAD_MAX_TOTAL) {
|
||||||
type: "file_upload_result",
|
safeSendViewer(ws, {
|
||||||
commandId,
|
type: "file_upload_result",
|
||||||
transferId: upload.transferId,
|
commandId,
|
||||||
path: upload.path,
|
transferId: upload.transferId,
|
||||||
ok: false,
|
path: upload.path,
|
||||||
error: "chunked uploads are disabled; refresh and retry",
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "file_delete": {
|
case "file_delete": {
|
||||||
@@ -311,6 +359,8 @@ export function handleFileBrowserMessage(clientId: string, payload: any, deps: W
|
|||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
safeSendViewer(session.viewer, { ...payload, thumbs });
|
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 {
|
} else {
|
||||||
safeSendViewer(session.viewer, payload);
|
safeSendViewer(session.viewer, payload);
|
||||||
}
|
}
|
||||||
@@ -350,6 +400,10 @@ export function handleProcessViewerMessage(ws: ServerWebSocket<SocketData>, raw:
|
|||||||
target.ws.send(encodeMessage({ type: "command", commandType: "process_list", id: commandId } as any));
|
target.ws.send(encodeMessage({ type: "command", commandType: "process_list", id: commandId } as any));
|
||||||
metrics.recordCommand("process_list");
|
metrics.recordCommand("process_list");
|
||||||
break;
|
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": {
|
case "process_kill": {
|
||||||
const pid = Number(payload.pid);
|
const pid = Number(payload.pid);
|
||||||
if (!Number.isFinite(pid) || pid <= 0) {
|
if (!Number.isFinite(pid) || pid <= 0) {
|
||||||
@@ -387,7 +441,17 @@ export function handleProcessViewerMessage(ws: ServerWebSocket<SocketData>, raw:
|
|||||||
|
|
||||||
export function handleProcessMessage(clientId: string, payload: any) {
|
export function handleProcessMessage(clientId: string, payload: any) {
|
||||||
for (const session of sessionManager.getProcessSessionsByClient(clientId)) {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type ThumbnailRecord = {
|
|||||||
const thumbnails = new Map<string, ThumbnailRecord>();
|
const thumbnails = new Map<string, ThumbnailRecord>();
|
||||||
const latestFrames = new Map<string, { bytes: Uint8Array; format: string; capturedAt: number }>();
|
const latestFrames = new Map<string, { bytes: Uint8Array; format: string; capturedAt: number }>();
|
||||||
const thumbnailRequests = new Map<string, number>();
|
const thumbnailRequests = new Map<string, number>();
|
||||||
|
const thumbnailVersionHWM = new Map<string, number>();
|
||||||
|
|
||||||
function touchThumbnailLRU(id: string) {
|
function touchThumbnailLRU(id: string) {
|
||||||
const existing = thumbnails.get(id);
|
const existing = thumbnails.get(id);
|
||||||
@@ -120,11 +121,14 @@ export async function generateThumbnail(id: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
const prior = thumbnails.get(id);
|
const prior = thumbnails.get(id);
|
||||||
const now = Date.now();
|
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);
|
if (prior) thumbnails.delete(id);
|
||||||
thumbnails.set(id, {
|
thumbnails.set(id, {
|
||||||
bytes: out,
|
bytes: out,
|
||||||
contentType: "image/webp",
|
contentType: "image/webp",
|
||||||
version: (prior?.version ?? 0) + 1,
|
version: newVersion,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
evictThumbnailsIfFull();
|
evictThumbnailsIfFull();
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
|
|||||||
"file_icon_result",
|
"file_icon_result",
|
||||||
"file_thumb_result",
|
"file_thumb_result",
|
||||||
"file_dirsize_result",
|
"file_dirsize_result",
|
||||||
|
"file_peek_result",
|
||||||
|
"file_hash_result",
|
||||||
"command_result",
|
"command_result",
|
||||||
"screenshot_result",
|
"screenshot_result",
|
||||||
"command_progress",
|
"command_progress",
|
||||||
"process_list_result",
|
"process_list_result",
|
||||||
|
"process_icon_result",
|
||||||
"script_result",
|
"script_result",
|
||||||
"plugin_event",
|
"plugin_event",
|
||||||
"notification",
|
"notification",
|
||||||
|
|||||||
Reference in New Issue
Block a user