feat: split client tooling and add OAuth server login

This commit is contained in:
2026-09-30 17:32:14 +08:00
parent 7cb5e36c2a
commit 2d054f6b72
230 changed files with 9875 additions and 853 deletions
+163
View File
@@ -0,0 +1,163 @@
// Package config owns the client-side connection settings consumed by the
// standalone setup tool and the Local Identity plugin.
package config
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strings"
clientlayout "bd2server/internal/client/layout"
)
const (
SchemaVersion = 2
FileName = "bd2.client.json"
CDNOfficial CDNMode = "official"
CDNLocal CDNMode = "local"
CDNServer CDNMode = "server"
)
type CDNMode string
type Settings struct {
SchemaVersion int `json:"schema_version"`
ServerOrigin string `json:"server_origin"`
CDNMode CDNMode `json:"cdn_mode"`
LocalResourceDirectory string `json:"local_resource_directory,omitempty"`
}
func Path(gameDir string) string {
if installation, err := clientlayout.Resolve(gameDir); err == nil {
return filepath.Join(installation.Config, FileName)
}
return filepath.Join(filepath.Clean(gameDir), "BepInEx", "config", FileName)
}
func Normalize(in Settings) (Settings, error) {
origin, err := NormalizeOrigin(in.ServerOrigin)
if err != nil {
return Settings{}, err
}
localDirectory := strings.TrimSpace(in.LocalResourceDirectory)
switch in.CDNMode {
case CDNOfficial, CDNServer:
if localDirectory != "" {
return Settings{}, errors.New("client config: local_resource_directory is only valid in local mode")
}
case CDNLocal:
if localDirectory == "" {
return Settings{}, errors.New("client config: local mode requires local_resource_directory")
}
localDirectory, err = filepath.Abs(filepath.Clean(localDirectory))
if err != nil {
return Settings{}, fmt.Errorf("client config: resolve local resource directory: %w", err)
}
default:
return Settings{}, fmt.Errorf("client config: unsupported CDN mode %q", in.CDNMode)
}
return Settings{
SchemaVersion: SchemaVersion,
ServerOrigin: origin,
CDNMode: in.CDNMode,
LocalResourceDirectory: localDirectory,
}, nil
}
func NormalizeOrigin(raw string) (string, error) {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("client config: parse server origin: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", errors.New("client config: server address must use http or https")
}
if parsed.Host == "" {
return "", errors.New("client config: server address must include a host")
}
if parsed.User != nil {
return "", errors.New("client config: credentials are not allowed in the server address")
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errors.New("client config: server address cannot contain a query or fragment")
}
if parsed.Path != "" && parsed.Path != "/" {
return "", errors.New("client config: enter only the server origin, without /game or another path")
}
if parsed.Scheme == "http" && !isLoopback(parsed.Hostname()) {
return "", errors.New("client config: non-loopback servers must use https")
}
parsed.Path = ""
parsed.RawPath = ""
return strings.TrimSuffix(parsed.String(), "/"), nil
}
func isLoopback(host string) bool {
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func Save(gameDir string, in Settings) (Settings, error) {
settings, err := Normalize(in)
if err != nil {
return Settings{}, err
}
path := Path(gameDir)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return Settings{}, fmt.Errorf("client config: create config directory: %w", err)
}
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return Settings{}, err
}
data = append(data, '\n')
temporary, err := os.CreateTemp(filepath.Dir(path), ".bd2-client-*.tmp")
if err != nil {
return Settings{}, fmt.Errorf("client config: create temporary config: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err = temporary.Chmod(0o600); err == nil {
_, err = temporary.Write(data)
}
if err == nil {
err = temporary.Sync()
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err != nil {
return Settings{}, fmt.Errorf("client config: stage config: %w", err)
}
if err := replaceFile(temporaryPath, path); err != nil {
return Settings{}, fmt.Errorf("client config: install config: %w", err)
}
return settings, nil
}
func Load(gameDir string) (Settings, error) {
data, err := os.ReadFile(Path(gameDir))
if err != nil {
return Settings{}, err
}
var settings Settings
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&settings); err != nil {
return Settings{}, fmt.Errorf("client config: decode: %w", err)
}
if settings.SchemaVersion != SchemaVersion {
return Settings{}, fmt.Errorf("client config: unsupported schema_version %d", settings.SchemaVersion)
}
return Normalize(settings)
}
+98
View File
@@ -0,0 +1,98 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestNormalize(t *testing.T) {
got, err := Normalize(Settings{ServerOrigin: " https://example.com:8443/ ", CDNMode: CDNServer})
if err != nil {
t.Fatal(err)
}
if got.SchemaVersion != SchemaVersion || got.ServerOrigin != "https://example.com:8443" || got.CDNMode != CDNServer {
t.Fatalf("normalized=%+v", got)
}
for _, bad := range []string{"example.com", "ftp://example.com", "http://192.168.1.8:8080", "https://u:p@example.com", "https://example.com/game/", "https://example.com?q=1"} {
if _, err := Normalize(Settings{ServerOrigin: bad, CDNMode: CDNOfficial}); err == nil {
t.Errorf("accepted origin %q", bad)
}
}
localRoot := t.TempDir()
local, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal, LocalResourceDirectory: localRoot})
if err != nil || local.LocalResourceDirectory != localRoot {
t.Fatalf("local=%+v err=%v", local, err)
}
if _, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal}); err == nil {
t.Fatal("accepted local mode without a resource directory")
}
if _, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNOfficial, LocalResourceDirectory: localRoot}); err == nil {
t.Fatal("accepted a local resource directory in official mode")
}
}
func TestSaveLoad(t *testing.T) {
dir := t.TempDir()
want := Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal, LocalResourceDirectory: t.TempDir()}
if _, err := Save(dir, want); err != nil {
t.Fatal(err)
}
got, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if got.ServerOrigin != want.ServerOrigin || got.CDNMode != want.CDNMode || got.SchemaVersion != SchemaVersion || got.LocalResourceDirectory != want.LocalResourceDirectory {
t.Fatalf("loaded=%+v", got)
}
data, err := os.ReadFile(Path(dir))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "secret") || strings.Contains(string(data), "token") {
t.Fatalf("client config unexpectedly stores a credential: %s", data)
}
updated := Settings{ServerOrigin: "https://friends.example:8443", CDNMode: CDNServer}
if _, err := Save(dir, updated); err != nil {
t.Fatalf("replace config: %v", err)
}
got, err = Load(dir)
if err != nil || got.ServerOrigin != updated.ServerOrigin || got.CDNMode != updated.CDNMode {
t.Fatalf("replaced=%+v err=%v", got, err)
}
}
func TestSaveOmitsLocalDirectoryOutsideLocalMode(t *testing.T) {
dir := t.TempDir()
if _, err := Save(dir, Settings{ServerOrigin: "https://example.com", CDNMode: CDNOfficial}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(Path(dir))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "local_resource_directory") {
t.Fatalf("official config contains local directory field: %s", data)
}
}
func TestPathUsesMacAppSiblingBepInEx(t *testing.T) {
parent := t.TempDir()
app := filepath.Join(parent, "BrownDust II.app")
for _, path := range []string{
filepath.Join(app, "Contents", "MacOS", "BrownDust II"),
filepath.Join(app, "Contents", "Resources", "Data", "resources.assets"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("test"), 0o700); err != nil {
t.Fatal(err)
}
}
want := filepath.Join(parent, "BepInEx", "config", FileName)
if got := Path(app); got != want {
t.Fatalf("Path()=%q want=%q", got, want)
}
}
+96
View File
@@ -0,0 +1,96 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const preferencesSchemaVersion = 1
type Preferences struct {
SchemaVersion int `json:"schema_version"`
GameDirectory string `json:"game_directory"`
}
func PreferencesPath() (string, error) {
root, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("locate user configuration directory: %w", err)
}
return filepath.Join(root, "BD2 Client Studio", "preferences.json"), nil
}
func LoadPreferences() (Preferences, error) {
path, err := PreferencesPath()
if err != nil {
return Preferences{}, err
}
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return Preferences{}, nil
}
if err != nil {
return Preferences{}, fmt.Errorf("read client preferences: %w", err)
}
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
var preferences Preferences
if err := decoder.Decode(&preferences); err != nil {
return Preferences{}, fmt.Errorf("decode client preferences: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return Preferences{}, errors.New("client preferences must contain exactly one JSON object")
}
if preferences.SchemaVersion != preferencesSchemaVersion || strings.TrimSpace(preferences.GameDirectory) == "" {
return Preferences{}, errors.New("client preferences are invalid")
}
preferences.GameDirectory = filepath.Clean(preferences.GameDirectory)
return preferences, nil
}
func SavePreferences(gameDirectory string) error {
abs, err := filepath.Abs(filepath.Clean(strings.TrimSpace(gameDirectory)))
if err != nil || strings.TrimSpace(gameDirectory) == "" {
return errors.New("client preferences require a valid game directory")
}
path, err := PreferencesPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create client preferences directory: %w", err)
}
data, err := json.MarshalIndent(Preferences{SchemaVersion: preferencesSchemaVersion, GameDirectory: abs}, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
temporary, err := os.CreateTemp(filepath.Dir(path), ".preferences-*.tmp")
if err != nil {
return fmt.Errorf("stage client preferences: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err = temporary.Chmod(0o600); err == nil {
_, err = temporary.Write(data)
}
if err == nil {
err = temporary.Sync()
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err != nil {
return fmt.Errorf("stage client preferences: %w", err)
}
if err := replaceFile(temporaryPath, path); err != nil {
return fmt.Errorf("install client preferences: %w", err)
}
return nil
}
@@ -0,0 +1,31 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestPreferencesRoundTrip(t *testing.T) {
root := t.TempDir()
t.Setenv("APPDATA", root)
game := filepath.Join(root, "game")
if err := SavePreferences(game); err != nil {
t.Fatal(err)
}
got, err := LoadPreferences()
if err != nil {
t.Fatal(err)
}
want, _ := filepath.Abs(game)
if got.SchemaVersion != preferencesSchemaVersion || got.GameDirectory != want {
t.Fatalf("preferences=%+v", got)
}
path, err := PreferencesPath()
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); err != nil {
t.Fatal(err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
)
const ReleaseFileName = "versions.json"
var (
clientVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
resourceVersionPattern = regexp.MustCompile(`^[0-9]{14}$`)
)
// ReleaseVersions is the exact client/resource tuple supported by one
// bd2client distribution. The release package carries the authoritative
// versions.json next to bd2client.exe.
type ReleaseVersions struct {
ClientVersion string `json:"client_version"`
GameDataVersion string `json:"game_data_version"`
BundleVersion string `json:"bundle_version"`
SeedDirectory string `json:"seed_directory"`
Plugins struct {
LocalIdentity string `json:"local_identity"`
CaptureEnvironment string `json:"capture_environment"`
LoginUI string `json:"login_ui"`
} `json:"plugins"`
}
func LoadReleaseVersions(path string) (ReleaseVersions, error) {
data, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return ReleaseVersions{}, fmt.Errorf("read client release versions: %w", err)
}
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
var versions ReleaseVersions
if err := decoder.Decode(&versions); err != nil {
return ReleaseVersions{}, fmt.Errorf("decode client release versions: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return ReleaseVersions{}, errors.New("client release versions must contain exactly one JSON object")
}
if !clientVersionPattern.MatchString(versions.ClientVersion) {
return ReleaseVersions{}, fmt.Errorf("invalid client_version %q", versions.ClientVersion)
}
if !resourceVersionPattern.MatchString(versions.BundleVersion) || !resourceVersionPattern.MatchString(versions.GameDataVersion) {
return ReleaseVersions{}, errors.New("bundle_version and game_data_version must be 14-digit timestamps")
}
if versions.SeedDirectory == "" ||
versions.Plugins.LocalIdentity == "" || versions.Plugins.LoginUI == "" || versions.Plugins.CaptureEnvironment == "" {
return ReleaseVersions{}, errors.New("client release versions are incomplete")
}
return versions, nil
}
func ReleaseVersionsBesideExecutable() (ReleaseVersions, error) {
executable, err := os.Executable()
if err != nil {
return ReleaseVersions{}, fmt.Errorf("locate bd2client executable: %w", err)
}
return LoadReleaseVersions(filepath.Join(filepath.Dir(executable), ReleaseFileName))
}
+39
View File
@@ -0,0 +1,39 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadReleaseVersions(t *testing.T) {
path := filepath.Join(t.TempDir(), ReleaseFileName)
data := `{"client_version":"2.35.10","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"go/seed/v2_35_10","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"}}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
got, err := LoadReleaseVersions(path)
if err != nil {
t.Fatal(err)
}
if got.ClientVersion != "2.35.10" || got.BundleVersion != "20260921135230" || got.GameDataVersion != "20260923193640" {
t.Fatalf("versions=%+v", got)
}
}
func TestLoadReleaseVersionsRejectsUnknownAndTrailingData(t *testing.T) {
for name, data := range map[string]string{
"unknown": `{"client_version":"2.35.10","unknown":true}`,
"trailing": `{}` + `{}`,
} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), ReleaseFileName)
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadReleaseVersions(path); err == nil {
t.Fatal("accepted invalid release versions")
}
})
}
}
@@ -0,0 +1,9 @@
//go:build !windows
package config
import "os"
func replaceFile(source, destination string) error {
return os.Rename(source, destination)
}
@@ -0,0 +1,34 @@
//go:build windows
package config
import (
"os"
"syscall"
"unsafe"
)
var moveFileEx = syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW")
func replaceFile(source, destination string) error {
sourcePtr, err := syscall.UTF16PtrFromString(source)
if err != nil {
return err
}
destinationPtr, err := syscall.UTF16PtrFromString(destination)
if err != nil {
return err
}
result, _, callErr := moveFileEx.Call(
uintptr(unsafe.Pointer(sourcePtr)),
uintptr(unsafe.Pointer(destinationPtr)),
0x1|0x8, // MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH
)
if result == 0 {
if callErr != syscall.Errno(0) {
return callErr
}
return os.ErrInvalid
}
return nil
}