diff --git a/edge-ai-platform/installer/app.go b/edge-ai-platform/installer/app.go index 97adb53..e665519 100644 --- a/edge-ai-platform/installer/app.go +++ b/edge-ai-platform/installer/app.go @@ -614,10 +614,12 @@ func (inst *Installer) LaunchServer() (string, error) { binPath := filepath.Join(installDir, binName) // Read config to get relay args - // Note: on Windows the server is built with CGO_ENABLED=0 -tags notray, - // so --tray is not available. On macOS the tray-enabled build is used. + // Windows: use --gui (web-based controller, no CGO needed) + // macOS/Linux: use --tray (system tray, CGO-enabled build) var args []string - if runtime.GOOS != "windows" { + if runtime.GOOS == "windows" { + args = append(args, "--gui") + } else { args = append(args, "--tray") } cfgPath := filepath.Join(platformConfigDir(), "config.json") diff --git a/edge-ai-platform/installer/platform_windows.go b/edge-ai-platform/installer/platform_windows.go index 780e9de..0add0b7 100644 --- a/edge-ai-platform/installer/platform_windows.go +++ b/edge-ai-platform/installer/platform_windows.go @@ -140,7 +140,7 @@ func installAutoRestart(installDir string) error { // Note: do not use /RL HIGHEST — it requires admin privileges cmd := exec.Command("schtasks", "/Create", "/TN", taskName, - "/TR", fmt.Sprintf(`"%s"`, binPath), + "/TR", fmt.Sprintf(`"%s" --gui`, binPath), "/SC", "ONLOGON", "/F", ) diff --git a/edge-ai-platform/server/internal/config/config.go b/edge-ai-platform/server/internal/config/config.go index b268fa1..dda9bee 100644 --- a/edge-ai-platform/server/internal/config/config.go +++ b/edge-ai-platform/server/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { RelayURL string RelayToken string TrayMode bool + GUIMode bool GiteaURL string } @@ -31,6 +32,7 @@ func Load() *Config { flag.StringVar(&cfg.RelayURL, "relay-url", "", "Relay server WebSocket URL (e.g. ws://relay-host:3800/tunnel/connect)") flag.StringVar(&cfg.RelayToken, "relay-token", "", "Authentication token for relay tunnel") flag.BoolVar(&cfg.TrayMode, "tray", false, "Run as system tray launcher") + flag.BoolVar(&cfg.GUIMode, "gui", false, "Run web-based GUI controller (no CGO required)") flag.StringVar(&cfg.GiteaURL, "gitea-url", "", "Gitea server URL for update checks (e.g. https://gitea.example.com)") flag.Parse() return cfg diff --git a/edge-ai-platform/server/main.go b/edge-ai-platform/server/main.go index 931ac9a..6bd1dad 100644 --- a/edge-ai-platform/server/main.go +++ b/edge-ai-platform/server/main.go @@ -64,6 +64,13 @@ func main() { return } + // GUI mode: launch web-based controller (CGO-free alternative to tray). + if cfg.GUIMode { + trayCfg := tray.LoadConfig() + tray.RunWebGUI(trayCfg) + return + } + logger := pkglogger.New(cfg.LogLevel) logger.Info("Starting Edge AI Platform Server %s (built: %s)", Version, BuildTime) diff --git a/edge-ai-platform/server/tray/tray.go b/edge-ai-platform/server/tray/tray.go index 2256883..c8d4862 100644 --- a/edge-ai-platform/server/tray/tray.go +++ b/edge-ai-platform/server/tray/tray.go @@ -229,6 +229,11 @@ func (a *TrayApp) openBrowser() { } } +// RunWebGUI falls back to Run() in tray-enabled builds (CGO_ENABLED=1). +func RunWebGUI(cfg *Config) { + Run(cfg) +} + // viewLogs opens the platform-appropriate log viewer. func (a *TrayApp) viewLogs() { var cmd *exec.Cmd diff --git a/edge-ai-platform/server/tray/webgui.go b/edge-ai-platform/server/tray/webgui.go new file mode 100644 index 0000000..2ed8d13 --- /dev/null +++ b/edge-ai-platform/server/tray/webgui.go @@ -0,0 +1,234 @@ +//go:build notray + +package tray + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "runtime" + "strings" + "sync" + "syscall" +) + +// webGUIApp manages the web-based GUI controller and server child process. +type webGUIApp struct { + mu sync.Mutex + cmd *exec.Cmd + running bool + cfg *Config +} + +// 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. +func RunWebGUI(cfg *Config) { + app := &webGUIApp{cfg: cfg} + + 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) + } + guiAddr := ln.Addr().String() + log.Printf("[gui] Control panel: http://%s", guiAddr) + + // Auto-start the server + app.startServer() + + // Open browser to the control panel + openBrowserURL(fmt.Sprintf("http://%s", guiAddr)) + + // Handle OS signals for graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-quit + log.Println("[gui] Shutting down...") + app.stopServer() + os.Exit(0) + }() + + // Serve (blocks) + if err := http.Serve(ln, mux); err != nil { + log.Fatalf("[gui] server error: %v", err) + } +} + +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 (a *webGUIApp) handleStatus(w http.ResponseWriter, r *http.Request) { + 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, + } + if a.cfg.Relay.URL != "" { + resp["relay"] = a.cfg.Relay.URL + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func (a *webGUIApp) handleStart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + a.startServer() + w.WriteHeader(http.StatusOK) +} + +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() { + a.mu.Lock() + defer a.mu.Unlock() + + if a.running { + return + } + + args := []string{ + "--port", fmt.Sprintf("%d", a.cfg.Server.Port), + "--host", a.cfg.Server.Host, + } + if a.cfg.Relay.URL != "" { + args = append(args, "--relay-url", a.cfg.Relay.URL) + } + if a.cfg.Relay.Token != "" { + args = append(args, "--relay-token", a.cfg.Relay.Token) + } + + exe, err := os.Executable() + if err != nil { + log.Printf("[gui] failed to get executable path: %v", err) + return + } + + a.cmd = exec.Command(exe, args...) + a.cmd.Stdout = os.Stdout + a.cmd.Stderr = os.Stderr + + if err := a.cmd.Start(); err != nil { + log.Printf("[gui] failed to start server: %v", err) + return + } + + a.running = true + log.Printf("[gui] Server started on port %d", a.cfg.Server.Port) + + go func() { + err := a.cmd.Wait() + a.mu.Lock() + a.running = false + a.mu.Unlock() + if err != nil { + log.Printf("[gui] server process exited: %v", err) + } else { + log.Println("[gui] server process exited normally") + } + }() +} + +func (a *webGUIApp) stopServer() { + a.mu.Lock() + defer a.mu.Unlock() + + if !a.running || a.cmd == nil || a.cmd.Process == nil { + return + } + + if runtime.GOOS == "windows" { + a.cmd.Process.Kill() + } else { + a.cmd.Process.Signal(syscall.SIGTERM) + } + log.Println("[gui] Server stop requested") +} + +func (a *webGUIApp) 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 = u[:i] + } + url = fmt.Sprintf("%s/?token=%s", u, a.cfg.Relay.Token) + } else { + host := a.cfg.Server.Host + if host == "0.0.0.0" || host == "" { + host = "127.0.0.1" + } + url = fmt.Sprintf("http://%s:%d", host, a.cfg.Server.Port) + } + openBrowserURL(url) +} + +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) + } +} diff --git a/edge-ai-platform/server/tray/webgui_html.go b/edge-ai-platform/server/tray/webgui_html.go new file mode 100644 index 0000000..77ce3b9 --- /dev/null +++ b/edge-ai-platform/server/tray/webgui_html.go @@ -0,0 +1,148 @@ +//go:build notray + +package tray + +const webGUIHTML = ` + +
+ + +