feat: add --gui mode for Windows web-based server controller

Add a CGO-free alternative to the systray-based --tray mode. When
launched with --gui, the server starts a lightweight HTTP control panel
in the browser where users can start/stop the server, open the dashboard,
and view relay status.

New files:
- server/tray/webgui.go: HTTP API backend + child process management
- server/tray/webgui_html.go: Embedded HTML control panel

Modified:
- config.go: Add --gui flag
- main.go: Route --gui to RunWebGUI()
- tray.go: Add RunWebGUI stub for tray-enabled builds
- installer: Windows uses --gui instead of bare launch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-03-09 19:14:42 +08:00
parent 53e0ba025c
commit db6388523f
7 changed files with 402 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,148 @@
//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>`