fix: tray icon, console window, and device detection on Windows
Tray icon: - Embed proper ICO files (green=running, red=stopped) instead of generic IDI_APPLICATION icon - Switch icon dynamically based on server state Console window: - FreeConsole() to fully detach from parent console - CREATE_NO_WINDOW flag on child server process to prevent black console window from appearing Device detection: - ResolvePython: add Windows venv paths (Scripts/python.exe) and %LOCALAPPDATA%\EdgeAIPlatform\venv, fallback to exec.LookPath - DetectDevices + startPython: prepend install dir to PATH so libusb-1.0.dll is found by pyusb/kp SDK - Also hide "cmd /c start" console when opening browser Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4e44406e71
commit
c8e628c6ca
@ -13,17 +13,32 @@ import (
|
||||
)
|
||||
|
||||
// ResolvePython finds the best Python interpreter for the given script path.
|
||||
// Search order: script-local venv → parent venv → ~/.edge-ai-platform/venv → system python3.
|
||||
// Search order: script-local venv → parent venv → platform config dir venv → system python3/python.
|
||||
func ResolvePython(scriptPath string) string {
|
||||
scriptDir := filepath.Dir(scriptPath)
|
||||
parentDir := filepath.Dir(scriptDir)
|
||||
|
||||
candidates := []string{
|
||||
filepath.Join(scriptDir, "venv", "bin", "python3"),
|
||||
filepath.Join(filepath.Dir(scriptDir), "venv", "bin", "python3"),
|
||||
// Build candidate list with both Unix and Windows venv layouts
|
||||
var candidates []string
|
||||
for _, base := range []string{scriptDir, parentDir} {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(base, "venv", "bin", "python3"), // Unix
|
||||
filepath.Join(base, "venv", "Scripts", "python.exe"), // Windows
|
||||
)
|
||||
}
|
||||
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(home, ".edge-ai-platform", "venv", "bin", "python3"))
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".edge-ai-platform", "venv", "bin", "python3"),
|
||||
filepath.Join(home, ".edge-ai-platform", "venv", "Scripts", "python.exe"),
|
||||
)
|
||||
}
|
||||
|
||||
// On Windows, also check %LOCALAPPDATA%\EdgeAIPlatform\venv
|
||||
if appData := os.Getenv("LOCALAPPDATA"); appData != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(appData, "EdgeAIPlatform", "venv", "Scripts", "python.exe"),
|
||||
)
|
||||
}
|
||||
|
||||
for _, p := range candidates {
|
||||
@ -32,6 +47,13 @@ func ResolvePython(scriptPath string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to system python
|
||||
for _, name := range []string{"python3", "python"} {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
return "python3"
|
||||
}
|
||||
|
||||
@ -70,6 +92,14 @@ func DetectDevices(scriptPath string) []driver.DeviceInfo {
|
||||
cmd := exec.Command(pythonBin, scriptPath)
|
||||
cmd.Stdin = nil
|
||||
|
||||
// Ensure libusb-1.0.dll can be found on Windows by adding the binary's
|
||||
// directory to PATH (the installer places the DLL there).
|
||||
scriptDir := filepath.Dir(scriptPath)
|
||||
installDir := filepath.Dir(scriptDir)
|
||||
cmd.Env = append(os.Environ(),
|
||||
fmt.Sprintf("PATH=%s;%s;%s", installDir, scriptDir, os.Getenv("PATH")),
|
||||
)
|
||||
|
||||
stdinPipe, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil
|
||||
|
||||
@ -113,6 +113,14 @@ func (d *KneronDriver) startPython() error {
|
||||
}
|
||||
}
|
||||
|
||||
// On Windows, ensure libusb-1.0.dll can be found by adding the install
|
||||
// directory to PATH (installer places the DLL there).
|
||||
if runtime.GOOS == "windows" {
|
||||
installDir := filepath.Dir(scriptDir)
|
||||
cmd.Env = append(cmd.Env,
|
||||
fmt.Sprintf("PATH=%s;%s;%s", installDir, scriptDir, os.Getenv("PATH")))
|
||||
}
|
||||
|
||||
stdinPipe, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stdin pipe: %w", err)
|
||||
|
||||
BIN
edge-ai-platform/server/tray/assets/icon_running.ico
Normal file
BIN
edge-ai-platform/server/tray/assets/icon_running.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
edge-ai-platform/server/tray/assets/icon_stopped.ico
Normal file
BIN
edge-ai-platform/server/tray/assets/icon_stopped.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@ -3,6 +3,7 @@
|
||||
package tray
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@ -13,6 +14,12 @@ import (
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:embed assets/icon_running.ico
|
||||
var icoRunning []byte
|
||||
|
||||
//go:embed assets/icon_stopped.ico
|
||||
var icoStopped []byte
|
||||
|
||||
// Windows API constants
|
||||
const (
|
||||
wmApp = 0x8000
|
||||
@ -21,7 +28,6 @@ const (
|
||||
wmDestroy = 0x0002
|
||||
wmLButtonUp = 0x0202
|
||||
wmRButtonUp = 0x0205
|
||||
wmContextMenu = 0x007B
|
||||
wmClose = 0x0010
|
||||
wmQueryEndSess = 0x0011
|
||||
|
||||
@ -42,14 +48,9 @@ const (
|
||||
mfEnabled = 0x00000000
|
||||
mfGrayed = 0x00000001
|
||||
|
||||
tpmRightAlign = 0x0008
|
||||
tpmBottomAlign = 0x0020
|
||||
|
||||
imageIcon = 1
|
||||
lrShared = 0x00008000
|
||||
idiApp = 32512 // default application icon
|
||||
|
||||
swHide = 0
|
||||
|
||||
createNoWindow = 0x08000000
|
||||
)
|
||||
|
||||
// NOTIFYICONDATAW for Shell_NotifyIconW
|
||||
@ -83,9 +84,12 @@ var (
|
||||
procPostMessage = user32.NewProc("PostMessageW")
|
||||
procGetCursorPos = user32.NewProc("GetCursorPos")
|
||||
procLoadIcon = user32.NewProc("LoadIconW")
|
||||
procCreateIconFromResource = user32.NewProc("CreateIconFromResource")
|
||||
procDestroyIcon = user32.NewProc("DestroyIcon")
|
||||
procGetModuleHandle = kernel32.NewProc("GetModuleHandleW")
|
||||
procGetConsoleWindow = kernel32.NewProc("GetConsoleWindow")
|
||||
procShowWindow = user32.NewProc("ShowWindow")
|
||||
procFreeConsole = kernel32.NewProc("FreeConsole")
|
||||
)
|
||||
|
||||
type point struct {
|
||||
@ -120,13 +124,33 @@ type msg struct {
|
||||
var globalApp *nativeTrayApp
|
||||
|
||||
type nativeTrayApp struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
running bool
|
||||
cfg *Config
|
||||
hwnd uintptr
|
||||
nid notifyIconData
|
||||
hIcon uintptr
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
running bool
|
||||
cfg *Config
|
||||
hwnd uintptr
|
||||
nid notifyIconData
|
||||
hIconRun uintptr
|
||||
hIconStop uintptr
|
||||
}
|
||||
|
||||
// loadIconFromICO creates an HICON from raw ICO data.
|
||||
// ICO format: 6-byte header + 16-byte directory entry + BMP/PNG data.
|
||||
// We skip the ICO header (6 + 16 = 22 bytes) and pass the BMP data
|
||||
// to CreateIconFromResource.
|
||||
func loadIconFromICO(data []byte) uintptr {
|
||||
if len(data) < 22 {
|
||||
return 0
|
||||
}
|
||||
// Skip ICO header (6 bytes) + directory entry (16 bytes) to get BMP data
|
||||
bmpData := data[22:]
|
||||
icon, _, _ := procCreateIconFromResource.Call(
|
||||
uintptr(unsafe.Pointer(&bmpData[0])),
|
||||
uintptr(len(bmpData)),
|
||||
1, // fIcon = TRUE
|
||||
0x30000, // version 0x00030000
|
||||
)
|
||||
return icon
|
||||
}
|
||||
|
||||
// RunWebGUI starts a native Windows system tray application.
|
||||
@ -134,16 +158,25 @@ func RunWebGUI(cfg *Config) {
|
||||
app := &nativeTrayApp{cfg: cfg}
|
||||
globalApp = app
|
||||
|
||||
// Hide console window
|
||||
// Hide and detach console window so closing it won't kill us
|
||||
if hwndConsole, _, _ := procGetConsoleWindow.Call(); hwndConsole != 0 {
|
||||
procShowWindow.Call(hwndConsole, swHide)
|
||||
}
|
||||
procFreeConsole.Call()
|
||||
|
||||
// Get module handle
|
||||
hInstance, _, _ := procGetModuleHandle.Call(0)
|
||||
|
||||
// Load default application icon
|
||||
app.hIcon, _, _ = procLoadIcon.Call(0, uintptr(idiApp))
|
||||
// Load icons from embedded ICO data
|
||||
app.hIconRun = loadIconFromICO(icoRunning)
|
||||
app.hIconStop = loadIconFromICO(icoStopped)
|
||||
// Fallback to system default if embedded icons fail
|
||||
if app.hIconStop == 0 {
|
||||
app.hIconStop, _, _ = procLoadIcon.Call(0, uintptr(32512)) // IDI_APPLICATION
|
||||
}
|
||||
if app.hIconRun == 0 {
|
||||
app.hIconRun = app.hIconStop
|
||||
}
|
||||
|
||||
// Register window class
|
||||
className := syscall.StringToUTF16Ptr("EdgeAITrayClass")
|
||||
@ -155,8 +188,8 @@ func RunWebGUI(cfg *Config) {
|
||||
}
|
||||
procRegisterClassEx.Call(uintptr(unsafe.Pointer(&wcx)))
|
||||
|
||||
// Create message-only window
|
||||
hwndMessageOnly := uintptr(0xFFFFFFFFFFFFFFFD) // HWND_MESSAGE = -3
|
||||
// Create message-only window (HWND_MESSAGE = -3)
|
||||
hwndMessageOnly := uintptr(0xFFFFFFFFFFFFFFFD)
|
||||
app.hwnd, _, _ = procCreateWindowEx.Call(
|
||||
0,
|
||||
uintptr(unsafe.Pointer(className)),
|
||||
@ -165,21 +198,21 @@ func RunWebGUI(cfg *Config) {
|
||||
hwndMessageOnly, 0, hInstance, 0,
|
||||
)
|
||||
|
||||
// Set up notification icon
|
||||
// Set up notification icon (start with stopped icon)
|
||||
app.nid = notifyIconData{
|
||||
CbSize: uint32(unsafe.Sizeof(notifyIconData{})),
|
||||
HWnd: app.hwnd,
|
||||
UID: 1,
|
||||
UFlags: nifMessage | nifIcon | nifTip,
|
||||
UCallbackMessage: wmTrayIcon,
|
||||
HIcon: app.hIcon,
|
||||
HIcon: app.hIconStop,
|
||||
}
|
||||
copy(app.nid.SzTip[:], syscall.StringToUTF16("Edge AI Platform - Starting..."))
|
||||
procShellNotifyIcon.Call(nimAdd, uintptr(unsafe.Pointer(&app.nid)))
|
||||
|
||||
// Auto-start the server
|
||||
app.startServer()
|
||||
app.updateTrayTip()
|
||||
app.updateTray()
|
||||
|
||||
// Handle OS signals
|
||||
quit := make(chan os.Signal, 1)
|
||||
@ -233,7 +266,7 @@ func wndProc(hwnd uintptr, umsg uint32, wParam, lParam uintptr) uintptr {
|
||||
} else {
|
||||
app.startServer()
|
||||
}
|
||||
app.updateTrayTip()
|
||||
app.updateTray()
|
||||
case idmDashboard:
|
||||
app.openDashboard()
|
||||
case idmQuit:
|
||||
@ -274,10 +307,8 @@ func (a *nativeTrayApp) showMenu() {
|
||||
procAppendMenu.Call(hMenu, uintptr(mfString|mfGrayed), 0,
|
||||
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(statusText))))
|
||||
|
||||
// Separator
|
||||
procAppendMenu.Call(hMenu, uintptr(mfSeparator), 0, 0)
|
||||
|
||||
// Toggle button
|
||||
var toggleText string
|
||||
if running {
|
||||
toggleText = "Stop Server"
|
||||
@ -287,7 +318,6 @@ func (a *nativeTrayApp) showMenu() {
|
||||
procAppendMenu.Call(hMenu, uintptr(mfString|mfEnabled), uintptr(idmToggle),
|
||||
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(toggleText))))
|
||||
|
||||
// Open Dashboard (grayed if not running)
|
||||
dashFlags := mfString | mfEnabled
|
||||
if !running {
|
||||
dashFlags = mfString | mfGrayed
|
||||
@ -295,34 +325,31 @@ func (a *nativeTrayApp) showMenu() {
|
||||
procAppendMenu.Call(hMenu, uintptr(dashFlags), uintptr(idmDashboard),
|
||||
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("Open Dashboard"))))
|
||||
|
||||
// Separator + Quit
|
||||
procAppendMenu.Call(hMenu, uintptr(mfSeparator), 0, 0)
|
||||
procAppendMenu.Call(hMenu, uintptr(mfString|mfEnabled), uintptr(idmQuit),
|
||||
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("Quit"))))
|
||||
|
||||
// Get cursor position and show menu
|
||||
var pt point
|
||||
procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
|
||||
procSetForegroundWindow.Call(a.hwnd)
|
||||
procTrackPopupMenu.Call(hMenu, 0, uintptr(pt.X), uintptr(pt.Y), 0, a.hwnd, 0)
|
||||
procDestroyMenu.Call(hMenu)
|
||||
|
||||
// Post empty message to dismiss menu properly
|
||||
procPostMessage.Call(a.hwnd, 0, 0, 0)
|
||||
}
|
||||
|
||||
func (a *nativeTrayApp) updateTrayTip() {
|
||||
func (a *nativeTrayApp) updateTray() {
|
||||
a.mu.Lock()
|
||||
running := a.running
|
||||
a.mu.Unlock()
|
||||
|
||||
var tip string
|
||||
if running {
|
||||
tip = fmt.Sprintf("Edge AI Platform - Running (:%d)", a.cfg.Server.Port)
|
||||
a.nid.HIcon = a.hIconRun
|
||||
tip := fmt.Sprintf("Edge AI Platform - Running (:%d)", a.cfg.Server.Port)
|
||||
copy(a.nid.SzTip[:], syscall.StringToUTF16(tip))
|
||||
} else {
|
||||
tip = "Edge AI Platform - Stopped"
|
||||
a.nid.HIcon = a.hIconStop
|
||||
copy(a.nid.SzTip[:], syscall.StringToUTF16("Edge AI Platform - Stopped"))
|
||||
}
|
||||
copy(a.nid.SzTip[:], syscall.StringToUTF16(tip))
|
||||
procShellNotifyIcon.Call(nimModify, uintptr(unsafe.Pointer(&a.nid)))
|
||||
}
|
||||
|
||||
@ -352,8 +379,10 @@ func (a *nativeTrayApp) startServer() {
|
||||
}
|
||||
|
||||
a.cmd = exec.Command(exe, args...)
|
||||
a.cmd.Stdout = os.Stdout
|
||||
a.cmd.Stderr = os.Stderr
|
||||
// Hide the child process console window
|
||||
a.cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
CreationFlags: createNoWindow,
|
||||
}
|
||||
|
||||
if err := a.cmd.Start(); err != nil {
|
||||
log.Printf("[gui] failed to start server: %v", err)
|
||||
@ -368,8 +397,7 @@ func (a *nativeTrayApp) startServer() {
|
||||
a.mu.Lock()
|
||||
a.running = false
|
||||
a.mu.Unlock()
|
||||
// Update tray tooltip from the message loop thread
|
||||
a.updateTrayTip()
|
||||
a.updateTray()
|
||||
if err != nil {
|
||||
log.Printf("[gui] server process exited: %v", err)
|
||||
} else {
|
||||
@ -408,7 +436,9 @@ func (a *nativeTrayApp) openDashboard() {
|
||||
url = fmt.Sprintf("http://%s:%d", host, a.cfg.Server.Port)
|
||||
}
|
||||
|
||||
exec.Command("cmd", "/c", "start", url).Start()
|
||||
cmd := exec.Command("cmd", "/c", "start", url)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow}
|
||||
cmd.Start()
|
||||
}
|
||||
|
||||
func (a *nativeTrayApp) cleanup() {
|
||||
@ -416,7 +446,6 @@ func (a *nativeTrayApp) cleanup() {
|
||||
procShellNotifyIcon.Call(nimDelete, uintptr(unsafe.Pointer(&a.nid)))
|
||||
}
|
||||
|
||||
// replacePrefix replaces old prefix with new if present.
|
||||
func replacePrefix(s, old, new string) string {
|
||||
if len(s) >= len(old) && s[:len(old)] == old {
|
||||
return new + s[len(old):]
|
||||
@ -424,7 +453,6 @@ func replacePrefix(s, old, new string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// indexOf returns the index of substr in s, or -1 if not found.
|
||||
func indexOf(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user