feat(all): split client tooling and add OAuth server login
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[:]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user