file browser 2.0
This commit is contained in:
@@ -2056,6 +2056,35 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
|||||||
payload, _ := envelope["payload"].(map[string]interface{})
|
payload, _ := envelope["payload"].(map[string]interface{})
|
||||||
path, _ := payload["path"].(string)
|
path, _ := payload["path"].(string)
|
||||||
return HandleFileExecute(ctx, env, cmdID, path)
|
return HandleFileExecute(ctx, env, cmdID, path)
|
||||||
|
case "file_icon":
|
||||||
|
payload, _ := envelope["payload"].(map[string]interface{})
|
||||||
|
items := parseIconRequestItems(payload["items"])
|
||||||
|
goSafe("file_icon", env.Cancel, func() {
|
||||||
|
if err := HandleFileIcon(ctx, env, cmdID, items); err != nil && err != context.Canceled {
|
||||||
|
log.Printf("file_icon error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
case "file_thumb":
|
||||||
|
payload, _ := envelope["payload"].(map[string]interface{})
|
||||||
|
items := parseThumbRequestItems(payload["items"])
|
||||||
|
goSafe("file_thumb", env.Cancel, func() {
|
||||||
|
if err := HandleFileThumbnail(ctx, env, cmdID, items); err != nil && err != context.Canceled {
|
||||||
|
log.Printf("file_thumb error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
case "file_dirsize":
|
||||||
|
path, _ := envelopePayloadString(envelope, "path")
|
||||||
|
dirsizeCtx, cancel := context.WithCancel(ctx)
|
||||||
|
registerCancellableCommand(cmdID, cancel)
|
||||||
|
goSafe("file_dirsize", env.Cancel, func() {
|
||||||
|
defer unregisterCommand(cmdID)
|
||||||
|
if err := HandleFolderSize(dirsizeCtx, env, cmdID, path); err != nil && err != context.Canceled {
|
||||||
|
log.Printf("file_dirsize 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)
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentRuntime "overlord-client/cmd/agent/runtime"
|
||||||
|
"overlord-client/cmd/agent/wire"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseIconRequestItems(raw interface{}) []wire.FileIconRequestItem {
|
||||||
|
arr, _ := raw.([]interface{})
|
||||||
|
out := make([]wire.FileIconRequestItem, 0, len(arr))
|
||||||
|
for _, v := range arr {
|
||||||
|
m := coerceStringMap(v)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, _ := m["key"].(string)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path, _ := m["path"].(string)
|
||||||
|
ext, _ := m["ext"].(string)
|
||||||
|
out = append(out, wire.FileIconRequestItem{Key: key, Path: path, Ext: ext})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseThumbRequestItems(raw interface{}) []wire.FileThumbnailRequestItem {
|
||||||
|
arr, _ := raw.([]interface{})
|
||||||
|
out := make([]wire.FileThumbnailRequestItem, 0, len(arr))
|
||||||
|
for _, v := range arr {
|
||||||
|
m := coerceStringMap(v)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, _ := m["key"].(string)
|
||||||
|
path, _ := m["path"].(string)
|
||||||
|
if key == "" || path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
size := 0
|
||||||
|
switch s := m["size"].(type) {
|
||||||
|
case float64:
|
||||||
|
size = int(s)
|
||||||
|
case int64:
|
||||||
|
size = int(s)
|
||||||
|
case uint64:
|
||||||
|
size = int(s)
|
||||||
|
case int:
|
||||||
|
size = s
|
||||||
|
}
|
||||||
|
out = append(out, wire.FileThumbnailRequestItem{Key: key, Path: path, Size: size})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func coerceStringMap(v interface{}) map[string]interface{} {
|
||||||
|
if m, ok := v.(map[string]interface{}); ok {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
if m, ok := v.(map[interface{}]interface{}); ok {
|
||||||
|
out := make(map[string]interface{}, len(m))
|
||||||
|
for k, val := range m {
|
||||||
|
if ks, ok := k.(string); ok {
|
||||||
|
out[ks] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleFileIcon(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: "file_icon_result",
|
||||||
|
CommandID: cmdID,
|
||||||
|
Icons: out,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleFileThumbnail(ctx context.Context, env *agentRuntime.Env, cmdID string, items []wire.FileThumbnailRequestItem) error {
|
||||||
|
out := make([]wire.FileThumbnailResultItem, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
jpegBytes, w, h, err := extractFileThumbnailJPEG(item.Path, item.Size)
|
||||||
|
ri := wire.FileThumbnailResultItem{Key: item.Key}
|
||||||
|
if err != nil {
|
||||||
|
ri.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
ri.JPEG = jpegBytes
|
||||||
|
ri.W = w
|
||||||
|
ri.H = h
|
||||||
|
}
|
||||||
|
out = append(out, ri)
|
||||||
|
}
|
||||||
|
return wire.WriteMsg(ctx, env.Conn, wire.FileThumbnailResult{
|
||||||
|
Type: "file_thumb_result",
|
||||||
|
CommandID: cmdID,
|
||||||
|
Thumbs: out,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleFolderSize(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error {
|
||||||
|
log.Printf("file_dirsize: %s", path)
|
||||||
|
|
||||||
|
var bytes, files, dirs int64
|
||||||
|
lastEmit := time.Now()
|
||||||
|
|
||||||
|
emit := func(done bool, errMsg string) error {
|
||||||
|
return wire.WriteMsg(ctx, env.Conn, wire.FolderSizeResult{
|
||||||
|
Type: "file_dirsize_result",
|
||||||
|
CommandID: cmdID,
|
||||||
|
Path: path,
|
||||||
|
Bytes: bytes,
|
||||||
|
Files: files,
|
||||||
|
Dirs: dirs,
|
||||||
|
Done: done,
|
||||||
|
Error: errMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
walkErr := filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil // skip unreadable entries
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
dirs++
|
||||||
|
} else {
|
||||||
|
files++
|
||||||
|
bytes += info.Size()
|
||||||
|
}
|
||||||
|
if time.Since(lastEmit) >= 400*time.Millisecond {
|
||||||
|
lastEmit = time.Now()
|
||||||
|
_ = emit(false, "")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
errMsg := ""
|
||||||
|
if walkErr != nil && walkErr != context.Canceled {
|
||||||
|
errMsg = walkErr.Error()
|
||||||
|
}
|
||||||
|
return emit(true, errMsg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func extractFileIconPNG(_, _ string) ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("file icons only supported on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFileThumbnailJPEG(_ string, _ int) ([]byte, int, int, error) {
|
||||||
|
return nil, 0, 0, fmt.Errorf("file thumbnails only supported on Windows")
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
//go:build windows
|
||||||
|
// +build windows
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"log"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
feShell32 = windows.NewLazySystemDLL("shell32.dll")
|
||||||
|
feOle32 = windows.NewLazySystemDLL("ole32.dll")
|
||||||
|
pSHGetFileInfoW = feShell32.NewProc("SHGetFileInfoW")
|
||||||
|
pSHCreateItemFromParsingName = feShell32.NewProc("SHCreateItemFromParsingName")
|
||||||
|
pCoInitializeEx = feOle32.NewProc("CoInitializeEx")
|
||||||
|
pCoUninitialize = feOle32.NewProc("CoUninitialize")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
shgfiIcon = 0x000000100
|
||||||
|
shgfiSmallIcon = 0x000000001
|
||||||
|
shgfiLargeIcon = 0x000000000
|
||||||
|
shgfiUseFileAttributes = 0x000000010
|
||||||
|
fileAttributeNormal = 0x00000080
|
||||||
|
coinitApartmentThreaded = 0x2
|
||||||
|
coinitDisableOLE1DDE = 0x4
|
||||||
|
|
||||||
|
siigbfResizeToFit = 0x00
|
||||||
|
siigbfBiggerSizeOk = 0x01
|
||||||
|
siigbfMemoryOnly = 0x02
|
||||||
|
siigbfIconOnly = 0x04
|
||||||
|
siigbfThumbnailOnly = 0x08
|
||||||
|
siigbfInCacheOnly = 0x10
|
||||||
|
)
|
||||||
|
|
||||||
|
type shFileInfoW struct {
|
||||||
|
HIcon uintptr
|
||||||
|
IIcon int32
|
||||||
|
DwAttributes uint32
|
||||||
|
SzDisplayName [260]uint16
|
||||||
|
SzTypeName [80]uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
var iidIShellItemImageFactory = windows.GUID{
|
||||||
|
Data1: 0xbcc18b79, Data2: 0xba16, Data3: 0x442f,
|
||||||
|
Data4: [8]byte{0x80, 0xc4, 0x8a, 0x59, 0xc3, 0x0c, 0x46, 0x3b},
|
||||||
|
}
|
||||||
|
|
||||||
|
type iShellItemImageFactoryVtbl struct {
|
||||||
|
QueryInterface uintptr
|
||||||
|
AddRef uintptr
|
||||||
|
Release uintptr
|
||||||
|
GetImage uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
type iShellItemImageFactory struct {
|
||||||
|
Vtbl *iShellItemImageFactoryVtbl
|
||||||
|
}
|
||||||
|
|
||||||
|
type comJob struct {
|
||||||
|
fn func()
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
comStartOnce sync.Once
|
||||||
|
comQueue chan comJob
|
||||||
|
)
|
||||||
|
|
||||||
|
func runOnCOMThread(fn func()) {
|
||||||
|
comStartOnce.Do(func() {
|
||||||
|
comQueue = make(chan comJob, 256)
|
||||||
|
go func() {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
pCoInitializeEx.Call(0, uintptr(coinitApartmentThreaded|coinitDisableOLE1DDE))
|
||||||
|
defer pCoUninitialize.Call()
|
||||||
|
for job := range comQueue {
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("com worker: recovered panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
job.fn()
|
||||||
|
}()
|
||||||
|
close(job.done)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
done := make(chan struct{})
|
||||||
|
comQueue <- comJob{fn: fn, done: done}
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
func hBitmapToRGBA(hBitmap uintptr) *image.RGBA {
|
||||||
|
var bm bitmapObj
|
||||||
|
pGetObjectW.Call(hBitmap, unsafe.Sizeof(bm), uintptr(unsafe.Pointer(&bm)))
|
||||||
|
if bm.BmWidth <= 0 || bm.BmHeight <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w := int(bm.BmWidth)
|
||||||
|
h := int(bm.BmHeight)
|
||||||
|
|
||||||
|
hdc, _, _ := pCreateCompatibleDC.Call(0)
|
||||||
|
if hdc == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer pDeleteDC.Call(hdc)
|
||||||
|
|
||||||
|
bih := bitmapInfoHeader{
|
||||||
|
BiSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
|
||||||
|
BiWidth: int32(w),
|
||||||
|
BiHeight: -int32(h),
|
||||||
|
BiPlanes: 1,
|
||||||
|
BiBitCount: 32,
|
||||||
|
}
|
||||||
|
pixels := make([]byte, w*h*4)
|
||||||
|
pGetDIBits.Call(hdc, hBitmap, 0, uintptr(h),
|
||||||
|
uintptr(unsafe.Pointer(&pixels[0])),
|
||||||
|
uintptr(unsafe.Pointer(&bih)),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
off := (y*w + x) * 4
|
||||||
|
img.SetRGBA(x, y, color.RGBA{
|
||||||
|
R: pixels[off+2],
|
||||||
|
G: pixels[off+1],
|
||||||
|
B: pixels[off+0],
|
||||||
|
A: pixels[off+3],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFileIconPNG(targetPath, ext string) ([]byte, error) {
|
||||||
|
var queryPath string
|
||||||
|
flags := uint32(shgfiIcon | shgfiLargeIcon)
|
||||||
|
if targetPath != "" {
|
||||||
|
queryPath = targetPath
|
||||||
|
} else if ext != "" {
|
||||||
|
queryPath = "x." + strings.TrimPrefix(strings.ToLower(ext), ".")
|
||||||
|
flags |= shgfiUseFileAttributes
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("path or ext required")
|
||||||
|
}
|
||||||
|
|
||||||
|
pathPtr, err := windows.UTF16PtrFromString(queryPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []byte
|
||||||
|
var outErr error
|
||||||
|
runOnCOMThread(func() {
|
||||||
|
var sfi shFileInfoW
|
||||||
|
ret, _, _ := pSHGetFileInfoW.Call(
|
||||||
|
uintptr(unsafe.Pointer(pathPtr)),
|
||||||
|
uintptr(fileAttributeNormal),
|
||||||
|
uintptr(unsafe.Pointer(&sfi)),
|
||||||
|
unsafe.Sizeof(sfi),
|
||||||
|
uintptr(flags),
|
||||||
|
)
|
||||||
|
if ret == 0 || sfi.HIcon == 0 {
|
||||||
|
outErr = fmt.Errorf("SHGetFileInfo failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer pDestroyIcon.Call(sfi.HIcon)
|
||||||
|
|
||||||
|
var ii iconInfo
|
||||||
|
r, _, _ := pGetIconInfo.Call(sfi.HIcon, uintptr(unsafe.Pointer(&ii)))
|
||||||
|
if r == 0 {
|
||||||
|
outErr = fmt.Errorf("GetIconInfo failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ii.HbmMask != 0 {
|
||||||
|
defer pDeleteObject.Call(uintptr(ii.HbmMask))
|
||||||
|
}
|
||||||
|
if ii.HbmColor == 0 {
|
||||||
|
outErr = fmt.Errorf("no color bitmap")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer pDeleteObject.Call(uintptr(ii.HbmColor))
|
||||||
|
|
||||||
|
img := hBitmapToRGBA(uintptr(ii.HbmColor))
|
||||||
|
if img == nil {
|
||||||
|
outErr = fmt.Errorf("bitmap conversion failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
outErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = buf.Bytes()
|
||||||
|
})
|
||||||
|
return out, outErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFileThumbnailJPEG(path string, edge int) ([]byte, int, int, error) {
|
||||||
|
if edge <= 0 {
|
||||||
|
edge = 128
|
||||||
|
}
|
||||||
|
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []byte
|
||||||
|
var outW, outH int
|
||||||
|
var outErr error
|
||||||
|
runOnCOMThread(func() {
|
||||||
|
var itemPtr uintptr
|
||||||
|
hr, _, _ := pSHCreateItemFromParsingName.Call(
|
||||||
|
uintptr(unsafe.Pointer(pathPtr)),
|
||||||
|
0,
|
||||||
|
uintptr(unsafe.Pointer(&iidIShellItemImageFactory)),
|
||||||
|
uintptr(unsafe.Pointer(&itemPtr)),
|
||||||
|
)
|
||||||
|
if hr != 0 || itemPtr == 0 {
|
||||||
|
outErr = fmt.Errorf("SHCreateItemFromParsingName failed: 0x%x", hr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
factory := (*iShellItemImageFactory)(unsafe.Pointer(itemPtr))
|
||||||
|
defer callRelease(factory.Vtbl.Release, itemPtr)
|
||||||
|
|
||||||
|
flags := uint32(siigbfThumbnailOnly | siigbfBiggerSizeOk)
|
||||||
|
var hBitmap uintptr
|
||||||
|
hr2 := callGetImage(factory.Vtbl.GetImage, itemPtr, int32(edge), int32(edge), flags, &hBitmap)
|
||||||
|
if hr2 != 0 || hBitmap == 0 {
|
||||||
|
outErr = fmt.Errorf("GetImage failed: 0x%x", hr2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer pDeleteObject.Call(hBitmap)
|
||||||
|
|
||||||
|
img := hBitmapToRGBA(hBitmap)
|
||||||
|
if img == nil {
|
||||||
|
outErr = fmt.Errorf("bitmap conversion failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
|
||||||
|
outErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = buf.Bytes()
|
||||||
|
outW = img.Bounds().Dx()
|
||||||
|
outH = img.Bounds().Dy()
|
||||||
|
})
|
||||||
|
return out, outW, outH, outErr
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//go:build windows && 386
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
func callGetImage(getImageFn, this uintptr, cx, cy int32, flags uint32, hBitmapOut *uintptr) uintptr {
|
||||||
|
hr, _, _ := syscall.SyscallN(
|
||||||
|
getImageFn,
|
||||||
|
this,
|
||||||
|
uintptr(uint32(cx)),
|
||||||
|
uintptr(uint32(cy)),
|
||||||
|
uintptr(flags),
|
||||||
|
uintptr(unsafe.Pointer(hBitmapOut)),
|
||||||
|
)
|
||||||
|
return hr
|
||||||
|
}
|
||||||
|
|
||||||
|
func callRelease(releaseFn, this uintptr) {
|
||||||
|
syscall.SyscallN(releaseFn, this)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//go:build windows && (amd64 || arm64)
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
func callGetImage(getImageFn, this uintptr, cx, cy int32, flags uint32, hBitmapOut *uintptr) uintptr {
|
||||||
|
sizeWord := uintptr(uint64(uint32(cx)) | (uint64(uint32(cy)) << 32))
|
||||||
|
hr, _, _ := syscall.SyscallN(
|
||||||
|
getImageFn,
|
||||||
|
this,
|
||||||
|
sizeWord,
|
||||||
|
uintptr(flags),
|
||||||
|
uintptr(unsafe.Pointer(hBitmapOut)),
|
||||||
|
)
|
||||||
|
return hr
|
||||||
|
}
|
||||||
|
|
||||||
|
func callRelease(releaseFn, this uintptr) {
|
||||||
|
syscall.SyscallN(releaseFn, this)
|
||||||
|
}
|
||||||
@@ -7,12 +7,15 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"overlord-client/cmd/agent/wire"
|
"overlord-client/cmd/agent/wire"
|
||||||
)
|
)
|
||||||
|
|
||||||
func enrichFileEntry(entry *wire.FileEntry, info os.FileInfo) {
|
func enrichFileEntry(entry *wire.FileEntry, info os.FileInfo) {
|
||||||
|
if d, ok := info.Sys().(*syscall.Win32FileAttributeData); ok {
|
||||||
|
entry.Attrs = d.FileAttributes
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ChangeFilePermissions(path string, mode string) error {
|
func ChangeFilePermissions(path string, mode string) error {
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ type FileEntry struct {
|
|||||||
Mode string `msgpack:"mode,omitempty"`
|
Mode string `msgpack:"mode,omitempty"`
|
||||||
Owner string `msgpack:"owner,omitempty"`
|
Owner string `msgpack:"owner,omitempty"`
|
||||||
Group string `msgpack:"group,omitempty"`
|
Group string `msgpack:"group,omitempty"`
|
||||||
|
// windows file attributes
|
||||||
|
Attrs uint32 `msgpack:"attrs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileListResult struct {
|
type FileListResult struct {
|
||||||
@@ -187,6 +189,55 @@ type FileSearchResult struct {
|
|||||||
Error string `msgpack:"error,omitempty"`
|
Error string `msgpack:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileIconRequestItem struct {
|
||||||
|
Key string `msgpack:"key"`
|
||||||
|
Path string `msgpack:"path,omitempty"`
|
||||||
|
Ext string `msgpack:"ext,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileIconResultItem struct {
|
||||||
|
Key string `msgpack:"key"`
|
||||||
|
PNG []byte `msgpack:"png,omitempty"`
|
||||||
|
Error string `msgpack:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileIconResult struct {
|
||||||
|
Type string `msgpack:"type"`
|
||||||
|
CommandID string `msgpack:"commandId,omitempty"`
|
||||||
|
Icons []FileIconResultItem `msgpack:"icons"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileThumbnailRequestItem struct {
|
||||||
|
Key string `msgpack:"key"`
|
||||||
|
Path string `msgpack:"path"`
|
||||||
|
Size int `msgpack:"size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileThumbnailResultItem struct {
|
||||||
|
Key string `msgpack:"key"`
|
||||||
|
JPEG []byte `msgpack:"jpeg,omitempty"`
|
||||||
|
W int `msgpack:"w,omitempty"`
|
||||||
|
H int `msgpack:"h,omitempty"`
|
||||||
|
Error string `msgpack:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileThumbnailResult struct {
|
||||||
|
Type string `msgpack:"type"`
|
||||||
|
CommandID string `msgpack:"commandId,omitempty"`
|
||||||
|
Thumbs []FileThumbnailResultItem `msgpack:"thumbs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FolderSizeResult struct {
|
||||||
|
Type string `msgpack:"type"`
|
||||||
|
CommandID string `msgpack:"commandId,omitempty"`
|
||||||
|
Path string `msgpack:"path"`
|
||||||
|
Bytes int64 `msgpack:"bytes"`
|
||||||
|
Files int64 `msgpack:"files"`
|
||||||
|
Dirs int64 `msgpack:"dirs"`
|
||||||
|
Done bool `msgpack:"done"`
|
||||||
|
Error string `msgpack:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ScriptResult struct {
|
type ScriptResult struct {
|
||||||
Type string `msgpack:"type"`
|
Type string `msgpack:"type"`
|
||||||
CommandID string `msgpack:"commandId,omitempty"`
|
CommandID string `msgpack:"commandId,omitempty"`
|
||||||
|
|||||||
@@ -266,6 +266,15 @@ function handleMessage(msg) {
|
|||||||
case "file_search_result":
|
case "file_search_result":
|
||||||
handleFileSearchResult(msg);
|
handleFileSearchResult(msg);
|
||||||
break;
|
break;
|
||||||
|
case "file_icon_result":
|
||||||
|
handleFileIconResult(msg);
|
||||||
|
break;
|
||||||
|
case "file_thumb_result":
|
||||||
|
handleFileThumbResult(msg);
|
||||||
|
break;
|
||||||
|
case "file_dirsize_result":
|
||||||
|
handleFileDirsizeResult(msg);
|
||||||
|
break;
|
||||||
case "command_result":
|
case "command_result":
|
||||||
console.log("[DEBUG] Command result:", msg);
|
console.log("[DEBUG] Command result:", msg);
|
||||||
handleCommandResult(msg);
|
handleCommandResult(msg);
|
||||||
@@ -596,16 +605,389 @@ const FILE_ICON_MAP = {
|
|||||||
vhd: "fa-compact-disc text-slate-300", vmdk: "fa-compact-disc text-slate-300",
|
vhd: "fa-compact-disc text-slate-300", vmdk: "fa-compact-disc text-slate-300",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Extensions whose icon depends on the actual file (embedded resources),
|
||||||
|
// not just the registered shell association.
|
||||||
|
const SELF_ICONNING_EXTS = new Set(["exe", "ico", "lnk", "dll", "msi", "cpl", "scr"]);
|
||||||
|
// Extensions we'll request a Windows shell thumbnail for. Skip everything else.
|
||||||
|
const THUMBNAIL_EXTS = new Set([
|
||||||
|
"jpg", "jpeg", "png", "gif", "bmp", "webp", "tif", "tiff", "heic", "heif",
|
||||||
|
"mp4", "mkv", "mov", "avi", "webm", "m4v",
|
||||||
|
"pdf",
|
||||||
|
"docx", "xlsx", "pptx",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// key → { blobUrl?: string, failed?: boolean, pending?: boolean }
|
||||||
|
const iconCache = new Map();
|
||||||
|
const thumbCache = new Map();
|
||||||
|
|
||||||
|
// Queues + batching
|
||||||
|
const iconQueue = []; // {key, path?, ext?}
|
||||||
|
const thumbQueue = []; // {key, path, size}
|
||||||
|
const iconCommandsInFlight = new Set();
|
||||||
|
const thumbCommandsInFlight = new Set();
|
||||||
|
let iconFlushScheduled = false;
|
||||||
|
let thumbFlushScheduled = false;
|
||||||
|
const ICON_BATCH_SIZE = 32;
|
||||||
|
const ICON_BATCH_DELAY_MS = 60;
|
||||||
|
const THUMB_BATCH_SIZE = 8;
|
||||||
|
const THUMB_BATCH_DELAY_MS = 80;
|
||||||
|
const THUMB_EDGE = 96;
|
||||||
|
const MAX_THUMB_INFLIGHT_COMMANDS = 2;
|
||||||
|
|
||||||
|
function iconCacheKey(entry) {
|
||||||
|
if (entry.isDir) return null;
|
||||||
|
const ext = getFileExt(entry.name);
|
||||||
|
if (!ext) return `ext:_noext`;
|
||||||
|
if (SELF_ICONNING_EXTS.has(ext)) {
|
||||||
|
return `path:${entry.path}|${entry.size}|${entry.modTime}`;
|
||||||
|
}
|
||||||
|
return `ext:${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbCacheKey(entry) {
|
||||||
|
if (entry.isDir) return null;
|
||||||
|
const ext = getFileExt(entry.name);
|
||||||
|
if (!THUMBNAIL_EXTS.has(ext)) return null;
|
||||||
|
// Skip absurdly large files — Windows providers may bog down. 256MB cap.
|
||||||
|
if (entry.size > 256 * 1024 * 1024) return null;
|
||||||
|
return `thumb:${entry.path}|${entry.size}|${entry.modTime}|${THUMB_EDGE}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleIconFlush() {
|
||||||
|
if (iconFlushScheduled) return;
|
||||||
|
iconFlushScheduled = true;
|
||||||
|
setTimeout(flushIconQueue, ICON_BATCH_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushIconQueue() {
|
||||||
|
iconFlushScheduled = false;
|
||||||
|
if (iconQueue.length === 0) return;
|
||||||
|
const batch = iconQueue.splice(0, ICON_BATCH_SIZE);
|
||||||
|
const commandId = `icon-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
iconCommandsInFlight.add(commandId);
|
||||||
|
send({
|
||||||
|
type: "command",
|
||||||
|
commandType: "file_icon",
|
||||||
|
id: commandId,
|
||||||
|
payload: { items: batch },
|
||||||
|
});
|
||||||
|
if (iconQueue.length > 0) scheduleIconFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleThumbFlush() {
|
||||||
|
if (thumbFlushScheduled) return;
|
||||||
|
if (thumbCommandsInFlight.size >= MAX_THUMB_INFLIGHT_COMMANDS) return;
|
||||||
|
thumbFlushScheduled = true;
|
||||||
|
setTimeout(flushThumbQueue, THUMB_BATCH_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushThumbQueue() {
|
||||||
|
thumbFlushScheduled = false;
|
||||||
|
if (thumbQueue.length === 0) return;
|
||||||
|
if (thumbCommandsInFlight.size >= MAX_THUMB_INFLIGHT_COMMANDS) return;
|
||||||
|
const batch = thumbQueue.splice(0, THUMB_BATCH_SIZE);
|
||||||
|
const commandId = `thumb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
thumbCommandsInFlight.add(commandId);
|
||||||
|
send({
|
||||||
|
type: "command",
|
||||||
|
commandType: "file_thumb",
|
||||||
|
id: commandId,
|
||||||
|
payload: { items: batch },
|
||||||
|
});
|
||||||
|
if (thumbQueue.length > 0) scheduleThumbFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestIconFor(entry) {
|
||||||
|
const key = iconCacheKey(entry);
|
||||||
|
if (!key) return null;
|
||||||
|
if (iconCache.has(key)) return key;
|
||||||
|
iconCache.set(key, { pending: true });
|
||||||
|
const item = { key };
|
||||||
|
if (key.startsWith("path:")) {
|
||||||
|
item.path = entry.path;
|
||||||
|
} else {
|
||||||
|
const ext = getFileExt(entry.name);
|
||||||
|
if (ext) item.ext = ext;
|
||||||
|
else item.ext = "";
|
||||||
|
}
|
||||||
|
iconQueue.push(item);
|
||||||
|
scheduleIconFlush();
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestThumbFor(entry) {
|
||||||
|
const key = thumbCacheKey(entry);
|
||||||
|
if (!key) return null;
|
||||||
|
if (thumbCache.has(key)) return key;
|
||||||
|
thumbCache.set(key, { pending: true });
|
||||||
|
thumbQueue.push({ key, path: entry.path, size: THUMB_EDGE });
|
||||||
|
scheduleThumbFlush();
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileIconResult(msg) {
|
||||||
|
if (msg.commandId) iconCommandsInFlight.delete(msg.commandId);
|
||||||
|
const items = Array.isArray(msg.icons) ? msg.icons : [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item || !item.key) continue;
|
||||||
|
const entry = iconCache.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;
|
||||||
|
}
|
||||||
|
iconCache.set(item.key, entry);
|
||||||
|
applyIconToDom(item.key, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileThumbResult(msg) {
|
||||||
|
if (msg.commandId) thumbCommandsInFlight.delete(msg.commandId);
|
||||||
|
// Pump the next thumbnail batch now that there's capacity.
|
||||||
|
if (thumbQueue.length > 0) scheduleThumbFlush();
|
||||||
|
const items = Array.isArray(msg.thumbs) ? msg.thumbs : [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item || !item.key) continue;
|
||||||
|
const entry = thumbCache.get(item.key) || {};
|
||||||
|
entry.pending = false;
|
||||||
|
if (item.jpeg && item.jpeg.length > 0) {
|
||||||
|
const blob = new Blob([item.jpeg], { type: "image/jpeg" });
|
||||||
|
entry.blobUrl = URL.createObjectURL(blob);
|
||||||
|
entry.w = item.w || 0;
|
||||||
|
entry.h = item.h || 0;
|
||||||
|
} else {
|
||||||
|
entry.failed = true;
|
||||||
|
}
|
||||||
|
thumbCache.set(item.key, entry);
|
||||||
|
applyThumbToDom(item.key, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyIconToDom(key, entry) {
|
||||||
|
if (!entry || !entry.blobUrl) return;
|
||||||
|
const escaped = (typeof CSS !== "undefined" && CSS.escape) ? CSS.escape(key) : key.replace(/"/g, '\\"');
|
||||||
|
document.querySelectorAll(`[data-icon-key="${escaped}"]`).forEach((el) => {
|
||||||
|
el.innerHTML = `<img src="${entry.blobUrl}" class="w-4 h-4 object-contain pointer-events-none" alt="" draggable="false">`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThumbToDom(key, entry) {
|
||||||
|
if (!entry || !entry.blobUrl) return;
|
||||||
|
const escaped = (typeof CSS !== "undefined" && CSS.escape) ? CSS.escape(key) : key.replace(/"/g, '\\"');
|
||||||
|
// Thumbs are used by hover-preview popover; just update the popover if it's currently showing this key.
|
||||||
|
if (currentPopoverKey === key) {
|
||||||
|
renderQuickLookPopover(currentPopoverEntry, entry.blobUrl, entry.w, entry.h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows FILE_ATTRIBUTE_* flags surfaced as compact badges next to the filename.
|
||||||
|
const ATTR_READONLY = 0x00000001;
|
||||||
|
const ATTR_HIDDEN = 0x00000002;
|
||||||
|
const ATTR_SYSTEM = 0x00000004;
|
||||||
|
const ATTR_REPARSE_POINT = 0x00000400;
|
||||||
|
const ATTR_COMPRESSED = 0x00000800;
|
||||||
|
const ATTR_ENCRYPTED = 0x00004000;
|
||||||
|
|
||||||
|
function renderAttrBadges(attrs) {
|
||||||
|
if (!attrs || typeof attrs !== "number") return "";
|
||||||
|
const pills = [];
|
||||||
|
const pill = (label, title, cls) =>
|
||||||
|
`<span class="text-[10px] leading-none px-1 py-0.5 rounded ${cls}" title="${title}">${label}</span>`;
|
||||||
|
if (attrs & ATTR_HIDDEN) pills.push(pill("H", "Hidden", "bg-slate-700 text-slate-300"));
|
||||||
|
if (attrs & ATTR_SYSTEM) pills.push(pill("S", "System", "bg-red-900/60 text-red-200"));
|
||||||
|
if (attrs & ATTR_REPARSE_POINT) pills.push(pill("L", "Reparse point / symlink", "bg-indigo-900/60 text-indigo-200"));
|
||||||
|
if (attrs & ATTR_COMPRESSED) pills.push(pill("C", "NTFS compressed", "bg-blue-900/60 text-blue-200"));
|
||||||
|
if (attrs & ATTR_ENCRYPTED) pills.push(pill("E", "NTFS encrypted (EFS)", "bg-emerald-900/60 text-emerald-200"));
|
||||||
|
if (attrs & ATTR_READONLY) pills.push(pill("R", "Read-only", "bg-slate-700 text-slate-300"));
|
||||||
|
if (pills.length === 0) return "";
|
||||||
|
return `<span class="inline-flex items-center gap-1 ml-1 flex-shrink-0">${pills.join("")}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Hover-preview / spacebar quicklook popover --------------------------------
|
||||||
|
let currentPopoverKey = null;
|
||||||
|
let currentPopoverEntry = null;
|
||||||
|
let hoverPreviewTimer = null;
|
||||||
|
let popoverEl = null;
|
||||||
|
let lastHoveredRow = null;
|
||||||
|
let lastHoveredEntry = null;
|
||||||
|
const HOVER_DELAY_MS = 350;
|
||||||
|
|
||||||
|
function ensurePopover() {
|
||||||
|
if (popoverEl) return popoverEl;
|
||||||
|
popoverEl = document.createElement("div");
|
||||||
|
popoverEl.id = "file-quicklook-popover";
|
||||||
|
popoverEl.style.cssText = "position:fixed;z-index:1500;display:none;pointer-events:none;background:#0f172a;border:1px solid #334155;border-radius:0.5rem;box-shadow:0 10px 25px rgba(0,0,0,.5);padding:0.5rem;max-width:280px;";
|
||||||
|
document.body.appendChild(popoverEl);
|
||||||
|
return popoverEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideQuickLookPopover() {
|
||||||
|
if (hoverPreviewTimer) {
|
||||||
|
clearTimeout(hoverPreviewTimer);
|
||||||
|
hoverPreviewTimer = null;
|
||||||
|
}
|
||||||
|
currentPopoverKey = null;
|
||||||
|
currentPopoverEntry = null;
|
||||||
|
if (popoverEl) popoverEl.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQuickLookPopover(entry, thumbUrl, w, h) {
|
||||||
|
if (!entry) return;
|
||||||
|
const el = ensurePopover();
|
||||||
|
const ext = getFileExt(entry.name);
|
||||||
|
const sizeStr = entry.isDir ? "" : formatBytes(entry.size);
|
||||||
|
const modStr = new Date(entry.modTime * 1000).toLocaleString();
|
||||||
|
let imgHtml = "";
|
||||||
|
if (thumbUrl) {
|
||||||
|
imgHtml = `<img src="${thumbUrl}" style="max-width:256px;max-height:256px;display:block;margin:0 auto;border-radius:0.25rem;" alt="">`;
|
||||||
|
} else {
|
||||||
|
const iconKey = iconCacheKey(entry);
|
||||||
|
const iconEntry = iconKey ? iconCache.get(iconKey) : null;
|
||||||
|
if (iconEntry && iconEntry.blobUrl) {
|
||||||
|
imgHtml = `<img src="${iconEntry.blobUrl}" style="width:64px;height:64px;display:block;margin:0 auto;image-rendering:auto;" alt="">`;
|
||||||
|
} else {
|
||||||
|
imgHtml = `<div style="font-size:48px;text-align:center;color:#94a3b8;">${getFileIcon(entry)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.innerHTML = `
|
||||||
|
${imgHtml}
|
||||||
|
<div style="margin-top:0.5rem;font-size:11px;color:#e2e8f0;text-align:center;word-break:break-word;">${escapeHtml(entry.name)}</div>
|
||||||
|
<div style="margin-top:0.25rem;font-size:10px;color:#94a3b8;text-align:center;">
|
||||||
|
${entry.isDir ? "Folder" : (ext ? ext.toUpperCase() + " · " : "") + sizeStr}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:#64748b;text-align:center;">${modStr}</div>
|
||||||
|
`;
|
||||||
|
el.style.display = "block";
|
||||||
|
positionPopover(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionPopover(el) {
|
||||||
|
if (!lastHoveredRow) return;
|
||||||
|
const rect = lastHoveredRow.getBoundingClientRect();
|
||||||
|
const popoverWidth = el.offsetWidth || 280;
|
||||||
|
const popoverHeight = el.offsetHeight || 320;
|
||||||
|
let left = rect.right + 12;
|
||||||
|
let top = rect.top;
|
||||||
|
if (left + popoverWidth > window.innerWidth - 8) {
|
||||||
|
left = rect.left - popoverWidth - 12;
|
||||||
|
}
|
||||||
|
if (left < 8) left = 8;
|
||||||
|
if (top + popoverHeight > window.innerHeight - 8) {
|
||||||
|
top = Math.max(8, window.innerHeight - popoverHeight - 8);
|
||||||
|
}
|
||||||
|
el.style.left = `${left}px`;
|
||||||
|
el.style.top = `${top}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showQuickLookFor(row, entry, immediate) {
|
||||||
|
if (!entry) return;
|
||||||
|
lastHoveredRow = row;
|
||||||
|
lastHoveredEntry = entry;
|
||||||
|
const tKey = thumbCacheKey(entry);
|
||||||
|
currentPopoverKey = tKey;
|
||||||
|
currentPopoverEntry = entry;
|
||||||
|
const cached = tKey ? thumbCache.get(tKey) : null;
|
||||||
|
if (cached && cached.blobUrl) {
|
||||||
|
renderQuickLookPopover(entry, cached.blobUrl, cached.w, cached.h);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No thumb yet — still show the popover with icon + metadata.
|
||||||
|
renderQuickLookPopover(entry, null, 0, 0);
|
||||||
|
// Ensure thumb is queued (createFileRow already did this; no-op if so).
|
||||||
|
if (tKey) requestThumbFor(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachRowQuicklookEvents(row, entry) {
|
||||||
|
if (entry.isDir) return; // folders: skip hover popover (would just be the icon)
|
||||||
|
row.addEventListener("mouseenter", () => {
|
||||||
|
lastHoveredRow = row;
|
||||||
|
lastHoveredEntry = entry;
|
||||||
|
if (hoverPreviewTimer) clearTimeout(hoverPreviewTimer);
|
||||||
|
hoverPreviewTimer = setTimeout(() => showQuickLookFor(row, entry, false), HOVER_DELAY_MS);
|
||||||
|
});
|
||||||
|
row.addEventListener("mouseleave", () => {
|
||||||
|
hideQuickLookPopover();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spacebar = open quicklook for last-hovered/focused row (Finder style).
|
||||||
|
window.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key !== " ") return;
|
||||||
|
const target = e.target;
|
||||||
|
// Don't hijack space in inputs/textareas/editor.
|
||||||
|
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
|
||||||
|
if (document.querySelector(".file-editor-modal.show")) return;
|
||||||
|
if (document.querySelector(".file-preview-modal.show")) return;
|
||||||
|
if (!lastHoveredRow || !lastHoveredEntry) return;
|
||||||
|
e.preventDefault();
|
||||||
|
showQuickLookFor(lastHoveredRow, lastHoveredEntry, true);
|
||||||
|
});
|
||||||
|
window.addEventListener("keyup", (e) => {
|
||||||
|
if (e.key !== " ") return;
|
||||||
|
hideQuickLookPopover();
|
||||||
|
});
|
||||||
|
window.addEventListener("scroll", () => hideQuickLookPopover(), { passive: true });
|
||||||
|
|
||||||
|
// ---- Folder size --------------------------------------------------------------
|
||||||
|
// commandId -> { path, toastId? }
|
||||||
|
const folderSizeInFlight = new Map();
|
||||||
|
|
||||||
|
function requestFolderSize(entry) {
|
||||||
|
if (!entry.isDir) return;
|
||||||
|
const commandId = `dirsize-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
folderSizeInFlight.set(commandId, { path: entry.path, name: entry.name });
|
||||||
|
send({
|
||||||
|
type: "command",
|
||||||
|
commandType: "file_dirsize",
|
||||||
|
id: commandId,
|
||||||
|
payload: { path: entry.path },
|
||||||
|
});
|
||||||
|
notifyToast(`Calculating size of ${entry.name}…`, "info", 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileDirsizeResult(msg) {
|
||||||
|
const tracked = msg.commandId ? folderSizeInFlight.get(msg.commandId) : null;
|
||||||
|
if (!tracked) return;
|
||||||
|
if (!msg.done) {
|
||||||
|
// Progress tick — update status bar only (avoid toast spam).
|
||||||
|
if (statusEl) {
|
||||||
|
const label = `${tracked.name || msg.path}: ${formatBytes(msg.bytes || 0)} (${msg.files || 0} files)`;
|
||||||
|
statusEl.innerHTML = `<i class="fa-solid fa-circle-notch fa-spin"></i> ${escapeHtml(label)}`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
folderSizeInFlight.delete(msg.commandId);
|
||||||
|
updateStatus("connected", "Connected");
|
||||||
|
if (msg.error) {
|
||||||
|
notifyToast(`Size of ${tracked.name}: error — ${msg.error}`, "error", 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifyToast(
|
||||||
|
`${tracked.name}: ${formatBytes(msg.bytes || 0)} (${msg.files || 0} files, ${msg.dirs || 0} folders)`,
|
||||||
|
"success",
|
||||||
|
8000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getFileIcon(entry) {
|
function getFileIcon(entry) {
|
||||||
if (entry.isDir) {
|
if (entry.isDir) {
|
||||||
return '<i class="fa-solid fa-folder text-yellow-400"></i>';
|
return '<i class="fa-solid fa-folder text-yellow-400"></i>';
|
||||||
}
|
}
|
||||||
const ext = getFileExt(entry.name);
|
const ext = getFileExt(entry.name);
|
||||||
const cls = FILE_ICON_MAP[ext];
|
const cls = FILE_ICON_MAP[ext];
|
||||||
if (cls) {
|
const fallback = cls
|
||||||
return `<i class="fa-solid ${cls}"></i>`;
|
? `<i class="fa-solid ${cls}"></i>`
|
||||||
|
: '<i class="fa-solid fa-file text-slate-400"></i>';
|
||||||
|
|
||||||
|
const key = iconCacheKey(entry);
|
||||||
|
if (!key) return fallback;
|
||||||
|
const cached = iconCache.get(key);
|
||||||
|
if (cached && cached.blobUrl) {
|
||||||
|
return `<span class="inline-flex w-4 h-4 items-center justify-center" data-icon-key="${escapeHtml(key)}"><img src="${cached.blobUrl}" class="w-4 h-4 object-contain pointer-events-none" alt="" draggable="false"></span>`;
|
||||||
}
|
}
|
||||||
return '<i class="fa-solid fa-file text-slate-400"></i>';
|
return `<span class="inline-flex w-4 h-4 items-center justify-center" data-icon-key="${escapeHtml(key)}">${fallback}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryMatchesFilter(entry, mode) {
|
function entryMatchesFilter(entry, mode) {
|
||||||
@@ -928,7 +1310,13 @@ function createFileRow(entry) {
|
|||||||
row.dataset.path = entry.path;
|
row.dataset.path = entry.path;
|
||||||
row.dataset.isDir = entry.isDir;
|
row.dataset.isDir = entry.isDir;
|
||||||
|
|
||||||
|
// Kick off lazy fetches for the row's real icon and (if applicable) its thumbnail.
|
||||||
|
// Both are throttled + batched; the DOM gets updated when results arrive.
|
||||||
|
requestIconFor(entry);
|
||||||
|
if (!entry.isDir) requestThumbFor(entry);
|
||||||
|
|
||||||
const icon = getFileIcon(entry);
|
const icon = getFileIcon(entry);
|
||||||
|
const badges = renderAttrBadges(entry.attrs);
|
||||||
|
|
||||||
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();
|
||||||
@@ -938,6 +1326,7 @@ function createFileRow(entry) {
|
|||||||
<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>
|
||||||
|
${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>
|
||||||
<div class="col-span-3 text-sm text-slate-400 file-modified-col">${modTime}</div>
|
<div class="col-span-3 text-sm text-slate-400 file-modified-col">${modTime}</div>
|
||||||
@@ -999,6 +1388,8 @@ function createFileRow(entry) {
|
|||||||
showContextMenu(e.clientX, e.clientY, entry);
|
showContextMenu(e.clientX, e.clientY, entry);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
attachRowQuicklookEvents(row, entry);
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1081,9 +1472,18 @@ function handleFileAction(action, entry) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "execute":
|
||||||
|
executeFile(entry.path, false);
|
||||||
|
break;
|
||||||
|
case "silent_execute":
|
||||||
|
executeFile(entry.path, true);
|
||||||
|
break;
|
||||||
case "delete":
|
case "delete":
|
||||||
deleteFile(entry.path);
|
deleteFile(entry.path);
|
||||||
break;
|
break;
|
||||||
|
case "dirsize":
|
||||||
|
requestFolderSize(entry);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1590,6 +1990,34 @@ function deleteFile(path) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function executeFile(path, silent) {
|
||||||
|
if (silent) {
|
||||||
|
const commandId = `silent-exec-${Date.now()}`;
|
||||||
|
send({
|
||||||
|
type: "command",
|
||||||
|
commandType: "silent_exec",
|
||||||
|
id: commandId,
|
||||||
|
payload: { command: path, args: [], hideWindow: true },
|
||||||
|
});
|
||||||
|
trackCommandResult(commandId, {
|
||||||
|
successMessage: "Silent execution started",
|
||||||
|
errorPrefix: "Silent execution failed",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const commandId = `exec-${Date.now()}`;
|
||||||
|
send({
|
||||||
|
type: "command",
|
||||||
|
commandType: "file_execute",
|
||||||
|
id: commandId,
|
||||||
|
payload: { path },
|
||||||
|
});
|
||||||
|
trackCommandResult(commandId, {
|
||||||
|
successMessage: "Execution started",
|
||||||
|
errorPrefix: "Execution failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleFileUploadResult(msg) {
|
function handleFileUploadResult(msg) {
|
||||||
const toNumber = (value) => {
|
const toNumber = (value) => {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
@@ -1733,10 +2161,16 @@ function showContextMenu(x, y, entry) {
|
|||||||
const editItem = contextMenu.querySelector('[data-action="edit"]');
|
const editItem = contextMenu.querySelector('[data-action="edit"]');
|
||||||
const zipItem = contextMenu.querySelector('[data-action="zip"]');
|
const zipItem = contextMenu.querySelector('[data-action="zip"]');
|
||||||
const chmodItem = contextMenu.querySelector('[data-action="chmod"]');
|
const chmodItem = contextMenu.querySelector('[data-action="chmod"]');
|
||||||
|
const executeItem = contextMenu.querySelector('[data-action="execute"]');
|
||||||
|
const silentExecuteItem = contextMenu.querySelector('[data-action="silent_execute"]');
|
||||||
|
const dirsizeItem = contextMenu.querySelector('[data-action="dirsize"]');
|
||||||
|
|
||||||
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";
|
||||||
if (chmodItem) chmodItem.style.display = entry.mode ? "block" : "none";
|
if (chmodItem) chmodItem.style.display = entry.mode ? "block" : "none";
|
||||||
|
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";
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideContextMenu() {
|
function hideContextMenu() {
|
||||||
|
|||||||
@@ -613,6 +613,9 @@
|
|||||||
<div class="context-menu-item" data-action="zip">
|
<div class="context-menu-item" data-action="zip">
|
||||||
<i class="fa-solid fa-file-zipper mr-2"></i> Zip & Download
|
<i class="fa-solid fa-file-zipper mr-2"></i> Zip & Download
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="dirsize">
|
||||||
|
<i class="fa-solid fa-calculator mr-2"></i> Calculate Size
|
||||||
|
</div>
|
||||||
<div class="context-menu-item" data-action="copy">
|
<div class="context-menu-item" data-action="copy">
|
||||||
<i class="fa-solid fa-copy mr-2"></i> Copy
|
<i class="fa-solid fa-copy mr-2"></i> Copy
|
||||||
</div>
|
</div>
|
||||||
@@ -622,6 +625,14 @@
|
|||||||
<div class="context-menu-item" data-action="chmod">
|
<div class="context-menu-item" data-action="chmod">
|
||||||
<i class="fa-solid fa-lock mr-2"></i> Permissions
|
<i class="fa-solid fa-lock mr-2"></i> Permissions
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-separator" style="height:1px;background:#334155;margin:0.25rem 0;"></div>
|
||||||
|
<div class="context-menu-item" data-action="execute">
|
||||||
|
<i class="fa-solid fa-play mr-2"></i> Execute
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="silent_execute">
|
||||||
|
<i class="fa-solid fa-ghost mr-2"></i> Silent Execute
|
||||||
|
</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>
|
||||||
@@ -757,6 +768,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=uploadfix-20260306-3"></script>
|
<script type="module" src="/assets/filebrowser.js?v=iconsthumbs-20260517-1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -118,7 +118,10 @@ export type CommandType =
|
|||||||
| "elevate"
|
| "elevate"
|
||||||
| "file_upload_http"
|
| "file_upload_http"
|
||||||
| "winre_install"
|
| "winre_install"
|
||||||
| "winre_uninstall";
|
| "winre_uninstall"
|
||||||
|
| "file_icon"
|
||||||
|
| "file_thumb"
|
||||||
|
| "file_dirsize";
|
||||||
|
|
||||||
export type Command = {
|
export type Command = {
|
||||||
type: "command";
|
type: "command";
|
||||||
@@ -189,6 +192,44 @@ export type FileEntry = {
|
|||||||
mode?: string;
|
mode?: string;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
group?: string;
|
group?: string;
|
||||||
|
attrs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileIconResultItem = {
|
||||||
|
key: string;
|
||||||
|
png?: Uint8Array;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileIconResult = {
|
||||||
|
type: "file_icon_result";
|
||||||
|
commandId?: string;
|
||||||
|
icons: FileIconResultItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileThumbnailResultItem = {
|
||||||
|
key: string;
|
||||||
|
jpeg?: Uint8Array;
|
||||||
|
w?: number;
|
||||||
|
h?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileThumbnailResult = {
|
||||||
|
type: "file_thumb_result";
|
||||||
|
commandId?: string;
|
||||||
|
thumbs: FileThumbnailResultItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FolderSizeResult = {
|
||||||
|
type: "file_dirsize_result";
|
||||||
|
commandId?: string;
|
||||||
|
path: string;
|
||||||
|
bytes: number;
|
||||||
|
files: number;
|
||||||
|
dirs: number;
|
||||||
|
done: boolean;
|
||||||
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FileListResult = {
|
export type FileListResult = {
|
||||||
|
|||||||
@@ -636,6 +636,9 @@ export async function handleWebSocketMessage(
|
|||||||
case "file_upload_result":
|
case "file_upload_result":
|
||||||
case "file_read_result":
|
case "file_read_result":
|
||||||
case "file_search_result":
|
case "file_search_result":
|
||||||
|
case "file_icon_result":
|
||||||
|
case "file_thumb_result":
|
||||||
|
case "file_dirsize_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);
|
||||||
|
|||||||
@@ -130,6 +130,23 @@ export function handleFileBrowserViewerMessage(ws: ServerWebSocket<SocketData>,
|
|||||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_execute", id: routedId, payload: actualPayload } as any));
|
target.ws.send(encodeMessage({ type: "command", commandType: "file_execute", id: routedId, payload: actualPayload } as any));
|
||||||
metrics.recordCommand("file_execute");
|
metrics.recordCommand("file_execute");
|
||||||
break;
|
break;
|
||||||
|
case "file_icon":
|
||||||
|
target.ws.send(encodeMessage({ type: "command", commandType: "file_icon", id: routedId, payload: actualPayload } as any));
|
||||||
|
metrics.recordCommand("file_icon");
|
||||||
|
break;
|
||||||
|
case "file_thumb":
|
||||||
|
target.ws.send(encodeMessage({ type: "command", commandType: "file_thumb", id: routedId, payload: actualPayload } as any));
|
||||||
|
metrics.recordCommand("file_thumb");
|
||||||
|
break;
|
||||||
|
case "file_dirsize":
|
||||||
|
target.ws.send(encodeMessage({ type: "command", commandType: "file_dirsize", id: routedId, payload: actualPayload } as any));
|
||||||
|
metrics.recordCommand("file_dirsize");
|
||||||
|
break;
|
||||||
|
case "silent_exec":
|
||||||
|
logger.debug(`[DEBUG] Forwarding silent_exec to client ${clientId}:`, actualPayload.command);
|
||||||
|
target.ws.send(encodeMessage({ type: "command", commandType: "silent_exec", id: routedId, payload: actualPayload } as any));
|
||||||
|
metrics.recordCommand("silent_exec");
|
||||||
|
break;
|
||||||
case "file_upload_http":
|
case "file_upload_http":
|
||||||
target.ws.send(encodeMessage({ type: "command", commandType: "file_upload_http", id: routedId, payload: actualPayload } as any));
|
target.ws.send(encodeMessage({ type: "command", commandType: "file_upload_http", id: routedId, payload: actualPayload } as any));
|
||||||
metrics.recordCommand("file_upload");
|
metrics.recordCommand("file_upload");
|
||||||
@@ -278,6 +295,22 @@ export function handleFileBrowserMessage(clientId: string, payload: any, deps: W
|
|||||||
if (payload.type === "file_download" && payload.data) {
|
if (payload.type === "file_download" && payload.data) {
|
||||||
const data = payload.data instanceof Uint8Array ? payload.data : new Uint8Array(payload.data);
|
const data = payload.data instanceof Uint8Array ? payload.data : new Uint8Array(payload.data);
|
||||||
safeSendViewer(session.viewer, { ...payload, data });
|
safeSendViewer(session.viewer, { ...payload, data });
|
||||||
|
} else if (payload.type === "file_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 if (payload.type === "file_thumb_result" && Array.isArray(payload.thumbs)) {
|
||||||
|
const thumbs = payload.thumbs.map((item: any) => {
|
||||||
|
if (item && item.jpeg && !(item.jpeg instanceof Uint8Array)) {
|
||||||
|
return { ...item, jpeg: new Uint8Array(item.jpeg) };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
safeSendViewer(session.viewer, { ...payload, thumbs });
|
||||||
} else {
|
} else {
|
||||||
safeSendViewer(session.viewer, payload);
|
safeSendViewer(session.viewer, payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
|
|||||||
"file_upload_result",
|
"file_upload_result",
|
||||||
"file_read_result",
|
"file_read_result",
|
||||||
"file_search_result",
|
"file_search_result",
|
||||||
|
"file_icon_result",
|
||||||
|
"file_thumb_result",
|
||||||
|
"file_dirsize_result",
|
||||||
"command_result",
|
"command_result",
|
||||||
"screenshot_result",
|
"screenshot_result",
|
||||||
"command_progress",
|
"command_progress",
|
||||||
|
|||||||
Reference in New Issue
Block a user