On Windows the system's grey title bar sat over the page as a strip of its own, and stayed light over a page set to dark; at the window's first width Claude Code's name was cut to "C." beside a full "Update to …" pill. Now the main window opens with its title bar and background in the page's --bg for the saved theme (Wails' Windows CustomTheme, #f4f4f6 / #1a1a1e), and the page reports the colour it shows (POST /api/window/titlebar) at start and after every change of theme, system or chosen, for Go to paint the caption with DWM and switch its buttons dark or light; elsewhere nothing changes. The agent row is a size container: under 250px the pill shows only its arrow (its words kept as its aria-label), under 150px the version goes, and the name gives way last. Tested on Windows 11 (sandboxed HOME): with theme light and dark, the bar and the page are one surface, caption buttons in the matching colour, the name whole. cli-update.test.cjs gains a zh subtest at 980/620/540px (fails on the old CSS). The GUI suite passes in Chromium (settings-groups is flaky on HEAD too), go vet, go build (GOOS=windows, GOOS=linux -tags nogui, dev) and go test -tags nogui ./... pass.
374 lines
12 KiB
Go
374 lines
12 KiB
Go
//go:build dev
|
|
|
|
package gui
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/yetone/magpie/internal/gateway"
|
|
)
|
|
|
|
// Development build (`make dev`): the UI is read from internal/gui/assets on
|
|
// every request, and the page reloads itself when a file there changes.
|
|
//
|
|
// `make dev` also splits the app in two, so a Go change doesn't close the
|
|
// windows: a shell (MAGPIE_DEV_ROLE=shell) has the windows and the tray and
|
|
// passes every request on to a backend (MAGPIE_DEV_ROLE=backend) with the
|
|
// API and the gateway. A Go change rebuilds and restarts only the backend;
|
|
// the page, told so, refreshes what it shows in place. What the backend asks
|
|
// of the windows goes back to the shell's control address.
|
|
|
|
func assetsDir() string {
|
|
_, file, _, _ := runtime.Caller(0)
|
|
return filepath.Join(filepath.Dir(file), "assets")
|
|
}
|
|
|
|
func staticFS() fs.FS { return os.DirFS(assetsDir()) }
|
|
|
|
// stamp is the newest modification time under assets, as a string.
|
|
func stamp() string {
|
|
var newest time.Time
|
|
filepath.WalkDir(assetsDir(), func(_ string, d fs.DirEntry, err error) error {
|
|
if err == nil && !d.IsDir() {
|
|
if info, err := d.Info(); err == nil && info.ModTime().After(newest) {
|
|
newest = info.ModTime()
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
return strconv.FormatInt(newest.UnixNano(), 10)
|
|
}
|
|
|
|
// boot tells this process from the one before it.
|
|
var boot = strconv.FormatInt(time.Now().UnixNano(), 36)
|
|
|
|
// A new stamp reloads the page; a new boot (a restarted backend) only
|
|
// refreshes what it shows, as coming back to the window does, so the tab
|
|
// and anything open stay put.
|
|
const reloadJS = `(async () => {
|
|
let seen = "", boot = "";
|
|
for (;;) {
|
|
try {
|
|
const r = await fetch("/api/dev/wait?since=" + seen + "&boot=" + boot, { cache: "no-store" });
|
|
const j = await r.json();
|
|
if (seen && j.stamp !== seen) { location.reload(); return; }
|
|
if (boot && j.boot !== boot && typeof load === "function") load();
|
|
seen = j.stamp; boot = j.boot;
|
|
} catch { await new Promise(r => setTimeout(r, 1000)); }
|
|
}
|
|
})();`
|
|
|
|
func devRoutes(mux *http.ServeMux) {
|
|
// long-poll: answers as soon as the assets change, or after 20s, or at
|
|
// once from a backend the page hasn't seen
|
|
mux.HandleFunc("GET /api/dev/wait", func(rw http.ResponseWriter, r *http.Request) {
|
|
since, was := r.URL.Query().Get("since"), r.URL.Query().Get("boot")
|
|
s := stamp()
|
|
for i := 0; i < 50 && since != "" && s == since && (was == "" || was == boot); i++ {
|
|
time.Sleep(400 * time.Millisecond)
|
|
s = stamp()
|
|
}
|
|
writeJSON(rw, map[string]string{"stamp": s, "boot": boot})
|
|
})
|
|
// the shell's import links, stashed where the page will ask for them
|
|
mux.HandleFunc("POST /api/dev/import", func(rw http.ResponseWriter, r *http.Request) {
|
|
io.WriteString(rw, stashImport(r.FormValue("link")))
|
|
})
|
|
mux.HandleFunc("GET /dev.js", func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.Header().Set("Content-Type", "text/javascript")
|
|
rw.Write([]byte(reloadJS))
|
|
})
|
|
}
|
|
|
|
// devPage injects the reload script into index.html and turns off caching.
|
|
func devPage(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.Header().Set("Cache-Control", "no-store")
|
|
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
|
|
next.ServeHTTP(rw, r)
|
|
return
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(assetsDir(), "index.html"))
|
|
if err != nil {
|
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
b = bytes.Replace(b, []byte("</body>"), []byte(`<script src="/dev.js"></script></body>`), 1)
|
|
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
rw.Write(b)
|
|
})
|
|
}
|
|
|
|
// devListen also serves the whole UI over plain HTTP when MAGPIE_DEV_UI names
|
|
// an address, so it can be opened in a browser with real devtools. The
|
|
// window actions become no-ops there; everything else is the real thing.
|
|
func devListen(h http.Handler) {
|
|
addr := os.Getenv("MAGPIE_DEV_UI")
|
|
if addr == "" {
|
|
return
|
|
}
|
|
ln, err := gateway.Listen(addr)
|
|
if err != nil {
|
|
log.Println("dev ui:", err)
|
|
return
|
|
}
|
|
log.Println("dev ui:", "http://"+addr+"/?theme=dark")
|
|
devUI = &http.Server{Handler: h}
|
|
go devUI.Serve(ln)
|
|
}
|
|
|
|
// devUI is the page served on MAGPIE_DEV_UI, nil when it isn't.
|
|
var devUI *http.Server
|
|
|
|
// devRole is this process's half of `make dev`: "backend", "shell", or ""
|
|
// for the whole app in one process.
|
|
func devRole() string { return os.Getenv("MAGPIE_DEV_ROLE") }
|
|
|
|
// devBackend serves the page and the API on MAGPIE_DEV_BACKEND for the shell.
|
|
//
|
|
// A restart is a handover (build/dev.sh): the new backend listens on the
|
|
// same addresses beside this one, then this one is sent handoverSignal and
|
|
// stops taking requests, finishes those in flight — an agent's stream
|
|
// through the gateway as long as it takes — and exits, so a Go change no
|
|
// longer cuts off the agents using the dev gateway.
|
|
func devBackend(handler func(Windows) http.Handler) error {
|
|
gateway.Handover = handoverSignal != nil
|
|
addr := os.Getenv("MAGPIE_DEV_BACKEND")
|
|
h := handler(remoteWindows(os.Getenv("MAGPIE_DEV_CONTROL")))
|
|
ln, err := gateway.Listen(addr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Println("dev backend:", "http://"+addr)
|
|
srv := &http.Server{Handler: h}
|
|
if handoverSignal == nil {
|
|
return srv.Serve(ln)
|
|
}
|
|
go srv.Serve(ln)
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, handoverSignal)
|
|
<-sig
|
|
log.Println("dev backend: handed over · finishing what's in flight")
|
|
// the page's long polls end within 20s; what the gateway streams, when it ends
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
go srv.Shutdown(ctx)
|
|
if devUI != nil {
|
|
go devUI.Shutdown(ctx)
|
|
}
|
|
stopServing()
|
|
srv.Shutdown(ctx)
|
|
return nil
|
|
}
|
|
|
|
// remoteWindows are the shell's windows, seen from the backend.
|
|
type remoteWindows string // the shell's control address
|
|
|
|
func (c remoteWindows) do(op, arg string) { c.post(op, url.Values{"arg": {arg}}) }
|
|
|
|
// post says what the shell answered; a shell from before an op answers nothing.
|
|
func (c remoteWindows) post(op string, form url.Values) string {
|
|
res, err := backendClient.PostForm("http://"+string(c)+"/"+op, form)
|
|
if err != nil {
|
|
log.Println("dev shell:", err)
|
|
return ""
|
|
}
|
|
defer res.Body.Close()
|
|
b, _ := io.ReadAll(io.LimitReader(res.Body, 64))
|
|
return string(b)
|
|
}
|
|
|
|
func (c remoteWindows) HidePanel() { c.do("hide", "") }
|
|
func (c remoteWindows) ShowMain(view string) { c.do("main", view) }
|
|
func (c remoteWindows) Quit() { c.do("quit", "") }
|
|
func (c remoteWindows) OpenURL(u string) { c.do("open", u) }
|
|
func (c remoteWindows) OpenFolder(path string) error {
|
|
if s := c.post("reveal", url.Values{"arg": {path}}); s != "ok" {
|
|
return fmt.Errorf("%s", s)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ChooseFolder's answer is the folder after "ok:", or what went wrong.
|
|
func (c remoteWindows) ChooseFolder(title string) (string, error) {
|
|
res, err := backendClient.PostForm("http://"+string(c)+"/choose", url.Values{"arg": {title}})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer res.Body.Close()
|
|
b, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10))
|
|
if dir, ok := strings.CutPrefix(string(b), "ok:"); ok {
|
|
return dir, nil
|
|
}
|
|
return "", fmt.Errorf("%s", b)
|
|
}
|
|
func (c remoteWindows) Copy(text string) bool {
|
|
return c.post("copy", url.Values{"arg": {text}}) == "ok"
|
|
}
|
|
|
|
// The glide rides beside the height, so a shell from before it still sizes.
|
|
func (c remoteWindows) FitPanel(height int, g Glide) {
|
|
c.post("fit", url.Values{"arg": {strconv.Itoa(height)}, "ms": {strconv.Itoa(g.MS)}, "ease": {g.ease()}})
|
|
}
|
|
func (c remoteWindows) TintPanel(rgba [4]uint8, ms int) bool {
|
|
q := url.Values{"c": {fmt.Sprintf("%d,%d,%d,%d", rgba[0], rgba[1], rgba[2], rgba[3])}, "ms": {strconv.Itoa(ms)}}
|
|
return c.post("tint", url.Values{"arg": {q.Encode()}}) == "ok"
|
|
}
|
|
func (c remoteWindows) TintTitleBar(rgba [4]uint8, dark bool) bool {
|
|
q := url.Values{"c": {fmt.Sprintf("%d,%d,%d,%d", rgba[0], rgba[1], rgba[2], rgba[3])}}
|
|
if dark {
|
|
q.Set("dark", "1")
|
|
}
|
|
return c.post("titlebar", url.Values{"arg": {q.Encode()}}) == "ok"
|
|
}
|
|
func (c remoteWindows) SetTextSize(percent int) {
|
|
c.post("textsize", url.Values{"arg": {strconv.Itoa(percent)}})
|
|
}
|
|
|
|
// backendClient waits out a backend's restart: a request made while the old
|
|
// one is gone connects to the new one once it listens.
|
|
var backendClient = &http.Client{Transport: &http.Transport{
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
var d net.Dialer
|
|
deadline := time.Now().Add(time.Minute)
|
|
for {
|
|
c, err := d.DialContext(ctx, network, addr)
|
|
if err == nil || time.Now().After(deadline) {
|
|
return c, err
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
}
|
|
},
|
|
}}
|
|
|
|
// devShell is the shell's handler, a proxy to the backend, and starts the
|
|
// control address the backend drives the windows through. nil outside the
|
|
// shell.
|
|
func devShell(h *host) http.Handler {
|
|
if devRole() != "shell" {
|
|
return nil
|
|
}
|
|
control := http.NewServeMux()
|
|
control.HandleFunc("POST /{op}", func(rw http.ResponseWriter, r *http.Request) {
|
|
arg := r.FormValue("arg")
|
|
switch r.PathValue("op") {
|
|
case "hide":
|
|
h.HidePanel()
|
|
case "main":
|
|
h.ShowMain(arg)
|
|
case "quit":
|
|
h.Quit()
|
|
case "open":
|
|
h.OpenURL(arg)
|
|
case "reveal":
|
|
if err := h.OpenFolder(arg); err != nil {
|
|
rw.Write([]byte(err.Error()))
|
|
return
|
|
}
|
|
rw.Write([]byte("ok"))
|
|
return
|
|
case "choose":
|
|
dir, err := h.ChooseFolder(arg)
|
|
if err != nil {
|
|
rw.Write([]byte(err.Error()))
|
|
return
|
|
}
|
|
rw.Write([]byte("ok:" + dir))
|
|
return
|
|
case "copy":
|
|
if h.Copy(arg) {
|
|
rw.Write([]byte("ok"))
|
|
return
|
|
}
|
|
case "fit":
|
|
q := url.Values{"h": {arg}, "ms": {r.FormValue("ms")}, "ease": {r.FormValue("ease")}}
|
|
if n, g, ok := parseFit(q); ok {
|
|
h.FitPanel(n, g)
|
|
}
|
|
case "textsize":
|
|
if n, err := strconv.Atoi(arg); err == nil {
|
|
h.SetTextSize(n)
|
|
}
|
|
case "tint":
|
|
q, _ := url.ParseQuery(arg)
|
|
if c, ms, ok := parseTint(q); ok && h.TintPanel(c, ms) {
|
|
rw.Write([]byte("ok"))
|
|
return
|
|
}
|
|
case "titlebar":
|
|
q, _ := url.ParseQuery(arg)
|
|
if c, _, ok := parseTint(q); ok && h.TintTitleBar(c, q.Get("dark") == "1") {
|
|
rw.Write([]byte("ok"))
|
|
return
|
|
}
|
|
}
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
})
|
|
go func() {
|
|
if err := http.ListenAndServe(os.Getenv("MAGPIE_DEV_CONTROL"), control); err != nil {
|
|
log.Println("dev control:", err)
|
|
}
|
|
}()
|
|
p := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "http", Host: os.Getenv("MAGPIE_DEV_BACKEND")})
|
|
p.Transport = backendClient.Transport
|
|
// A restart cuts off whatever was in flight. A read is asked again, of
|
|
// the backend that comes up (the dial waits for it), so the page never
|
|
// sees the cut; a write may have landed, so it fails as it is.
|
|
p.ErrorHandler = func(rw http.ResponseWriter, r *http.Request, err error) {
|
|
n, _ := r.Context().Value(retryKey{}).(int)
|
|
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && n < 3 && r.Context().Err() == nil {
|
|
p.ServeHTTP(rw, r.WithContext(context.WithValue(r.Context(), retryKey{}, n+1)))
|
|
return
|
|
}
|
|
rw.WriteHeader(http.StatusBadGateway)
|
|
}
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
// The webview's requests often come without a Content-Length, which
|
|
// Wails reads as 0, and a proxy drops a body of length 0: say it's
|
|
// unknown so a POST's body gets through.
|
|
if r.ContentLength == 0 && r.Body != nil && r.Body != http.NoBody && r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
r.ContentLength = -1
|
|
}
|
|
p.ServeHTTP(rw, r)
|
|
})
|
|
}
|
|
|
|
// stash keeps an import link for the page, in the backend under `make dev`.
|
|
func stash(link string) string {
|
|
if devRole() != "shell" {
|
|
return stashImport(link)
|
|
}
|
|
res, err := backendClient.PostForm("http://"+os.Getenv("MAGPIE_DEV_BACKEND")+"/api/dev/import", url.Values{"link": {link}})
|
|
if err != nil {
|
|
log.Println("dev import:", err)
|
|
return ""
|
|
}
|
|
defer res.Body.Close()
|
|
b, _ := io.ReadAll(res.Body)
|
|
return strings.TrimSpace(string(b))
|
|
}
|
|
|
|
// retryKey counts how often a read cut off by a backend restart was asked
|
|
// again.
|
|
type retryKey struct{}
|