diff --git a/edge-ai-platform/installer/platform_windows.go b/edge-ai-platform/installer/platform_windows.go index 0add0b7..5112783 100644 --- a/edge-ai-platform/installer/platform_windows.go +++ b/edge-ai-platform/installer/platform_windows.go @@ -129,31 +129,40 @@ func removeQuarantine(installDir string) { } func installAutoRestart(installDir string) error { - taskName := "EdgeAIPlatformServer" binPath := filepath.Join(installDir, "edge-ai-server.exe") + regValue := fmt.Sprintf(`"%s" --gui`, binPath) - // Remove existing task if present - exec.Command("schtasks", "/Delete", "/TN", taskName, "/F").Run() - - // Use schtasks instead of PowerShell Register-ScheduledTask - // to avoid XML parsing issues on non-English Windows - // Note: do not use /RL HIGHEST — it requires admin privileges - cmd := exec.Command("schtasks", "/Create", - "/TN", taskName, - "/TR", fmt.Sprintf(`"%s" --gui`, binPath), - "/SC", "ONLOGON", - "/F", + // Use Registry Run key for auto-start (no admin required, works on all Windows locales) + cmd := exec.Command("reg", "add", + `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, + "/v", "EdgeAIPlatformServer", + "/t", "REG_SZ", + "/d", regValue, + "/f", ) if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("scheduled task creation failed: %s — %w", string(out), err) + return fmt.Errorf("registry auto-start setup failed: %s — %w", string(out), err) } - // Start the task immediately - exec.Command("schtasks", "/Run", "/TN", taskName).Run() + // Also clean up any old scheduled task + exec.Command("schtasks", "/Delete", "/TN", "EdgeAIPlatformServer", "/F").Run() + + // Start the server immediately + startCmd := exec.Command(binPath, "--gui") + startCmd.Dir = installDir + startCmd.Start() return nil } func removeAutoRestart() { + // Remove Registry Run key + exec.Command("reg", "delete", + `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, + "/v", "EdgeAIPlatformServer", + "/f", + ).Run() + + // Also clean up any old scheduled task exec.Command("schtasks", "/Delete", "/TN", "EdgeAIPlatformServer", "/F").Run() } diff --git a/edge-ai-platform/server/tray/webgui.go b/edge-ai-platform/server/tray/webgui.go index 2ed8d13..2545029 100644 --- a/edge-ai-platform/server/tray/webgui.go +++ b/edge-ai-platform/server/tray/webgui.go @@ -1,136 +1,332 @@ -//go:build notray +//go:build notray && windows package tray import ( - "encoding/json" "fmt" "log" - "net" - "net/http" "os" "os/exec" "os/signal" - "runtime" - "strings" "sync" "syscall" + "unsafe" ) -// webGUIApp manages the web-based GUI controller and server child process. -type webGUIApp struct { +// Windows API constants +const ( + wmApp = 0x8000 + wmTrayIcon = wmApp + 1 + wmCommand = 0x0111 + wmDestroy = 0x0002 + wmLButtonUp = 0x0202 + wmRButtonUp = 0x0205 + wmContextMenu = 0x007B + wmClose = 0x0010 + wmQueryEndSess = 0x0011 + + nimAdd = 0x00000000 + nimModify = 0x00000001 + nimDelete = 0x00000002 + + nifMessage = 0x00000001 + nifIcon = 0x00000002 + nifTip = 0x00000004 + + idmToggle = 1001 + idmDashboard = 1002 + idmQuit = 1003 + + mfString = 0x00000000 + mfSeparator = 0x00000800 + mfEnabled = 0x00000000 + mfGrayed = 0x00000001 + + tpmRightAlign = 0x0008 + tpmBottomAlign = 0x0020 + + imageIcon = 1 + lrShared = 0x00008000 + idiApp = 32512 // default application icon + + swHide = 0 +) + +// NOTIFYICONDATAW for Shell_NotifyIconW +type notifyIconData struct { + CbSize uint32 + HWnd uintptr + UID uint32 + UFlags uint32 + UCallbackMessage uint32 + HIcon uintptr + SzTip [128]uint16 +} + +var ( + shell32 = syscall.NewLazyDLL("shell32.dll") + user32 = syscall.NewLazyDLL("user32.dll") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procShellNotifyIcon = shell32.NewProc("Shell_NotifyIconW") + procRegisterClassEx = user32.NewProc("RegisterClassExW") + procCreateWindowEx = user32.NewProc("CreateWindowExW") + procDefWindowProc = user32.NewProc("DefWindowProcW") + procGetMessage = user32.NewProc("GetMessageW") + procTranslateMessage = user32.NewProc("TranslateMessage") + procDispatchMessage = user32.NewProc("DispatchMessageW") + procPostQuitMessage = user32.NewProc("PostQuitMessage") + procCreatePopupMenu = user32.NewProc("CreatePopupMenu") + procAppendMenu = user32.NewProc("AppendMenuW") + procTrackPopupMenu = user32.NewProc("TrackPopupMenu") + procDestroyMenu = user32.NewProc("DestroyMenu") + procSetForegroundWindow = user32.NewProc("SetForegroundWindow") + procPostMessage = user32.NewProc("PostMessageW") + procGetCursorPos = user32.NewProc("GetCursorPos") + procLoadIcon = user32.NewProc("LoadIconW") + procGetModuleHandle = kernel32.NewProc("GetModuleHandleW") + procGetConsoleWindow = kernel32.NewProc("GetConsoleWindow") + procShowWindow = user32.NewProc("ShowWindow") +) + +type point struct { + X, Y int32 +} + +type wndClassEx struct { + Size uint32 + Style uint32 + WndProc uintptr + ClsExtra int32 + WndExtra int32 + Instance uintptr + Icon uintptr + Cursor uintptr + Background uintptr + MenuName *uint16 + ClassName *uint16 + IconSm uintptr +} + +type msg struct { + HWnd uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt point +} + +// global state for the wndProc callback +var globalApp *nativeTrayApp + +type nativeTrayApp struct { mu sync.Mutex cmd *exec.Cmd running bool cfg *Config + hwnd uintptr + nid notifyIconData + hIcon uintptr } -// RunWebGUI starts a lightweight HTTP server that serves a browser-based -// control panel for managing the Edge AI server process. This is the -// CGO-free alternative to the systray-based Run() function. +// RunWebGUI starts a native Windows system tray application. func RunWebGUI(cfg *Config) { - app := &webGUIApp{cfg: cfg} + app := &nativeTrayApp{cfg: cfg} + globalApp = app - mux := http.NewServeMux() - mux.HandleFunc("/", app.handleIndex) - mux.HandleFunc("/api/status", app.handleStatus) - mux.HandleFunc("/api/start", app.handleStart) - mux.HandleFunc("/api/stop", app.handleStop) - mux.HandleFunc("/api/open-dashboard", app.handleOpenDashboard) - mux.HandleFunc("/api/quit", app.handleQuit) - - // Listen on a random available port - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - log.Fatalf("[gui] failed to listen: %v", err) + // Hide console window + if hwndConsole, _, _ := procGetConsoleWindow.Call(); hwndConsole != 0 { + procShowWindow.Call(hwndConsole, swHide) } - guiAddr := ln.Addr().String() - log.Printf("[gui] Control panel: http://%s", guiAddr) + + // Get module handle + hInstance, _, _ := procGetModuleHandle.Call(0) + + // Load default application icon + app.hIcon, _, _ = procLoadIcon.Call(0, uintptr(idiApp)) + + // Register window class + className := syscall.StringToUTF16Ptr("EdgeAITrayClass") + wcx := wndClassEx{ + Size: uint32(unsafe.Sizeof(wndClassEx{})), + WndProc: syscall.NewCallback(wndProc), + Instance: hInstance, + ClassName: className, + } + procRegisterClassEx.Call(uintptr(unsafe.Pointer(&wcx))) + + // Create message-only window + hwndMessageOnly := uintptr(0xFFFFFFFFFFFFFFFD) // HWND_MESSAGE = -3 + app.hwnd, _, _ = procCreateWindowEx.Call( + 0, + uintptr(unsafe.Pointer(className)), + uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("Edge AI Platform"))), + 0, 0, 0, 0, 0, + hwndMessageOnly, 0, hInstance, 0, + ) + + // Set up notification icon + app.nid = notifyIconData{ + CbSize: uint32(unsafe.Sizeof(notifyIconData{})), + HWnd: app.hwnd, + UID: 1, + UFlags: nifMessage | nifIcon | nifTip, + UCallbackMessage: wmTrayIcon, + HIcon: app.hIcon, + } + 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() - // Open browser to the control panel - openBrowserURL(fmt.Sprintf("http://%s", guiAddr)) - - // Handle OS signals for graceful shutdown + // Handle OS signals quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) go func() { <-quit - log.Println("[gui] Shutting down...") - app.stopServer() + app.cleanup() os.Exit(0) }() - // Serve (blocks) - if err := http.Serve(ln, mux); err != nil { - log.Fatalf("[gui] server error: %v", err) + log.Printf("[gui] System tray started, server on port %d", cfg.Server.Port) + + // Windows message loop + var m msg + for { + ret, _, _ := procGetMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) + if ret == 0 { // WM_QUIT + break + } + procTranslateMessage.Call(uintptr(unsafe.Pointer(&m))) + procDispatchMessage.Call(uintptr(unsafe.Pointer(&m))) } + + app.cleanup() } -func (a *webGUIApp) handleIndex(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, webGUIHTML) +func wndProc(hwnd uintptr, umsg uint32, wParam, lParam uintptr) uintptr { + app := globalApp + if app == nil { + ret, _, _ := procDefWindowProc.Call(hwnd, uintptr(umsg), wParam, lParam) + return ret + } + + switch umsg { + case wmTrayIcon: + switch lParam { + case wmLButtonUp, wmRButtonUp: + app.showMenu() + } + return 0 + + case wmCommand: + cmdID := int(wParam & 0xFFFF) + switch cmdID { + case idmToggle: + app.mu.Lock() + running := app.running + app.mu.Unlock() + if running { + app.stopServer() + } else { + app.startServer() + } + app.updateTrayTip() + case idmDashboard: + app.openDashboard() + case idmQuit: + app.stopServer() + procShellNotifyIcon.Call(nimDelete, uintptr(unsafe.Pointer(&app.nid))) + procPostQuitMessage.Call(0) + } + return 0 + + case wmClose, wmDestroy: + app.cleanup() + procPostQuitMessage.Call(0) + return 0 + + case wmQueryEndSess: + app.stopServer() + return 1 // allow session end + } + + ret, _, _ := procDefWindowProc.Call(hwnd, uintptr(umsg), wParam, lParam) + return ret } -func (a *webGUIApp) handleStatus(w http.ResponseWriter, r *http.Request) { +func (a *nativeTrayApp) showMenu() { + hMenu, _, _ := procCreatePopupMenu.Call() + a.mu.Lock() running := a.running a.mu.Unlock() - resp := map[string]interface{}{ - "running": running, - "port": a.cfg.Server.Port, - "host": a.cfg.Server.Host, + // Status line (disabled) + var statusText string + if running { + statusText = fmt.Sprintf("Status: Running (:%d)", a.cfg.Server.Port) + } else { + statusText = "Status: Stopped" } - if a.cfg.Relay.URL != "" { - resp["relay"] = a.cfg.Relay.URL + 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" + } else { + toggleText = "Start Server" } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + 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 + } + 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 *webGUIApp) handleStart(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return +func (a *nativeTrayApp) updateTrayTip() { + 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) + } else { + tip = "Edge AI Platform - Stopped" } - a.startServer() - w.WriteHeader(http.StatusOK) + copy(a.nid.SzTip[:], syscall.StringToUTF16(tip)) + procShellNotifyIcon.Call(nimModify, uintptr(unsafe.Pointer(&a.nid))) } -func (a *webGUIApp) handleStop(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - a.stopServer() - w.WriteHeader(http.StatusOK) -} - -func (a *webGUIApp) handleOpenDashboard(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - a.openDashboard() - w.WriteHeader(http.StatusOK) -} - -func (a *webGUIApp) handleQuit(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - w.WriteHeader(http.StatusOK) - go func() { - a.stopServer() - os.Exit(0) - }() -} - -func (a *webGUIApp) startServer() { +func (a *nativeTrayApp) startServer() { a.mu.Lock() defer a.mu.Unlock() @@ -172,6 +368,8 @@ func (a *webGUIApp) startServer() { a.mu.Lock() a.running = false a.mu.Unlock() + // Update tray tooltip from the message loop thread + a.updateTrayTip() if err != nil { log.Printf("[gui] server process exited: %v", err) } else { @@ -180,7 +378,7 @@ func (a *webGUIApp) startServer() { }() } -func (a *webGUIApp) stopServer() { +func (a *nativeTrayApp) stopServer() { a.mu.Lock() defer a.mu.Unlock() @@ -188,21 +386,17 @@ func (a *webGUIApp) stopServer() { return } - if runtime.GOOS == "windows" { - a.cmd.Process.Kill() - } else { - a.cmd.Process.Signal(syscall.SIGTERM) - } + a.cmd.Process.Kill() log.Println("[gui] Server stop requested") } -func (a *webGUIApp) openDashboard() { +func (a *nativeTrayApp) openDashboard() { var url string if a.cfg.Relay.URL != "" && a.cfg.Relay.Token != "" { u := a.cfg.Relay.URL - u = strings.Replace(u, "wss://", "https://", 1) - u = strings.Replace(u, "ws://", "http://", 1) - if i := strings.Index(u, "/tunnel"); i != -1 { + u = replacePrefix(u, "wss://", "https://") + u = replacePrefix(u, "ws://", "http://") + if i := indexOf(u, "/tunnel"); i != -1 { u = u[:i] } url = fmt.Sprintf("%s/?token=%s", u, a.cfg.Relay.Token) @@ -213,22 +407,29 @@ func (a *webGUIApp) openDashboard() { } url = fmt.Sprintf("http://%s:%d", host, a.cfg.Server.Port) } - openBrowserURL(url) + + exec.Command("cmd", "/c", "start", url).Start() } -func openBrowserURL(url string) { - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", url) - case "linux": - cmd = exec.Command("xdg-open", url) - case "windows": - cmd = exec.Command("cmd", "/c", "start", url) - default: - return - } - if err := cmd.Start(); err != nil { - log.Printf("[gui] failed to open browser: %v", err) - } +func (a *nativeTrayApp) cleanup() { + a.stopServer() + 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):] + } + 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 { + return i + } + } + return -1 } diff --git a/edge-ai-platform/server/tray/webgui_html.go b/edge-ai-platform/server/tray/webgui_html.go deleted file mode 100644 index 77ce3b9..0000000 --- a/edge-ai-platform/server/tray/webgui_html.go +++ /dev/null @@ -1,148 +0,0 @@ -//go:build notray - -package tray - -const webGUIHTML = ` - -
- - -