gui, update: saving the Settings page keeps the models hidden from each agent, and update checks go through magpie's proxy and say so (#294: 设置代理之后只有模型代理生效了,代理没有用于检查 magpie 是否有更新) — POST /api/settings carried over the models' names, levels and images but not HiddenModels, which the page never sends, so changing the theme, the language or anything else there showed every model hidden one by one on the Agents page again; it is carried over with the others. The update feed and the release came through http.DefaultTransport, proxied only because main installs netproxy first; the update client now takes netproxy.Func on a transport of its own (Settings' Proxy, else *_PROXY, else the system's). A check that failed at start-up, before a proxy was set, stayed failed for six hours: saving a new proxy now checks again through it. And the Settings row read "requests to vendors go through {proxy}", which says update checks don't: it now names both. TestSettingsSaveKeepsWhatItDoesNotSend (every field prefsKeep doesn't send survives a save of the theme alone; fails on main with HiddenModels emptied), TestUpdateGoesThroughTheProxy (feed and asset at hosts only a local proxy answers; fails without the transport), TestSettingsProxyRechecksAFailedUpdate (fails without the re-check). account-proxy, provider-proxy and click-scroll GUI tests pass; go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestUserPath, TestClaudeSignedOut, TestClaudeAccountNamedAsItsLogin, known flakes under load, pass alone).
This commit is contained in:
7 files changed
+234
-9
No files matched your search
+8
-1
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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": "图像",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user