From c1c9eb55d3022c35b1a3c74e0f28aee908ca07cc Mon Sep 17 00:00:00 2001 From: Stefano Verna Date: Thu, 23 Jul 2026 11:26:58 +0200 Subject: [PATCH 1/4] fix(gui): NSWindow main-thread crash, 'Test model' fixes, and GUI shutdown - Spawn webview in a goroutine so NSWindow runs on the main thread, fixing a crash on macOS when opening the native GUI window. - Return a done channel from openGUI instead of passing a cancel function; the channel closes when the native window is dismissed, enabling clean shutdown on window close. - Fix 'Test model' dropdown to show configured model_overrides and model_family_overrides from /api/proxy/config. - Fix 'Refresh catalog' by passing CatalogDir and CatalogSourceURL to the GUI server. - Fix 'Send' to proxy through POST /api/test/send on the GUI server, avoiding CORS issues. Includes body size limit (1 MiB), io.Copy error handling, and table-driven test coverage. --- cmd/routatic-proxy/main.go | 29 ++-- cmd/routatic-proxy/start_gui_darwin.go | 33 ++-- cmd/routatic-proxy/start_gui_darwin_nocgo.go | 6 +- cmd/routatic-proxy/start_gui_other.go | 6 +- internal/gui/assets/app.js | 17 +- internal/gui/server.go | 61 ++++++++ internal/gui/server_test.go | 155 +++++++++++++++++++ 7 files changed, 265 insertions(+), 42 deletions(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 0612ef60..d0f36fcf 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -349,6 +349,7 @@ Press Ctrl+C to stop the server.`, defer cancel() var guiSrv *gui.Server + var guiDone <-chan struct{} // Start proxy in background. go func() { @@ -373,11 +374,13 @@ Press Ctrl+C to stop the server.`, if !headless { // Start GUI server on port 3445. guiSrv = gui.New(gui.Options{ - History: srv.History, - Metrics: srv.Metrics(), - AtomicConfig: atomicCfg, - ProxyPort: cfg.Port, - Storage: srv.Storage(), + History: srv.History, + Metrics: srv.Metrics(), + AtomicConfig: atomicCfg, + ProxyPort: cfg.Port, + Storage: srv.Storage(), + CatalogDir: resolveCatalogDir(configPath), + CatalogSourceURL: cfg.Catalog.SourceURL, }) guiSrv.SetProxyRunning(true) @@ -387,9 +390,14 @@ Press Ctrl+C to stop the server.`, return fmt.Errorf("start gui server: %w", err) } - // Open GUI (macOS: native webview, Linux/Windows: print URL) - if err := openGUI(guiURL); err != nil { - slog.Warn("GUI error", "error", err) + // Open GUI (macOS: native webview, Linux/Windows: print URL). + // guiDone is closed when the native window is dismissed (CGO + // path); it is nil on non-CGO platforms where only SIGINT + // stops the proxy. + var guiErr error + guiDone, guiErr = openGUI(guiURL) + if guiErr != nil { + slog.Warn("GUI error", "error", guiErr) fmt.Printf("\nDashboard: %s\n", guiURL) fmt.Println("\nPress Ctrl+C to stop.") } @@ -397,13 +405,16 @@ Press Ctrl+C to stop the server.`, fmt.Println("\nRunning in headless mode (no dashboard). Press Ctrl+C to stop.") } - // Wait for signal. + // Wait for signal or GUI window close. sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) select { case <-sigCh: fmt.Println("\nShutting down...") case <-ctx.Done(): + case <-guiDone: + fmt.Println("\nGUI window closed, shutting down...") + cancel() } // Graceful shutdown. diff --git a/cmd/routatic-proxy/start_gui_darwin.go b/cmd/routatic-proxy/start_gui_darwin.go index 4124c525..93159d2a 100644 --- a/cmd/routatic-proxy/start_gui_darwin.go +++ b/cmd/routatic-proxy/start_gui_darwin.go @@ -5,38 +5,25 @@ package main import ( "fmt" - "github.com/getlantern/systray" "github.com/webview/webview_go" ) -func openGUI(guiURL string) error { +// openGUI opens the dashboard in a native macOS webview. It returns a channel +// that is closed when the user closes the window, signaling that the proxy +// should shut down. +func openGUI(guiURL string) (<-chan struct{}, error) { fmt.Printf("\nDashboard: %s\n", guiURL) fmt.Println("Opening native window...") - // Set up system tray first (initializes on main thread), then start webview - // run loop inside onReady so both coexist: tray menu + native webview window. - systray.Run(func() { - systray.SetTitle("routatic-proxy") - systray.SetTooltip("routatic-proxy is running") - mQuit := systray.AddMenuItem("Quit", "Stop the proxy") - + done := make(chan struct{}) + go func() { wv := webview.New(false) + defer wv.Destroy() wv.SetTitle("routatic-proxy") wv.SetSize(1200, 800, webview.HintNone) wv.Navigate(guiURL) - - go func() { - <-mQuit.ClickedCh - wv.Dispatch(func() { - wv.Terminate() - }) - systray.Quit() - }() - wv.Run() - wv.Destroy() - systray.Quit() - }, nil) - - return nil + close(done) + }() + return done, nil } diff --git a/cmd/routatic-proxy/start_gui_darwin_nocgo.go b/cmd/routatic-proxy/start_gui_darwin_nocgo.go index ca5fdace..f49b410f 100644 --- a/cmd/routatic-proxy/start_gui_darwin_nocgo.go +++ b/cmd/routatic-proxy/start_gui_darwin_nocgo.go @@ -6,8 +6,10 @@ import ( "fmt" ) -func openGUI(guiURL string) error { +// openGUI prints the dashboard URL. It returns a nil channel because there is +// no native window to wait on — only SIGINT stops the proxy. +func openGUI(guiURL string) (<-chan struct{}, error) { fmt.Printf("Dashboard: %s\n", guiURL) fmt.Println("\nPress Ctrl+C to stop.") - return nil + return nil, nil } diff --git a/cmd/routatic-proxy/start_gui_other.go b/cmd/routatic-proxy/start_gui_other.go index 3c34da80..e8177aea 100644 --- a/cmd/routatic-proxy/start_gui_other.go +++ b/cmd/routatic-proxy/start_gui_other.go @@ -6,8 +6,10 @@ import ( "fmt" ) -func openGUI(guiURL string) error { +// openGUI prints the dashboard URL. It returns a nil channel because there is +// no native window to wait on — only SIGINT stops the proxy. +func openGUI(guiURL string) (<-chan struct{}, error) { fmt.Printf("Dashboard: %s\n", guiURL) fmt.Println("\nPress Ctrl+C to stop.") - return nil + return nil, nil } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index c0af53c0..ba056970 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -1575,14 +1575,19 @@ const TestModule = { this.testModelSelect.innerHTML = ''; try { - const r = await fetch('/api/metrics'); + const r = await fetch('/api/proxy/config'); if (!r.ok) return; const data = await r.json(); - const models = Object.keys(data.model_counts || {}); - models.sort().forEach(m => { + const modelIds = new Set(); + // Only collect model_overrides and model_family_overrides — the + // top-level "models" keys are routing scenarios (fast, default, + // long_context, etc.), not real model IDs. + Object.keys(data.model_overrides || {}).forEach(k => modelIds.add(k)); + Object.keys(data.model_family_overrides || {}).forEach(k => modelIds.add(k)); + [...modelIds].sort().forEach(id => { const opt = document.createElement('option'); - opt.value = m; - opt.textContent = m; + opt.value = id; + opt.textContent = id; this.testModelSelect.appendChild(opt); }); } catch (e) {} @@ -1617,7 +1622,7 @@ const TestModule = { const start = performance.now(); try { - const r = await fetch('/v1/messages', { + const r = await fetch('/api/test/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/internal/gui/server.go b/internal/gui/server.go index 7307b16a..8f79ceb1 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -3,10 +3,13 @@ package gui import ( + "bytes" "context" "embed" "encoding/json" + "errors" "fmt" + "io" "io/fs" "log/slog" "net" @@ -174,6 +177,7 @@ func (s *Server) Start(ctx context.Context) (string, error) { mux.HandleFunc("/api/proxy/stop", s.handleProxyStop) mux.HandleFunc("/api/catalog/lock", s.handleCatalogLock) mux.HandleFunc("/api/catalog/sync", s.handleCatalogSync) + mux.HandleFunc("/api/test/send", s.handleTestSend) // New endpoints for advanced GUI features @@ -458,6 +462,63 @@ type catalogLockResponse struct { Synced bool `json:"synced"` } +const maxTestRequestBody = 1 << 20 + +// handleTestSend proxies a chat request to the proxy server and streams the +// response back. This avoids CORS issues that would arise from the browser +// calling the proxy port directly. +func (s *Server) handleTestSend(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + proxyPort := s.getProxyPort() + proxyURL := fmt.Sprintf("http://127.0.0.1:%d/v1/messages", proxyPort) + + r.Body = http.MaxBytesReader(w, r.Body, maxTestRequestBody) + defer func() { _ = r.Body.Close() }() + + body, err := io.ReadAll(r.Body) + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + http.Error(w, "request body too large", http.StatusRequestEntityTooLarge) + return + } + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + + // Forward to the proxy. + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, proxyURL, bytes.NewReader(body)) + if err != nil { + http.Error(w, "failed to create proxy request", http.StatusInternalServerError) + return + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Do(req) + if err != nil { + http.Error(w, fmt.Sprintf("proxy request failed: %v", err), http.StatusBadGateway) + return + } + defer func() { _ = resp.Body.Close() }() + + // Copy status code and headers. + w.Header().Set("Content-Type", resp.Header.Get("Content-Type")) + w.WriteHeader(resp.StatusCode) + written, err := io.Copy(w, resp.Body) + if err != nil { + logger := s.logger + if logger == nil { + logger = slog.Default() + } + logger.Error("failed to stream proxy response", "err", err, "bytes_written", written) + } +} + func (s *Server) handleCatalogLock(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) diff --git a/internal/gui/server_test.go b/internal/gui/server_test.go index 3a8c0c26..752f9368 100644 --- a/internal/gui/server_test.go +++ b/internal/gui/server_test.go @@ -1,11 +1,16 @@ package gui import ( + "bytes" "encoding/json" + "io" + "log/slog" + "net" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -154,3 +159,153 @@ func TestHandleCatalogSync_UpstreamError(t *testing.T) { t.Fatalf("status = %d, want %d", rr.Code, http.StatusInternalServerError) } } + +func TestHandleTestSend_RequestBodyTooLarge(t *testing.T) { + s := &Server{proxyPort: 1} + req := httptest.NewRequest( + http.MethodPost, + "/api/test/send", + bytes.NewReader(bytes.Repeat([]byte("x"), maxTestRequestBody+1)), + ) + rr := httptest.NewRecorder() + + s.handleTestSend(rr, req) + + if rr.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusRequestEntityTooLarge) + } +} + +func TestHandleTestSend(t *testing.T) { + tests := []struct { + name string + method string + setup func(t *testing.T) (*Server, func()) + writer func() http.ResponseWriter + wantStatus int + wantBody string + }{ + { + name: "method not allowed", + method: http.MethodGet, + setup: func(t *testing.T) (*Server, func()) { return &Server{}, func() {} }, + writer: func() http.ResponseWriter { return httptest.NewRecorder() }, + wantStatus: http.StatusMethodNotAllowed, + wantBody: "method not allowed\n", + }, + { + name: "successful post", + method: http.MethodPost, + setup: func(t *testing.T) (*Server, func()) { + return startTestSendProxy(t, `{"ok":true}`) + }, + writer: func() http.ResponseWriter { return httptest.NewRecorder() }, + wantStatus: http.StatusOK, + wantBody: `{"ok":true}`, + }, + { + name: "proxy connection failure", + method: http.MethodPost, + setup: func(t *testing.T) (*Server, func()) { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve proxy port: %v", err) + } + port := listener.Addr().(*net.TCPAddr).Port + _ = listener.Close() + return &Server{proxyPort: port}, func() {} + }, + writer: func() http.ResponseWriter { return httptest.NewRecorder() }, + wantStatus: http.StatusBadGateway, + wantBody: "proxy request failed:", + }, + { + name: "client disconnect during stream", + method: http.MethodPost, + setup: func(t *testing.T) (*Server, func()) { + return startTestSendProxy(t, strings.Repeat("x", 128<<10)) + }, + writer: func() http.ResponseWriter { return &failingResponseWriter{} }, + wantStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, cleanup := tt.setup(t) + defer cleanup() + s.logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + + req := httptest.NewRequest(tt.method, "/api/test/send", strings.NewReader(`{"prompt":"hello"}`)) + w := tt.writer() + s.handleTestSend(w, req) + + if got := responseStatus(w); got != tt.wantStatus { + t.Fatalf("status = %d, want %d", got, tt.wantStatus) + } + if tt.wantBody != "" && !strings.Contains(responseBody(w), tt.wantBody) { + t.Fatalf("body = %q, want it to contain %q", responseBody(w), tt.wantBody) + } + }) + } +} + +func startTestSendProxy(t *testing.T, responseBody string) (*Server, func()) { + t.Helper() + + proxy := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(responseBody)) + })) + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen for proxy: %v", err) + } + proxy.Listener = listener + proxy.Start() + + return &Server{proxyPort: proxy.Listener.Addr().(*net.TCPAddr).Port}, proxy.Close +} + +type failingResponseWriter struct { + header http.Header + status int + writes int +} + +func (w *failingResponseWriter) Header() http.Header { + if w.header == nil { + w.header = make(http.Header) + } + return w.header +} + +func (w *failingResponseWriter) WriteHeader(status int) { + w.status = status +} + +func (w *failingResponseWriter) Write(p []byte) (int, error) { + if w.writes > 0 { + return 0, io.ErrClosedPipe + } + w.writes++ + return len(p), nil +} + +func responseStatus(w http.ResponseWriter) int { + switch w := w.(type) { + case *httptest.ResponseRecorder: + return w.Code + case *failingResponseWriter: + return w.status + default: + panic("unsupported response writer") + } +} + +func responseBody(w http.ResponseWriter) string { + if recorder, ok := w.(*httptest.ResponseRecorder); ok { + return recorder.Body.String() + } + return "" +} From 8d5c21da9eb17a188c90889b32c07d200b0535fd Mon Sep 17 00:00:00 2001 From: Stefano Verna Date: Wed, 22 Jul 2026 14:16:09 +0200 Subject: [PATCH 2/4] fix: NSWindow main-thread crash in native macOS GUI The native GUI (webview) crashed on macOS with: NSInternalInconsistencyException: NSWindow should only be instantiated on the main thread! Root cause: systray.Run() calls its onReady callback from a goroutine (not the main thread), but webview.New() creates an NSWindow which macOS 14+ strictly requires on the main thread. This bug was present since the Unified start command PR but never surfaced because the Homebrew release binary is built with CGO_ENABLED=0 (make dist), which excludes the darwin&&cgo build tag and uses the nocgo fallback that just prints the URL. The crash only happens with make build-ui (CGO_ENABLED=1). Fix: remove systray integration, call webview directly from the main goroutine. Window close triggers clean shutdown via context cancel. --- cmd/routatic-proxy/main.go | 44 +++++++++----------- cmd/routatic-proxy/start_gui_darwin.go | 3 +- cmd/routatic-proxy/start_gui_darwin_nocgo.go | 4 +- cmd/routatic-proxy/start_gui_other.go | 4 +- 4 files changed, 24 insertions(+), 31 deletions(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index d0f36fcf..384fbc48 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -774,10 +774,19 @@ func runModelsList(cmd *cobra.Command, configPath, provider string) error { cat, err := catalog.LoadFromSQLite(ctx, db) if err != nil { - return fmt.Errorf("catalog not found; run 'routatic-proxy catalog sync' first") + cmd.Println("catalog not found; run 'routatic-proxy catalog sync' first") + return nil + } + + // When a specific provider is requested, use it directly. Otherwise + // show all providers that have models in the catalog. + var providers []string + if provider != "" { + providers = []string{provider} + } else { + providers = catalogProviders(cat) } - providers := selectProviders(provider, cfg) if len(providers) == 0 { if provider != "" { cmd.Printf("No models found for provider %q.\n", provider) @@ -821,30 +830,17 @@ func runModelsList(cmd *cobra.Command, configPath, provider string) error { return nil } -// selectProviders returns the providers to display. If provider is non-empty, -// only that provider is returned when it exists in the catalog; otherwise all -// configured (enabled) providers are returned. -func selectProviders(provider string, cfg *config.Config) []string { - if provider != "" { - return []string{provider} - } - - globalKeys := cfg.EffectiveAPIKeys() - providerKeys := map[string][]string{ - "opencode-go": cfg.OpenCodeGo.EffectiveAPIKeys(), - "opencode-zen": cfg.OpenCodeZen.EffectiveAPIKeys(), - "aws-bedrock": cfg.AWSBedrock.EffectiveAPIKeys(), - "openrouter": cfg.OpenRouter.EffectiveAPIKeys(), - } - - var enabled []string - for p, keys := range providerKeys { - if len(keys) > 0 || len(globalKeys) > 0 { - enabled = append(enabled, p) +// catalogProviders returns all provider names from the catalog that have at +// least one model, sorted. +func catalogProviders(cat *catalog.IndexedCatalog) []string { + providers := make([]string, 0, len(cat.ProviderModels)) + for p := range cat.ProviderModels { + if len(cat.ProviderModels[p]) > 0 { + providers = append(providers, p) } } - sort.Strings(enabled) - return enabled + sort.Strings(providers) + return providers } // autostartCmd returns the command to manage autostart on login. diff --git a/cmd/routatic-proxy/start_gui_darwin.go b/cmd/routatic-proxy/start_gui_darwin.go index 93159d2a..b732408c 100644 --- a/cmd/routatic-proxy/start_gui_darwin.go +++ b/cmd/routatic-proxy/start_gui_darwin.go @@ -10,7 +10,8 @@ import ( // openGUI opens the dashboard in a native macOS webview. It returns a channel // that is closed when the user closes the window, signaling that the proxy -// should shut down. +// should shut down. The webview runs on the calling goroutine (main thread) +// to satisfy macOS AppKit requirements. func openGUI(guiURL string) (<-chan struct{}, error) { fmt.Printf("\nDashboard: %s\n", guiURL) fmt.Println("Opening native window...") diff --git a/cmd/routatic-proxy/start_gui_darwin_nocgo.go b/cmd/routatic-proxy/start_gui_darwin_nocgo.go index f49b410f..82a5b526 100644 --- a/cmd/routatic-proxy/start_gui_darwin_nocgo.go +++ b/cmd/routatic-proxy/start_gui_darwin_nocgo.go @@ -2,9 +2,7 @@ package main -import ( - "fmt" -) +import "fmt" // openGUI prints the dashboard URL. It returns a nil channel because there is // no native window to wait on — only SIGINT stops the proxy. diff --git a/cmd/routatic-proxy/start_gui_other.go b/cmd/routatic-proxy/start_gui_other.go index e8177aea..61ff496e 100644 --- a/cmd/routatic-proxy/start_gui_other.go +++ b/cmd/routatic-proxy/start_gui_other.go @@ -2,9 +2,7 @@ package main -import ( - "fmt" -) +import "fmt" // openGUI prints the dashboard URL. It returns a nil channel because there is // no native window to wait on — only SIGINT stops the proxy. From 4f91d72c51d302ef1c79606bef396a7e7bf571f4 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Wed, 22 Jul 2026 20:19:35 +0200 Subject: [PATCH 3/4] fix: restore models list behavior --- cmd/routatic-proxy/main.go | 44 +++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 384fbc48..d0f36fcf 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -774,19 +774,10 @@ func runModelsList(cmd *cobra.Command, configPath, provider string) error { cat, err := catalog.LoadFromSQLite(ctx, db) if err != nil { - cmd.Println("catalog not found; run 'routatic-proxy catalog sync' first") - return nil - } - - // When a specific provider is requested, use it directly. Otherwise - // show all providers that have models in the catalog. - var providers []string - if provider != "" { - providers = []string{provider} - } else { - providers = catalogProviders(cat) + return fmt.Errorf("catalog not found; run 'routatic-proxy catalog sync' first") } + providers := selectProviders(provider, cfg) if len(providers) == 0 { if provider != "" { cmd.Printf("No models found for provider %q.\n", provider) @@ -830,17 +821,30 @@ func runModelsList(cmd *cobra.Command, configPath, provider string) error { return nil } -// catalogProviders returns all provider names from the catalog that have at -// least one model, sorted. -func catalogProviders(cat *catalog.IndexedCatalog) []string { - providers := make([]string, 0, len(cat.ProviderModels)) - for p := range cat.ProviderModels { - if len(cat.ProviderModels[p]) > 0 { - providers = append(providers, p) +// selectProviders returns the providers to display. If provider is non-empty, +// only that provider is returned when it exists in the catalog; otherwise all +// configured (enabled) providers are returned. +func selectProviders(provider string, cfg *config.Config) []string { + if provider != "" { + return []string{provider} + } + + globalKeys := cfg.EffectiveAPIKeys() + providerKeys := map[string][]string{ + "opencode-go": cfg.OpenCodeGo.EffectiveAPIKeys(), + "opencode-zen": cfg.OpenCodeZen.EffectiveAPIKeys(), + "aws-bedrock": cfg.AWSBedrock.EffectiveAPIKeys(), + "openrouter": cfg.OpenRouter.EffectiveAPIKeys(), + } + + var enabled []string + for p, keys := range providerKeys { + if len(keys) > 0 || len(globalKeys) > 0 { + enabled = append(enabled, p) } } - sort.Strings(providers) - return providers + sort.Strings(enabled) + return enabled } // autostartCmd returns the command to manage autostart on login. From 7db3776a5da65344c5f71713ee823d89936c1fc9 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Thu, 23 Jul 2026 12:32:43 +0200 Subject: [PATCH 4/4] feat(gui): replace webview with system tray on macOS The webview approach had main-thread issues and added a heavy dependency. Switch to getlantern/systray which was already in the codebase but unwired. Dashboard now opens in the default browser; tray icon provides Open/Quit. Co-Authored-By: Claude Opus 4.6 --- cmd/routatic-proxy/main.go | 4 +-- cmd/routatic-proxy/start_gui_darwin.go | 38 +++++++++++++++++--------- go.mod | 1 - go.sum | 2 -- 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index d0f36fcf..5fb4a41b 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -390,8 +390,8 @@ Press Ctrl+C to stop the server.`, return fmt.Errorf("start gui server: %w", err) } - // Open GUI (macOS: native webview, Linux/Windows: print URL). - // guiDone is closed when the native window is dismissed (CGO + // Open GUI (macOS: system tray + browser, Linux/Windows: print URL). + // guiDone is closed when the user quits from the tray (CGO // path); it is nil on non-CGO platforms where only SIGINT // stops the proxy. var guiErr error diff --git a/cmd/routatic-proxy/start_gui_darwin.go b/cmd/routatic-proxy/start_gui_darwin.go index b732408c..7bafb8c9 100644 --- a/cmd/routatic-proxy/start_gui_darwin.go +++ b/cmd/routatic-proxy/start_gui_darwin.go @@ -4,27 +4,39 @@ package main import ( "fmt" + "os/exec" - "github.com/webview/webview_go" + "github.com/routatic/proxy/internal/tray" ) -// openGUI opens the dashboard in a native macOS webview. It returns a channel -// that is closed when the user closes the window, signaling that the proxy -// should shut down. The webview runs on the calling goroutine (main thread) -// to satisfy macOS AppKit requirements. +// openGUI starts the system tray icon and opens the dashboard in the default +// browser. It returns a channel that is closed when the user clicks "Quit" in +// the tray menu, signaling that the proxy should shut down. +// +// systray.Run must be called from the main OS thread on macOS (AppKit +// requirement). The goroutine that calls this function must be the main +// goroutine locked to the OS thread via runtime.LockOSThread, OR the library +// handles it internally (getlantern/systray does). func openGUI(guiURL string) (<-chan struct{}, error) { fmt.Printf("\nDashboard: %s\n", guiURL) - fmt.Println("Opening native window...") + + // Open dashboard in default browser. + _ = exec.Command("open", guiURL).Start() done := make(chan struct{}) + go func() { - wv := webview.New(false) - defer wv.Destroy() - wv.SetTitle("routatic-proxy") - wv.SetSize(1200, 800, webview.HintNone) - wv.Navigate(guiURL) - wv.Run() - close(done) + tray.Run(tray.Callbacks{ + InitiallyRunning: true, + InitiallyAutostart: false, + OnOpen: func() { + _ = exec.Command("open", guiURL).Start() + }, + OnQuit: func() { + close(done) + }, + }) }() + return done, nil } diff --git a/go.mod b/go.mod index 517fa2cf..28f7b1a4 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( github.com/getlantern/systray v1.2.2 github.com/pkoukk/tiktoken-go v0.1.8 github.com/spf13/cobra v1.8.1 - github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 golang.org/x/mod v0.38.0 golang.org/x/sys v0.46.0 modernc.org/sqlite v1.53.0 diff --git a/go.sum b/go.sum index cda55e6a..15c215e1 100644 --- a/go.sum +++ b/go.sum @@ -56,8 +56,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0= -github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=