Files
magpie/internal/gui/web.go
T
yetone 575e694ed7 settings: a Text size preference (文字大小) that makes everything in magpie's windows larger, 100/110/125/150%, with Ctrl/Cmd + = − 0 in the window and the tray panel, and Windows' text drawn in its own UI faces (Discord, Clarence Dan: the font size is hard on the eyes on Windows) — Settings → Preferences gains a Text size row under Language whose pick posts to a new POST /api/settings/text-size (saved as settings.TextSize, validated against 100/110/125/150, 0 read as 100, and kept by the Settings page's general save, which never sends it). The size is each webview's own zoom, as a browser zooms, not CSS zoom: CSS zoom leaves getBoundingClientRect and clientX in zoomed pixels while offsets, scrollTop and 100vh are not, and app.js, routing.js and library.js place menus, popovers and the panel's height by those measures; under the webview's zoom the page is laid out again in fewer, larger CSS pixels and every measure agrees. Windows and Linux use Wails' SetZoom (WebView2's zoom factor, WebKitGTK's zoom level; the Zoom window option at creation, so the pages open at the saved size), the Mac WKWebView.pageZoom through cgo (macOS 11+), since Wails' Mac zoom is magnification, which lays nothing out again. None is under 100% because Wails clamps SetZoom below 1 on Windows and Linux. The windows grow around the pages: the panel's width and the height its page asks for (still in CSS pixels) are multiplied by the zoom, capped at the screen's work area (textsize.go panelFrame), and resized at once when the size changes; the main window's minimum is 560×420 CSS pixels at the size, capped by the screen, so a window smaller than that grows. Runtime changes wait for the app to be ready (whenReady), and at startup the Mac applies the zoom once its webviews exist. The Mac header, drawn under the native traffic lights which do not grow, divides its height, left padding and the modal's top by --zoom (set from boot.js before the first paint and from the settings), so it stays 50 points tall and level with them. The shortcuts are Cmd on the Mac and Ctrl elsewhere (the other modifier does nothing), step through the sizes and 0 goes back to 100%, with a "Text size 125%" toast; in magpie web the row is hidden and the keys are left to the browser's own zoom. On Windows the page names its faces (html.win): Segoe UI Variable Text, Segoe UI, then Microsoft YaHei UI for Chinese rather than whatever WebView2 falls back to; the Mac and Linux stack is unchanged, and there is no automatic size bump. New strings in en and zh. Tested: internal/gui/tests/text-size.test.cjs in Chromium and WebKit, en and zh, light and dark — a pick posts and does not scroll the settings page, stays picked after a reload with --zoom from boot.js, the Settings page's other saves leave it, the shortcuts step and reset in the window and the panel, and at 150% (the smallest window, 840×630 points, as 560×420 CSS pixels at device scale 1.5) no view, the header or the panel runs off to the side, the Mac header is 50 points; screenshots at 100% and 150% looked at. currency.test.cjs's viewport is 480 tall instead of 420, as settings-groups' is: the new row put the Currency control under the footer, where WebKit's hover could not reach it. Go: TestTextSizeFrames (panel and window sizes) and TestTextSizeSetting (the endpoint saves, zooms the windows, rejects 133, survives the general save, boot.js carries it) in internal/gui, and the settings round trip. go build -tags nogui (and GOOS=linux, GOOS=windows, plain darwin), go test -tags nogui ./... (TestUserPath flaked once under load and passes alone) and the whole GUI suite with --test-concurrency=2 pass. Not tried in the real Wails window on any platform: the Mac pageZoom, WebView2's zoom factor with the resized panel and the Windows font are untested there.
2026-09-30 02:51:17 +08:00

192 lines
6.7 KiB
Go

package gui
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/yetone/magpie/internal/update"
)
// `magpie web` serves the window's page to a browser, for a computer that
// can't show the app — no desktop, WSL, a server reached over SSH — with
// the gateway beside it as the app has. What is the desktop's (the panel,
// the tray, the Dock) does nothing there; a folder is named, not opened.
// webHost is the Windows a browser tab stands for.
type webHost struct{ quit func() }
func (webHost) HidePanel() {}
func (webHost) ShowMain(string) {}
func (h webHost) Quit() { h.quit() }
func (webHost) OpenURL(string) {} // the page opens links itself
func (webHost) Copy(string) bool { return false }
func (webHost) FitPanel(int, Glide) {}
func (webHost) TintPanel([4]uint8, int) bool { return false }
func (webHost) SetTextSize(int) {} // the browser zooms its own tab
func (webHost) OpenFolder(path string) error {
return errors.New("in the browser magpie can't open folders: it is " + tilde(path))
}
func (webHost) ChooseFolder(string) (string, error) {
return "", errors.New("in the browser magpie can't show a folder picker: type the folder's path")
}
// Web is a started `magpie web`: its address and the link that signs a
// browser in.
type Web struct {
Addr, Link string
key string
srv *http.Server
quit chan struct{}
}
// webReexec is set when the page's restart to update has put the new
// version in: Wait then runs it in this one's place.
var webReexec atomic.Bool
// webRunKey hands a run's own key to the version it restarts into, so the
// browser's cookie still opens the page (#111).
const webRunKey = "MAGPIE_WEB_RUNKEY"
// StartWeb serves the page on addr (host:port), as the given version of
// magpie, which keeps itself up to date as the app does. Every request needs the
// key the link carries, taken once into a cookie other sites' pages can't
// send with a POST (every change is one), so neither a page elsewhere nor
// anyone else on the network reaches the settings and keys behind it.
func StartWeb(addr, version string) (*Web, error) {
Version = version
key, fixed, err := webKey()
if err != nil {
return nil, err
}
var keep time.Duration
if fixed {
keep = webCookieAge
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
w := &Web{Addr: ln.Addr().String(), key: key, quit: make(chan struct{})}
host, port, _ := net.SplitHostPort(w.Addr)
if ip := net.ParseIP(host); ip == nil || ip.IsUnspecified() {
host = "127.0.0.1"
}
var once sync.Once
quit := func() { once.Do(func() { close(w.quit) }) }
w.Link = "http://" + net.JoinHostPort(host, port) + "/?k=" + url.QueryEscape(key)
w.srv = &http.Server{
Handler: webGuard("magpie_web_"+port, key, keep, Handler(webHost{quit}, startBackend())),
ReadHeaderTimeout: 30 * time.Second,
}
go w.srv.Serve(ln)
updates.start()
return w, nil
}
// Wait returns once the page's Quit is used. After a restart to update it
// runs the new version in this one's place, with the same arguments and
// key, so the page open in the browser finds it where it was.
func (w *Web) Wait() error {
<-w.quit
time.Sleep(200 * time.Millisecond) // the answer to the Quit gets out
w.srv.Close()
if !webReexec.Load() {
return nil
}
exe, err := update.Executable()
if err != nil {
return err
}
return update.Reexec(exe, os.Args[1:], append(os.Environ(), webRunKey+"="+w.key))
}
// webKeyMin is the shortest MAGPIE_WEB_KEY taken: it is all that stands
// between the network and the keys behind the page.
const webKeyMin = 16
// webCookieAge is how long a browser keeps a fixed key's cookie (the most
// Chrome allows); a run's own key lives in a cookie that goes with the
// browser session, as the key goes with the run.
const webCookieAge = 400 * 24 * time.Hour
// webKey is the key the link carries: a new one each run, unless
// MAGPIE_WEB_KEY names one — for a page kept running as a service, which
// browsers then stay signed in to across restarts. fixed says which.
func webKey() (key string, fixed bool, err error) {
if k := os.Getenv(webRunKey); k != "" {
os.Unsetenv(webRunKey) // the run's alone, not its children's
if len(k) == 32 && strings.Trim(k, "0123456789abcdef") == "" && os.Getenv("MAGPIE_WEB_KEY") == "" {
return k, false, nil
}
}
if k := os.Getenv("MAGPIE_WEB_KEY"); k != "" {
if len(k) < webKeyMin {
return "", false, fmt.Errorf("MAGPIE_WEB_KEY is %d characters; it needs at least %d", len(k), webKeyMin)
}
// a cookie can't hold a space, ; " \ or a comma: the browser would
// be sent one that isn't the key, and turned away
if strings.Trim(k, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") != "" {
return "", false, errors.New("MAGPIE_WEB_KEY takes letters, digits and - . _ ~ only (openssl rand -hex 16 makes one)")
}
return k, true, nil
}
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b), false, nil
}
// webGuard lets through requests with the key: in the link's k, which it
// trades for a cookie (kept for keep, or the browser session when 0) and
// a clean address, or in that cookie.
func webGuard(cookie, key string, keep time.Duration, next http.Handler) http.Handler {
same := func(v string) bool { return subtle.ConstantTimeCompare([]byte(v), []byte(key)) == 1 }
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if q := r.URL.Query(); q.Has("k") {
if !same(q.Get("k")) {
http.Error(rw, "this link's key is not this magpie web's: use the link it printed when it started", http.StatusUnauthorized)
return
}
http.SetCookie(rw, &http.Cookie{Name: cookie, Value: key, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int(keep / time.Second)})
q.Del("k")
u := *r.URL
u.RawQuery = q.Encode()
http.Redirect(rw, r, u.RequestURI(), http.StatusSeeOther)
return
}
if c, err := r.Cookie(cookie); err != nil || !same(c.Value) {
http.Error(rw, "open the link magpie web printed when it started: it carries the key", http.StatusUnauthorized)
return
}
next.ServeHTTP(rw, r)
})
}
// LANAddrs are this computer's addresses on the local network, for the
// links to print when the page is served there.
func LANAddrs() []string {
var out []string
as, _ := net.InterfaceAddrs()
for _, a := range as {
n, ok := a.(*net.IPNet)
if !ok || n.IP.IsLoopback() || n.IP.To4() == nil || !n.IP.IsPrivate() {
continue
}
out = append(out, n.IP.String())
}
return out
}
// isWeb: the page is served to a browser by `magpie web`.
func isWeb(w Windows) bool { _, ok := w.(webHost); return ok }