Files
magpie/internal/gui/dev_on.go
T
yetone 402a96c39d Rename dial to magpie, with a magpie for a logo
The product, module path (github.com/yetone/magpie), binaries, env vars
(MAGPIE_ADDR, MAGPIE_DEV_UI, MAGPIE_THEME, MAGPIE_DEBUG), config and cache
folders (~/.config/magpie, ~/.cache/magpie), bundle id (com.yetone.magpie),
gateway provider id and token, and every UI string now say magpie.

On first run magpie copies ~/.config/dial and ~/.cache/dial to the new
folders when they don't exist yet (copy, not move, so a still-running dial
keeps working; the old folders can be deleted by hand later).

The logo is a small round magpie in profile: one path for the header and
the tray, and the app tile adds the white belly and the blue-green tail.
The header mark is drawn in ink rather than the accent colour, which
fought the text in light mode.
2026-09-23 23:55:11 +08:00

105 lines
2.9 KiB
Go

//go:build dev
package gui
import (
"bytes"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"time"
)
// 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. Go
// changes still need a rebuild; `make dev` restarts on those too.
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)
}
const reloadJS = `(async () => {
let seen = "";
for (;;) {
try {
const r = await fetch("/api/dev/wait?since=" + seen, { cache: "no-store" });
const s = (await r.json()).stamp;
if (seen && s !== seen) { location.reload(); return; }
seen = s;
} 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
mux.HandleFunc("GET /api/dev/wait", func(rw http.ResponseWriter, r *http.Request) {
since := r.URL.Query().Get("since")
s := stamp()
for i := 0; i < 50 && since != "" && s == since; i++ {
time.Sleep(400 * time.Millisecond)
s = stamp()
}
writeJSON(rw, map[string]string{"stamp": s})
})
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
}
go func() {
log.Println("dev ui:", "http://"+addr+"/?theme=dark")
if err := http.ListenAndServe(addr, h); err != nil {
log.Println("dev ui:", err)
}
}()
}