From ee8a371cf45fc07f0b46f4cc3495f5fbdd037729 Mon Sep 17 00:00:00 2001 From: yetone Date: Wed, 30 Sep 2026 16:03:52 +0800 Subject: [PATCH] =?UTF-8?q?gui,=20update:=20saving=20the=20Settings=20page?= =?UTF-8?q?=20keeps=20the=20models=20hidden=20from=20each=20agent,=20and?= =?UTF-8?q?=20update=20checks=20go=20through=20magpie's=20proxy=20and=20sa?= =?UTF-8?q?y=20so=20(#294:=20=E8=AE=BE=E7=BD=AE=E4=BB=A3=E7=90=86=E4=B9=8B?= =?UTF-8?q?=E5=90=8E=E5=8F=AA=E6=9C=89=E6=A8=A1=E5=9E=8B=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E7=94=9F=E6=95=88=E4=BA=86=EF=BC=8C=E4=BB=A3=E7=90=86=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E7=94=A8=E4=BA=8E=E6=A3=80=E6=9F=A5=20magpie=20?= =?UTF-8?q?=E6=98=AF=E5=90=A6=E6=9C=89=E6=9B=B4=E6=96=B0)=20=E2=80=94=20PO?= =?UTF-8?q?ST=20/api/settings=20carried=20over=20the=20models'=20names,=20?= =?UTF-8?q?levels=20and=20images=20but=20not=20HiddenModels,=20which=20the?= =?UTF-8?q?=20page=20never=20sends,=20so=20changing=20the=20theme,=20the?= =?UTF-8?q?=20language=20or=20anything=20else=20there=20showed=20every=20m?= =?UTF-8?q?odel=20hidden=20one=20by=20one=20on=20the=20Agents=20page=20aga?= =?UTF-8?q?in;=20it=20is=20carried=20over=20with=20the=20others.=20The=20u?= =?UTF-8?q?pdate=20feed=20and=20the=20release=20came=20through=20http.Defa?= =?UTF-8?q?ultTransport,=20proxied=20only=20because=20main=20installs=20ne?= =?UTF-8?q?tproxy=20first;=20the=20update=20client=20now=20takes=20netprox?= =?UTF-8?q?y.Func=20on=20a=20transport=20of=20its=20own=20(Settings'=20Pro?= =?UTF-8?q?xy,=20else=20*=5FPROXY,=20else=20the=20system's).=20A=20check?= =?UTF-8?q?=20that=20failed=20at=20start-up,=20before=20a=20proxy=20was=20?= =?UTF-8?q?set,=20stayed=20failed=20for=20six=20hours:=20saving=20a=20new?= =?UTF-8?q?=20proxy=20now=20checks=20again=20through=20it.=20And=20the=20S?= =?UTF-8?q?ettings=20row=20read=20"requests=20to=20vendors=20go=20through?= =?UTF-8?q?=20{proxy}",=20which=20says=20update=20checks=20don't:=20it=20n?= =?UTF-8?q?ow=20names=20both.=20TestSettingsSaveKeepsWhatItDoesNotSend=20(?= =?UTF-8?q?every=20field=20prefsKeep=20doesn't=20send=20survives=20a=20sav?= =?UTF-8?q?e=20of=20the=20theme=20alone;=20fails=20on=20main=20with=20Hidd?= =?UTF-8?q?enModels=20emptied),=20TestUpdateGoesThroughTheProxy=20(feed=20?= =?UTF-8?q?and=20asset=20at=20hosts=20only=20a=20local=20proxy=20answers;?= =?UTF-8?q?=20fails=20without=20the=20transport),=20TestSettingsProxyReche?= =?UTF-8?q?cksAFailedUpdate=20(fails=20without=20the=20re-check).=20accoun?= =?UTF-8?q?t-proxy,=20provider-proxy=20and=20click-scroll=20GUI=20tests=20?= =?UTF-8?q?pass;=20go=20vet,=20go=20build=20-tags=20nogui=20(darwin,=20GOO?= =?UTF-8?q?S=3Dlinux),=20GOOS=3Dwindows=20go=20build,=20go=20test=20-tags?= =?UTF-8?q?=20nogui=20./...=20pass=20(TestUserPath,=20TestClaudeSignedOut,?= =?UTF-8?q?=20TestClaudeAccountNamedAsItsLogin,=20known=20flakes=20under?= =?UTF-8?q?=20load,=20pass=20alone).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gui/api.go | 9 +++- internal/gui/assets/app.js | 8 ++-- internal/gui/assets/i18n.js | 6 +-- internal/gui/settings_keep_test.go | 72 ++++++++++++++++++++++++++++++ internal/gui/update_proxy_test.go | 68 ++++++++++++++++++++++++++++ internal/update/proxy_test.go | 67 +++++++++++++++++++++++++++ internal/update/update.go | 13 +++++- 7 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 internal/gui/update_proxy_test.go create mode 100644 internal/update/proxy_test.go diff --git a/internal/gui/api.go b/internal/gui/api.go index 871ef009..249ad7c3 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -460,8 +460,10 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { in.AgentOrder, in.AgentsHidden, in.AgentsShown = cur.AgentOrder, cur.AgentsHidden, cur.AgentsShown in.Window = cur.Window // the window's own, as it was last resized // and what other pages keep here: the models' names, levels, images and - // who sees them, and sharing on the network, set on its own + // who sees them, the ones hidden from an agent one by one, and sharing + // on the network, set on its own in.Visible, in.ModelNames, in.ModelEfforts, in.ModelImages = cur.Visible, cur.ModelNames, cur.ModelEfforts, cur.ModelImages + in.HiddenModels = cur.HiddenModels in.LAN, in.LANKey = cur.LAN, cur.LANKey in.RedactRules = cur.RedactRules // the masking rules, set on their own // used or left is the Usage page's toggle as much as Settings', set on its own @@ -490,6 +492,11 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { if (in.TrayUsage != cur.TrayUsage || in.TrayUsageEvery != cur.TrayUsageEvery) && onTrayUsage != nil { onTrayUsage() } + // an update check that failed, without the proxy set just now, is + // tried again through it, not in six hours (#294) + if strings.TrimSpace(in.Proxy) != strings.TrimSpace(cur.Proxy) && updates.json().State == "error" { + go updates.check() + } writeJSON(rw, settingsState()) }) // whether usage reads as used or left: the Usage page's toggle and diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index e27ccfe3..8a5777bf 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -8390,7 +8390,7 @@ function renderTrayUsage(s, keep) { } const TRAY_EVERY = [1, 3, 5, 10, 30]; -// renderProxy: magpie's own requests to vendors follow the system proxy on +// renderProxy: magpie's own requests to vendors, and its update checks, follow the system proxy on // their own; this row says which one, and lets it be turned off or set. let proxyCustom = false; // Custom picked, nothing typed yet function renderProxy(s, keep) { @@ -8398,11 +8398,11 @@ function renderProxy(s, keep) { const mode = proxyCustom ? "custom" : cur; const sub = $("#proxySub"); sub.textContent = { - settings: t("Requests to vendors go through {proxy}", { proxy: s.proxyNow }), + settings: t("Requests to vendors and update checks go through {proxy}", { proxy: s.proxyNow }), system: t("Following the system proxy, {proxy}", { proxy: s.proxyNow }), environment: t("Following HTTPS_PROXY, {proxy}", { proxy: s.proxyNow }), - off: t("Off: requests to vendors go direct"), - none: t("No system proxy found; requests to vendors go direct"), + off: t("Off: requests to vendors and update checks go direct"), + none: t("No system proxy found; requests to vendors and update checks go direct"), }[s.proxySource] || ""; const box = $("#proxySegs"); box.replaceChildren(); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index af1bd0b1..99cbdd56 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -97,11 +97,11 @@ const I18N = { "Quick panel": "快捷面板", "Main window": "主窗口", "Proxy": "代理", - "Requests to vendors go through {proxy}": "对模型厂商的请求经由 {proxy}", + "Requests to vendors and update checks go through {proxy}": "对模型厂商的请求和检查更新都经由 {proxy}", "Following the system proxy, {proxy}": "跟随系统代理:{proxy}", "Following HTTPS_PROXY, {proxy}": "跟随 HTTPS_PROXY:{proxy}", - "Off: requests to vendors go direct": "已关闭:对模型厂商的请求直连", - "No system proxy found; requests to vendors go direct": "未发现系统代理,对模型厂商的请求直连", + "Off: requests to vendors and update checks go direct": "已关闭:对模型厂商的请求和检查更新都直连", + "No system proxy found; requests to vendors and update checks go direct": "未发现系统代理,对模型厂商的请求和检查更新都直连", "Off": "关闭", "On": "开启", "Images": "图像", diff --git a/internal/gui/settings_keep_test.go b/internal/gui/settings_keep_test.go index 364a841d..7ade6f1d 100644 --- a/internal/gui/settings_keep_test.go +++ b/internal/gui/settings_keep_test.go @@ -3,10 +3,14 @@ package gui import ( "net/http" "net/http/httptest" + "os" "path/filepath" + "reflect" + "regexp" "strings" "testing" + "github.com/yetone/magpie/internal/redact" "github.com/yetone/magpie/internal/settings" ) @@ -34,3 +38,71 @@ func TestSettingsSaveKeepsModelChoices(t *testing.T) { t.Fatalf("theme %q names %v efforts %v images %v", s.Theme, s.ModelNames, s.ModelEfforts, s.ModelImages) } } + +// Everything in settings the Settings page doesn't send — the models hidden +// from an agent one by one among it — outlives a save of that page: each +// field prefsKeep leaves out is set here, and must be as it was after a +// save of the theme alone. A field added to settings is either sent by the +// page or has to be set here, and kept by the handler. +func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) { + h := t.TempDir() + t.Setenv("HOME", h) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache")) + js, err := os.ReadFile("assets/app.js") + if err != nil { + t.Fatal(err) + } + _, fn, _ := strings.Cut(string(js), "\nfunction prefsKeep(s) {") + fn, _, _ = strings.Cut(fn, "\n}\n") + sent := map[string]bool{} + for _, m := range regexp.MustCompile(`(\w+):`).FindAllStringSubmatch(fn, -1) { + sent[m[1]] = true + } + if !sent["theme"] || !sent["proxy"] { + t.Fatalf("prefsKeep not read from app.js: %v", sent) + } + was := settings.Settings{ + AgentOrder: []string{"codex"}, + AgentsHidden: []string{"goose"}, + AgentsShown: []string{"pi"}, + Visible: map[string][]string{"claude": {"anthropic"}}, + HiddenModels: map[string][]string{"claude": {"p/m"}, "codex": {"p/n", "group/g"}}, + ModelNames: map[string]string{"p/m": "Mine"}, + ModelEfforts: map[string][]string{"p/m": {"low"}}, + ModelImages: map[string]bool{"p/m": true}, + RedactRules: []redact.Rule{{Kind: "prefix", Prefix: "oc_sk_"}}, + LAN: true, + LANKey: "sk-lan", + QuotaLeft: true, + TextSize: 125, + Window: []int{900, 700}, + } + if err := settings.Save(was); err != nil { + t.Fatal(err) + } + was = settings.Load() + rec := httptest.NewRecorder() + Handler(nil, nil).ServeHTTP(rec, httptest.NewRequest("POST", "/api/settings", strings.NewReader(`{"theme":"dark"}`))) + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + now := settings.Load() + if now.Theme != "dark" { + t.Fatalf("theme %q", now.Theme) + } + wv, nv, typ := reflect.ValueOf(was), reflect.ValueOf(now), reflect.TypeOf(was) + for i := range typ.NumField() { + name, _, _ := strings.Cut(typ.Field(i).Tag.Get("json"), ",") + if sent[name] { + continue + } + if wv.Field(i).IsZero() { + t.Errorf("%s: not sent by the Settings page; set it in this test and keep it in POST /api/settings", typ.Field(i).Name) + continue + } + if !reflect.DeepEqual(wv.Field(i).Interface(), nv.Field(i).Interface()) { + t.Errorf("%s: %v after a save of the Settings page, was %v", typ.Field(i).Name, nv.Field(i).Interface(), wv.Field(i).Interface()) + } + } +} diff --git a/internal/gui/update_proxy_test.go b/internal/gui/update_proxy_test.go new file mode 100644 index 00000000..9cadb45a --- /dev/null +++ b/internal/gui/update_proxy_test.go @@ -0,0 +1,68 @@ +package gui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/yetone/magpie/internal/settings" + "github.com/yetone/magpie/internal/update" +) + +// An update check that failed going direct is tried again as soon as a +// proxy is set on the Settings page, through it (#294), rather than the +// error staying up till the next check six hours on. +func TestSettingsProxyRechecksAFailedUpdate(t *testing.T) { + h := t.TempDir() + t.Setenv("HOME", h) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache")) + for _, k := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"} { + t.Setenv(k, "") + } + var hits atomic.Int32 + px := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Host == "feed.magpie.invalid" { + hits.Add(1) + } + json.NewEncoder(w).Encode(update.Release{Version: "9.9.9"}) + })) + defer px.Close() + // direct, the feed is nowhere to be found + if err := settings.Save(settings.Settings{Proxy: "direct"}); err != nil { + t.Fatal(err) + } + t.Setenv("MAGPIE_UPDATE_FEED", "http://feed.magpie.invalid/api/latest") + old := updates + updates = &updater{} + defer func() { updates = old }() + updates.check() + if j := updates.json(); j.State != "error" { + t.Fatalf("direct: %+v", j) + } + + rec := httptest.NewRecorder() + Handler(nil, nil).ServeHTTP(rec, httptest.NewRequest("POST", "/api/settings", strings.NewReader(`{"proxy":"`+px.URL+`"}`))) + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + deadline := time.Now().Add(5 * time.Second) + for { + j := updates.json() + if j.State != "error" && j.State != "checking" { + if j.Latest != "9.9.9" || hits.Load() != 1 { + t.Fatalf("%+v, %d through the proxy", j, hits.Load()) + } + return + } + if time.Now().After(deadline) { + t.Fatalf("not checked again through the proxy: %+v, %d through it", j, hits.Load()) + } + time.Sleep(20 * time.Millisecond) + } +} diff --git a/internal/update/proxy_test.go b/internal/update/proxy_test.go new file mode 100644 index 00000000..0e29d219 --- /dev/null +++ b/internal/update/proxy_test.go @@ -0,0 +1,67 @@ +package update + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "slices" + "sync" + "testing" + + "github.com/yetone/magpie/internal/settings" +) + +// The update feed and a release's download go through the proxy set in +// Settings, as magpie's other requests do (#294): a network that reaches +// usemagpie.ai and GitHub only through it gets its updates too. +func TestUpdateGoesThroughTheProxy(t *testing.T) { + h := t.TempDir() + t.Setenv("HOME", h) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config")) + for _, k := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"} { + t.Setenv(k, "") + } + body := []byte("new magpie") + sum := sha256.Sum256(body) + var mu sync.Mutex + var seen []string + // the proxy: a plain http:// request comes to it whole, by its URL + px := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + seen = append(seen, r.URL.String()) + mu.Unlock() + if r.URL.Path == "/asset" { + w.Write(body) + return + } + json.NewEncoder(w).Encode(Release{Version: "9.9.9", Assets: map[string]Asset{ + BinaryAsset(): {URL: "http://releases.magpie.invalid/asset", SHA256: hex.EncodeToString(sum[:])}, + }}) + })) + defer px.Close() + if err := settings.Save(settings.Settings{Proxy: px.URL}); err != nil { + t.Fatal(err) + } + // hosts that resolve nowhere: only the proxy can answer for them + t.Setenv("MAGPIE_UPDATE_FEED", "http://feed.magpie.invalid/api/latest") + rel, err := Latest(context.Background()) + if err != nil { + t.Fatal(err) + } + if rel.Version != "9.9.9" { + t.Fatalf("version %q", rel.Version) + } + path := filepath.Join(t.TempDir(), "magpie.new") + if err := download(context.Background(), rel.Assets[BinaryAsset()], path); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if want := []string{"http://feed.magpie.invalid/api/latest", "http://releases.magpie.invalid/asset"}; !slices.Equal(seen, want) { + t.Fatalf("through the proxy: %v, want %v", seen, want) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index 3d31e275..047ab6db 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -26,6 +26,7 @@ import ( "strings" "time" + "github.com/yetone/magpie/internal/netproxy" "github.com/yetone/magpie/internal/proc" ) @@ -56,7 +57,17 @@ type Asset struct { SHA256 string `json:"sha256"` } -var client = &http.Client{Timeout: 10 * time.Minute} +// client asks the feed and downloads a release through the proxy the rest +// of magpie's requests take — Settings' Proxy, else the environment's, else +// the system's (#294) — on a transport of its own, whatever the process has +// done to http.DefaultTransport. +var client = &http.Client{Timeout: 10 * time.Minute, Transport: proxied()} + +func proxied() http.RoundTripper { + t := http.DefaultTransport.(*http.Transport).Clone() + t.Proxy = netproxy.Func + return t +} // Latest asks the feed for the newest release. func Latest(ctx context.Context) (*Release, error) {