package desktop import ( "context" "encoding/json" "errors" "fmt" "io" "io/fs" "net" "net/http" "os" "os/exec" "os/signal" "runtime" "strings" "syscall" "time" frontendassets "github.com/Apale7/opencode-provider-switch/frontend" appcore "github.com/Apale7/opencode-provider-switch/internal/app" "github.com/Apale7/opencode-provider-switch/internal/config" ) type RunOptions struct { ConfigPath string Version string ListenAddr string OpenBrowser bool ShutdownWait time.Duration } type apiEnvelope struct { Data any `json:"data,omitempty"` Error string `json:"error,omitempty"` } type metaView struct { Version string `json:"version"` Shell string `json:"shell"` URL string `json:"url"` } func Run(opts RunOptions) error { if strings.TrimSpace(opts.ConfigPath) == "" { opts.ConfigPath = config.DefaultPath() } if strings.TrimSpace(opts.ListenAddr) == "" { opts.ListenAddr = "127.0.0.1:0" } if opts.ShutdownWait <= 0 { opts.ShutdownWait = 5 * time.Second } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() instance := New(opts.ConfigPath) listener, err := net.Listen("tcp", opts.ListenAddr) if err != nil { return fmt.Errorf("listen desktop control panel: %w", err) } defer listener.Close() url := "http://" + listener.Addr().String() handler, err := newHandler(instance, opts.Version, url) if err != nil { return err } srv := &http.Server{ Handler: handler, ReadHeaderTimeout: 10 * time.Second, } errCh := make(chan error, 1) go func() { errCh <- srv.Serve(listener) }() fmt.Printf("ocswitch desktop control panel: %s\n", url) if opts.OpenBrowser { if err := openBrowser(url); err != nil { fmt.Fprintf(os.Stderr, "warning: open browser: %v\n", err) } } select { case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), opts.ShutdownWait) defer cancel() _ = instance.Service().StopProxy(shutdownCtx) return srv.Shutdown(shutdownCtx) case err := <-errCh: if errors.Is(err, http.ErrServerClosed) { return nil } return err } } func newHandler(instance *App, version string, baseURL string) (http.Handler, error) { assets, err := frontendassets.DistFS() if err != nil { return nil, fmt.Errorf("load web assets: %w", err) } api := http.NewServeMux() b := instance.Bindings() api.HandleFunc("/api/meta", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeMethodNotAllowed(w, http.MethodGet) return } writeJSON(w, http.StatusOK, apiEnvelope{Data: metaView{Version: version, Shell: instance.shellName(), URL: baseURL}}) }) api.HandleFunc("/api/overview", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeMethodNotAllowed(w, http.MethodGet) return } data, err := b.GetOverview(r.Context()) writeResult(w, data, err) }) api.HandleFunc("/api/providers", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeMethodNotAllowed(w, http.MethodGet) return } data, err := b.ListProviders(r.Context()) writeResult(w, data, err) }) api.HandleFunc("/api/aliases", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeMethodNotAllowed(w, http.MethodGet) return } data, err := b.ListAliases(r.Context()) writeResult(w, data, err) }) api.HandleFunc("/api/desktop-prefs", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: data, err := b.GetDesktopPrefs(r.Context()) writeResult(w, data, err) case http.MethodPost: var in appcore.DesktopPrefsInput if !decodeJSONBody(w, r, &in) { return } data, err := b.SaveDesktopPrefs(r.Context(), in) writeResult(w, data, err) default: writeMethodNotAllowed(w, http.MethodGet, http.MethodPost) } }) api.HandleFunc("/api/proxy/status", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeMethodNotAllowed(w, http.MethodGet) return } data, err := b.GetProxyStatus(r.Context()) writeResult(w, data, err) }) api.HandleFunc("/api/proxy/start", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeMethodNotAllowed(w, http.MethodPost) return } data, err := b.StartProxy(r.Context()) writeResult(w, data, err) }) api.HandleFunc("/api/proxy/stop", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeMethodNotAllowed(w, http.MethodPost) return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() data, err := b.StopProxy(ctx) writeResult(w, data, err) }) api.HandleFunc("/api/doctor", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeMethodNotAllowed(w, http.MethodPost) return } data, err := b.RunDoctor(r.Context()) writeJSON(w, http.StatusOK, apiEnvelope{Data: appcore.DoctorRunResult{Report: data, Error: errorString(err)}}) }) api.HandleFunc("/api/opencode-sync/preview", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeMethodNotAllowed(w, http.MethodPost) return } var in appcore.SyncInput if !decodeJSONBody(w, r, &in) { return } data, err := b.PreviewOpenCodeSync(r.Context(), in) writeResult(w, data, err) }) api.HandleFunc("/api/opencode-sync/apply", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeMethodNotAllowed(w, http.MethodPost) return } var in appcore.SyncInput if !decodeJSONBody(w, r, &in) { return } data, err := b.SyncOpenCode(r.Context(), in) writeResult(w, data, err) }) fileServer := http.FileServer(http.FS(assets)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/") { api.ServeHTTP(w, r) return } serveSPA(w, r, assets, fileServer) }), nil } func serveSPA(w http.ResponseWriter, r *http.Request, assets fs.FS, next http.Handler) { path := strings.TrimPrefix(r.URL.Path, "/") if path == "" { path = "index.html" } if _, err := fs.Stat(assets, path); err == nil { next.ServeHTTP(w, r) return } r = r.Clone(r.Context()) r.URL.Path = "/index.html" next.ServeHTTP(w, r) } func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) bool { defer r.Body.Close() body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil { writeJSON(w, http.StatusBadRequest, apiEnvelope{Error: err.Error()}) return false } if len(strings.TrimSpace(string(body))) == 0 { return true } if err := json.Unmarshal(body, dst); err != nil { writeJSON(w, http.StatusBadRequest, apiEnvelope{Error: "invalid json: " + err.Error()}) return false } return true } func writeResult(w http.ResponseWriter, data any, err error) { if err != nil { writeJSON(w, http.StatusBadRequest, apiEnvelope{Error: err.Error()}) return } writeJSON(w, http.StatusOK, apiEnvelope{Data: data}) } func writeMethodNotAllowed(w http.ResponseWriter, allowed ...string) { if len(allowed) > 0 { w.Header().Set("Allow", strings.Join(allowed, ", ")) } writeJSON(w, http.StatusMethodNotAllowed, apiEnvelope{Error: "method not allowed"}) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func errorString(err error) string { if err == nil { return "" } return err.Error() } func openBrowser(url string) error { commands := browserCommands(url) var errs []string for _, args := range commands { if _, err := exec.LookPath(args[0]); err != nil { err = nil continue } if err := exec.Command(args[0], args[1:]...).Start(); err == nil { return nil } else { errs = append(errs, err.Error()) } } if len(errs) == 0 { return fmt.Errorf("no browser launcher found") } return fmt.Errorf(strings.Join(errs, "; ")) } func browserCommands(url string) [][]string { switch runtime.GOOS { case "darwin": return [][]string{{"open", url}} case "windows": return [][]string{{"rundll32", "url.dll,FileProtocolHandler", url}} default: return [][]string{{"xdg-open", url}, {"gio", "open", url}} } }