From 4b252ee69795d02c3b57a7622bf09a584179b969 Mon Sep 17 00:00:00 2001 From: ant-black <1016930479@qq.com> Date: Sat, 9 May 2026 20:58:18 +0800 Subject: [PATCH] fix: keep launch proxy overrides temporary --- backend/app_instance_start.go | 10 +-- backend/app_instance_start_prepare.go | 10 ++- backend/app_instance_start_proxy.go | 72 +++++++++++++++---- backend/app_instance_start_test.go | 49 +++++++++++++ backend/app_launchcode.go | 3 +- .../internal/launchcode/profile_api_ops.go | 6 +- .../launchcode/runtime_session_api.go | 3 +- backend/internal/launchcode/server.go | 2 + .../internal/launchcode/server_http_launch.go | 3 +- .../internal/launchcode/server_http_utils.go | 8 +++ backend/test/launchcode/server_params_test.go | 47 ++++++++++++ .../pages/launchApiDocs/structuredApiDocs.ts | 1 + frontend/src/wailsjs/go/models.ts | 4 ++ 13 files changed, 188 insertions(+), 30 deletions(-) diff --git a/backend/app_instance_start.go b/backend/app_instance_start.go index dec98fbc..4e110665 100644 --- a/backend/app_instance_start.go +++ b/backend/app_instance_start.go @@ -1,7 +1,7 @@ package backend func (a *App) BrowserInstanceStart(profileId string) (*BrowserProfile, error) { - return a.browserInstanceStartInternal(profileId, nil, nil, false, false, false) + return a.browserInstanceStartInternal(profileId, nil, nil, false, false, false, "", "") } func shouldPreferVisibleWindowForStartWithParams(startURLs []string) bool { @@ -10,17 +10,17 @@ func shouldPreferVisibleWindowForStartWithParams(startURLs []string) bool { // BrowserInstanceStartDirect 仅本次启动走直连,不落库修改实例代理配置。 func (a *App) BrowserInstanceStartDirect(profileId string) (*BrowserProfile, error) { - return a.browserInstanceStartInternal(profileId, nil, nil, false, false, true) + return a.browserInstanceStartInternal(profileId, nil, nil, false, false, true, "", "") } // BrowserInstanceStartWithParams 通过额外参数启动实例(仅本次启动生效,不落库) func (a *App) BrowserInstanceStartWithParams(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool) (*BrowserProfile, error) { preferVisibleWindow := shouldPreferVisibleWindowForStartWithParams(startURLs) - return a.browserInstanceStartInternal(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow, false) + return a.browserInstanceStartInternal(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow, false, "", "") } -func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool, forceDirectProxy bool) (*BrowserProfile, error) { - input := newBrowserStartInput(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow, forceDirectProxy) +func (a *App) browserInstanceStartInternal(profileId string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool, forceDirectProxy bool, proxyId string, proxyConfig string) (*BrowserProfile, error) { + input := newBrowserStartInput(profileId, extraLaunchArgs, startURLs, skipDefaultStartURLs, preferVisibleWindow, forceDirectProxy, proxyId, proxyConfig) a.browserMgr.Mutex.Lock() defer a.browserMgr.Mutex.Unlock() diff --git a/backend/app_instance_start_prepare.go b/backend/app_instance_start_prepare.go index e9762333..d9b575fd 100644 --- a/backend/app_instance_start_prepare.go +++ b/backend/app_instance_start_prepare.go @@ -17,6 +17,8 @@ type browserStartInput struct { SkipDefaultStartURLs bool PreferVisibleWindow bool ForceDirectProxy bool + TemporaryProxyID string + TemporaryProxyConfig string } type browserStartPlan struct { @@ -34,7 +36,7 @@ type browserStartPlan struct { totalReadyTimeout time.Duration } -func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool, forceDirectProxy bool) browserStartInput { +func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs []string, skipDefaultStartURLs bool, preferVisibleWindow bool, forceDirectProxy bool, proxyID string, proxyConfig string) browserStartInput { normalizedExtraLaunchArgs := normalizeNonEmptyStrings(extraLaunchArgs) if preferVisibleWindow { normalizedExtraLaunchArgs = ensureNewWindowLaunchArg(normalizedExtraLaunchArgs) @@ -47,9 +49,15 @@ func newBrowserStartInput(profileID string, extraLaunchArgs []string, startURLs SkipDefaultStartURLs: skipDefaultStartURLs, PreferVisibleWindow: preferVisibleWindow, ForceDirectProxy: forceDirectProxy, + TemporaryProxyID: strings.TrimSpace(proxyID), + TemporaryProxyConfig: strings.TrimSpace(proxyConfig), } } +func (input browserStartInput) hasTemporaryProxy() bool { + return strings.TrimSpace(input.TemporaryProxyID) != "" || strings.TrimSpace(input.TemporaryProxyConfig) != "" +} + func (plan *browserStartPlan) releaseBridgeIfNeeded(a *App) { if plan == nil || a == nil { return diff --git a/backend/app_instance_start_proxy.go b/backend/app_instance_start_proxy.go index f2a0b372..58531fa9 100644 --- a/backend/app_instance_start_proxy.go +++ b/backend/app_instance_start_proxy.go @@ -7,15 +7,42 @@ import ( "strings" ) +const temporaryDirectProxyID = "__direct__" + func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *BrowserProfile) (string, string, bool, error) { log := logger.New("Browser") proxies := a.getLatestProxies() profileID := input.ProfileID + if input.ForceDirectProxy { + log.Warn("按请求直连启动实例", + logger.F("profile_id", profileID), + logger.F("proxy_id", profile.ProxyId), + ) + return "direct://", "", false, nil + } + + resolvedProxyID := strings.TrimSpace(profile.ProxyId) resolvedProxyConfig := strings.TrimSpace(profile.ProxyConfig) - if profile.ProxyId != "" { + usingTemporaryProxy := input.hasTemporaryProxy() + if usingTemporaryProxy { + var err error + resolvedProxyID, resolvedProxyConfig, err = resolveTemporaryBrowserStartProxy(input.TemporaryProxyID, input.TemporaryProxyConfig, proxies) + if err != nil { + startErr := fmt.Errorf("实例启动失败:%s", err.Error()) + profile.LastError = startErr.Error() + log.Error("一次性代理配置无效", + logger.F("profile_id", profileID), + logger.F("temporary_proxy_id", input.TemporaryProxyID), + logger.F("error", err.Error()), + logger.F("reason", startErr.Error()), + ) + return "", "", false, startErr + } + } else if resolvedProxyID != "" { for _, item := range proxies { - if strings.EqualFold(item.ProxyId, profile.ProxyId) { + if strings.EqualFold(item.ProxyId, resolvedProxyID) { + resolvedProxyID = strings.TrimSpace(item.ProxyId) resolvedProxyConfig = strings.TrimSpace(item.ProxyConfig) break } @@ -26,21 +53,17 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser logger.F("profile_id", profileID), logger.F("proxy_id", profile.ProxyId), logger.F("profile_proxy_config", profile.ProxyConfig), + logger.F("temporary_proxy", usingTemporaryProxy), + logger.F("temporary_proxy_id", input.TemporaryProxyID), + logger.F("temporary_proxy_config", input.TemporaryProxyConfig), logger.F("resolved_proxy_config", resolvedProxyConfig), ) - if input.ForceDirectProxy { - log.Warn("按请求直连启动实例", - logger.F("profile_id", profileID), - logger.F("proxy_id", profile.ProxyId), - ) - return "direct://", "", false, nil - } - if supported, errorMsg := proxy.ValidateProxyConfig(resolvedProxyConfig, proxies, profile.ProxyId); !supported { + if supported, errorMsg := proxy.ValidateProxyConfig(resolvedProxyConfig, proxies, resolvedProxyID); !supported { startErr := fmt.Errorf("实例启动失败:%s", errorMsg) profile.LastError = startErr.Error() log.Error("代理配置无效", logger.F("profile_id", profileID), - logger.F("proxy_id", profile.ProxyId), + logger.F("proxy_id", resolvedProxyID), logger.F("error", errorMsg), logger.F("reason", startErr.Error()), ) @@ -48,7 +71,7 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser } if proxy.IsSingBoxProtocol(resolvedProxyConfig) { - socksURL, bridgeErr := a.singboxMgr.EnsureBridge(resolvedProxyConfig, proxies, profile.ProxyId) + socksURL, bridgeErr := a.singboxMgr.EnsureBridge(resolvedProxyConfig, proxies, resolvedProxyID) if bridgeErr != nil { startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(sing-box)。原因:%v。请检查代理节点配置、sing-box 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr) log.Error("代理桥接失败(sing-box)", @@ -62,8 +85,8 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser return socksURL, "", false, nil } - if proxy.RequiresBridge(resolvedProxyConfig, proxies, profile.ProxyId) || proxy.RequiresLocalProxyBridgeForBrowser(resolvedProxyConfig) { - socksURL, bridgeKey, bridgeErr := a.xrayMgr.AcquireBridge(resolvedProxyConfig, proxies, profile.ProxyId) + if proxy.RequiresBridge(resolvedProxyConfig, proxies, resolvedProxyID) || proxy.RequiresLocalProxyBridgeForBrowser(resolvedProxyConfig) { + socksURL, bridgeKey, bridgeErr := a.xrayMgr.AcquireBridge(resolvedProxyConfig, proxies, resolvedProxyID) if bridgeErr != nil { startErr := fmt.Errorf("实例启动失败:代理桥接启动失败(xray)。原因:%v。请检查代理节点配置、xray 可执行文件是否存在,以及本地端口是否被占用。", bridgeErr) log.Error("代理桥接失败(xray)", @@ -79,3 +102,24 @@ func (a *App) resolveBrowserStartProxy(input browserStartInput, profile *Browser return resolvedProxyConfig, "", false, nil } + +func resolveTemporaryBrowserStartProxy(proxyID string, proxyConfig string, proxies []BrowserProxy) (string, string, error) { + proxyID = strings.TrimSpace(proxyID) + proxyConfig = strings.TrimSpace(proxyConfig) + if proxyID == "" { + return "", proxyConfig, nil + } + + for _, item := range proxies { + if strings.EqualFold(item.ProxyId, proxyID) { + return strings.TrimSpace(item.ProxyId), strings.TrimSpace(item.ProxyConfig), nil + } + } + if strings.EqualFold(proxyID, temporaryDirectProxyID) { + return temporaryDirectProxyID, "direct://", nil + } + if proxyConfig != "" { + return "", proxyConfig, nil + } + return "", "", fmt.Errorf("代理ID不存在(proxy id not found: %s),且未提供 proxyConfig", proxyID) +} diff --git a/backend/app_instance_start_test.go b/backend/app_instance_start_test.go index f634d305..2cb72054 100644 --- a/backend/app_instance_start_test.go +++ b/backend/app_instance_start_test.go @@ -461,6 +461,55 @@ func TestSanitizeManagedLaunchArgsKeepsUnmanagedFlags(t *testing.T) { } } +func TestResolveBrowserStartProxyUsesTemporaryProxyWithoutMutatingProfile(t *testing.T) { + t.Parallel() + + cfg := config.DefaultConfig() + cfg.Browser.Proxies = []config.BrowserProxy{ + {ProxyId: "stored-proxy", ProxyName: "Stored", ProxyConfig: "http://127.0.0.1:18080"}, + {ProxyId: "runtime-proxy", ProxyName: "Runtime", ProxyConfig: "http://127.0.0.1:28080"}, + } + app := NewApp("") + app.config = cfg + app.browserMgr = browser.NewManager(cfg, t.TempDir()) + profile := &BrowserProfile{ + ProfileId: "profile-temporary-proxy", + ProfileName: "Temporary Proxy", + ProxyId: "stored-proxy", + ProxyConfig: "http://127.0.0.1:18080", + } + input := newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "runtime-proxy", "") + + effectiveProxy, bridgeKey, releaseBridge, err := app.resolveBrowserStartProxy(input, profile) + if err != nil { + t.Fatalf("resolveBrowserStartProxy returned error: %v", err) + } + if effectiveProxy != "http://127.0.0.1:28080" { + t.Fatalf("expected temporary proxy, got %q", effectiveProxy) + } + if bridgeKey != "" || releaseBridge { + t.Fatalf("plain HTTP proxy should not acquire bridge: key=%q release=%v", bridgeKey, releaseBridge) + } + if profile.ProxyId != "stored-proxy" || profile.ProxyConfig != "http://127.0.0.1:18080" { + t.Fatalf("temporary proxy should not mutate profile: %+v", profile) + } + + fallbackInput := newBrowserStartInput(profile.ProfileId, nil, nil, false, false, false, "missing-proxy", "http://127.0.0.1:38080") + effectiveProxy, bridgeKey, releaseBridge, err = app.resolveBrowserStartProxy(fallbackInput, profile) + if err != nil { + t.Fatalf("fallback temporary proxy returned error: %v", err) + } + if effectiveProxy != "http://127.0.0.1:38080" { + t.Fatalf("expected fallback temporary proxy config, got %q", effectiveProxy) + } + if bridgeKey != "" || releaseBridge { + t.Fatalf("fallback HTTP proxy should not acquire bridge: key=%q release=%v", bridgeKey, releaseBridge) + } + if profile.ProxyId != "stored-proxy" || profile.ProxyConfig != "http://127.0.0.1:18080" { + t.Fatalf("fallback temporary proxy should not mutate profile: %+v", profile) + } +} + func TestAppendLaunchTargetsUsesConfiguredDefaultStartURLs(t *testing.T) { t.Parallel() diff --git a/backend/app_launchcode.go b/backend/app_launchcode.go index 751d5851..7baa2176 100644 --- a/backend/app_launchcode.go +++ b/backend/app_launchcode.go @@ -14,7 +14,8 @@ func (a *App) StartInstance(profileId string) (*browser.Profile, error) { // StartInstanceWithParams 实现 launchcode.BrowserStarterWithParams 接口 func (a *App) StartInstanceWithParams(profileId string, params launchcode.LaunchRequestParams) (*browser.Profile, error) { - return a.BrowserInstanceStartWithParams(profileId, params.LaunchArgs, params.StartURLs, params.SkipDefaultStartURLs) + preferVisibleWindow := shouldPreferVisibleWindowForStartWithParams(params.StartURLs) + return a.browserInstanceStartInternal(profileId, params.LaunchArgs, params.StartURLs, params.SkipDefaultStartURLs, preferVisibleWindow, false, params.ProxyId, params.ProxyConfig) } // StatusInstance 实现 launchcode.BrowserStatusProvider 接口 diff --git a/backend/internal/launchcode/profile_api_ops.go b/backend/internal/launchcode/profile_api_ops.go index ac62b425..935fb1a2 100644 --- a/backend/internal/launchcode/profile_api_ops.go +++ b/backend/internal/launchcode/profile_api_ops.go @@ -60,11 +60,7 @@ func (s *LaunchServer) maybeAutoLaunchProfile(profile *browser.Profile, req Prof params := LaunchRequestParams{} if req.Start != nil { - params = LaunchRequestParams{ - LaunchArgs: normalizeStringSlice(req.Start.LaunchArgs), - StartURLs: normalizeStringSlice(req.Start.StartURLs), - SkipDefaultStartURLs: req.Start.SkipDefaultStartURLs, - } + params = normalizeLaunchRequestParams(*req.Start) } launchedProfile, err := s.launchProfile(profile.ProfileId, params) diff --git a/backend/internal/launchcode/runtime_session_api.go b/backend/internal/launchcode/runtime_session_api.go index 839da8de..cc13f0fd 100644 --- a/backend/internal/launchcode/runtime_session_api.go +++ b/backend/internal/launchcode/runtime_session_api.go @@ -85,8 +85,7 @@ func (s *LaunchServer) handleRuntimeSession(w http.ResponseWriter, r *http.Reque return } - req.LaunchArgs = normalizeStringSlice(req.LaunchArgs) - req.StartURLs = normalizeStringSlice(req.StartURLs) + req.LaunchRequestParams = normalizeLaunchRequestParams(req.LaunchRequestParams) profile, launchCode, status, errMsg := s.launchBySelector(selector, req.LaunchRequestParams) if errMsg != "" { writeJSON(w, status, map[string]interface{}{ diff --git a/backend/internal/launchcode/server.go b/backend/internal/launchcode/server.go index 6acba63f..79a8593b 100644 --- a/backend/internal/launchcode/server.go +++ b/backend/internal/launchcode/server.go @@ -30,6 +30,8 @@ type LaunchRequestParams struct { LaunchArgs []string `json:"launchArgs"` StartURLs []string `json:"startUrls"` SkipDefaultStartURLs bool `json:"skipDefaultStartUrls"` + ProxyId string `json:"proxyId"` + ProxyConfig string `json:"proxyConfig"` } // LaunchRequest POST /api/launch 的请求体 diff --git a/backend/internal/launchcode/server_http_launch.go b/backend/internal/launchcode/server_http_launch.go index c23cc603..d4eb6a07 100644 --- a/backend/internal/launchcode/server_http_launch.go +++ b/backend/internal/launchcode/server_http_launch.go @@ -90,8 +90,7 @@ func (s *LaunchServer) handleLaunchWithBody(w http.ResponseWriter, r *http.Reque return } - req.LaunchArgs = normalizeStringSlice(req.LaunchArgs) - req.StartURLs = normalizeStringSlice(req.StartURLs) + req.LaunchRequestParams = normalizeLaunchRequestParams(req.LaunchRequestParams) if selector.MatchMode == launchMatchModeAll { profiles, status, errMsg := s.launchAllBySelector(selector, req.LaunchRequestParams) if errMsg != "" { diff --git a/backend/internal/launchcode/server_http_utils.go b/backend/internal/launchcode/server_http_utils.go index 649eed4e..970604b3 100644 --- a/backend/internal/launchcode/server_http_utils.go +++ b/backend/internal/launchcode/server_http_utils.go @@ -75,6 +75,14 @@ func normalizeStringSlice(items []string) []string { return out } +func normalizeLaunchRequestParams(params LaunchRequestParams) LaunchRequestParams { + params.LaunchArgs = normalizeStringSlice(params.LaunchArgs) + params.StartURLs = normalizeStringSlice(params.StartURLs) + params.ProxyId = strings.TrimSpace(params.ProxyId) + params.ProxyConfig = strings.TrimSpace(params.ProxyConfig) + return params +} + func remoteIP(remoteAddr string) string { host, _, err := net.SplitHostPort(remoteAddr) if err != nil { diff --git a/backend/test/launchcode/server_params_test.go b/backend/test/launchcode/server_params_test.go index 8dc891bb..2ffd2912 100644 --- a/backend/test/launchcode/server_params_test.go +++ b/backend/test/launchcode/server_params_test.go @@ -93,6 +93,53 @@ func TestLaunchWithParams(t *testing.T) { } } +func TestLaunchWithTemporaryProxyParams(t *testing.T) { + svc := newInMemoryService() + starter := newMockStarterWithParams() + starter.addProfile(&browser.Profile{ + ProfileId: "profile-temporary-proxy", + ProfileName: "temporary-proxy", + ProxyId: "stored-proxy", + ProxyConfig: "http://127.0.0.1:18080", + Pid: 322, + DebugPort: 9556, + DebugReady: true, + Running: true, + LastStartAt: "2026-05-09T00:00:00Z", + LastError: "", + LaunchCode: "", + RuntimeWarning: "", + }) + + code, err := svc.EnsureCode("profile-temporary-proxy") + if err != nil { + t.Fatalf("EnsureCode 失败: %v", err) + } + + handler := buildTestHandler(svc, starter) + body := map[string]interface{}{ + "code": code, + "proxyConfig": " http://127.0.0.1:28080 ", + } + payload, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/api/launch", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("期望 200,实际 %d,body=%s", w.Code, w.Body.String()) + } + if starter.lastParams.ProxyConfig != "http://127.0.0.1:28080" { + t.Fatalf("一次性 proxyConfig 未透传或未归一化: %+v", starter.lastParams) + } + profile := starter.profiles["profile-temporary-proxy"] + if profile.ProxyConfig != "http://127.0.0.1:18080" || profile.ProxyId != "stored-proxy" { + t.Fatalf("启动接口不应覆盖实例原代理: %+v", profile) + } +} + func TestLaunchWithParamsUsingCodeAsKeywordFallback(t *testing.T) { svc := newInMemoryService() starter := newMockStarterWithParams() diff --git a/frontend/src/modules/browser/pages/launchApiDocs/structuredApiDocs.ts b/frontend/src/modules/browser/pages/launchApiDocs/structuredApiDocs.ts index e86a2b91..6ceaaaa4 100644 --- a/frontend/src/modules/browser/pages/launchApiDocs/structuredApiDocs.ts +++ b/frontend/src/modules/browser/pages/launchApiDocs/structuredApiDocs.ts @@ -488,6 +488,7 @@ export const STRUCTURED_API_ENDPOINT_DOCS: StructuredApiEndpointDoc[] = [ notes: [ 'selector 为空且没有任何兼容顶层选择字段时返回 400。', 'matchMode=all 只在这个接口可用。', + 'proxyId / proxyConfig 只影响本次启动,不覆盖实例原代理。', ], }, { diff --git a/frontend/src/wailsjs/go/models.ts b/frontend/src/wailsjs/go/models.ts index a6de00d9..75e2e48e 100755 --- a/frontend/src/wailsjs/go/models.ts +++ b/frontend/src/wailsjs/go/models.ts @@ -899,6 +899,8 @@ export namespace launchcode { launchArgs: string[]; startUrls: string[]; skipDefaultStartUrls: boolean; + proxyId: string; + proxyConfig: string; static createFrom(source: any = {}) { return new LaunchRequestParams(source); @@ -909,6 +911,8 @@ export namespace launchcode { this.launchArgs = source["launchArgs"]; this.startUrls = source["startUrls"]; this.skipDefaultStartUrls = source["skipDefaultStartUrls"]; + this.proxyId = source["proxyId"]; + this.proxyConfig = source["proxyConfig"]; } }