Files
magpie/internal/settings/settings.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

348 lines
14 KiB
Go

// Package settings keeps the few preferences the desktop app has: which
// palette to paint with, which language to speak, how the agents are
// arranged. Everything else magpie knows is derived from the agents' own
// files.
//
// The file is ~/.config/magpie/settings.json; a missing file means "follow
// the system" for both.
package settings
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"github.com/yetone/magpie/internal/redact"
)
// Settings is what the user chose. "" and "system" both mean "follow the OS".
type Settings struct {
Theme string `json:"theme,omitempty"` // system | light | dark
Lang string `json:"lang,omitempty"` // system | en | zh
Tray string `json:"tray,omitempty"` // what clicking the tray icon opens: panel | window
// SessionTerminal is the Mac app that opens a resumed session, by bundle
// id. "" and "system" follow the .command file association.
SessionTerminal string `json:"sessionTerminal,omitempty"`
// Currency is what a cost — the Usage page's, the tray panel's, the
// TUI's and the CLI's — is shown converted to: usd (its native
// currency, list prices being in dollars) or cny, at a live exchange
// rate (see internal/fx). A vendor's own balance, already in its own
// currency (a Chinese relay's ¥), is never touched by this.
Currency string `json:"currency,omitempty"`
// Dock keeps magpie in the Mac's Dock as well as the menu bar, for a
// menu bar too full to show its icon.
Dock bool `json:"dock,omitempty"`
// DockWindow shows it in the Dock only while its window is open, so
// Cmd-Tab reaches the window without an icon kept there the rest of
// the time. Dock wins over it.
DockWindow bool `json:"dockWindow,omitempty"`
// Proxy for magpie's own requests to vendors: "" follows the
// environment and then the system, "direct" uses none, anything else
// is the proxy (http://, https:// or socks5://; host:port means http).
Proxy string `json:"proxy,omitempty"`
// Redact keeps secrets in what agents send (API keys, private keys,
// tokens, passwords) from the vendors behind magpie: they go as
// placeholders, and come back as they were. RedactPersonal does the same
// for emails, phone numbers and ID and bank card numbers, and
// RedactWords for the user's own words. RedactRules are the user's own
// rules for secrets magpie's don't know (a gateway's oc_sk_… key), a
// prefix or a pattern each, masked with the secrets while Redact is on.
Redact bool `json:"redact,omitempty"`
RedactPersonal bool `json:"redactPersonal,omitempty"`
RedactWords []string `json:"redactWords,omitempty"`
RedactRules []redact.Rule `json:"redactRules,omitempty"`
// LAN shares the gateway on the local network, for agents on other
// machines; a request from one must carry LANKey as its API key, a
// key magpie makes when LAN is first turned on.
LAN bool `json:"lan,omitempty"`
LANKey string `json:"lanKey,omitempty"`
// CodexWarmup starts a ChatGPT account's next window as soon as the
// last one resets, with one tiny request, so it counts from then (a
// Codex window starts at its first use): "" off, "week" the weekly
// window, "all" the 5-hour one too.
CodexWarmup string `json:"codexWarmup,omitempty"`
// ClaudeWarmup is CodexWarmup for the Claude accounts, the request
// sent through Claude Code.
ClaudeWarmup string `json:"claudeWarmup,omitempty"`
// CodexWarmAt starts each ChatGPT account's 5-hour window at a time of
// day of the user's choosing, local "15:04", with the same tiny
// request: an account whose 5-hour window isn't running then gets one,
// so the windows line up with the day (06:00 gives three by 21:00, where
// the first use at 9 gives two by the end of it); "" off. It works
// with CodexWarmup or without it. ClaudeWarmAt is the Claude accounts'.
CodexWarmAt string `json:"codexWarmAt,omitempty"`
ClaudeWarmAt string `json:"claudeWarmAt,omitempty"`
// WorkBuddyCheckin presses WorkBuddy's daily check-in (签到) for each
// signed-in WorkBuddy (China) account once a Beijing day, claiming the
// credits it gives while its event runs.
WorkBuddyCheckin bool `json:"workbuddyCheckin,omitempty"`
// NoStats stops the one event a day that counts magpie's users (see
// internal/stats).
NoStats bool `json:"noStats,omitempty"`
// Vision is the model that describes an image to a model that can't see
// it: a model's id (provider/model, group/<id>), "off" to turn such an
// image away, or empty for one magpie picks (see gateway.seer).
Vision string `json:"vision,omitempty"`
// ImageGen is the model magpie's generate_image tool draws with (the
// gateway's /v1/images/generations when a request names no model): a
// model's id, "off", or empty for one magpie picks (gateway.drawer).
ImageGen string `json:"imageGen,omitempty"`
// TrayUsage is the subscription or plan whose windows are shown beside
// the tray icon, by its provider and account ("claude|a@b.c"); "" none.
TrayUsage string `json:"trayUsage,omitempty"`
// TrayUsageEvery is how often, in minutes, that text is brought up to
// date; 0 is every 3 (one of TrayEvery).
TrayUsageEvery int `json:"trayUsageEvery,omitempty"`
// QuotaLeft shows a subscription's windows by how much of each is left,
// not used: the Usage page, the tray panel and the menu bar alike.
QuotaLeft bool `json:"quotaLeft,omitempty"`
// TextSize is how large the window's and the tray panel's pages are
// drawn, in percent (one of TextSizes): the webviews' own zoom, as a
// browser's, so the text and everything around it grow together.
TextSize int `json:"textSize,omitempty"`
// How the agents are listed, by agent id. AgentOrder comes first, as
// ordered; an agent it doesn't name (one installed since) follows in
// magpie's own order. A hidden agent is folded away at the bottom of the
// list; a shown one stays in view even while nothing is set on it, which
// otherwise folds it away too. The agents' own files never hear of it.
AgentOrder []string `json:"agentOrder,omitempty"`
AgentsHidden []string `json:"agentsHidden,omitempty"`
AgentsShown []string `json:"agentsShown,omitempty"`
// Visible narrows the models an agent is shown, by agent id: the
// families (the tag a provider or group is given), provider ids and
// group ids its lists hold. An agent it doesn't name is shown them all.
Visible map[string][]string `json:"visible,omitempty"`
// ModelNames are the names the user gave models, by "<provider
// id>/<model id>": agents, the gateway's model list and magpie itself
// show them for the vendor's (see provider.SetModelName).
ModelNames map[string]string `json:"modelNames,omitempty"`
// ModelEfforts are the reasoning levels the user keeps of a model's,
// by "<provider id>/<model id>": the lists magpie hands out offer only
// those (see provider.SetModelEfforts).
ModelEfforts map[string][]string `json:"modelEfforts,omitempty"`
// ModelImages is whether the user said a model takes images, by
// "<provider id>/<model id>". Absent leaves it to the vendor's list.
ModelImages map[string]bool `json:"modelImages,omitempty"`
// The main window's size when it was last resized, width and height,
// so it opens at it again after a restart.
Window []int `json:"window,omitempty"`
}
// Arrange puts items in the order the user gave the agents, those named
// first and the rest after in the order they came, and splits off the
// hidden ones, which keep that order too. id names an item's agent.
func Arrange[T any](s Settings, items []T, id func(T) string) (shown, hidden []T) {
rank := map[string]int{}
for i, x := range s.AgentOrder {
if _, dup := rank[x]; !dup {
rank[x] = i
}
}
sorted := slices.Clone(items)
slices.SortStableFunc(sorted, func(a, b T) int {
ra, oka := rank[id(a)]
rb, okb := rank[id(b)]
switch {
case oka && okb:
return ra - rb
case oka:
return -1
case okb:
return 1
}
return 0
})
for _, x := range sorted {
if slices.Contains(s.AgentsHidden, id(x)) {
hidden = append(hidden, x)
} else {
shown = append(shown, x)
}
}
return shown, hidden
}
// Themes and Langs are the accepted values, in the order the UI offers them.
var (
Themes = []string{"system", "light", "dark"}
Langs = []string{"system", "en", "zh"}
Trays = []string{"panel", "window"}
Currencies = []string{"usd", "cny"}
// Warmups are CodexWarmup's and ClaudeWarmup's values, off as "".
Warmups = []string{"", "week", "all"}
// TrayEvery are TrayUsageEvery's values, in minutes.
TrayEvery = []int{1, 3, 5, 10, 30}
// TextSizes are TextSize's values, in percent. None is under 100: the
// webviews' zoom on Windows and Linux (Wails' SetZoom) goes no lower.
TextSizes = []int{100, 110, 125, 150}
)
var validTerminalBundleID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$`)
// Path is the settings file.
func Path() string {
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
return filepath.Join(x, "magpie", "settings.json")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "magpie", "settings.json")
}
// Dir is the folder every magpie file lives in.
func Dir() string { return filepath.Dir(Path()) }
// Load reads the settings; anything missing or unreadable is the default.
func Load() Settings {
var s Settings
if b, err := os.ReadFile(Path()); err == nil {
_ = json.Unmarshal(b, &s)
}
return s.normal()
}
// CheckProxy says whether p is a proxy setting magpie takes: "" (follow),
// "direct", or an http://, https:// or socks5:// address (host:port
// meaning http). The global Proxy and a provider's own are both checked
// with it.
func CheckProxy(p string) error {
p = strings.TrimSpace(p)
if p == "" || p == "direct" {
return nil
}
raw := p
if !strings.Contains(raw, "://") {
raw = "http://" + raw
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" || !slices.Contains([]string{"http", "https", "socks5", "socks5h"}, u.Scheme) {
return fmt.Errorf("proxy must look like http://127.0.0.1:7890 or socks5://127.0.0.1:1080, not %q", p)
}
return nil
}
// Save validates and writes the settings.
func Save(s Settings) error {
s = s.normal()
if !slices.Contains(Themes, s.Theme) {
return fmt.Errorf("theme must be one of %v, not %q", Themes, s.Theme)
}
if !slices.Contains(Langs, s.Lang) {
return fmt.Errorf("language must be one of %v, not %q", Langs, s.Lang)
}
if !slices.Contains(Trays, s.Tray) {
return fmt.Errorf("tray must be one of %v, not %q", Trays, s.Tray)
}
if s.SessionTerminal != "" && s.SessionTerminal != "system" && !validTerminalBundleID.MatchString(s.SessionTerminal) {
return fmt.Errorf("session terminal must be an app bundle id or system, not %q", s.SessionTerminal)
}
if !slices.Contains(Currencies, s.Currency) {
return fmt.Errorf("currency must be one of %v, not %q", Currencies, s.Currency)
}
if !slices.Contains(Warmups, s.CodexWarmup) {
return fmt.Errorf("codex warm-up must be off, week or all, not %q", s.CodexWarmup)
}
if !slices.Contains(Warmups, s.ClaudeWarmup) {
return fmt.Errorf("claude warm-up must be off, week or all, not %q", s.ClaudeWarmup)
}
for _, at := range []string{s.CodexWarmAt, s.ClaudeWarmAt} {
if _, _, ok := Clock(at); at != "" && !ok {
return fmt.Errorf("a warm-up's time of day must look like 06:00, not %q", at)
}
}
if !slices.Contains(TrayEvery, s.TrayUsageEvery) {
return fmt.Errorf("the menu bar's usage is refreshed every %v minutes, not %d", TrayEvery, s.TrayUsageEvery)
}
if !slices.Contains(TextSizes, s.TextSize) {
return fmt.Errorf("text size must be one of %v percent, not %d", TextSizes, s.TextSize)
}
s.Proxy = strings.TrimSpace(s.Proxy)
if err := CheckProxy(s.Proxy); err != nil {
return err
}
s.Vision = strings.TrimSpace(s.Vision)
if s.Vision != "" && s.Vision != "off" && !strings.Contains(s.Vision, "/") {
return fmt.Errorf("the vision model must be a model's id such as openai/gpt-5-mini, or off, not %q", s.Vision)
}
s.ImageGen = strings.TrimSpace(s.ImageGen)
if s.ImageGen != "" && s.ImageGen != "off" && !strings.Contains(s.ImageGen, "/") {
return fmt.Errorf("the image generation model must be a model's id such as openai/gpt-image-1, or off, not %q", s.ImageGen)
}
rules, err := redact.CheckRules(s.RedactRules)
if err != nil {
return err
}
s.RedactRules = rules
s.AgentOrder, s.AgentsHidden, s.AgentsShown = ids(s.AgentOrder), ids(s.AgentsHidden), ids(s.AgentsShown)
if err := os.MkdirAll(Dir(), 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return os.WriteFile(Path(), append(b, '\n'), 0o644)
}
func (s Settings) normal() Settings {
if s.Theme == "" {
s.Theme = "system"
}
if s.Lang == "" {
s.Lang = "system"
}
if s.Tray == "" {
s.Tray = "panel"
}
if s.Currency == "" {
s.Currency = "usd"
}
if s.CodexWarmup == "off" {
s.CodexWarmup = ""
}
if s.ClaudeWarmup == "off" {
s.ClaudeWarmup = ""
}
if s.TrayUsageEvery == 0 {
s.TrayUsageEvery = 3
}
if s.TextSize == 0 {
s.TextSize = 100
}
// a time of day as 06:00 whichever way it came (6:00, 06:00:00)
for _, at := range []*string{&s.CodexWarmAt, &s.ClaudeWarmAt} {
*at = strings.TrimSpace(*at)
if h, m, ok := Clock(*at); ok {
*at = fmt.Sprintf("%02d:%02d", h, m)
}
}
return s
}
// Clock reads a time of day, "06:00" (seconds, as a time field may send
// them, are dropped), as its hour and minute.
func Clock(at string) (hour, min int, ok bool) {
for _, layout := range []string{"15:04", "15:04:05"} {
if t, err := time.Parse(layout, at); err == nil {
return t.Hour(), t.Minute(), true
}
}
return 0, 0, false
}
// ids trims, drops empties and repeats, and keeps the first of each.
func ids(in []string) []string {
var out []string
for _, x := range in {
if x = strings.TrimSpace(x); x != "" && !slices.Contains(out, x) {
out = append(out, x)
}
}
return out
}