fix: stabilize login and proxy-aware game startup

This commit is contained in:
2026-10-04 01:21:58 +08:00
parent 9bb85d5962
commit 40e764be7f
11 changed files with 295 additions and 12 deletions
+89
View File
@@ -0,0 +1,89 @@
package app
import (
"net"
"net/url"
"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, ""
}
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)
}
}
entry := "UNITY_NOPROXY=" + strings.Join(parts, ",")
if index >= 0 {
result[index] = entry
} else {
result = append(result, entry)
}
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
}
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 ""
}
@@ -0,0 +1,38 @@
//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 ""
}
+44
View File
@@ -0,0 +1,44 @@
package app
import (
"reflect"
"strings"
"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 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")
}
}
}
}
@@ -0,0 +1,30 @@
//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))
}
+11 -1
View File
@@ -25,6 +25,16 @@ func launchGame(target string) error {
if err := exec.Command("pgrep", "-x", "BrownDust II").Run(); err == nil {
return errGameAlreadyRunning
}
args := append([]string{target, "--args"}, gameLaunchArguments()...)
// 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()...)
return exec.Command("open", args...).Start()
}
@@ -4,6 +4,7 @@ package app
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -79,6 +80,7 @@ func launchGame(target string) error {
}
command := visibleCommand(target, gameLaunchArguments()...)
command.Dir = filepath.Dir(target)
command.Env = gameProxyEnvironment(os.Environ(), systemGameProxy())
if err := command.Start(); err != nil {
return err
}
+32 -8
View File
@@ -228,6 +228,9 @@ internal static class LoginController
private static void ShowLoginPanel(object introUI)
{
// The official manual-login branch cancels the 10-second startup watchdog.
// Browser authentication waits for the user and must not retain that timer.
CancelMaintenanceTimeout.Invoke(introUI, null);
AccessTokens.Clear();
LoginInProgress = false;
EstablishedGameSession = false;
@@ -269,12 +272,15 @@ internal static class LoginController
private static IEnumerator DeviceLogin(object introUI, string provider)
{
string endpoint = new Uri(ServerRoot, "auth/device").AbsoluteUri;
int generation = Volatile.Read(ref RecoveryGeneration);
Uri origin = ServerRoot;
string endpoint = new Uri(origin, "auth/device").AbsoluteUri;
byte[] body = Encoding.UTF8.GetBytes(JsonUtility.ToJson(new DeviceRequest { provider = provider }));
{
ControlProbeResult request = null;
yield return AuthRequest(introUI, new Uri(endpoint), UnityWebRequest.kHttpVerbPOST, body, null, result => request = result);
if (request == null) yield break;
if (!IsCurrentLogin(generation, origin)) { request.Body = null; yield break; }
if (!request.Success)
{
LoginInProgress = false;
@@ -299,24 +305,24 @@ internal static class LoginController
yield break;
}
Application.OpenURL(start.start_url);
yield return PollDevice(introUI, start);
yield return PollDevice(introUI, start, generation, origin);
}
}
private static IEnumerator PollDevice(object introUI, DeviceStart start)
private static IEnumerator PollDevice(object introUI, DeviceStart start, int generation, Uri origin)
{
int generation = Volatile.Read(ref RecoveryGeneration);
int delay = Math.Max(1, start.poll_interval);
float deadline = Time.realtimeSinceStartup + Math.Max(30, start.expires_in);
string endpoint = new Uri(ServerRoot, "auth/device/" + Uri.EscapeDataString(start.transaction_id) + "/poll").AbsoluteUri;
string endpoint = new Uri(origin, "auth/device/" + Uri.EscapeDataString(start.transaction_id) + "/poll").AbsoluteUri;
while (Time.realtimeSinceStartup < deadline)
{
yield return new WaitForSecondsRealtime(delay);
if (generation != Volatile.Read(ref RecoveryGeneration)) yield break;
if (!IsCurrentLogin(generation, origin)) { start.device_secret = null; yield break; }
{
ControlProbeResult request = null;
yield return AuthRequest(introUI, new Uri(endpoint), UnityWebRequest.kHttpVerbPOST, Array.Empty<byte>(), "Device " + start.device_secret, result => request = result);
if (request == null) yield break;
if (!IsCurrentLogin(generation, origin)) { request.Body = null; start.device_secret = null; yield break; }
if (request.StatusCode == 202)
{
continue;
@@ -345,7 +351,7 @@ internal static class LoginController
Log?.LogError("Login transaction returned incomplete credentials");
yield break;
}
CompleteInteractiveLogin(introUI, result);
CompleteInteractiveLogin(introUI, result, generation, origin);
yield break;
}
}
@@ -353,8 +359,17 @@ internal static class LoginController
Log?.LogError("Login transaction expired");
}
private static void CompleteInteractiveLogin(object introUI, TokenResult result)
private static bool IsCurrentLogin(int generation, Uri origin) =>
generation == Volatile.Read(ref RecoveryGeneration) && origin != null && ServerRoot != null && SameOrigin(origin, ServerRoot);
private static void CompleteInteractiveLogin(object introUI, TokenResult result, int generation, Uri origin)
{
if (!IsCurrentLogin(generation, origin))
{
result.access_token = null;
result.refresh_token = null;
return;
}
if (!RefreshCredentials.IsSupported)
{
AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in);
@@ -370,6 +385,12 @@ internal static class LoginController
}
Action confirmed = delegate
{
if (!IsCurrentLogin(generation, origin))
{
result.access_token = null;
result.refresh_token = null;
return;
}
try
{
bool autoLogin = PlayerPrefs.GetInt("StandaloneAutoLogin", 0) != 0;
@@ -483,6 +504,8 @@ internal static class LoginController
bool credentialRejected = (request.StatusCode == 401 || request.StatusCode == 409) && request.RefreshInvalid;
if (credentialRejected)
{
Log?.LogWarning("Automatic-login credential rejected: HTTP=" + request.StatusCode +
", refreshInvalid=" + request.RefreshInvalid + ", classification=invalid-credential");
refreshToken = null;
attemptID = null;
ClearSavedLogin();
@@ -576,6 +599,7 @@ internal static class LoginController
try
{
ContinueMaintenance = true;
Log?.LogInfo("Authenticated login is requesting maintenance information: automatic=" + automatic);
SendMaintenance.Invoke(introUI, new object[] { automatic });
}
finally
+1
View File
@@ -14,6 +14,7 @@ internal static class LoginRuntime
internal static ManualLogSource Log;
internal static MethodInfo SetIntroState;
internal static MethodInfo SendMaintenance;
internal static MethodInfo CancelMaintenanceTimeout;
internal static Uri ServerRoot;
internal static MethodInfo OpenPCLoginPopup;
internal static MethodInfo EnterGame;
+6 -1
View File
@@ -31,6 +31,9 @@ public sealed class Plugin : BaseUnityPlugin
try
{
Log = Logger;
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("UNITY_PROXYSERVER")) &&
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("UNITY_NOPROXY")))
Logger.LogInfo("Game loopback transport uses launch-time Unity proxy bypass");
AppDomain.CurrentDomain.ProcessExit += (_, _) => DisposeGameRelay();
Game.Validate(typeof(Plugin).Assembly, Bd2Build.Versions.Game, message => Logger.LogInfo(message));
EnsureRecoveryHost();
@@ -49,6 +52,8 @@ public sealed class Plugin : BaseUnityPlugin
throw new MissingMethodException("IntroUI.Awake() was not found (client version mismatch)");
}
SendMaintenance = Game.Method<IntroUI>(ui => ui.SendMaintenanceInfo(false));
CancelMaintenanceTimeout = introUI.GetGameMethod(
"CancelMaintenanceTimeout", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, Type.EmptyTypes, null);
SetIntroState = introUI.GetGameMethod("SetIntroState", BindingFlags.Instance | BindingFlags.NonPublic);
EnterGame = introUI.GetGameMethod(
"Enter",
@@ -72,7 +77,7 @@ public sealed class Plugin : BaseUnityPlugin
MethodInfo exponentialBackOff = networkManager?.GetGameMethod(
"ExponetialBackOff",
BindingFlags.Instance | BindingFlags.NonPublic);
if (SendMaintenance == null || SetIntroState == null || OpenPCLoginPopup == null ||
if (SendMaintenance == null || CancelMaintenanceTimeout == null || CancelMaintenanceTimeout.ReturnType != typeof(void) || SetIntroState == null || OpenPCLoginPopup == null ||
accessTokenGetter == null || clearPCLocalData == null || disposeWebRequest == null ||
clientNetworkError == null || exponentialBackOff == null || EnterGame == null)
{
+6 -1
View File
@@ -78,7 +78,7 @@ internal sealed class SecureGameRelay : IDisposable
{
try { using (client) await Serve(client).ConfigureAwait(false); }
catch (Exception ex) when (ex is IOException || ex is SocketException || ex is OperationCanceledException || ex is InvalidDataException)
{ /* Close failed transport: the game's existing ConnectionError recovery handles it. */ }
{ log?.LogWarning("Native game relay connection failed: " + ex.GetType().Name); }
catch (Exception ex) { log?.LogWarning("Native game transport failed: " + ex.GetType().Name); }
finally { slots.Release(); }
});
@@ -108,6 +108,8 @@ internal sealed class SecureGameRelay : IDisposable
throw new InvalidDataException("Invalid relay request");
if (!Uri.TryCreate(local, start[1], out Uri target) || !TryResolve(target, out Uri remote))
throw new InvalidDataException("Invalid relay target");
bool maintenance = remote.AbsolutePath.Equals("/game/MaintenanceInfo", StringComparison.Ordinal);
if (maintenance) log?.LogInfo("Game relay received MaintenanceInfo request");
Dictionary<string, string> headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
int length = 0;
bool lengthSeen = false;
@@ -139,6 +141,9 @@ internal sealed class SecureGameRelay : IDisposable
}
response = await PlatformControlHttp.Send(remote, start[0], body, null, 4, lifetime.Token,
lifetime.Token, 64 * 1024 * 1024, headers).ConfigureAwait(false);
if (maintenance || response.StatusCode == 0)
log?.LogInfo("Game relay upstream result: path=" + remote.AbsolutePath + " status=" + response.StatusCode +
" error=" + (response.Error ?? "none"));
if (response.StatusCode == 0 || response.Data == null) throw new IOException("Native game transport unavailable");
StringBuilder output = new StringBuilder("HTTP/1.1 ").Append(response.StatusCode.ToString(CultureInfo.InvariantCulture)).Append(" Response\r\nConnection: close\r\nCache-Control: no-store\r\n");
foreach (KeyValuePair<string, string> item in response.Headers)
+36 -1
View File
@@ -225,6 +225,39 @@ internal static class SessionRecovery
{
try
{
try
{
const BindingFlags diagnosticFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
object packet = __0?.GetType().GetGameProperty("PacketData", diagnosticFlags)?.GetValue(__0, null);
string path = packet?.GetType().GetGameProperty("SendPath", diagnosticFlags)?.GetValue(packet, null) as string;
if (path == "MaintenanceInfo")
{
string server = packet?.GetType().GetGameField("RequestServerURL", diagnosticFlags)?.GetValue(packet) as string;
string endpoint = Uri.TryCreate(server, UriKind.Absolute, out Uri parsed) ? parsed.GetLeftPart(UriPartial.Authority) + parsed.AbsolutePath : "invalid";
string message = __0?.GetType().GetGameProperty("Message", diagnosticFlags)?.GetValue(__0, null) as string ?? string.Empty;
string category = message.IndexOf("tim", StringComparison.OrdinalIgnoreCase) >= 0 ? "Timeout" :
message.IndexOf("cert", StringComparison.OrdinalIgnoreCase) >= 0 || message.IndexOf("TLS", StringComparison.OrdinalIgnoreCase) >= 0 || message.IndexOf("SSL", StringComparison.OrdinalIgnoreCase) >= 0 ? "TLS" : "Other";
Log?.LogDebug("Maintenance backoff: endpoint=" + endpoint + ", category=" + category + ", observedRequests=" + GameRequests.Count);
foreach (WeakReference<UnityWebRequest> reference in GameRequests)
{
if (!reference.TryGetTarget(out UnityWebRequest request) || !request.isDone) continue;
if (!TryGetOwnedRequestUri(request.url, out Uri original) || original.AbsolutePath != "/game/MaintenanceInfo") continue;
string error = request.error ?? string.Empty;
string safeError = error.IndexOf("Insecure", StringComparison.OrdinalIgnoreCase) >= 0 ? "InsecureConnectionBlocked" :
error.IndexOf("connect", StringComparison.OrdinalIgnoreCase) >= 0 ? "ConnectionFailure" :
error.IndexOf("tim", StringComparison.OrdinalIgnoreCase) >= 0 ? "Timeout" :
error.IndexOf("HTTP", StringComparison.OrdinalIgnoreCase) >= 0 ? "HttpFailure" :
error.IndexOf("SSL", StringComparison.OrdinalIgnoreCase) >= 0 || error.IndexOf("cert", StringComparison.OrdinalIgnoreCase) >= 0 ? "TlsFailure" : "Unclassified";
Log?.LogWarning("Maintenance completed transport: result=" + request.result + ", status=" + request.responseCode +
", error=" + safeError + ", errorLength=" + error.Length);
}
}
}
catch (Exception ex)
{
Log?.LogDebug("Maintenance transport diagnostic unavailable: " + ex.GetType().Name);
}
if (ServerRoot == null || !IsConfiguredServerFailure(__0) || !IsTransportFailure(__0))
{
return true;
@@ -443,8 +476,10 @@ internal static class SessionRecovery
}
}
internal static bool SuppressNetworkErrorDuringRecovery()
internal static bool SuppressNetworkErrorDuringRecovery(object __0, object __1)
{
Log?.LogWarning("Client network error: type=" + __0 + ", code=" + __1 +
", recovering=" + (Volatile.Read(ref SessionRecoveryInProgress) != 0));
return Volatile.Read(ref SessionRecoveryInProgress) == 0;
}