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:
jim800121chen 2026-03-09 21:01:59 +08:00
parent 4e44406e71
commit c8e628c6ca
5 changed files with 114 additions and 48 deletions

View File

@ -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

View File

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -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 {