feat: replace web GUI with native Windows system tray via syscall

- webgui.go: Native Windows tray icon + right-click popup menu using
  pure syscall (Shell_NotifyIconW, CreatePopupMenu, etc.)
  - No CGO required, no external dependencies
  - Shows server status, Start/Stop, Open Dashboard, Quit
  - Hides console window on startup
  - Handles OS signals and Windows session end
- webgui_other.go: Stub for non-Windows notray builds
- Remove webgui_html.go (browser-based approach no longer needed)
- Fix auto-start: replace schtasks (fails on Chinese Windows) with
  Registry Run key (HKCU\...\Run) which works without admin and
  has no locale encoding issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-03-09 20:41:26 +08:00
parent db6388523f
commit 74a850a00c
4 changed files with 350 additions and 273 deletions

View File

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

View File

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

View File

@ -1,148 +0,0 @@
//go:build notray
package tray
const webGUIHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edge AI Platform Controller</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #1a1a2e; color: #e0e0e0;
display: flex; justify-content: center; align-items: center;
min-height: 100vh; padding: 20px;
}
.card {
background: #16213e; border-radius: 12px; padding: 32px;
width: 100%; max-width: 420px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
h1 {
font-size: 18px; font-weight: 600; margin-bottom: 24px;
color: #fff; text-align: center;
}
.status-row {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px; background: #0f3460; border-radius: 8px;
margin-bottom: 12px;
}
.dot {
width: 12px; height: 12px; border-radius: 50%;
flex-shrink: 0; transition: background 0.3s;
}
.dot.running { background: #00d26a; box-shadow: 0 0 8px rgba(0,210,106,0.5); }
.dot.stopped { background: #ff4757; box-shadow: 0 0 8px rgba(255,71,87,0.4); }
.status-text { font-size: 14px; }
.status-text strong { color: #fff; }
.info-row {
font-size: 13px; color: #8a9cc5; padding: 6px 16px;
margin-bottom: 4px;
}
.buttons {
display: flex; gap: 10px; margin-top: 20px;
}
button {
flex: 1; padding: 12px 16px; border: none; border-radius: 8px;
font-size: 14px; font-weight: 500; cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
}
button:hover { opacity: 0.85; }
button:active { transform: scale(0.97); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: #533483; color: #fff; }
.btn-success { background: #00d26a; color: #1a1a2e; }
.btn-danger { background: #ff4757; color: #fff; }
.btn-secondary { background: #2d3561; color: #e0e0e0; }
.btn-quit {
margin-top: 12px; width: 100%; background: transparent;
border: 1px solid #444; color: #888; font-size: 13px;
padding: 8px;
}
.btn-quit:hover { border-color: #ff4757; color: #ff4757; }
</style>
</head>
<body>
<div class="card">
<h1>Edge AI Platform</h1>
<div class="status-row">
<div id="dot" class="dot stopped"></div>
<div class="status-text">
Status: <strong id="status-text">Checking...</strong>
</div>
</div>
<div id="info-relay" class="info-row" style="display:none"></div>
<div class="buttons">
<button id="btn-toggle" class="btn-primary" onclick="toggleServer()" disabled>
Start Server
</button>
<button id="btn-dashboard" class="btn-success" onclick="openDashboard()" disabled>
Open Dashboard
</button>
</div>
<button class="btn-quit" onclick="quitApp()">Quit</button>
</div>
<script>
let serverRunning = false;
async function fetchStatus() {
try {
const r = await fetch('/api/status');
const d = await r.json();
serverRunning = d.running;
document.getElementById('dot').className = 'dot ' + (d.running ? 'running' : 'stopped');
document.getElementById('status-text').textContent =
d.running ? 'Running (:' + d.port + ')' : 'Stopped';
const btn = document.getElementById('btn-toggle');
btn.disabled = false;
btn.textContent = d.running ? 'Stop Server' : 'Start Server';
btn.className = d.running ? 'btn-danger' : 'btn-primary';
document.getElementById('btn-dashboard').disabled = !d.running;
const relayEl = document.getElementById('info-relay');
if (d.relay) {
relayEl.style.display = 'block';
relayEl.textContent = 'Relay: ' + d.relay;
} else {
relayEl.style.display = 'none';
}
} catch (e) {
document.getElementById('status-text').textContent = 'Connection lost';
document.getElementById('dot').className = 'dot stopped';
}
}
async function toggleServer() {
const btn = document.getElementById('btn-toggle');
btn.disabled = true;
await fetch(serverRunning ? '/api/stop' : '/api/start', { method: 'POST' });
setTimeout(fetchStatus, 1000);
}
async function openDashboard() {
await fetch('/api/open-dashboard', { method: 'POST' });
}
async function quitApp() {
if (confirm('Stop server and quit?')) {
await fetch('/api/quit', { method: 'POST' });
document.getElementById('status-text').textContent = 'Shutting down...';
}
}
fetchStatus();
setInterval(fetchStatus, 2000);
</script>
</body>
</html>`

View File

@ -0,0 +1,15 @@
//go:build notray && !windows
package tray
import (
"fmt"
"os"
)
// RunWebGUI is not supported on non-Windows platforms without CGO.
// Use --tray mode instead (requires CGO_ENABLED=1).
func RunWebGUI(_ *Config) {
fmt.Fprintln(os.Stderr, "GUI mode is only available on Windows. Use --tray mode instead (requires CGO_ENABLED=1).")
os.Exit(1)
}