feat(all): split client tooling and add OAuth server login

This commit is contained in:
2026-09-30 17:32:14 +08:00
parent 7cb5e36c2a
commit 50e2572385
230 changed files with 9875 additions and 853 deletions
+519
View File
@@ -0,0 +1,519 @@
// Package app serves bd2client's embedded, loopback-only setup interface.
package app
import (
"context"
"crypto/rand"
"crypto/subtle"
"embed"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"sync"
"time"
clientconfig "bd2server/internal/client/config"
clientlayout "bd2server/internal/client/layout"
clientsetup "bd2server/internal/client/setup"
)
//go:embed web/index.html
var webFS embed.FS
var errGameAlreadyRunning = errors.New("Brown Dust II is already running")
type Options struct {
Listen string
NoBrowser bool
InitialGameDir string
Logger *slog.Logger
LogPath string
Versions clientconfig.ReleaseVersions
LocalIdentityPlugin string
LoginUIPlugin string
}
type request struct {
GameDirectory string `json:"game_directory"`
ServerOrigin string `json:"server_origin"`
CDNMode clientconfig.CDNMode `json:"cdn_mode"`
LocalResourceDirectory string `json:"local_resource_directory"`
UILanguage string `json:"ui_language"`
}
type response struct {
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
Data any `json:"data,omitempty"`
}
type handler struct {
token string
origin string
initialGameDir string
browse func(string) (string, error)
browseResources func(string) (string, error)
shutdown func()
quitOnce sync.Once
logger *slog.Logger
logPath string
versions clientconfig.ReleaseVersions
initialSettings clientconfig.Settings
autoOpen bool
localIdentityPlugin string
loginUIPlugin string
}
func Run(options Options) error {
logger := options.Logger
if logger == nil {
logger = slog.Default()
}
listen := options.Listen
if listen == "" {
listen = "127.0.0.1:0"
}
listener, err := net.Listen("tcp", listen)
if err != nil {
logger.Error("could not start local interface", "error", err)
return fmt.Errorf("start bd2client interface: %w", err)
}
address := listener.Addr().(*net.TCPAddr)
if !address.IP.IsLoopback() {
_ = listener.Close()
logger.Error("refused non-loopback interface", "address", listener.Addr().String())
return errors.New("bd2client interface must listen on a loopback address")
}
token, err := newToken()
if err != nil {
_ = listener.Close()
logger.Error("could not create local interface session", "error", err)
return err
}
origin := "http://" + listener.Addr().String()
initialSettings := clientconfig.Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: clientconfig.CDNOfficial}
autoOpen := false
if options.InitialGameDir != "" {
if _, inspectErr := clientsetup.Inspect(options.InitialGameDir, options.Versions); inspectErr != nil {
logger.Warn("saved game directory is no longer valid", "error", inspectErr)
} else if loaded, loadErr := clientconfig.Load(options.InitialGameDir); loadErr != nil {
logger.Warn("saved client connection settings are unavailable", "error", loadErr)
} else {
initialSettings = loaded
autoOpen = true
}
}
server := &http.Server{
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
done := make(chan struct{})
h := &handler{
token: token,
origin: origin,
initialGameDir: options.InitialGameDir,
browse: browseForGameDirectory,
browseResources: browseForResourceDirectory,
logger: logger,
logPath: options.LogPath,
versions: options.Versions,
initialSettings: initialSettings,
autoOpen: autoOpen,
localIdentityPlugin: options.LocalIdentityPlugin,
loginUIPlugin: options.LoginUIPlugin,
shutdown: func() {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = server.Shutdown(ctx)
}()
},
}
server.Handler = h.routes()
logger.Info("local interface listening", "address", listener.Addr().String())
go func() {
err := server.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("local interface stopped unexpectedly", "error", err)
}
close(done)
}()
pageURL := origin + "/?session=" + token
fmt.Fprintf(os.Stdout, "BD2 Client Studio: %s\n", pageURL)
if !options.NoBrowser {
if err := openBrowser(pageURL); err != nil {
logger.Warn("could not open client window automatically", "error", err)
} else {
logger.Info("client window opened")
}
} else {
logger.Info("automatic client window disabled")
}
<-done
logger.Info("local interface stopped")
return nil
}
func (h *handler) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", h.index)
mux.HandleFunc("POST /api/browse", h.observe("browse game directory", h.authorize(h.browseDirectory)))
mux.HandleFunc("POST /api/browse-resources", h.observe("browse resource directory", h.authorize(h.browseResourceDirectory)))
mux.HandleFunc("POST /api/inspect", h.observe("inspect game directory", h.authorize(h.inspect)))
mux.HandleFunc("POST /api/resources", h.observe("check resource policy", h.authorize(h.resources)))
mux.HandleFunc("POST /api/save", h.observe("save settings", h.authorize(h.save)))
mux.HandleFunc("POST /api/patch", h.observe("patch client", h.authorize(h.patch)))
mux.HandleFunc("POST /api/install", h.observe("install plugins", h.authorize(h.install)))
mux.HandleFunc("POST /api/launch", h.observe("launch game", h.authorize(h.launch)))
mux.HandleFunc("POST /api/quit", h.observe("quit", h.authorize(h.quit)))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Frame-Options", "DENY")
mux.ServeHTTP(w, r)
})
}
func (h *handler) index(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" || r.URL.Query().Get("session") != h.token {
http.NotFound(w, r)
return
}
data, err := webFS.ReadFile("web/index.html")
if err != nil {
http.Error(w, "embedded interface unavailable", http.StatusInternalServerError)
return
}
tmpl, err := template.New("index").Parse(string(data))
if err != nil {
http.Error(w, "embedded interface invalid", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; connect-src 'self'; font-src 'self'")
_ = tmpl.Execute(w, map[string]string{
"Token": h.token, "GameDirectory": h.initialGameDir, "LogPath": h.logPath, "Platform": runtime.GOOS,
"ServerOrigin": h.initialSettings.ServerOrigin, "CDNMode": string(h.initialSettings.CDNMode),
"LocalResourceDirectory": h.initialSettings.LocalResourceDirectory,
"AutoOpen": fmt.Sprintf("%t", h.autoOpen),
})
}
func (h *handler) authorize(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
provided := r.Header.Get("X-BD2-Session")
if len(provided) != len(h.token) || subtle.ConstantTimeCompare([]byte(provided), []byte(h.token)) != 1 {
h.log().Warn("API request rejected", "operation", r.URL.Path, "reason", "invalid session")
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if origin := r.Header.Get("Origin"); origin != "" && origin != h.origin {
h.log().Warn("API request rejected", "operation", r.URL.Path, "reason", "foreign origin")
http.Error(w, "forbidden origin", http.StatusForbidden)
return
}
next(w, r)
}
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
func (h *handler) observe(operation string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tracked := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next(tracked, r)
if tracked.status >= http.StatusBadRequest {
h.log().Error("client API operation failed", "operation", operation, "status", tracked.status)
return
}
h.log().Info("client API operation completed", "operation", operation, "status", tracked.status)
}
}
func (h *handler) log() *slog.Logger {
if h.logger != nil {
return h.logger
}
return slog.Default()
}
func (h *handler) browseDirectory(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
}
h.log().Info("directory selection opened", "kind", "game")
dir, err := h.browse(input.UILanguage)
if err != nil {
h.writeError(w, err)
return
}
if dir == "" {
h.log().Info("directory selection cancelled", "kind", "game")
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Selection cancelled"})
return
}
status, err := clientsetup.Inspect(dir, h.versions)
if err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Game client found", Data: status})
}
func (h *handler) browseResourceDirectory(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
}
h.log().Info("directory selection opened", "kind", "resources")
dir, err := h.browseResources(input.UILanguage)
if err != nil {
h.writeError(w, err)
return
}
if dir == "" {
h.log().Info("directory selection cancelled", "kind", "resources")
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Selection cancelled"})
return
}
policy, err := clientsetup.FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{
ServerOrigin: "http://127.0.0.1",
CDNMode: clientconfig.CDNLocal,
LocalResourceDirectory: dir,
}, h.versions)
if err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Local resource directory found", Data: policy})
}
func (h *handler) inspect(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
}
status, err := clientsetup.Inspect(input.GameDirectory, h.versions)
if err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Game directory is valid", Data: status})
}
func (h *handler) resources(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
defer cancel()
policy, err := clientsetup.FetchResourcePolicy(ctx, nil, input.settings(), h.versions)
if err != nil {
h.writeError(w, err)
return
}
message := "The client will use the release-locked official CDN"
if policy.Mode == clientconfig.CDNLocal {
message = "Local resources verified"
} else if policy.Mode == clientconfig.CDNServer {
message = "Server resource policy verified"
}
h.writeJSON(w, http.StatusOK, response{OK: true, Message: message, Data: policy})
}
func (h *handler) save(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
}
settings, err := clientsetup.SaveSettings(input.GameDirectory, input.settings(), h.versions)
if err != nil {
h.writeError(w, err)
return
}
if err := clientconfig.SavePreferences(input.GameDirectory); err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Connection settings saved", Data: settings})
}
func (h *handler) patch(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
}
result, err := clientsetup.Patch(input.GameDirectory, input.settings(), h.versions)
if err != nil {
h.writeError(w, err)
return
}
if err := clientconfig.SavePreferences(input.GameDirectory); err != nil {
h.writeError(w, err)
return
}
message := "Client entry point patched; the original backup was retained"
if !result.Changed {
message = "Client patch is already complete; no asset file was rewritten"
}
h.writeJSON(w, http.StatusOK, response{OK: true, Message: message, Data: result})
}
func (h *handler) install(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
}
result, err := clientsetup.InstallPlugins(
input.GameDirectory,
input.settings(),
h.versions,
h.localIdentityPlugin,
h.loginUIPlugin,
)
if err != nil {
h.writeError(w, err)
return
}
if err := clientconfig.SavePreferences(input.GameDirectory); err != nil {
h.writeError(w, err)
return
}
message := "BD2 client plugins installed or updated"
if !result.LocalIdentity.Changed && !result.LoginUI.Changed {
message = "BD2 client plugins are already up to date; no DLL was rewritten"
}
h.writeJSON(w, http.StatusOK, response{OK: true, Message: message, Data: result})
}
func (h *handler) launch(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
}
status, err := clientsetup.Inspect(input.GameDirectory, h.versions)
if err != nil {
h.writeError(w, err)
return
}
if status.PatchedURL != clientsetup.PatchPlaceholder {
h.writeError(w, errors.New("apply the client patch before launching the game"))
return
}
if !status.BepInEx {
h.writeError(w, errors.New("install BepInEx before launching the game"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
defer cancel()
if _, err := clientsetup.FetchResourcePolicy(ctx, nil, input.settings(), h.versions); err != nil {
h.writeError(w, err)
return
}
if _, err := clientsetup.SaveSettings(input.GameDirectory, input.settings(), h.versions); err != nil {
h.writeError(w, err)
return
}
if err := clientconfig.SavePreferences(input.GameDirectory); err != nil {
h.writeError(w, err)
return
}
installation, err := clientlayout.Resolve(status.GameDirectory)
if err != nil {
h.writeError(w, err)
return
}
if !installation.SupportedOnHost() {
h.writeError(w, fmt.Errorf("cannot launch a %s game client from this operating system", installation.Kind))
return
}
for _, pluginName := range []string{"BD2LocalIdentity.dll", "BD2LoginUI.dll"} {
info, statErr := os.Stat(filepath.Join(installation.Plugins, pluginName))
if statErr != nil || !info.Mode().IsRegular() {
h.writeError(w, fmt.Errorf("install or update the client plugins before launching; %s is missing", pluginName))
return
}
}
if err := launchGame(installation.LaunchTarget()); err != nil {
if errors.Is(err, errGameAlreadyRunning) {
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Brown Dust II is already running"})
return
}
h.writeError(w, fmt.Errorf("launch Brown Dust II: %w", err))
return
}
h.log().Info("game launch requested", "platform", installation.Kind, "client_version", status.ClientVersion)
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Brown Dust II started"})
}
func (h *handler) quit(w http.ResponseWriter, _ *http.Request) {
h.log().Info("client exit requested")
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Client tool exited"})
h.quitOnce.Do(h.shutdown)
}
func (h *handler) decode(w http.ResponseWriter, r *http.Request) (request, bool) {
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
var input request
if err := decoder.Decode(&input); err != nil {
h.writeError(w, fmt.Errorf("invalid request: %w", err))
return request{}, false
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
h.writeError(w, errors.New("request must contain exactly one JSON object"))
return request{}, false
}
return input, true
}
func (r request) settings() clientconfig.Settings {
return clientconfig.Settings{
ServerOrigin: r.ServerOrigin,
CDNMode: r.CDNMode,
LocalResourceDirectory: r.LocalResourceDirectory,
}
}
func (h *handler) writeError(w http.ResponseWriter, err error) {
h.log().Error("client operation error", "error", err)
h.writeJSON(w, http.StatusBadRequest, response{OK: false, Message: err.Error()})
}
func (h *handler) writeJSON(w http.ResponseWriter, status int, value response) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func newToken() (string, error) {
buffer := make([]byte, 32)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("generate UI session: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
+129
View File
@@ -0,0 +1,129 @@
package app
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
clientconfig "bd2server/internal/client/config"
)
func TestIndexRequiresSessionAndServesEmbeddedStudio(t *testing.T) {
h := &handler{token: "test-session", origin: "http://127.0.0.1"}
server := httptest.NewServer(h.routes())
defer server.Close()
response, err := http.Get(server.URL + "/")
if err != nil {
t.Fatal(err)
}
_ = response.Body.Close()
if response.StatusCode != http.StatusNotFound {
t.Fatalf("without session status=%d", response.StatusCode)
}
response, err = http.Get(server.URL + "/?session=test-session")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
buffer, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
page := string(buffer)
for _, marker := range []string{
"BD2 Client Studio", "test-session", `name="bd2-platform"`,
`id="directoryScene"`, `id="serverScene"`, `id="deskScene"`,
`id="gameDir"`, `id="origin"`, `id="patch"`, `id="install"`, `id="launch"`,
`value="official"`, `value="local"`, `value="server"`,
`id="localResourceDir"`, `id="browseResources"`,
`Asia/Shanghai`, `Asia/Hong_Kong`, `Asia/Macau`, `Asia/Taipei`,
`const zhCN=CHINA_TIME_ZONES.has(detectedTimeZone)`,
"opening-curtain", "is-entering", "@keyframes reveal", "prefers-reduced-motion",
} {
if !strings.Contains(page, marker) {
t.Errorf("page lacks %q", marker)
}
}
if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("Cache-Control") != "no-store" {
t.Fatalf("security headers=%v", response.Header)
}
}
func TestAPIRejectsMalformedAndTrailingJSON(t *testing.T) {
h := &handler{token: "test-session", origin: "http://local.invalid", versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}
for name, body := range map[string]string{
"malformed": `{`,
"trailing": `{}` + `{}`,
"unknown": `{"unexpected":true}`,
} {
t.Run(name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/inspect", strings.NewReader(body))
request.Header.Set("X-BD2-Session", "test-session")
request.Header.Set("Origin", "http://local.invalid")
response := httptest.NewRecorder()
h.routes().ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
})
}
}
func TestAPIRejectsMissingTokenAndForeignOrigin(t *testing.T) {
h := &handler{token: "test-session", origin: "http://local.invalid", browse: func(string) (string, error) { return "", nil }}
for name, values := range map[string][2]string{
"missing token": {"", "http://local.invalid"},
"foreign origin": {"test-session", "https://attacker.invalid"},
} {
t.Run(name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/browse", strings.NewReader("{}"))
request.Header.Set("X-BD2-Session", values[0])
request.Header.Set("Origin", values[1])
response := httptest.NewRecorder()
h.routes().ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
})
}
}
func TestInspectAPI(t *testing.T) {
dir := t.TempDir()
for path, data := range map[string][]byte{
filepath.Join(dir, "BrownDust II.exe"): []byte("exe"),
filepath.Join(dir, "BrownDust II_Data", "resources.assets"): []byte("not a real Unity file"),
filepath.Join(dir, "BrownDust II_Data", "globalgamemanagers"): []byte("\x002.35.10\x00"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
h := &handler{token: "test-session", origin: "http://local.invalid", versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}
body, _ := json.Marshal(request{GameDirectory: dir})
req := httptest.NewRequest(http.MethodPost, "/api/inspect", strings.NewReader(string(body)))
req.Header.Set("X-BD2-Session", "test-session")
req.Header.Set("Origin", "http://local.invalid")
recorder := httptest.NewRecorder()
h.routes().ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var result response
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if !result.OK {
t.Fatalf("response=%+v", result)
}
}
+147
View File
@@ -0,0 +1,147 @@
//go:build windows
package app
import (
"fmt"
"runtime"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
const (
coinitApartmentThreaded = 0x2
clsctxInprocServer = 0x1
fosNoChangeDir = 0x00000008
fosPickFolders = 0x00000020
fosForceFileSystem = 0x00000040
fosPathMustExist = 0x00000800
fosDontAddToRecent = 0x02000000
sigdnFileSystemPath = 0x80058000
errorCancelled = 0x800704c7
)
var (
ole32DLL = windows.NewLazySystemDLL("ole32.dll")
user32DLL = windows.NewLazySystemDLL("user32.dll")
coInitializeEx = ole32DLL.NewProc("CoInitializeEx")
coUninitialize = ole32DLL.NewProc("CoUninitialize")
coCreateInstance = ole32DLL.NewProc("CoCreateInstance")
coTaskMemFree = ole32DLL.NewProc("CoTaskMemFree")
getForegroundWindow = user32DLL.NewProc("GetForegroundWindow")
clsidFileOpenDialog = windows.GUID{Data1: 0xdc1c5a9c, Data2: 0xe88a, Data3: 0x4dde, Data4: [8]byte{0xa5, 0xa1, 0x60, 0xf8, 0x2a, 0x20, 0xae, 0xf7}}
iidIFileOpenDialog = windows.GUID{Data1: 0xd57c7288, Data2: 0xd4ad, Data3: 0x4768, Data4: [8]byte{0xbe, 0x02, 0x9d, 0x96, 0x95, 0x32, 0xd9, 0x60}}
)
// comObject is sufficient for IFileOpenDialog and IShellItem because COM
// interfaces begin with a pointer to a vtable. The methods used below are
// selected by their documented vtable positions.
type comObject struct {
vtable *[29]uintptr
}
func browseForDirectory(titleText string) (string, error) {
// COM apartment state belongs to an OS thread. Keep this handler on one
// thread from initialization until every interface has been released.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
result, _, _ := coInitializeEx.Call(0, coinitApartmentThreaded)
if hresultFailed(result) {
return "", hresultError("initialize Windows directory picker", result)
}
defer coUninitialize.Call()
var dialog *comObject
result, _, _ = coCreateInstance.Call(
uintptr(unsafe.Pointer(&clsidFileOpenDialog)),
0,
clsctxInprocServer,
uintptr(unsafe.Pointer(&iidIFileOpenDialog)),
uintptr(unsafe.Pointer(&dialog)),
)
if hresultFailed(result) {
return "", hresultError("create Windows directory picker", result)
}
if dialog == nil {
return "", fmt.Errorf("create Windows directory picker: the system returned no dialog")
}
defer comRelease(dialog)
var options uint32
result = comCall(dialog, 10, uintptr(unsafe.Pointer(&options))) // IFileDialog::GetOptions
if hresultFailed(result) {
return "", hresultError("read Windows directory picker options", result)
}
options |= fosNoChangeDir | fosPickFolders | fosForceFileSystem | fosPathMustExist | fosDontAddToRecent
result = comCall(dialog, 9, uintptr(options)) // IFileDialog::SetOptions
if hresultFailed(result) {
return "", hresultError("set Windows directory picker options", result)
}
title, err := windows.UTF16PtrFromString(titleText)
if err != nil {
return "", fmt.Errorf("set Windows directory picker title: %w", err)
}
result = comCall(dialog, 17, uintptr(unsafe.Pointer(title))) // IFileDialog::SetTitle
runtime.KeepAlive(title)
if hresultFailed(result) {
return "", hresultError("set Windows directory picker title", result)
}
owner, _, _ := getForegroundWindow.Call()
result = comCall(dialog, 3, owner) // IModalWindow::Show
if uint32(result) == errorCancelled {
return "", nil
}
if hresultFailed(result) {
return "", hresultError("show Windows directory picker", result)
}
var item *comObject
result = comCall(dialog, 20, uintptr(unsafe.Pointer(&item))) // IFileDialog::GetResult
if hresultFailed(result) {
return "", hresultError("read selected directory", result)
}
if item == nil {
return "", fmt.Errorf("read selected directory: the system returned no directory")
}
defer comRelease(item)
var path *uint16
result = comCall(item, 5, sigdnFileSystemPath, uintptr(unsafe.Pointer(&path))) // IShellItem::GetDisplayName
if hresultFailed(result) {
return "", hresultError("read selected directory path", result)
}
if path == nil {
return "", fmt.Errorf("read selected directory path: the system returned an empty path")
}
defer coTaskMemFree.Call(uintptr(unsafe.Pointer(path)))
return windows.UTF16PtrToString(path), nil
}
func comCall(object *comObject, method int, args ...uintptr) uintptr {
callArgs := make([]uintptr, 1, len(args)+1)
callArgs[0] = uintptr(unsafe.Pointer(object))
callArgs = append(callArgs, args...)
result, _, _ := syscall.SyscallN(object.vtable[method], callArgs...)
return result
}
func comRelease(object *comObject) {
if object != nil {
comCall(object, 2) // IUnknown::Release
}
}
func hresultFailed(result uintptr) bool {
return int32(uint32(result)) < 0
}
func hresultError(action string, result uintptr) error {
return fmt.Errorf("%s: HRESULT 0x%08X", action, uint32(result))
}
+128
View File
@@ -0,0 +1,128 @@
package app
import (
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"runtime"
"sync"
)
const (
clientLogName = "bd2client.log"
clientLogBackupName = "bd2client.log.1"
clientLogMaxBytes = 2 << 20
)
// OpenPersistentLogger creates the GUI client's bounded, persistent log next
// to the executable. The active log is capped at 2 MiB and one previous log is
// retained, so a client left installed for a long time cannot grow without
// limit.
func OpenPersistentLogger(executablePath string) (*slog.Logger, io.Closer, string, error) {
if executablePath == "" {
var err error
executablePath, err = os.Executable()
if err != nil {
return nil, nil, "", fmt.Errorf("locate bd2client executable: %w", err)
}
}
absolute, err := filepath.Abs(executablePath)
if err != nil {
return nil, nil, "", fmt.Errorf("resolve bd2client executable path: %w", err)
}
logDirectory := filepath.Join(filepath.Dir(absolute), "logs")
if runtime.GOOS == "darwin" {
home, homeErr := os.UserHomeDir()
if homeErr != nil {
return nil, nil, "", fmt.Errorf("locate macOS user home for logs: %w", homeErr)
}
logDirectory = filepath.Join(home, "Library", "Logs", "BD2 Client Studio")
}
if err := os.MkdirAll(logDirectory, 0o700); err != nil {
return nil, nil, "", fmt.Errorf("create bd2client log directory: %w", err)
}
path := filepath.Join(logDirectory, clientLogName)
writer, err := openRollingLog(path, filepath.Join(logDirectory, clientLogBackupName), clientLogMaxBytes)
if err != nil {
return nil, nil, "", err
}
logger := slog.New(slog.NewTextHandler(writer, &slog.HandlerOptions{Level: slog.LevelInfo}))
return logger, writer, path, nil
}
type rollingLog struct {
mu sync.Mutex
path string
backupPath string
maxBytes int64
file *os.File
size int64
}
func openRollingLog(path, backupPath string, maxBytes int64) (*rollingLog, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return nil, fmt.Errorf("open bd2client log: %w", err)
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("inspect bd2client log: %w", err)
}
return &rollingLog{
path: path,
backupPath: backupPath,
maxBytes: maxBytes,
file: file,
size: info.Size(),
}, nil
}
func (w *rollingLog) Write(data []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return 0, os.ErrClosed
}
if w.size > 0 && w.size+int64(len(data)) > w.maxBytes {
if err := w.rotate(); err != nil {
return 0, err
}
}
written, err := w.file.Write(data)
w.size += int64(written)
return written, err
}
func (w *rollingLog) rotate() error {
if err := w.file.Close(); err != nil {
return fmt.Errorf("close bd2client log for rotation: %w", err)
}
w.file = nil
if err := os.Remove(w.backupPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("replace bd2client log backup: %w", err)
}
if err := os.Rename(w.path, w.backupPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("rotate bd2client log: %w", err)
}
file, err := os.OpenFile(w.path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("create rotated bd2client log: %w", err)
}
w.file = file
w.size = 0
return nil
}
func (w *rollingLog) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
+107
View File
@@ -0,0 +1,107 @@
package app
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"unicode"
"unicode/utf8"
)
func TestOpenPersistentLoggerUsesExecutableLogDirectory(t *testing.T) {
root := t.TempDir()
executable := filepath.Join(root, "bd2client.exe")
logger, closer, logPath, err := OpenPersistentLogger(executable)
if err != nil {
t.Fatal(err)
}
logger.Info("test entry")
if err := closer.Close(); err != nil {
t.Fatal(err)
}
wantPath := filepath.Join(root, "logs", clientLogName)
if logPath != wantPath {
t.Fatalf("log path=%q want=%q", logPath, wantPath)
}
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(data, []byte("test entry")) {
t.Fatalf("log does not contain test entry: %s", data)
}
}
func TestPersistentOperationalLogsUseEnglish(t *testing.T) {
var output bytes.Buffer
logger := slog.New(slog.NewTextHandler(&output, nil))
h := &handler{token: "test-session", origin: "http://local.invalid", logger: logger}
body := `{"game_directory":"Z:\\missing","server_origin":"http://127.0.0.1:8080","cdn_mode":"official","local_resource_directory":"","ui_language":"zh-CN"}`
request := httptest.NewRequest(http.MethodPost, "/api/inspect", strings.NewReader(body))
request.Header.Set("X-BD2-Session", "test-session")
request.Header.Set("Origin", "http://local.invalid")
response := httptest.NewRecorder()
h.routes().ServeHTTP(response, request)
for len(output.Bytes()) > 0 {
r, size := utf8.DecodeRune(output.Bytes())
if unicode.Is(unicode.Han, r) {
t.Fatalf("operational log contains Han character %q: %s", r, output.String())
}
output.Next(size)
}
}
func TestRollingLogRetainsOneBackup(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, clientLogName)
backup := filepath.Join(root, clientLogBackupName)
writer, err := openRollingLog(path, backup, 8)
if err != nil {
t.Fatal(err)
}
if _, err := writer.Write([]byte("first")); err != nil {
t.Fatal(err)
}
if _, err := writer.Write([]byte("second")); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
active, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
previous, err := os.ReadFile(backup)
if err != nil {
t.Fatal(err)
}
if string(active) != "second" || string(previous) != "first" {
t.Fatalf("active=%q backup=%q", active, previous)
}
}
func TestAuthorizationLogDoesNotIncludeSessionValues(t *testing.T) {
var output bytes.Buffer
logger := slog.New(slog.NewTextHandler(&output, nil))
h := &handler{token: "expected-session-secret", origin: "http://local.invalid", logger: logger}
request := httptest.NewRequest(http.MethodPost, "/api/inspect", strings.NewReader("{}"))
request.Header.Set("X-BD2-Session", "provided-session-secret")
request.Header.Set("Origin", "http://local.invalid")
response := httptest.NewRecorder()
h.routes().ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
logged := output.String()
for _, secret := range []string{"expected-session-secret", "provided-session-secret"} {
if strings.Contains(logged, secret) {
t.Fatalf("log contains session value %q: %s", secret, logged)
}
}
}
+65
View File
@@ -0,0 +1,65 @@
//go:build darwin
package app
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
func ShowFatalError(err error) {
if err == nil {
return
}
message := strings.ReplaceAll(err.Error(), `"`, `\"`)
_ = exec.Command("osascript", "-e", `display alert "BD2 Client Studio" message "`+message+`" as critical`).Run()
}
func openBrowser(url string) error {
return exec.Command("open", url).Start()
}
func browseForGameDirectory(language string) (string, error) {
prompt := "Select the Brown Dust II.app bundle or its parent folder"
if language == "zh-CN" {
prompt = "选择 Brown Dust II.app 或其所在文件夹"
}
return macDirectoryPicker(prompt)
}
func browseForResourceDirectory(language string) (string, error) {
prompt := "Select the CDN directory containing ServerData and GameData"
if language == "zh-CN" {
prompt = "选择包含 ServerData 和 GameData 的 CDN 目录"
}
return macDirectoryPicker(prompt)
}
func macDirectoryPicker(prompt string) (string, error) {
prompt = strings.ReplaceAll(prompt, `"`, `\"`)
command := exec.Command("osascript", "-e", `POSIX path of (choose folder with prompt "`+prompt+`")`)
output, err := command.Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return "", nil
}
return "", fmt.Errorf("open macOS directory picker: %w", err)
}
return strings.TrimSpace(string(output)), nil
}
func launchGame(target 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)
}
if err := exec.Command("pgrep", "-x", "BrownDust II").Run(); err == nil {
return errGameAlreadyRunning
}
return exec.Command("open", target).Start()
}
+36
View File
@@ -0,0 +1,36 @@
//go:build !windows && !darwin
package app
import (
"errors"
"fmt"
"os"
"os/exec"
"runtime"
)
func ShowFatalError(err error) {
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "BD2 Client Studio:", err)
}
}
func openBrowser(url string) error {
if runtime.GOOS == "darwin" {
return exec.Command("open", url).Start()
}
return exec.Command("xdg-open", url).Start()
}
func browseForGameDirectory(string) (string, error) {
return "", errors.New("the native directory picker is unavailable on this platform; enter the Windows client directory manually")
}
func browseForResourceDirectory(string) (string, error) {
return "", errors.New("the native directory picker is unavailable on this platform; enter the resource directory manually")
}
func launchGame(string) error {
return errors.New("the Brown Dust II client is not supported on Linux")
}
+182
View File
@@ -0,0 +1,182 @@
//go:build windows
package app
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
var (
platformUser32DLL = syscall.NewLazyDLL("user32.dll")
messageBoxW = platformUser32DLL.NewProc("MessageBoxW")
enumWindowsProc = platformUser32DLL.NewProc("EnumWindows")
getWindowThreadProcessIDProc = platformUser32DLL.NewProc("GetWindowThreadProcessId")
isWindowVisibleProc = platformUser32DLL.NewProc("IsWindowVisible")
showWindowAsyncProc = platformUser32DLL.NewProc("ShowWindowAsync")
setForegroundWindowProc = platformUser32DLL.NewProc("SetForegroundWindow")
)
// ShowFatalError keeps startup failures visible even though the release
// executable uses the Windows GUI subsystem and therefore has no console.
func ShowFatalError(err error) {
if err == nil {
return
}
message, conversionErr := syscall.UTF16PtrFromString(fmt.Sprintf(
"BD2 Client Studio could not start:\n\n%s\n\nSee the logs directory next to bd2client.exe for details.", err,
))
if conversionErr != nil {
return
}
title, conversionErr := syscall.UTF16PtrFromString("BD2 Client Studio")
if conversionErr != nil {
return
}
messageBoxW.Call(0, uintptr(unsafe.Pointer(message)), uintptr(unsafe.Pointer(title)), 0x10)
}
// CREATE_NO_WINDOW prevents console-subsystem helpers such as powershell.exe
// from allocating a visible console when bd2client is built as a Windows GUI
// executable. HideWindow also covers helpers that elect to create a window
// despite inheriting no console from the parent process.
const createNoWindow = 0x08000000
func hiddenCommand(name string, args ...string) *exec.Cmd {
command := exec.Command(name, args...)
command.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: createNoWindow,
}
return command
}
// visibleCommand suppresses a console allocation without hiding the GUI
// window created by the child process. It must be used for the game itself;
// hiddenCommand is reserved for background helper processes.
func visibleCommand(name string, args ...string) *exec.Cmd {
command := exec.Command(name, args...)
command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow}
return command
}
func openBrowser(url string) error {
for _, edge := range edgeCandidates() {
if info, err := os.Stat(edge); err == nil && !info.IsDir() {
return hiddenCommand(edge, "--app="+url, "--window-size=1100,760", "--no-first-run").Start()
}
}
return hiddenCommand("rundll32.exe", "url.dll,FileProtocolHandler", url).Start()
}
func edgeCandidates() []string {
var candidates []string
if edge, err := exec.LookPath("msedge.exe"); err == nil {
candidates = append(candidates, edge)
}
for _, root := range []string{os.Getenv("ProgramFiles(x86)"), os.Getenv("ProgramFiles"), os.Getenv("LOCALAPPDATA")} {
if root != "" {
candidates = append(candidates, filepath.Join(root, "Microsoft", "Edge", "Application", "msedge.exe"))
}
}
return candidates
}
func browseForGameDirectory(language string) (string, error) {
title := "Select the Brown Dust II installation directory"
if language == "zh-CN" {
title = "选择 Brown Dust II 安装目录"
}
return browseForDirectory(title)
}
func browseForResourceDirectory(language string) (string, error) {
title := "Select the CDN directory containing ServerData and GameData"
if language == "zh-CN" {
title = "选择包含 ServerData 和 GameData 的 CDN 目录"
}
return browseForDirectory(title)
}
func launchGame(target string) error {
if processID, running, err := windowsExecutableProcessID(filepath.Base(target)); err != nil {
return err
} else if running {
if !activateProcessWindow(processID, 5*time.Second) {
return fmt.Errorf("Brown Dust II is running, but its window could not be restored")
}
return errGameAlreadyRunning
}
command := visibleCommand(target)
command.Dir = filepath.Dir(target)
if err := command.Start(); err != nil {
return err
}
// Unity creates the top-level window asynchronously. Best-effort foreground
// activation prevents the new window from opening behind Client Studio.
activateProcessWindow(uint32(command.Process.Pid), 15*time.Second)
return nil
}
func windowsExecutableProcessID(name string) (uint32, bool, error) {
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return 0, false, err
}
defer windows.CloseHandle(snapshot)
entry := windows.ProcessEntry32{Size: uint32(unsafe.Sizeof(windows.ProcessEntry32{}))}
if err := windows.Process32First(snapshot, &entry); err != nil {
return 0, false, err
}
for {
if strings.EqualFold(windows.UTF16ToString(entry.ExeFile[:]), name) {
return entry.ProcessID, true, nil
}
if err := windows.Process32Next(snapshot, &entry); err != nil {
if err == windows.ERROR_NO_MORE_FILES {
return 0, false, nil
}
return 0, false, err
}
}
}
func activateProcessWindow(processID uint32, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for {
if window := topLevelWindowForProcess(processID); window != 0 {
const swRestore = 9
showWindowAsyncProc.Call(window, swRestore)
setForegroundWindowProc.Call(window)
return true
}
if time.Now().After(deadline) {
return false
}
time.Sleep(100 * time.Millisecond)
}
}
func topLevelWindowForProcess(processID uint32) uintptr {
var found uintptr
callback := syscall.NewCallback(func(window uintptr, _ uintptr) uintptr {
var owner uint32
getWindowThreadProcessIDProc.Call(window, uintptr(unsafe.Pointer(&owner)))
visible, _, _ := isWindowVisibleProc.Call(window)
if owner == processID && visible != 0 {
found = window
return 0
}
return 1
})
enumWindowsProc.Call(callback, 0)
return found
}
@@ -0,0 +1,40 @@
//go:build windows
package app
import "testing"
func TestHiddenCommandNeverAllocatesVisibleConsole(t *testing.T) {
command := hiddenCommand("powershell.exe", "-NoProfile")
if command.SysProcAttr == nil {
t.Fatal("hidden command has no Windows process attributes")
}
if !command.SysProcAttr.HideWindow {
t.Fatal("hidden command does not request a hidden window")
}
if command.SysProcAttr.CreationFlags&createNoWindow == 0 {
t.Fatalf("hidden command creation flags %#x omit CREATE_NO_WINDOW", command.SysProcAttr.CreationFlags)
}
}
func TestVisibleCommandDoesNotHideGUIWindow(t *testing.T) {
command := visibleCommand("Brown Dust II.exe")
if command.SysProcAttr == nil {
t.Fatal("visible command has no Windows process attributes")
}
if command.SysProcAttr.HideWindow {
t.Fatal("visible game command requests a hidden window")
}
if command.SysProcAttr.CreationFlags&createNoWindow == 0 {
t.Fatalf("visible command creation flags %#x omit CREATE_NO_WINDOW", command.SysProcAttr.CreationFlags)
}
}
func TestHRESULTFailureClassification(t *testing.T) {
if hresultFailed(0) || hresultFailed(1) {
t.Fatal("successful HRESULT classified as failure")
}
if !hresultFailed(errorCancelled) || !hresultFailed(0x80004005) {
t.Fatal("failed HRESULT classified as success")
}
}
File diff suppressed because one or more lines are too long
+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
}
+63
View File
@@ -0,0 +1,63 @@
package introdb
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/sha1"
"fmt"
)
const PageSize = 4096
var Header = []byte("SQLite format 3\x00")
func decryptPages(in []byte) ([]byte, error) { return cryptPages(in, false) }
func encryptPages(in []byte) ([]byte, error) { return cryptPages(in, true) }
func cryptPages(in []byte, encrypt bool) ([]byte, error) {
if len(in) == 0 || len(in)%PageSize != 0 {
return nil, fmt.Errorf("dbcrypt: database length %d is not a non-zero multiple of %d", len(in), PageSize)
}
block, err := aes.NewCipher(deriveKey())
if err != nil {
return nil, err
}
out := make([]byte, len(in))
for start := 0; start < len(in); start += PageSize {
var mode cipher.BlockMode = cipher.NewCBCEncrypter(block, Header)
if !encrypt {
mode = cipher.NewCBCDecrypter(block, Header)
}
mode.CryptBlocks(out[start:start+PageSize], in[start:start+PageSize])
}
return out, nil
}
func deriveKey() []byte {
password := []byte(fmt.Sprintf("%X", sha1.Sum([]byte("spdhdnlwmrpavmtm"))))
return pbkdf2SHA1(password, Header, 2010, 32)
}
func pbkdf2SHA1(password, salt []byte, iterations, length int) []byte {
var result []byte
for block := uint32(1); len(result) < length; block++ {
message := append(append([]byte{}, salt...), byte(block>>24), byte(block>>16), byte(block>>8), byte(block))
u := hmacSHA1(password, message)
t := append([]byte{}, u...)
for i := 1; i < iterations; i++ {
u = hmacSHA1(password, u)
for j := range t {
t[j] ^= u[j]
}
}
result = append(result, t...)
}
return result[:length]
}
func hmacSHA1(key, message []byte) []byte {
h := hmac.New(sha1.New, key)
_, _ = h.Write(message)
return h.Sum(nil)
}
+486
View File
@@ -0,0 +1,486 @@
// Package introdb patches Brown Dust II's embedded Intro database without
// changing the size or layout of Unity's resources.assets file.
package introdb
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
clientlayout "bd2server/internal/client/layout"
)
const (
oldURL = "https://mt.bd2.pmang.cloud/"
)
var salt = Header
// Result describes a completed in-place client patch. BackupPath is the
// immutable pre-patch copy and is never overwritten by a later invocation.
type Result struct {
AssetsPath string
BackupPath string
ObjectPath int64
ObjectSize uint32
OldURL string
NewURL string
Changed bool
}
// VerifyResult is useful to patch-client's --verify mode and to diagnostics.
type VerifyResult struct {
AssetsPath string
ObjectPath int64
ObjectSize uint32
URL string
}
// PatchClient resolves resources.assets below gameDir, verifies the embedded
// TextAsset named Intro, makes a one-time .bak copy, then atomically replaces
// resources.assets. newURL must be exactly as long as the original URL.
func PatchClient(gameDir, newURL string) (Result, error) {
if len(newURL) != len(oldURL) {
return Result{}, fmt.Errorf("introdb: local URL must be exactly %d bytes (got %d): %q", len(oldURL), len(newURL), newURL)
}
if !isASCII(newURL) {
return Result{}, errors.New("introdb: local URL must be ASCII")
}
assets, err := ResourcesPath(gameDir)
if err != nil {
return Result{}, err
}
b, err := os.ReadFile(assets)
if err != nil {
return Result{}, fmt.Errorf("introdb: read assets: %w", err)
}
entry, script, err := findIntro(b)
if err != nil {
return Result{}, err
}
plain, err := DecryptPages(script)
if err != nil {
return Result{}, fmt.Errorf("introdb: decrypt Intro TextAsset: %w", err)
}
// A repeated command is intentionally a no-op. This makes automation safe
// while refusing to overwrite an Intro database that was patched to a
// different endpoint by another tool.
if bytes.Count(plain, []byte(oldURL)) == 0 {
current, findErr := urlInDB(plain)
if findErr != nil {
return Result{}, findErr
}
if current == newURL {
return Result{assets, assets + ".bak", entry.pathID, entry.size, current, newURL, false}, nil
}
return Result{}, fmt.Errorf("introdb: LIVE_URL is already %q, not the expected official URL", current)
}
if err := validateIntroDB(plain, oldURL); err != nil {
return Result{}, err
}
updated := bytes.Replace(plain, []byte(oldURL), []byte(newURL), 1)
ciphertext, err := EncryptPages(updated)
if err != nil {
return Result{}, err
}
copy(b[entry.scriptStart:entry.scriptStart+int64(len(ciphertext))], ciphertext)
// Verify the staged bytes before touching the installed asset. This catches
// a parser/cipher regression even though the payload length never changes.
_, stagedScript, err := findIntro(b)
if err != nil {
return Result{}, fmt.Errorf("introdb: re-read staged Intro: %w", err)
}
stagedPlain, err := DecryptPages(stagedScript)
if err != nil {
return Result{}, fmt.Errorf("introdb: decrypt staged Intro: %w", err)
}
if err := validateIntroDB(stagedPlain, newURL); err != nil {
return Result{}, fmt.Errorf("introdb: staged verification: %w", err)
}
backup := assets + ".bak"
if _, err := os.Stat(backup); errors.Is(err, os.ErrNotExist) {
if err := copyFile(assets, backup); err != nil {
return Result{}, fmt.Errorf("introdb: create backup: %w", err)
}
} else if err != nil {
return Result{}, fmt.Errorf("introdb: inspect backup: %w", err)
}
if err := atomicWrite(assets, b); err != nil {
return Result{}, err
}
return Result{assets, backup, entry.pathID, entry.size, oldURL, newURL, true}, nil
}
// VerifyClient reads and decrypts the embedded Intro TextAsset. It verifies
// the SQLite header plus the expected table/key markers before returning URL.
func VerifyClient(gameDir string) (VerifyResult, error) {
assets, err := ResourcesPath(gameDir)
if err != nil {
return VerifyResult{}, err
}
b, err := os.ReadFile(assets)
if err != nil {
return VerifyResult{}, fmt.Errorf("introdb: read assets: %w", err)
}
e, script, err := findIntro(b)
if err != nil {
return VerifyResult{}, err
}
p, err := DecryptPages(script)
if err != nil {
return VerifyResult{}, fmt.Errorf("introdb: decrypt Intro TextAsset: %w", err)
}
if !bytes.HasPrefix(p, salt) {
return VerifyResult{}, errors.New("introdb: decrypted Intro is not a SQLite database")
}
if !bytes.Contains(p, []byte("ServerURLTable")) || !bytes.Contains(p, []byte("LIVE_URL")) {
return VerifyResult{}, errors.New("introdb: Intro database lacks ServerURLTable/LIVE_URL markers")
}
url, err := urlInDB(p)
if err != nil {
return VerifyResult{}, err
}
return VerifyResult{assets, e.pathID, e.size, url}, nil
}
// ResourcesPath returns the conventional standalone Windows resource location.
func ResourcesPath(gameDir string) (string, error) {
if gameDir == "" {
return "", errors.New("introdb: empty game directory")
}
installation, err := clientlayout.Resolve(gameDir)
if err != nil {
return "", fmt.Errorf("introdb: resolve game layout: %w", err)
}
p := installation.Resources
st, err := os.Stat(p)
if err != nil {
return "", fmt.Errorf("introdb: resources.assets not found at %q: %w", p, err)
}
if st.IsDir() {
return "", fmt.Errorf("introdb: resources.assets path is a directory: %q", p)
}
return p, nil
}
// DecryptPages decrypts the game's independent 4096-byte AES-CBC pages.
func DecryptPages(in []byte) ([]byte, error) { return decryptPages(in) }
// EncryptPages encrypts the game's independent 4096-byte AES-CBC pages.
func EncryptPages(in []byte) ([]byte, error) { return encryptPages(in) }
func validateIntroDB(p []byte, expected string) error {
if !bytes.HasPrefix(p, salt) {
return errors.New("introdb: decrypted Intro is not a SQLite database (wrong cipher parameters or asset)")
}
if !bytes.Contains(p, []byte("ServerURLTable")) || !bytes.Contains(p, []byte("LIVE_URL")) {
return errors.New("introdb: database does not contain ServerURLTable/LIVE_URL")
}
if bytes.Count(p, []byte(expected)) != 1 {
return fmt.Errorf("introdb: expected LIVE_URL value %q exactly once, found %d", expected, bytes.Count(p, []byte(expected)))
}
return nil
}
func urlInDB(p []byte) (string, error) {
if bytes.Count(p, []byte(oldURL)) == 1 {
return oldURL, nil
}
// Patching stays strictly exact-length, so an already-patched value can be
// diagnosed without needing a SQLite C dependency. Search only the record
// containing LIVE_URL; unrelated configuration URLs are also present.
marker := []byte("LIVE_URL")
for start := 0; ; {
i := bytes.Index(p[start:], marker)
if i < 0 {
break
}
i += start + len(marker)
limit := i + 128
if limit > len(p) {
limit = len(p)
}
if j := bytes.Index(p[i:limit], []byte("http")); j >= 0 {
at := i + j
if at+len(oldURL) <= len(p) {
u := p[at : at+len(oldURL)]
if bytes.HasSuffix(u, []byte("/")) && isASCII(string(u)) {
return string(u), nil
}
}
}
start = i
}
return "", errors.New("introdb: cannot locate LIVE_URL value")
}
func isASCII(s string) bool {
for _, c := range []byte(s) {
if c < 0x20 || c > 0x7e {
return false
}
}
return true
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
_, e := io.Copy(out, in)
ce := out.Close()
if e != nil {
return e
}
return ce
}
func atomicWrite(path string, data []byte) error {
tmp := path + ".bd2server.tmp"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("introdb: stage patched assets: %w", err)
}
if _, err := f.Write(data); err != nil {
_ = f.Close()
return fmt.Errorf("introdb: stage patched assets: %w", err)
}
if err := f.Sync(); err != nil {
_ = f.Close()
return fmt.Errorf("introdb: sync staged assets: %w", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("introdb: close staged assets: %w", err)
}
// Go's Windows implementation replaces an existing destination (covered by
// TestPatchClientTransaction). Prefer that one-step replacement. A rollback
// path remains for Windows filesystems that reject replacement by rename.
if err := os.Rename(tmp, path); err == nil {
return nil
}
rollback := path + ".bd2server.rollback"
if _, err := os.Stat(rollback); err == nil {
return fmt.Errorf("introdb: cannot safely replace assets; rollback file exists: %q", rollback)
}
if err := os.Rename(path, rollback); err != nil {
return fmt.Errorf("introdb: replace assets: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
if restoreErr := os.Rename(rollback, path); restoreErr != nil {
return fmt.Errorf("introdb: replacement failed (%v) and rollback restore failed (%v); original is %q", err, restoreErr, rollback)
}
return fmt.Errorf("introdb: replace assets (original restored): %w", err)
}
if err := os.Remove(rollback); err != nil {
return fmt.Errorf("introdb: patched successfully but could not remove rollback copy %q: %w", rollback, err)
}
return nil
}
// Kept here so the object parser and patch transaction stay together.
type textAsset struct {
pathID int64
size uint32
scriptStart int64
scriptSize int
}
type reader struct {
b []byte
off int
little bool
}
func (r *reader) need(n int) error {
if n < 0 || r.off+n > len(r.b) {
return io.ErrUnexpectedEOF
}
return nil
}
func (r *reader) u8() (byte, error) {
if err := r.need(1); err != nil {
return 0, err
}
v := r.b[r.off]
r.off++
return v, nil
}
func (r *reader) u16() (uint16, error) {
if err := r.need(2); err != nil {
return 0, err
}
v := binary.LittleEndian.Uint16(r.b[r.off:])
if !r.little {
v = binary.BigEndian.Uint16(r.b[r.off:])
}
r.off += 2
return v, nil
}
func (r *reader) u32() (uint32, error) {
if err := r.need(4); err != nil {
return 0, err
}
v := binary.LittleEndian.Uint32(r.b[r.off:])
if !r.little {
v = binary.BigEndian.Uint32(r.b[r.off:])
}
r.off += 4
return v, nil
}
func (r *reader) i32() (int32, error) { v, e := r.u32(); return int32(v), e }
func (r *reader) i64() (int64, error) {
if e := r.need(8); e != nil {
return 0, e
}
v := binary.LittleEndian.Uint64(r.b[r.off:])
if !r.little {
v = binary.BigEndian.Uint64(r.b[r.off:])
}
r.off += 8
return int64(v), nil
}
func (r *reader) u64() (uint64, error) { v, e := r.i64(); return uint64(v), e }
func (r *reader) skip(n int) error {
if e := r.need(n); e != nil {
return e
}
r.off += n
return nil
}
func (r *reader) align4() { r.off = (r.off + 3) &^ 3 }
func (r *reader) str() (string, error) {
n, e := r.u32()
if e != nil {
return "", e
}
if n > uint32(len(r.b)-r.off) {
return "", io.ErrUnexpectedEOF
}
s := string(r.b[r.off : r.off+int(n)])
r.off += int(n)
r.align4()
return s, nil
}
func (r *reader) cstr() (string, error) {
start := r.off
for r.off < len(r.b) && r.b[r.off] != 0 {
r.off++
}
if r.off == len(r.b) {
return "", io.ErrUnexpectedEOF
}
s := string(r.b[start:r.off])
r.off++
return s, nil
}
func findIntro(file []byte) (textAsset, []byte, error) {
if len(file) < 48 {
return textAsset{}, nil, errors.New("introdb: truncated Unity serialized file")
}
version := binary.BigEndian.Uint32(file[8:12])
if version < 14 {
return textAsset{}, nil, fmt.Errorf("introdb: Unity serialized version %d is unsupported", version)
}
dataOff := binary.BigEndian.Uint64(file[32:40])
if dataOff >= uint64(len(file)) {
return textAsset{}, nil, errors.New("introdb: invalid Unity data offset")
}
// Unity records endian at byte 16: 0 means little endian for metadata.
r := reader{b: file[48:int(dataOff)], little: file[16] == 0}
if _, e := r.cstr(); e != nil {
return textAsset{}, nil, fmt.Errorf("introdb: user version: %w", e)
}
if _, e := r.i32(); e != nil {
return textAsset{}, nil, e
}
if _, e := r.u8(); e != nil {
return textAsset{}, nil, e
}
typeCount, e := r.i32()
if e != nil || typeCount < 0 || typeCount > 100000 {
return textAsset{}, nil, fmt.Errorf("introdb: invalid type count %d", typeCount)
}
classes := make([]int32, typeCount)
for i := range classes {
c, e := r.i32()
if e != nil {
return textAsset{}, nil, e
}
classes[i] = c
if _, e = r.u8(); e != nil {
return textAsset{}, nil, e
}
if _, e = r.u16(); e != nil {
return textAsset{}, nil, e
}
if c == 114 {
if e = r.skip(16); e != nil {
return textAsset{}, nil, e
}
}
if e = r.skip(16); e != nil {
return textAsset{}, nil, e
}
}
count, e := r.i32()
if e != nil || count < 0 || count > 10000000 {
return textAsset{}, nil, fmt.Errorf("introdb: invalid object count %d", count)
}
var candidates []textAsset
for i := int32(0); i < count; i++ {
// Since serialized version 14, Unity aligns object records to four bytes
// before their 64-bit path ID (not to an eight-byte boundary).
r.off = (r.off + 48 + 3) &^ 3
r.off -= 48
pid, e := r.i64()
if e != nil {
return textAsset{}, nil, e
}
start, e := r.u64()
if e != nil {
return textAsset{}, nil, e
}
size, e := r.u32()
if e != nil {
return textAsset{}, nil, e
}
typ, e := r.i32()
if e != nil {
return textAsset{}, nil, e
}
if typ < 0 || int(typ) >= len(classes) {
return textAsset{}, nil, fmt.Errorf("introdb: object %d has invalid type ID %d", i, typ)
}
if classes[typ] != 49 {
continue
}
abs := int64(dataOff) + int64(start)
if abs < 0 || abs+int64(size) > int64(len(file)) {
return textAsset{}, nil, fmt.Errorf("introdb: TextAsset object %d range outside file (start=%d size=%d)", i, start, size)
}
candidates = append(candidates, textAsset{pathID: pid, size: size, scriptStart: abs})
}
for _, c := range candidates {
object := reader{b: file[c.scriptStart : c.scriptStart+int64(c.size)], little: r.little}
name, e := object.str()
if e != nil {
continue
}
n, e := object.u32()
if e != nil || n > uint32(len(object.b)-object.off) {
continue
}
if name == "Intro" {
c.scriptStart += int64(object.off)
c.scriptSize = int(n)
return c, file[c.scriptStart : c.scriptStart+int64(n)], nil
}
}
return textAsset{}, nil, errors.New("introdb: TextAsset named Intro was not found")
}
+130
View File
@@ -0,0 +1,130 @@
package introdb
import (
"bytes"
"os"
"path/filepath"
"testing"
)
func referenceClientDir(t *testing.T) string {
t.Helper()
dir := os.Getenv("BD2_TEST_CLIENT_DIR")
if dir == "" {
t.Skip("set BD2_TEST_CLIENT_DIR to enable read-only client integration tests")
}
return dir
}
func TestPagesRoundTrip(t *testing.T) {
p := make([]byte, PageSize*2)
copy(p, salt)
for i := 16; i < len(p); i++ {
p[i] = byte(i * 31)
}
c, err := EncryptPages(p)
if err != nil {
t.Fatal(err)
}
got, err := DecryptPages(c)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, p) {
t.Fatal("page cipher did not round-trip")
}
}
func TestPagesRejectPartialPage(t *testing.T) {
if _, err := DecryptPages(make([]byte, PageSize-1)); err == nil {
t.Fatal("accepted partial page")
}
}
func TestValidateRequiresUniqueURL(t *testing.T) {
p := append([]byte{}, salt...)
p = append(p, []byte(" ServerURLTable LIVE_URL "+oldURL)...)
if err := validateIntroDB(p, oldURL); err != nil {
t.Fatal(err)
}
if err := validateIntroDB(append(p, []byte(oldURL)...), oldURL); err == nil {
t.Fatal("accepted duplicated URL")
}
}
// This is deliberately read-only. When the reference client is present, it
// proves the Unity metadata parser and crypto parameters against the real file.
func TestKnownClientVerify(t *testing.T) {
knownClientDir := referenceClientDir(t)
if _, err := os.Stat(knownClientDir); os.IsNotExist(err) {
t.Skip("reference client is not available")
}
v, err := VerifyClient(knownClientDir)
if err != nil {
t.Fatal(err)
}
if v.URL != oldURL && v.URL != "http://127.0.0.1:8080/game/" {
t.Fatalf("unexpected LIVE_URL=%q", v.URL)
}
}
// The transaction test copies the reference asset into a test directory, never
// mutating the installed client. It covers Unity object lookup, backup,
// encryption, atomic replacement, and the post-patch diagnostic together.
func TestPatchClientTransaction(t *testing.T) {
knownClientDir := referenceClientDir(t)
if _, err := os.Stat(knownClientDir); os.IsNotExist(err) {
t.Skip("reference client is not available")
}
tmp := t.TempDir()
dir := filepath.Join(tmp, "BrownDust II_Data")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
src, err := ResourcesPath(knownClientDir)
if err != nil {
t.Fatal(err)
}
if current, err := VerifyClient(knownClientDir); err == nil && current.URL != oldURL {
// The installed research client is normally patched. Exercise the
// transaction against its immutable pre-patch backup in that case.
if _, err := os.Stat(src + ".bak"); err != nil {
t.Skipf("official pre-patch asset is unavailable: %v", err)
}
src += ".bak"
}
dst := filepath.Join(dir, "resources.assets")
in, err := os.ReadFile(src)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, in, 0o600); err != nil {
t.Fatal(err)
}
const local = "http://127.0.0.1:8080/bd2x/"
r, err := PatchClient(tmp, local)
if err != nil {
t.Fatal(err)
}
if r.OldURL != oldURL || r.NewURL != local || r.BackupPath != dst+".bak" {
t.Fatalf("unexpected patch result: %#v", r)
}
if !r.Changed {
t.Fatal("first patch was not reported as changed")
}
if _, err := os.Stat(r.BackupPath); err != nil {
t.Fatalf("backup missing: %v", err)
}
v, err := VerifyClient(tmp)
if err != nil {
t.Fatal(err)
}
if v.URL != local {
t.Fatalf("LIVE_URL=%q, want %q", v.URL, local)
}
repeated, err := PatchClient(tmp, local)
if err != nil {
t.Fatalf("idempotent patch: %v", err)
}
if repeated.Changed {
t.Fatal("idempotent patch was reported as changed")
}
}
+120
View File
@@ -0,0 +1,120 @@
// Package layout resolves the supported Windows and macOS Brown Dust II
// installation layouts without relying on the host running bd2client.
package layout
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
type Kind string
const (
Windows Kind = "windows"
MacOS Kind = "macos"
)
type Installation struct {
Kind Kind
Selected string
Root string
Executable string
Data string
Resources string
Managers string
BepInEx string
Config string
Plugins string
Disabled string
}
func Resolve(selected string) (Installation, error) {
if strings.TrimSpace(selected) == "" {
return Installation{}, errors.New("select the Brown Dust II installation directory")
}
abs, err := filepath.Abs(strings.TrimSpace(selected))
if err != nil {
return Installation{}, fmt.Errorf("resolve game directory: %w", err)
}
abs = filepath.Clean(abs)
if installation, ok := windowsLayout(abs); ok {
return installation, nil
}
if installation, ok := macLayout(abs); ok {
return installation, nil
}
return Installation{}, errors.New("the selected directory is not a complete Brown Dust II Windows or macOS client")
}
func windowsLayout(root string) (Installation, bool) {
executable := filepath.Join(root, "BrownDust II.exe")
data := filepath.Join(root, "BrownDust II_Data")
if !regularFile(executable) || !regularFile(filepath.Join(data, "resources.assets")) {
return Installation{}, false
}
return newInstallation(Windows, root, root, executable, data, filepath.Join(root, "BepInEx")), true
}
func macLayout(selected string) (Installation, bool) {
candidates := []string{selected}
if !strings.EqualFold(filepath.Ext(selected), ".app") {
candidates = append(candidates, filepath.Join(selected, "BrownDust II.app"))
}
for _, app := range candidates {
contents := filepath.Join(app, "Contents")
executable := filepath.Join(contents, "MacOS", "BrownDust II")
data := filepath.Join(contents, "Resources", "Data")
if regularFile(executable) && regularFile(filepath.Join(data, "resources.assets")) {
// BepInEx Unix distributions are normally extracted beside the
// .app bundle. Also accept an installation placed inside Contents.
bepInEx := filepath.Join(filepath.Dir(app), "BepInEx")
insideBundle := filepath.Join(contents, "BepInEx")
if directoryExists(insideBundle) && !directoryExists(bepInEx) {
bepInEx = insideBundle
}
return newInstallation(MacOS, selected, app, executable, data, bepInEx), true
}
}
return Installation{}, false
}
func newInstallation(kind Kind, selected, root, executable, data, bepInEx string) Installation {
return Installation{
Kind: kind,
Selected: selected,
Root: root,
Executable: executable,
Data: data,
Resources: filepath.Join(data, "resources.assets"),
Managers: filepath.Join(data, "globalgamemanagers"),
BepInEx: bepInEx,
Config: filepath.Join(bepInEx, "config"),
Plugins: filepath.Join(bepInEx, "plugins"),
Disabled: filepath.Join(bepInEx, "disabled"),
}
}
func (i Installation) LaunchTarget() string {
if i.Kind == MacOS {
return i.Root
}
return i.Executable
}
func (i Installation) SupportedOnHost() bool {
return (i.Kind == Windows && runtime.GOOS == "windows") || (i.Kind == MacOS && runtime.GOOS == "darwin")
}
func regularFile(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular()
}
func directoryExists(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
+40
View File
@@ -0,0 +1,40 @@
package layout
import (
"os"
"path/filepath"
"testing"
)
func writeFile(t *testing.T, path string) {
t.Helper()
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)
}
}
func TestResolveWindows(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "BrownDust II.exe"))
writeFile(t, filepath.Join(root, "BrownDust II_Data", "resources.assets"))
got, err := Resolve(root)
if err != nil || got.Kind != Windows || got.Resources != filepath.Join(root, "BrownDust II_Data", "resources.assets") || got.Plugins != filepath.Join(root, "BepInEx", "plugins") {
t.Fatalf("layout=%+v err=%v", got, err)
}
}
func TestResolveMacAppAndParent(t *testing.T) {
parent := t.TempDir()
app := filepath.Join(parent, "BrownDust II.app")
writeFile(t, filepath.Join(app, "Contents", "MacOS", "BrownDust II"))
writeFile(t, filepath.Join(app, "Contents", "Resources", "Data", "resources.assets"))
for _, selected := range []string{app, parent} {
got, err := Resolve(selected)
if err != nil || got.Kind != MacOS || got.Root != app || got.BepInEx != filepath.Join(parent, "BepInEx") || got.LaunchTarget() != app {
t.Fatalf("selected=%q layout=%+v err=%v", selected, got, err)
}
}
}
+119
View File
@@ -0,0 +1,119 @@
package plugin
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"
clientlayout "bd2server/internal/client/layout"
)
const (
BepInExReleasesURL = "https://github.com/BepInEx/BepInEx/releases"
)
type Spec struct {
fileName string
}
var (
LocalIdentity = Spec{fileName: "BD2LocalIdentity.dll"}
LoginUI = Spec{fileName: "BD2LoginUI.dll"}
)
func (s Spec) FileName() string { return s.fileName }
func (s Spec) validate() error {
if s.fileName == "" || filepath.Base(s.fileName) != s.fileName || filepath.Ext(s.fileName) != ".dll" {
return errors.New("clientplugin: invalid plugin specification")
}
return nil
}
type Result struct {
Destination string
Changed bool
}
func ResolvePackaged(spec Spec, explicit string) (string, error) {
if err := spec.validate(); err != nil {
return "", err
}
if explicit != "" {
return filepath.Clean(explicit), nil
}
executable, err := os.Executable()
if err != nil {
return "", fmt.Errorf("clientplugin: resolve client tool executable: %w", err)
}
return filepath.Join(filepath.Dir(executable), "plugins", spec.fileName), nil
}
// Install verifies that the user installed BepInEx, then atomically stages the
// packaged plugin into its plugins directory. It never installs or downloads
// BepInEx itself.
func Install(spec Spec, gameDir, source string) (Result, error) {
if err := spec.validate(); err != nil {
return Result{}, err
}
if gameDir == "" || source == "" {
return Result{}, errors.New("clientplugin: game directory and plugin source are required")
}
gameDir = filepath.Clean(gameDir)
source = filepath.Clean(source)
installation, err := clientlayout.Resolve(gameDir)
if err != nil {
return Result{}, fmt.Errorf("clientplugin: resolve game layout: %w", err)
}
bepInEx := filepath.Join(installation.BepInEx, "core", "BepInEx.dll")
if info, err := os.Stat(bepInEx); err != nil || info.IsDir() {
return Result{}, fmt.Errorf("clientplugin: BepInEx is not installed; install it manually from %s, then run the client tool again; %s was not copied", BepInExReleasesURL, spec.fileName)
}
sourceData, err := os.ReadFile(source)
if err != nil {
return Result{}, fmt.Errorf("clientplugin: read packaged %s: %w", spec.fileName, err)
}
if len(sourceData) == 0 {
return Result{}, fmt.Errorf("clientplugin: packaged %s is empty", spec.fileName)
}
pluginDir := installation.Plugins
destination := filepath.Join(pluginDir, spec.fileName)
if installed, err := os.ReadFile(destination); err == nil {
if bytes.Equal(hash(installed), hash(sourceData)) {
return Result{Destination: destination}, nil
}
} else if !errors.Is(err, os.ErrNotExist) {
return Result{}, fmt.Errorf("clientplugin: inspect installed plugin: %w", err)
}
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
return Result{}, fmt.Errorf("clientplugin: create plugin directory: %w", err)
}
temporary, err := os.CreateTemp(pluginDir, "."+spec.fileName+"-*.tmp")
if err != nil {
return Result{}, fmt.Errorf("clientplugin: create temporary plugin: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if _, err = io.Copy(temporary, bytes.NewReader(sourceData)); err == nil {
err = temporary.Sync()
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err != nil {
return Result{}, fmt.Errorf("clientplugin: stage plugin: %w", err)
}
if err := replaceFile(temporaryPath, destination); err != nil {
return Result{}, fmt.Errorf("clientplugin: install plugin (close the game client first): %w", err)
}
return Result{Destination: destination, Changed: true}, nil
}
func hash(data []byte) []byte {
sum := sha256.Sum256(data)
return sum[:]
}
+113
View File
@@ -0,0 +1,113 @@
package plugin
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) {
for _, spec := range []Spec{LocalIdentity, LoginUI} {
t.Run(spec.FileName(), func(t *testing.T) {
gameDir := t.TempDir()
for path, data := range map[string][]byte{
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
source := filepath.Join(t.TempDir(), spec.FileName())
if err := os.WriteFile(source, []byte("plugin"), 0o600); err != nil {
t.Fatal(err)
}
_, err := Install(spec, gameDir, source)
if err == nil || !strings.Contains(err.Error(), BepInExReleasesURL) {
t.Fatalf("missing BepInEx error=%v", err)
}
if _, statErr := os.Stat(filepath.Join(gameDir, "BepInEx", "plugins", spec.FileName())); !os.IsNotExist(statErr) {
t.Fatalf("plugin was copied without BepInEx: %v", statErr)
}
})
}
}
func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
for _, spec := range []Spec{LocalIdentity, LoginUI} {
t.Run(spec.FileName(), func(t *testing.T) {
gameDir := t.TempDir()
for path, data := range map[string][]byte{
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
source := filepath.Join(t.TempDir(), spec.FileName())
if err := os.WriteFile(source, []byte("v1"), 0o600); err != nil {
t.Fatal(err)
}
first, err := Install(spec, gameDir, source)
if err != nil || !first.Changed {
t.Fatalf("first install=%+v err=%v", first, err)
}
second, err := Install(spec, gameDir, source)
if err != nil || second.Changed {
t.Fatalf("idempotent install=%+v err=%v", second, err)
}
if err := os.WriteFile(source, []byte("v2"), 0o600); err != nil {
t.Fatal(err)
}
third, err := Install(spec, gameDir, source)
if err != nil || !third.Changed {
t.Fatalf("update=%+v err=%v", third, err)
}
got, err := os.ReadFile(third.Destination)
if err != nil || string(got) != "v2" {
t.Fatalf("installed=%q err=%v", got, err)
}
})
}
}
func TestInstallKeepsPluginsSeparate(t *testing.T) {
gameDir := t.TempDir()
for path, data := range map[string][]byte{
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
for _, spec := range []Spec{LocalIdentity, LoginUI} {
source := filepath.Join(t.TempDir(), spec.FileName())
if err := os.WriteFile(source, []byte(spec.FileName()), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Install(spec, gameDir, source); err != nil {
t.Fatal(err)
}
}
for _, spec := range []Spec{LocalIdentity, LoginUI} {
path := filepath.Join(gameDir, "BepInEx", "plugins", spec.FileName())
data, err := os.ReadFile(path)
if err != nil || string(data) != spec.FileName() {
t.Fatalf("%s=%q err=%v", spec.FileName(), data, err)
}
}
}
@@ -0,0 +1,9 @@
//go:build !windows
package plugin
import "os"
func replaceFile(source, destination string) error {
return os.Rename(source, destination)
}
@@ -0,0 +1,34 @@
//go:build windows
package plugin
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
}
+323
View File
@@ -0,0 +1,323 @@
// Package setup implements the filesystem and network operations exposed by
// bd2client. It contains no server runtime dependencies.
package setup
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
clientconfig "bd2server/internal/client/config"
"bd2server/internal/client/introdb"
clientlayout "bd2server/internal/client/layout"
"bd2server/internal/client/plugin"
)
const PatchPlaceholder = "http://127.0.0.1:8080/game/"
type GameStatus struct {
GameDirectory string `json:"game_directory"`
ClientVersion string `json:"client_version"`
Executable bool `json:"executable"`
Resources bool `json:"resources"`
BepInEx bool `json:"bepinex"`
Config bool `json:"config"`
PatchedURL string `json:"patched_url,omitempty"`
}
var clientVersionPattern = regexp.MustCompile(`(?:^|\x00)([0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,3})(?:\x00)`)
type ResourcePolicy struct {
Mode clientconfig.CDNMode `json:"mode"`
ServerDataURL string `json:"server_data_url"`
GameDataURL string `json:"game_data_url"`
BundleVersion string `json:"bundle_version"`
GameDataVersion string `json:"game_data_version"`
LocalDirectory string `json:"local_directory,omitempty"`
}
type InstallResult struct {
LocalIdentity plugin.Result `json:"local_identity"`
LoginUI plugin.Result `json:"login_ui"`
}
func Inspect(gameDir string, versions clientconfig.ReleaseVersions) (GameStatus, error) {
if strings.TrimSpace(gameDir) == "" {
return GameStatus{}, errors.New("select the Brown Dust II installation directory")
}
installation, err := clientlayout.Resolve(gameDir)
if err != nil {
return GameStatus{}, err
}
status := GameStatus{GameDirectory: installation.Root}
status.Executable = regularFile(installation.Executable)
status.Resources = regularFile(installation.Resources)
status.BepInEx = regularFile(filepath.Join(installation.BepInEx, "core", "BepInEx.dll"))
status.Config = regularFile(filepath.Join(installation.Config, clientconfig.FileName))
status.ClientVersion, err = detectClientVersion(installation.Managers)
if err != nil {
return status, err
}
if status.ClientVersion != versions.ClientVersion {
return status, fmt.Errorf("unsupported Brown Dust II client version %s; this bd2client release requires %s", status.ClientVersion, versions.ClientVersion)
}
if verified, verifyErr := introdb.VerifyClient(installation.Root); verifyErr == nil {
status.PatchedURL = verified.URL
}
return status, nil
}
func SaveSettings(gameDir string, settings clientconfig.Settings, versions clientconfig.ReleaseVersions) (clientconfig.Settings, error) {
if _, err := Inspect(gameDir, versions); err != nil {
return clientconfig.Settings{}, err
}
return clientconfig.Save(gameDir, settings)
}
func Patch(gameDir string, settings clientconfig.Settings, versions clientconfig.ReleaseVersions) (introdb.Result, error) {
if _, err := SaveSettings(gameDir, settings, versions); err != nil {
return introdb.Result{}, err
}
result, err := introdb.PatchClient(gameDir, PatchPlaceholder)
if err != nil {
return introdb.Result{}, err
}
if _, err := introdb.VerifyClient(gameDir); err != nil {
return introdb.Result{}, fmt.Errorf("verify patched client resources: %w", err)
}
if _, err := disableLegacyPlugin(gameDir); err != nil {
return introdb.Result{}, err
}
return result, nil
}
func InstallPlugins(
gameDir string,
settings clientconfig.Settings,
versions clientconfig.ReleaseVersions,
localIdentitySource string,
loginUISource string,
) (InstallResult, error) {
status, err := Inspect(gameDir, versions)
if err != nil {
return InstallResult{}, err
}
if !status.BepInEx {
return InstallResult{}, fmt.Errorf("BepInEx is not installed; install it from %s before installing the plugins", plugin.BepInExReleasesURL)
}
if _, err := clientconfig.Save(gameDir, settings); err != nil {
return InstallResult{}, err
}
localSource, err := plugin.ResolvePackaged(plugin.LocalIdentity, localIdentitySource)
if err != nil {
return InstallResult{}, err
}
loginSource, err := plugin.ResolvePackaged(plugin.LoginUI, loginUISource)
if err != nil {
return InstallResult{}, err
}
local, err := plugin.Install(plugin.LocalIdentity, gameDir, localSource)
if err != nil {
return InstallResult{}, err
}
login, err := plugin.Install(plugin.LoginUI, gameDir, loginSource)
if err != nil {
return InstallResult{}, err
}
return InstallResult{LocalIdentity: local, LoginUI: login}, nil
}
func FetchResourcePolicy(ctx context.Context, client *http.Client, settings clientconfig.Settings, versions clientconfig.ReleaseVersions) (ResourcePolicy, error) {
normalized, err := clientconfig.Normalize(settings)
if err != nil {
return ResourcePolicy{}, err
}
if normalized.CDNMode == clientconfig.CDNOfficial {
return ResourcePolicy{Mode: clientconfig.CDNOfficial}, nil
}
if normalized.CDNMode == clientconfig.CDNLocal {
root, err := inspectLocalResourceDirectory(normalized.LocalResourceDirectory, versions)
if err != nil {
return ResourcePolicy{}, err
}
return ResourcePolicy{
Mode: clientconfig.CDNLocal,
ServerDataURL: localResourceURL(filepath.Join(root, "ServerData")),
GameDataURL: localResourceURL(filepath.Join(root, "GameData")),
BundleVersion: versions.BundleVersion,
GameDataVersion: versions.GameDataVersion,
LocalDirectory: root,
}, nil
}
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
body, err := json.Marshal(map[string]clientconfig.CDNMode{"cdn_mode": normalized.CDNMode})
if err != nil {
return ResourcePolicy{}, err
}
endpoint := normalized.ServerOrigin + "/client/resources"
request, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
if err != nil {
return ResourcePolicy{}, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return ResourcePolicy{}, fmt.Errorf("request server resource policy: %w", err)
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, 64<<10)
responseBody, err := io.ReadAll(limited)
if err != nil {
return ResourcePolicy{}, fmt.Errorf("read server resource policy response: %w", err)
}
if response.StatusCode != http.StatusOK {
message := strings.TrimSpace(string(responseBody))
if len(message) > 300 {
message = message[:300]
}
return ResourcePolicy{}, fmt.Errorf("server rejected CDN mode %s (HTTP %d): %s", normalized.CDNMode, response.StatusCode, message)
}
var policy ResourcePolicy
if err := json.Unmarshal(responseBody, &policy); err != nil {
return ResourcePolicy{}, fmt.Errorf("decode server resource policy: %w", err)
}
if policy.Mode != normalized.CDNMode {
return ResourcePolicy{}, fmt.Errorf("server returned CDN mode %q, expected %q", policy.Mode, normalized.CDNMode)
}
if err := validatePublicURL("ServerData", policy.ServerDataURL); err != nil {
return ResourcePolicy{}, err
}
if err := validatePublicURL("GameData", policy.GameDataURL); err != nil {
return ResourcePolicy{}, err
}
if policy.BundleVersion == "" || policy.GameDataVersion == "" {
return ResourcePolicy{}, errors.New("server resource policy is missing bundle_version or game_data_version")
}
if policy.BundleVersion != versions.BundleVersion || policy.GameDataVersion != versions.GameDataVersion {
return ResourcePolicy{}, fmt.Errorf(
"server resource versions do not match this client release: bundle=%s (want %s), GameData=%s (want %s)",
policy.BundleVersion, versions.BundleVersion, policy.GameDataVersion, versions.GameDataVersion,
)
}
return policy, nil
}
func inspectLocalResourceDirectory(raw string, versions clientconfig.ReleaseVersions) (string, error) {
root, err := filepath.Abs(filepath.Clean(strings.TrimSpace(raw)))
if err != nil {
return "", fmt.Errorf("resolve local resource directory: %w", err)
}
for _, relative := range []string{
filepath.Join("ServerData", "StandaloneWindows64", "HD", versions.BundleVersion, "catalog_alpha.json"),
filepath.Join("ServerData", "StandaloneWindows64", "HD", versions.BundleVersion, "catalog_alpha.hash"),
filepath.Join("GameData", versions.GameDataVersion, "release", "common-dbdata.info"),
filepath.Join("GameData", versions.GameDataVersion, "release", "common-dbdata.bin"),
} {
info, statErr := os.Stat(filepath.Join(root, relative))
if statErr != nil || !info.Mode().IsRegular() {
return "", fmt.Errorf("local resource directory is missing %s", relative)
}
}
return root, nil
}
func detectClientVersion(path string) (string, error) {
info, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("read Brown Dust II client version metadata: %w", err)
}
if !info.Mode().IsRegular() {
return "", errors.New("Brown Dust II client version metadata is not a regular file")
}
if info.Size() <= 0 || info.Size() > 64<<20 {
return "", fmt.Errorf("Brown Dust II client version metadata has an invalid size: %d", info.Size())
}
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read Brown Dust II client version metadata: %w", err)
}
matches := clientVersionPattern.FindAllSubmatch(data, -1)
versions := make(map[string]struct{})
for _, match := range matches {
versions[string(match[1])] = struct{}{}
}
if len(versions) != 1 {
return "", fmt.Errorf("could not identify one unambiguous Brown Dust II client version in %s", path)
}
for version := range versions {
return version, nil
}
panic("unreachable")
}
func localResourceURL(path string) string {
slashed := filepath.ToSlash(filepath.Clean(path))
if filepath.VolumeName(path) != "" && !strings.HasPrefix(slashed, "/") {
slashed = "/" + slashed
}
return (&url.URL{Scheme: "file", Path: slashed}).String()
}
func validatePublicURL(name, raw string) error {
parsed, err := url.Parse(raw)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return fmt.Errorf("server returned an invalid %s URL", name)
}
if parsed.Scheme == "http" && !resourceLoopback(parsed.Hostname()) {
return fmt.Errorf("server returned an invalid %s URL", name)
}
return nil
}
func resourceLoopback(host string) bool {
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func regularFile(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func disableLegacyPlugin(gameDir string) (string, error) {
installation, err := clientlayout.Resolve(gameDir)
if err != nil {
return "", err
}
source := filepath.Join(installation.Plugins, "PluginLocalRes.dll")
destination := filepath.Join(installation.Disabled, "PluginLocalRes.dll")
if _, err := os.Stat(source); errors.Is(err, os.ErrNotExist) {
return "", nil
} else if err != nil {
return "", fmt.Errorf("inspect legacy local resource plugin: %w", err)
}
if _, err := os.Stat(destination); err == nil {
return "", errors.New("the legacy local resource plugin exists in both active and disabled directories; remove one copy manually")
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
}
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
return "", err
}
if err := os.Rename(source, destination); err != nil {
return "", fmt.Errorf("disable legacy local resource plugin: %w", err)
}
return destination, nil
}
+173
View File
@@ -0,0 +1,173 @@
package setup
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
clientconfig "bd2server/internal/client/config"
)
func testVersions() clientconfig.ReleaseVersions {
return clientconfig.ReleaseVersions{
ClientVersion: "2.35.10", BundleVersion: "20260921135230", GameDataVersion: "20260923193640",
}
}
func TestInspectRequiresGameFiles(t *testing.T) {
dir := t.TempDir()
if _, err := Inspect(dir, testVersions()); err == nil {
t.Fatal("accepted empty directory")
}
for path, data := range map[string][]byte{
filepath.Join(dir, "BrownDust II.exe"): []byte("exe"),
filepath.Join(dir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
filepath.Join(dir, "BrownDust II_Data", "globalgamemanagers"): []byte("\x002.35.10\x00"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
status, err := Inspect(dir, testVersions())
if err != nil {
t.Fatal(err)
}
if !status.Executable || !status.Resources || status.BepInEx {
t.Fatalf("status=%+v", status)
}
}
func TestInspectRejectsUnsupportedClientVersion(t *testing.T) {
dir := t.TempDir()
for path, data := range map[string][]byte{
filepath.Join(dir, "BrownDust II.exe"): []byte("exe"),
filepath.Join(dir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
filepath.Join(dir, "BrownDust II_Data", "globalgamemanagers"): []byte("\x002.36.0\x00"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
status, err := Inspect(dir, testVersions())
if err == nil || status.ClientVersion != "2.36.0" {
t.Fatalf("status=%+v err=%v", status, err)
}
}
func TestInspectMacApp(t *testing.T) {
parent := t.TempDir()
app := filepath.Join(parent, "BrownDust II.app")
for path, data := range map[string][]byte{
filepath.Join(app, "Contents", "MacOS", "BrownDust II"): []byte("binary"),
filepath.Join(app, "Contents", "Resources", "Data", "resources.assets"): []byte("assets"),
filepath.Join(app, "Contents", "Resources", "Data", "globalgamemanagers"): []byte("\x002.35.10\x00"),
filepath.Join(parent, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o700); err != nil {
t.Fatal(err)
}
}
status, err := Inspect(app, testVersions())
if err != nil || status.GameDirectory != app || status.ClientVersion != "2.35.10" || !status.BepInEx {
t.Fatalf("status=%+v err=%v", status, err)
}
}
func TestFetchResourcePolicyRejectsServerVersionMismatch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(ResourcePolicy{
Mode: clientconfig.CDNServer, ServerDataURL: "https://cdn.example/ServerData",
GameDataURL: "https://cdn.example/GameData", BundleVersion: "wrong", GameDataVersion: "wrong",
})
}))
defer server.Close()
_, err := FetchResourcePolicy(context.Background(), server.Client(), clientconfig.Settings{ServerOrigin: server.URL, CDNMode: clientconfig.CDNServer}, testVersions())
if err == nil {
t.Fatal("accepted mismatched server resource versions")
}
}
func TestFetchResourcePolicy(t *testing.T) {
var method string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method = r.Method
var request map[string]string
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Error(err)
}
if request["cdn_mode"] != "server" {
t.Errorf("request=%v", request)
}
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(ResourcePolicy{
Mode: clientconfig.CDNServer,
ServerDataURL: "https://cdn.example/ServerData",
GameDataURL: "https://cdn.example/GameData",
BundleVersion: "20260921135230",
GameDataVersion: "20260923193640",
})
}))
defer server.Close()
policy, err := FetchResourcePolicy(context.Background(), server.Client(), clientconfig.Settings{
ServerOrigin: server.URL,
CDNMode: clientconfig.CDNServer,
}, testVersions())
if err != nil {
t.Fatal(err)
}
if method != http.MethodPut || policy.Mode != clientconfig.CDNServer || policy.BundleVersion != "20260921135230" {
t.Fatalf("method=%s policy=%+v", method, policy)
}
}
func TestLocalResourcesDoNotContactServer(t *testing.T) {
root := t.TempDir()
for _, relative := range []string{
filepath.Join("ServerData", "StandaloneWindows64", "HD", "20260921135230", "catalog_alpha.json"),
filepath.Join("ServerData", "StandaloneWindows64", "HD", "20260921135230", "catalog_alpha.hash"),
filepath.Join("GameData", "20260923193640", "release", "common-dbdata.info"),
filepath.Join("GameData", "20260923193640", "release", "common-dbdata.bin"),
} {
path := filepath.Join(root, relative)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("resource"), 0o600); err != nil {
t.Fatal(err)
}
}
policy, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{
ServerOrigin: "http://127.0.0.1:8080",
CDNMode: clientconfig.CDNLocal,
LocalResourceDirectory: root,
}, testVersions())
if err != nil {
t.Fatal(err)
}
if policy.Mode != clientconfig.CDNLocal || policy.LocalDirectory != root || policy.ServerDataURL == "" || policy.GameDataURL == "" {
t.Fatalf("policy=%+v", policy)
}
}
func TestOfficialDoesNotContactServer(t *testing.T) {
policy, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{
ServerOrigin: "https://example.com",
CDNMode: clientconfig.CDNOfficial,
}, testVersions())
if err != nil || policy.Mode != clientconfig.CDNOfficial {
t.Fatalf("policy=%+v err=%v", policy, err)
}
}