From 4a9046e28fe7e5273d670a7d62e8f4e4b36a8c07 Mon Sep 17 00:00:00 2001 From: GcsSloop Date: Sat, 28 Mar 2026 02:36:40 +0800 Subject: [PATCH 1/3] fix(accounts): show exact reset time for 7d window --- .../features/accounts/AccountsPage.test.tsx | 27 ++++++++++++------- .../src/features/accounts/AccountsPage.tsx | 18 ++----------- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/frontend/src/features/accounts/AccountsPage.test.tsx b/frontend/src/features/accounts/AccountsPage.test.tsx index f09d2c9..f113df3 100644 --- a/frontend/src/features/accounts/AccountsPage.test.tsx +++ b/frontend/src/features/accounts/AccountsPage.test.tsx @@ -1829,7 +1829,7 @@ describe("AccountsPage", () => { expect(screen.getByText("90%")).toBeInTheDocument(); }); - it("formats official reset windows with time for 5H and date for 7D", async () => { + it("formats official reset windows with explicit time for both 5H and 7D", async () => { const now = new Date(); const primaryReset = new Date(now.getTime() + 60 * 60 * 1000); const secondaryReset = new Date(now.getTime() + 2 * 60 * 60 * 1000); @@ -1926,12 +1926,20 @@ describe("AccountsPage", () => { element?.textContent === primaryResetDateTime, ), ).toBeInTheDocument(); + const secondaryResetTime = secondaryReset.toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + const secondaryResetDateTime = `${secondaryReset.toLocaleDateString("zh-CN", { + month: "numeric", + day: "numeric", + })} ${secondaryResetTime}`; expect( screen.getByText( - secondaryReset.toLocaleDateString("zh-CN", { - month: "numeric", - day: "numeric", - }), + (_content, element) => + element?.textContent === secondaryResetTime || + element?.textContent === secondaryResetDateTime, ), ).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "详情-official-main" })); @@ -1948,12 +1956,13 @@ describe("AccountsPage", () => { element?.textContent === detailPrimaryDateTimeValue, ), ).toBeInTheDocument(); + const detailSecondaryValue = `33% · ${secondaryResetTime}`; + const detailSecondaryDateTimeValue = `33% · ${secondaryResetDateTime}`; expect( within(detailModal).getByText( - `33% · ${secondaryReset.toLocaleDateString("zh-CN", { - month: "numeric", - day: "numeric", - })}`, + (_content, element) => + element?.textContent === detailSecondaryValue || + element?.textContent === detailSecondaryDateTimeValue, ), ).toBeInTheDocument(); }); diff --git a/frontend/src/features/accounts/AccountsPage.tsx b/frontend/src/features/accounts/AccountsPage.tsx index 2cf9fc5..ce1e594 100644 --- a/frontend/src/features/accounts/AccountsPage.tsx +++ b/frontend/src/features/accounts/AccountsPage.tsx @@ -402,20 +402,6 @@ function formatResetTime(value: string | undefined, language: AppLanguage) { })}`; } -function formatResetDate(value: string | undefined, language: AppLanguage) { - if (!value) { - return "--"; - } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return "--"; - } - return date.toLocaleDateString(language, { - month: "numeric", - day: "numeric", - }); -} - function formatTomorrowMidnight(language: AppLanguage, now = new Date()) { const nextMidnight = new Date(now); nextMidnight.setHours(24, 0, 0, 0); @@ -1359,7 +1345,7 @@ export function AccountsPage({ { label: "7D", remainingPercent: clampPercent(100 - record.secondary_used_percent), - resetLabel: formatResetDate(record.secondary_resets_at, language), + resetLabel: formatResetTime(record.secondary_resets_at, language), }, ] : isPPChatAccount(record) && (record.ppchat_today_added_quota ?? 0) > 0 @@ -1844,7 +1830,7 @@ export function AccountsPage({ {(100 - detailAccount.secondary_used_percent).toFixed(0)}% ·{" "} - {formatResetDate(detailAccount.secondary_resets_at, language)} + {formatResetTime(detailAccount.secondary_resets_at, language)} From 265c7e6d9e5d6ec337e76b0be1e11478850646e5 Mon Sep 17 00:00:00 2001 From: GcsSloop Date: Sat, 28 Mar 2026 02:41:56 +0800 Subject: [PATCH 2/3] docs: add system proxy resolution plan --- .../2026-03-28-system-proxy-resolution.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/plans/2026-03-28-system-proxy-resolution.md diff --git a/docs/plans/2026-03-28-system-proxy-resolution.md b/docs/plans/2026-03-28-system-proxy-resolution.md new file mode 100644 index 0000000..600b326 --- /dev/null +++ b/docs/plans/2026-03-28-system-proxy-resolution.md @@ -0,0 +1,79 @@ +# System Proxy Resolution Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make `system` upstream proxy mode read the actual OS system proxy on macOS and Windows instead of only using environment variables. + +**Architecture:** Keep the existing `netproxy` transport as the single proxy decision point. Add a platform-aware system proxy resolver behind `ResolveProxy`, with Linux and unsupported environments falling back to `http.ProxyFromEnvironment`. + +**Tech Stack:** Go, standard library `net/http`, platform commands on macOS, Windows registry / command helpers, existing backend tests. + +--- + +### Task 1: Add failing tests for system proxy resolution + +**Files:** +- Modify: `backend/internal/netproxy/transport_test.go` + +**Step 1: Write failing tests** +- Add a test proving `system` mode uses an injected system proxy resolver result instead of environment variables. +- Add a test proving `system` mode falls back to environment variables when system proxy lookup returns nothing. + +**Step 2: Run tests to verify failure** +Run: `cd backend && go test ./internal/netproxy -run 'TestResolveProxyUsesSystemProxyResolver|TestResolveProxySystemModeFallsBackToEnvironment'` +Expected: FAIL because no injectable system proxy resolver exists yet. + +**Step 3: Commit after green** +```bash +git add backend/internal/netproxy/transport_test.go backend/internal/netproxy/*.go +git commit -m "feat(netproxy): resolve system proxy from os settings" +``` + +### Task 2: Implement platform-aware system proxy resolution + +**Files:** +- Modify: `backend/internal/netproxy/transport.go` +- Create: `backend/internal/netproxy/system_proxy.go` +- Create: `backend/internal/netproxy/system_proxy_darwin.go` +- Create: `backend/internal/netproxy/system_proxy_windows.go` +- Create: `backend/internal/netproxy/system_proxy_other.go` + +**Step 1: Add resolver abstraction** +- Introduce a package-level `systemProxyResolver` function variable for test injection. +- Keep `ResolveProxy` as the only public decision point. + +**Step 2: Implement system mode behavior** +- In `system` mode, try OS proxy lookup first. +- If system lookup yields a valid proxy URL, return it. +- If lookup is unavailable or empty, fall back to `http.ProxyFromEnvironment`. + +**Step 3: Implement macOS resolver** +- Read the active network service proxy via `scutil --proxy`. +- Support HTTP and HTTPS proxies. +- Respect enabled flags and host/port presence. +- Prefer HTTPS proxy for `https` requests. + +**Step 4: Implement Windows resolver** +- Read `ProxyEnable` and `ProxyServer` from `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`. +- Support both per-scheme and single proxy formats. +- Prefer HTTPS proxy for `https` requests. + +**Step 5: Implement fallback resolver for unsupported platforms** +- Return nil so Linux/others keep environment behavior. + +### Task 3: Verify integrated behavior + +**Files:** +- Modify only if needed: existing tests + +**Step 1: Run targeted backend tests** +Run: `cd backend && go test ./internal/netproxy ./internal/usagedrv/... ./internal/bootstrap` +Expected: PASS + +**Step 2: Run broader API/provider smoke tests** +Run: `cd backend && go test ./internal/api ./internal/providers/...` +Expected: PASS + +**Step 3: Review diff** +Run: `git diff --stat` +Expected: only netproxy-related implementation and tests. From e8f36b1f5abf073bcab0a396ea2ffad4968c1aad Mon Sep 17 00:00:00 2001 From: GcsSloop Date: Sat, 28 Mar 2026 02:48:54 +0800 Subject: [PATCH 3/3] feat(netproxy): resolve system proxy from os settings --- backend/internal/netproxy/system_proxy.go | 10 +++ .../internal/netproxy/system_proxy_darwin.go | 50 +++++++++++++++ .../internal/netproxy/system_proxy_other.go | 12 ++++ .../internal/netproxy/system_proxy_windows.go | 62 +++++++++++++++++++ backend/internal/netproxy/transport.go | 21 +++++++ backend/internal/netproxy/transport_test.go | 56 +++++++++++++++++ 6 files changed, 211 insertions(+) create mode 100644 backend/internal/netproxy/system_proxy.go create mode 100644 backend/internal/netproxy/system_proxy_darwin.go create mode 100644 backend/internal/netproxy/system_proxy_other.go create mode 100644 backend/internal/netproxy/system_proxy_windows.go diff --git a/backend/internal/netproxy/system_proxy.go b/backend/internal/netproxy/system_proxy.go new file mode 100644 index 0000000..56a1cb1 --- /dev/null +++ b/backend/internal/netproxy/system_proxy.go @@ -0,0 +1,10 @@ +package netproxy + +import "net/url" + +func proxyURLFromParts(scheme string, host string, port string) (*url.URL, error) { + if host == "" || port == "" { + return nil, nil + } + return url.Parse(scheme + "://" + host + ":" + port) +} diff --git a/backend/internal/netproxy/system_proxy_darwin.go b/backend/internal/netproxy/system_proxy_darwin.go new file mode 100644 index 0000000..319e320 --- /dev/null +++ b/backend/internal/netproxy/system_proxy_darwin.go @@ -0,0 +1,50 @@ +//go:build darwin + +package netproxy + +import ( + "bufio" + "net/http" + "net/url" + "os/exec" + "strings" +) + +func resolveSystemProxy(req *http.Request) (*url.URL, error) { + cmd := exec.Command("scutil", "--proxy") + output, err := cmd.Output() + if err != nil { + return nil, nil + } + values := parseScutilProxyOutput(string(output)) + if strings.EqualFold(req.URL.Scheme, "https") { + if proxyURL, err := proxyFromScutil(values, "HTTPS"); proxyURL != nil || err != nil { + return proxyURL, err + } + } + return proxyFromScutil(values, "HTTP") +} + +func parseScutilProxyOutput(raw string) map[string]string { + values := map[string]string{} + scanner := bufio.NewScanner(strings.NewReader(raw)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || !strings.Contains(line, " : ") { + continue + } + parts := strings.SplitN(line, " : ", 2) + if len(parts) != 2 { + continue + } + values[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + return values +} + +func proxyFromScutil(values map[string]string, prefix string) (*url.URL, error) { + if values[prefix+"Enable"] != "1" { + return nil, nil + } + return proxyURLFromParts("http", values[prefix+"Proxy"], values[prefix+"Port"]) +} diff --git a/backend/internal/netproxy/system_proxy_other.go b/backend/internal/netproxy/system_proxy_other.go new file mode 100644 index 0000000..3dd6a62 --- /dev/null +++ b/backend/internal/netproxy/system_proxy_other.go @@ -0,0 +1,12 @@ +//go:build !darwin && !windows + +package netproxy + +import ( + "net/http" + "net/url" +) + +func resolveSystemProxy(req *http.Request) (*url.URL, error) { + return nil, nil +} diff --git a/backend/internal/netproxy/system_proxy_windows.go b/backend/internal/netproxy/system_proxy_windows.go new file mode 100644 index 0000000..3a4d7de --- /dev/null +++ b/backend/internal/netproxy/system_proxy_windows.go @@ -0,0 +1,62 @@ +//go:build windows + +package netproxy + +import ( + "net/http" + "net/url" + "strings" + + "golang.org/x/sys/windows/registry" +) + +func resolveSystemProxy(req *http.Request) (*url.URL, error) { + key, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Internet Settings`, registry.QUERY_VALUE) + if err != nil { + return nil, nil + } + defer key.Close() + + enabled, _, err := key.GetIntegerValue("ProxyEnable") + if err != nil || enabled == 0 { + return nil, nil + } + server, _, err := key.GetStringValue("ProxyServer") + if err != nil { + return nil, nil + } + return parseWindowsProxyServer(req, server) +} + +func parseWindowsProxyServer(req *http.Request, raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + entries := strings.Split(raw, ";") + perScheme := map[string]string{} + defaultEntry := "" + for _, entry := range entries { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if strings.Contains(entry, "=") { + parts := strings.SplitN(entry, "=", 2) + perScheme[strings.ToLower(strings.TrimSpace(parts[0]))] = strings.TrimSpace(parts[1]) + continue + } + defaultEntry = entry + } + selected := defaultEntry + if value, ok := perScheme[strings.ToLower(req.URL.Scheme)]; ok { + selected = value + } + if selected == "" { + return nil, nil + } + if strings.Contains(selected, "://") { + return url.Parse(selected) + } + return url.Parse("http://" + selected) +} diff --git a/backend/internal/netproxy/transport.go b/backend/internal/netproxy/transport.go index d6ac26c..85858fc 100644 --- a/backend/internal/netproxy/transport.go +++ b/backend/internal/netproxy/transport.go @@ -15,6 +15,20 @@ type settingsReader interface { GetAppSettings() (settings.AppSettings, error) } +var systemProxyResolver = resolveSystemProxy + +func SetSystemProxyResolverForTest(resolver func(*http.Request) (*url.URL, error)) func() { + previous := systemProxyResolver + if resolver == nil { + systemProxyResolver = resolveSystemProxy + } else { + systemProxyResolver = resolver + } + return func() { + systemProxyResolver = previous + } +} + func NewHTTPClient(repo settingsReader) *http.Client { transport := defaultTransportClone() transport.Proxy = func(req *http.Request) (*url.URL, error) { @@ -44,6 +58,13 @@ func ResolveProxy(req *http.Request, repo settingsReader) (*url.URL, error) { } return proxyURL, nil case "", settings.UpstreamProxyModeSystem: + proxyURL, err := systemProxyResolver(req) + if err != nil { + return nil, err + } + if proxyURL != nil { + return proxyURL, nil + } return http.ProxyFromEnvironment(req) default: return http.ProxyFromEnvironment(req) diff --git a/backend/internal/netproxy/transport_test.go b/backend/internal/netproxy/transport_test.go index 23b2730..eaae8bc 100644 --- a/backend/internal/netproxy/transport_test.go +++ b/backend/internal/netproxy/transport_test.go @@ -2,6 +2,7 @@ package netproxy_test import ( "net/http" + "net/url" "testing" "github.com/gcssloop/codex-router/backend/internal/netproxy" @@ -61,3 +62,58 @@ func TestResolveProxyUsesManualMode(t *testing.T) { t.Fatalf("ResolveProxy = %q, want %q", got.String(), "http://user:pass@127.0.0.1:7890") } } + +func TestResolveProxyUsesSystemProxyResolver(t *testing.T) { + t.Parallel() + + restore := netproxy.SetSystemProxyResolverForTest(func(req *http.Request) (*url.URL, error) { + return url.Parse("http://127.0.0.1:7897") + }) + defer restore() + + reader := stubSettingsReader{value: settings.AppSettings{ + UpstreamProxyMode: settings.UpstreamProxyModeSystem, + }} + req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) + if err != nil { + t.Fatalf("NewRequest returned error: %v", err) + } + + got, err := netproxy.ResolveProxy(req, reader) + if err != nil { + t.Fatalf("ResolveProxy returned error: %v", err) + } + if got == nil { + t.Fatal("ResolveProxy = nil, want system proxy URL") + } + if got.String() != "http://127.0.0.1:7897" { + t.Fatalf("ResolveProxy = %q, want %q", got.String(), "http://127.0.0.1:7897") + } +} + +func TestResolveProxySystemModeFallsBackToEnvironment(t *testing.T) { + t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888") + restore := netproxy.SetSystemProxyResolverForTest(func(req *http.Request) (*url.URL, error) { + return nil, nil + }) + defer restore() + + reader := stubSettingsReader{value: settings.AppSettings{ + UpstreamProxyMode: settings.UpstreamProxyModeSystem, + }} + req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) + if err != nil { + t.Fatalf("NewRequest returned error: %v", err) + } + + got, err := netproxy.ResolveProxy(req, reader) + if err != nil { + t.Fatalf("ResolveProxy returned error: %v", err) + } + if got == nil { + t.Fatal("ResolveProxy = nil, want environment proxy URL") + } + if got.String() != "http://127.0.0.1:8888" { + t.Fatalf("ResolveProxy = %q, want %q", got.String(), "http://127.0.0.1:8888") + } +}