feat(all): configure proxies, reuse native HTTP and stabilize recovery

Persist direct/manual proxy settings in Client Studio and apply them to resource,
Unity and native requests, with loopback bypass and no automatic system proxy.
Pool WinHTTP/curl connections, bound workers and deadlines, cancel safely,
and log queue and transport timings without exposing sensitive paths.
Return explicit relay failures, probe through native HTTP, retry interrupted
requests and recognize completed home, field and pack scenes during recovery.
This commit is contained in:
2026-10-04 20:10:01 +08:00
parent aec053f7d2
commit e4276eb493
23 changed files with 1001 additions and 410 deletions
+8 -4
View File
@@ -33,6 +33,7 @@ type Options struct {
}
type Request struct {
ProxyURL string `json:"proxy_url"`
GameDirectory string `json:"game_directory"`
ServerOrigin string `json:"server_origin"`
CDNMode clientconfig.CDNMode `json:"cdn_mode"`
@@ -41,7 +42,7 @@ type Request struct {
}
func (r Request) settings() clientconfig.Settings {
return clientconfig.Settings{ServerOrigin: r.ServerOrigin, CDNMode: r.CDNMode, LocalResourceDirectory: r.LocalResourceDirectory}
return clientconfig.Settings{ServerOrigin: r.ServerOrigin, ProxyURL: r.ProxyURL, CDNMode: r.CDNMode, LocalResourceDirectory: r.LocalResourceDirectory}
}
type Response struct {
@@ -51,6 +52,7 @@ type Response struct {
}
type InitialState struct {
ProxyURL string `json:"proxy_url"`
Platform string `json:"platform"`
ClientVersion string `json:"client_version"`
GameVersion string `json:"game_version"`
@@ -75,7 +77,7 @@ type Studio struct {
cancel context.CancelFunc
mu sync.Mutex
quitOnce sync.Once
launch func(string) error
launch func(string, string) error
savePreferences func(string) error
}
@@ -133,6 +135,7 @@ func (s *Studio) Initialize() InitialState {
s.log().Warn("saved client connection settings are unavailable", "error", err)
return state
}
state.ProxyURL = settings.ProxyURL
state.ServerOrigin = settings.ServerOrigin
state.CDNMode = settings.CDNMode
state.LocalResourceDirectory = settings.LocalResourceDirectory
@@ -295,7 +298,8 @@ func (s *Studio) Launch(input Request) (Response, error) {
if _, err := clientsetup.FetchResourcePolicy(ctx, nil, input.settings(), s.options.Versions); err != nil {
return Response{}, err
}
if _, err := clientsetup.SaveSettings(input.GameDirectory, input.settings(), s.options.Versions); err != nil {
settings, err := clientsetup.SaveSettings(input.GameDirectory, input.settings(), s.options.Versions)
if err != nil {
return Response{}, err
}
if err := s.savePreferences(input.GameDirectory); err != nil {
@@ -314,7 +318,7 @@ func (s *Studio) Launch(input Request) (Response, error) {
return Response{}, fmt.Errorf("install or update the client plugins before launching; %s is missing", name)
}
}
if err := s.launch(installation.LaunchTarget()); err != nil {
if err := s.launch(installation.LaunchTarget(), settings.ProxyURL); err != nil {
if errors.Is(err, errGameAlreadyRunning) {
return success("Brown Dust II is already running", nil), nil
}
+31 -77
View File
@@ -1,89 +1,43 @@
package app
import (
"net"
"net/url"
"strings"
)
import "strings"
// Unity reads these variables before managed plugins can run.
func gameProxyEnvironment(environment []string, systemProxy string) []string {
result := append([]string(nil), environment...)
lookup := func(name string) (int, string) {
for index, entry := range result {
key, value, found := strings.Cut(entry, "=")
if found && strings.EqualFold(key, name) {
return index, value
}
}
return -1, ""
func isGameProxyEnvironmentKey(key string) bool {
switch strings.ToLower(key) {
case "unity_proxyserver", "unity_noproxy", "http_proxy", "https_proxy", "all_proxy", "no_proxy", "bd2_client_proxy_url":
return true
default:
return false
}
if index, _ := lookup("UNITY_PROXYSERVER"); index < 0 && validUnityProxy(systemProxy) {
result = append(result, "UNITY_PROXYSERVER="+systemProxy)
}
index, bypass := lookup("UNITY_NOPROXY")
parts := strings.FieldsFunc(bypass, func(character rune) bool { return character == ',' || character == ';' })
for _, local := range []string{"localhost", "127.0.0.1", "::1"} {
present := false
for _, part := range parts {
if strings.EqualFold(strings.TrimSpace(part), local) {
present = true
}
}
if !present {
parts = append(parts, local)
}
// Player settings are authoritative; stale process and OS proxy values cannot win.
func gameProxyEnvironment(environment []string, proxyURL string) []string {
result := make([]string, 0, len(environment)+12)
for _, entry := range environment {
key, _, _ := strings.Cut(entry, "=")
if !isGameProxyEnvironmentKey(key) {
result = append(result, entry)
}
}
entry := "UNITY_NOPROXY=" + strings.Join(parts, ",")
if index >= 0 {
result[index] = entry
} else {
result = append(result, entry)
for _, key := range []string{"UNITY_PROXYSERVER", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", "BD2_CLIENT_PROXY_URL"} {
result = append(result, key+"="+proxyURL)
}
for _, key := range []string{"UNITY_NOPROXY", "NO_PROXY", "no_proxy"} {
result = append(result, key+"=localhost,127.0.0.1,::1")
}
return result
}
func validUnityProxy(proxy string) bool {
parsed, err := url.Parse(proxy)
if err != nil || parsed.Scheme != "http" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "" {
return false
// LaunchServices needs explicit empty values when the player selects direct.
func gameOpenArguments(target string, environment []string, proxyURL string) []string {
args := []string{target}
for _, entry := range gameProxyEnvironment(environment, proxyURL) {
key, _, found := strings.Cut(entry, "=")
if found && isGameProxyEnvironmentKey(key) {
args = append(args, "--env", entry)
}
}
host, port, err := net.SplitHostPort(parsed.Host)
if err != nil || host == "" || port == "" {
return false
}
_, err = net.LookupPort("tcp", port)
return err == nil
}
// Unity accepts one proxy, so per-scheme configurations must agree.
func sharedWindowsProxy(raw string) string {
if !strings.Contains(raw, "=") {
proxy := "http://" + strings.TrimSpace(raw)
if validUnityProxy(proxy) {
return proxy
}
return ""
}
var httpProxy, httpsProxy string
for _, entry := range strings.Split(raw, ";") {
key, value, found := strings.Cut(strings.TrimSpace(entry), "=")
if !found {
return ""
}
switch strings.ToLower(key) {
case "http":
httpProxy = value
case "https":
httpsProxy = value
}
}
if httpProxy == "" || !strings.EqualFold(httpProxy, httpsProxy) {
return ""
}
proxy := "http://" + httpProxy
if validUnityProxy(proxy) {
return proxy
}
return ""
args = append(args, "--args")
return append(args, gameLaunchArguments()...)
}
@@ -1,38 +0,0 @@
//go:build darwin
package app
import (
"net"
"os/exec"
"strconv"
"strings"
)
func systemGameProxy() string {
output, err := exec.Command("/usr/sbin/scutil", "--proxy").Output()
if err != nil {
return ""
}
values := map[string]string{}
for _, line := range strings.Split(string(output), "\n") {
key, value, found := strings.Cut(strings.TrimSpace(line), " : ")
if found {
values[key] = strings.TrimSpace(value)
}
}
if values["ProxyAutoConfigEnable"] == "1" || values["ProxyAutoDiscoveryEnable"] == "1" ||
values["HTTPEnable"] != "1" || values["HTTPSEnable"] != "1" ||
values["HTTPProxy"] != values["HTTPSProxy"] || values["HTTPPort"] != values["HTTPSPort"] {
return ""
}
port, err := strconv.Atoi(values["HTTPPort"])
if err != nil || port < 1 || port > 65535 {
return ""
}
proxy := "http://" + net.JoinHostPort(values["HTTPProxy"], strconv.Itoa(port))
if validUnityProxy(proxy) {
return proxy
}
return ""
}
+58 -31
View File
@@ -6,39 +6,66 @@ import (
"testing"
)
func TestGameProxyEnvironmentPreservesExplicitAndIsIdempotent(t *testing.T) {
input := []string{"PATH=kept", "UNITY_PROXYSERVER=http://explicit:8080", "UNITY_NOPROXY=example.org;localhost"}
got := gameProxyEnvironment(input, "http://system:8888")
if !reflect.DeepEqual(got, gameProxyEnvironment(got, "http://other:9999")) {
t.Fatal("environment is not idempotent")
}
if got[1] != input[1] || input[2] != "UNITY_NOPROXY=example.org;localhost" {
t.Fatal("explicit environment changed or input mutated")
}
if !strings.Contains(got[2], "127.0.0.1") || !strings.Contains(got[2], "::1") {
t.Fatal("loopback bypass missing")
}
}
func TestSharedWindowsProxy(t *testing.T) {
for _, test := range []struct{ input, want string }{
{"127.0.0.1:12451", "http://127.0.0.1:12451"},
{"http=proxy:8080;https=proxy:8080", "http://proxy:8080"},
{"http=proxy:8080;https=other:8080", ""},
{"https=proxy:8080", ""}, {"user:password@proxy:8080", ""}, {"proxy:99999", ""},
} {
if got := sharedWindowsProxy(test.input); got != test.want {
t.Errorf("proxy configuration result mismatch")
func TestGameProxyEnvironmentAuthoritative(t *testing.T) {
input := []string{"PATH=kept", "UNITY_PROXYSERVER=http://stale:8080", "uNiTy_NoPrOxY=*", "http_proxy=http://stale:8080", "HTTPS_PROXY=http://stale:8080", "All_Proxy=http://stale:8080", "NO_PROXY=*", "bd2_client_proxy_url=http://stale:8080"}
original := append([]string(nil), input...)
for _, proxy := range []string{"", "http://127.0.0.1:12451"} {
got := gameProxyEnvironment(input, proxy)
if !reflect.DeepEqual(got, gameProxyEnvironment(got, proxy)) {
t.Fatal("environment is not idempotent")
}
}
}
func TestGameProxyEnvironmentIgnoresInvalidSystemProxy(t *testing.T) {
for _, proxy := range []string{"", "https://proxy:443", "http://user:password@proxy:8080", "http://proxy:8080/path"} {
for _, entry := range gameProxyEnvironment(nil, proxy) {
if strings.HasPrefix(entry, "UNITY_PROXYSERVER=") {
t.Fatal("invalid system proxy accepted")
if got[0] != "PATH=kept" || !reflect.DeepEqual(input, original) {
t.Fatal("unrelated environment or input changed")
}
seen := map[string]string{}
for _, entry := range got[1:] {
key, value, _ := strings.Cut(entry, "=")
if !isGameProxyEnvironmentKey(key) || strings.Contains(value, "stale") {
t.Fatal("stale proxy survived")
}
seen[key] = value
}
for _, key := range []string{"UNITY_PROXYSERVER", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", "BD2_CLIENT_PROXY_URL"} {
if value, ok := seen[key]; !ok || value != proxy {
t.Errorf("missing authoritative %s override", key)
}
}
for _, key := range []string{"UNITY_NOPROXY", "NO_PROXY", "no_proxy"} {
if seen[key] != "localhost,127.0.0.1,::1" {
t.Errorf("loopback bypass missing for %s", key)
}
}
}
}
func TestGameProxyEnvironmentKeys(t *testing.T) {
for _, key := range []string{"UNITY_PROXYSERVER", "unity_noproxy", "http_proxy", "HTTPS_PROXY", "All_Proxy", "NO_PROXY", "bd2_client_proxy_url"} {
if !isGameProxyEnvironmentKey(key) {
t.Errorf("proxy key %q not recognized", key)
}
}
for _, key := range []string{"PATH", "SECRET", "HTTP_PROXY_PASSWORD", "NO_PROXY_EXTRA"} {
if isGameProxyEnvironmentKey(key) {
t.Errorf("unrelated key %q recognized", key)
}
}
}
func TestGameOpenArgumentsOverridesLaunchServicesProxy(t *testing.T) {
args := gameOpenArguments("/Applications/BrownDust II.app", []string{"HTTP_PROXY=http://stale:80", "PATH=private"}, "")
overrides := map[string]bool{}
for index := 1; index < len(args) && args[index] != "--args"; index += 2 {
if args[index] != "--env" {
t.Fatal("missing explicit LaunchServices environment flag")
}
overrides[args[index+1]] = true
}
for _, entry := range []string{"HTTP_PROXY=", "http_proxy=", "HTTPS_PROXY=", "ALL_PROXY=", "UNITY_PROXYSERVER=", "BD2_CLIENT_PROXY_URL=", "UNITY_NOPROXY=localhost,127.0.0.1,::1"} {
if !overrides[entry] {
t.Errorf("LaunchServices override missing: %s", entry)
}
}
if overrides["PATH=private"] || overrides["HTTP_PROXY=http://stale:80"] {
t.Fatal("unrelated or stale environment forwarded")
}
}
@@ -1,30 +0,0 @@
//go:build windows
package app
import (
"golang.org/x/sys/windows"
"syscall"
"unsafe"
)
func systemGameProxy() string {
var config struct {
AutoDetect int32
AutoConfigURL, Proxy, Bypass *uint16
}
procedure := syscall.NewLazyDLL("winhttp.dll").NewProc("WinHttpGetIEProxyConfigForCurrentUser")
globalFree := syscall.NewLazyDLL("kernel32.dll").NewProc("GlobalFree")
result, _, _ := procedure.Call(uintptr(unsafe.Pointer(&config)))
defer func() {
for _, pointer := range []*uint16{config.AutoConfigURL, config.Proxy, config.Bypass} {
if pointer != nil {
globalFree.Call(uintptr(unsafe.Pointer(pointer)))
}
}
}()
if result == 0 || config.AutoDetect != 0 || config.AutoConfigURL != nil || config.Proxy == nil {
return ""
}
return sharedWindowsProxy(windows.UTF16PtrToString(config.Proxy))
}
+2 -11
View File
@@ -3,7 +3,6 @@
package app
import (
"errors"
"fmt"
"os"
"os/exec"
@@ -17,7 +16,7 @@ func ShowFatalError(err error) {
}
}
func launchGame(target string) error {
func launchGame(target, proxyURL string) error {
info, err := os.Stat(target)
if err != nil || !info.IsDir() || !strings.EqualFold(filepath.Ext(target), ".app") {
return fmt.Errorf("invalid macOS application bundle %q", target)
@@ -27,14 +26,6 @@ func launchGame(target string) error {
}
// LaunchServices does not inherit open's environment; --env explicitly
// supplies the Unity variables to the newly launched application.
args := []string{target}
for _, entry := range gameProxyEnvironment(os.Environ(), systemGameProxy()) {
key, _, found := strings.Cut(entry, "=")
if found && (strings.EqualFold(key, "UNITY_PROXYSERVER") || strings.EqualFold(key, "UNITY_NOPROXY")) {
args = append(args, "--env", entry)
}
}
args = append(args, "--args")
args = append(args, gameLaunchArguments()...)
args := gameOpenArguments(target, os.Environ(), proxyURL)
return exec.Command("open", args...).Start()
}
+1 -1
View File
@@ -14,6 +14,6 @@ func ShowFatalError(err error) {
}
}
func launchGame(string) error {
func launchGame(string, string) error {
return errors.New("the Brown Dust II client is not supported on Linux")
}
+2 -2
View File
@@ -69,7 +69,7 @@ func visibleCommand(name string, args ...string) *exec.Cmd {
return command
}
func launchGame(target string) error {
func launchGame(target, proxyURL string) error {
if processID, running, err := windowsExecutableProcessID(filepath.Base(target)); err != nil {
return err
} else if running {
@@ -80,7 +80,7 @@ func launchGame(target string) error {
}
command := visibleCommand(target, gameLaunchArguments()...)
command.Dir = filepath.Dir(target)
command.Env = gameProxyEnvironment(os.Environ(), systemGameProxy())
command.Env = gameProxyEnvironment(os.Environ(), proxyURL)
if err := command.Start(); err != nil {
return err
}
File diff suppressed because one or more lines are too long