feat(all): add verified gacha flows and native client studio

This commit is contained in:
2026-09-30 23:49:00 +08:00
parent addc394cae
commit bf4f259bad
66 changed files with 2387 additions and 2351 deletions
+241 -427
View File
@@ -1,20 +1,12 @@
// Package app serves bd2client's embedded, loopback-only setup interface.
// Package app exposes desktop business operations through native Go bindings.
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"
@@ -32,8 +24,6 @@ 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
@@ -42,7 +32,7 @@ type Options struct {
LoginUIPlugin string
}
type request struct {
type Request struct {
GameDirectory string `json:"game_directory"`
ServerOrigin string `json:"server_origin"`
CDNMode clientconfig.CDNMode `json:"cdn_mode"`
@@ -50,470 +40,294 @@ type request struct {
UILanguage string `json:"ui_language"`
}
type response struct {
func (r Request) settings() clientconfig.Settings {
return clientconfig.Settings{ServerOrigin: r.ServerOrigin, CDNMode: r.CDNMode, LocalResourceDirectory: r.LocalResourceDirectory}
}
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
type InitialState struct {
Platform string `json:"platform"`
LogPath string `json:"log_path"`
GameDirectory string `json:"game_directory"`
ServerOrigin string `json:"server_origin"`
CDNMode clientconfig.CDNMode `json:"cdn_mode"`
LocalResourceDirectory string `json:"local_resource_directory"`
AutoOpen bool `json:"auto_open"`
}
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
type NativeHost struct {
BrowseDirectory func(context.Context, string) (string, error)
Quit func(context.Context)
}
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)
})
type Studio struct {
options Options
host NativeHost
ctx context.Context
ctxMu sync.RWMutex
cancel context.CancelFunc
mu sync.Mutex
quitOnce sync.Once
launch func(string) error
savePreferences func(string) error
}
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 NewStudio(options Options, host NativeHost) *Studio {
return &Studio{options: options, host: host, ctx: context.Background(), launch: launchGame, savePreferences: clientconfig.SavePreferences}
}
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)
func (s *Studio) Startup(ctx context.Context) {
owned, cancel := context.WithCancel(ctx)
s.ctxMu.Lock()
if s.cancel != nil {
s.cancel()
}
s.ctx, s.cancel = owned, cancel
s.ctxMu.Unlock()
}
func (s *Studio) Shutdown() {
s.ctxMu.Lock()
cancel := s.cancel
s.cancel = nil
s.ctxMu.Unlock()
if cancel != nil {
cancel()
}
}
type statusWriter struct {
http.ResponseWriter
status int
func (s *Studio) context() context.Context {
s.ctxMu.RLock()
ctx := s.ctx
s.ctxMu.RUnlock()
return ctx
}
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
func (s *Studio) log() *slog.Logger {
if s.options.Logger != nil {
return s.options.Logger
}
return slog.Default()
}
func (h *handler) browseDirectory(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
func (s *Studio) Initialize() InitialState {
s.mu.Lock()
defer s.mu.Unlock()
state := InitialState{Platform: runtime.GOOS, LogPath: s.options.LogPath, GameDirectory: s.options.InitialGameDir, ServerOrigin: "http://127.0.0.1:8080", CDNMode: clientconfig.CDNOfficial}
if state.GameDirectory == "" {
return state
}
h.log().Info("directory selection opened", "kind", "game")
dir, err := h.browse(input.UILanguage)
if _, err := clientsetup.Inspect(state.GameDirectory, s.options.Versions); err != nil {
s.log().Warn("saved game directory is no longer valid", "error", err)
return state
}
settings, err := clientconfig.Load(state.GameDirectory)
if err != nil {
h.writeError(w, err)
return
s.log().Warn("saved client connection settings are unavailable", "error", err)
return state
}
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})
state.ServerOrigin = settings.ServerOrigin
state.CDNMode = settings.CDNMode
state.LocalResourceDirectory = settings.LocalResourceDirectory
state.AutoOpen = true
return state
}
func (h *handler) browseResourceDirectory(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
func (s *Studio) perform(operation string, run func() (Response, error)) (Response, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.context().Err(); err != nil {
return Response{}, err
}
h.log().Info("directory selection opened", "kind", "resources")
dir, err := h.browseResources(input.UILanguage)
result, err := run()
if err != nil {
h.writeError(w, err)
return
s.log().Error("client operation failed", "operation", operation, "error", err)
return Response{}, err
}
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})
s.log().Info("client operation completed", "operation", operation)
return result, nil
}
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 success(message string, data any) Response {
return Response{OK: true, Message: message, Data: data}
}
func (h *handler) resources(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
func (s *Studio) chooseDirectory(language, kind string) (string, error) {
if s.host.BrowseDirectory == nil {
return "", errors.New("native directory picker is unavailable")
}
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
prompt := "Select the Brown Dust II installation directory"
if kind == "resources" {
prompt = "Select the CDN directory containing ServerData and GameData"
}
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 language == "zh-CN" {
prompt = "选择 Brown Dust II 安装目录"
if kind == "resources" {
prompt = "选择包含 ServerData 和 GameData 的 CDN 目录"
}
}
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
s.log().Info("directory selection opened", "kind", kind)
dir, err := s.host.BrowseDirectory(s.context(), prompt)
if err == nil && dir == "" {
s.log().Info("directory selection cancelled", "kind", kind)
}
return dir, err
}
func (s *Studio) Browse(input Request) (Response, error) {
return s.perform("browse game directory", func() (Response, error) {
dir, err := s.chooseDirectory(input.UILanguage, "game")
if err != nil {
return Response{}, err
}
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"})
if dir == "" {
return success("Selection cancelled", nil), nil
}
status, err := clientsetup.Inspect(dir, s.options.Versions)
return success("Game client found", status), err
})
}
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 (s *Studio) BrowseResources(input Request) (Response, error) {
return s.perform("browse resource directory", func() (Response, error) {
dir, err := s.chooseDirectory(input.UILanguage, "resources")
if err != nil {
return Response{}, err
}
if dir == "" {
return success("Selection cancelled", nil), nil
}
policy, err := clientsetup.FetchResourcePolicy(s.context(), nil, clientconfig.Settings{ServerOrigin: "http://127.0.0.1", CDNMode: clientconfig.CDNLocal, LocalResourceDirectory: dir}, s.options.Versions)
return success("Local resource directory found", policy), err
})
}
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 (s *Studio) Inspect(input Request) (Response, error) {
return s.perform("inspect game directory", func() (Response, error) {
status, err := clientsetup.Inspect(input.GameDirectory, s.options.Versions)
return success("Game directory is valid", status), err
})
}
func (r request) settings() clientconfig.Settings {
return clientconfig.Settings{
ServerOrigin: r.ServerOrigin,
CDNMode: r.CDNMode,
LocalResourceDirectory: r.LocalResourceDirectory,
}
func (s *Studio) Resources(input Request) (Response, error) {
return s.perform("check resource policy", func() (Response, error) {
ctx, cancel := context.WithTimeout(s.context(), 12*time.Second)
defer cancel()
policy, err := clientsetup.FetchResourcePolicy(ctx, nil, input.settings(), s.options.Versions)
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"
}
return success(message, policy), err
})
}
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 (s *Studio) Save(input Request) (Response, error) {
return s.perform("save settings", func() (Response, error) {
settings, err := clientsetup.SaveSettings(input.GameDirectory, input.settings(), s.options.Versions)
if err != nil {
return Response{}, err
}
if err := s.savePreferences(input.GameDirectory); err != nil {
return Response{}, err
}
return success("Connection settings saved", settings), nil
})
}
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 (s *Studio) Patch(input Request) (Response, error) {
return s.perform("patch client", func() (Response, error) {
result, err := clientsetup.Patch(input.GameDirectory, input.settings(), s.options.Versions)
if err != nil {
return Response{}, err
}
if err := s.savePreferences(input.GameDirectory); err != nil {
return Response{}, err
}
message := "Client entry point patched; the original backup was retained"
if !result.Changed {
message = "Client patch is already complete; no asset file was rewritten"
}
return success(message, result), nil
})
}
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
func (s *Studio) Install(input Request) (Response, error) {
return s.perform("install plugins", func() (Response, error) {
result, err := clientsetup.InstallPlugins(input.GameDirectory, input.settings(), s.options.Versions, s.options.LocalIdentityPlugin, s.options.LoginUIPlugin)
if err != nil {
return Response{}, err
}
if err := s.savePreferences(input.GameDirectory); err != nil {
return Response{}, err
}
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"
}
return success(message, result), nil
})
}
func (s *Studio) Launch(input Request) (Response, error) {
return s.perform("launch game", func() (Response, error) {
status, err := clientsetup.Inspect(input.GameDirectory, s.options.Versions)
if err != nil {
return Response{}, err
}
if status.PatchedURL != clientsetup.PatchPlaceholder {
return Response{}, errors.New("apply the client patch before launching the game")
}
if !status.BepInEx {
return Response{}, errors.New("install BepInEx before launching the game")
}
ctx, cancel := context.WithTimeout(s.context(), 12*time.Second)
defer cancel()
if _, err := clientsetup.FetchResourcePolicy(ctx, nil, input.settings(), s.options.Versions); err != nil {
return Response{}, err
}
if _, err := clientsetup.SaveSettings(input.GameDirectory, input.settings(), s.options.Versions); err != nil {
return Response{}, err
}
if err := s.savePreferences(input.GameDirectory); err != nil {
return Response{}, err
}
installation, err := clientlayout.Resolve(status.GameDirectory)
if err != nil {
return Response{}, err
}
if !installation.SupportedOnHost() {
return Response{}, fmt.Errorf("cannot launch a %s game client from this operating system", installation.Kind)
}
for _, name := range []string{"BD2LocalIdentity.dll", "BD2LoginUI.dll"} {
info, err := os.Stat(filepath.Join(installation.Plugins, name))
if err != nil || !info.Mode().IsRegular() {
return Response{}, fmt.Errorf("install or update the client plugins before launching; %s is missing", name)
}
}
if err := s.launch(installation.LaunchTarget()); err != nil {
if errors.Is(err, errGameAlreadyRunning) {
return success("Brown Dust II is already running", nil), nil
}
return Response{}, fmt.Errorf("launch Brown Dust II: %w", err)
}
s.log().Info("game launch requested", "platform", installation.Kind, "client_version", status.ClientVersion)
return success("Brown Dust II started", nil), nil
})
}
func (s *Studio) Quit() {
s.quitOnce.Do(func() {
s.log().Info("client exit requested")
if s.host.Quit != nil {
s.host.Quit(s.context())
}
})
}
+163 -85
View File
@@ -1,10 +1,10 @@
package app
import (
"encoding/json"
"io"
"context"
"errors"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -13,89 +13,183 @@ import (
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 + "/")
func TestEmbeddedStudioUsesNativeBindings(t *testing.T) {
page, err := fs.ReadFile(webFS, "web/index.html")
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)
text := string(page)
for _, marker := range []string{
"BD2 Client Studio", "test-session", `name="bd2-platform"`,
"BD2 Client Studio", "window.go.app.Studio", "bridge().Initialize()",
`id="directoryScene"`, `id="serverScene"`, `id="deskScene"`,
`id="gameDir"`, `id="origin"`, `id="patch"`, `id="install"`, `id="launch"`,
`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",
"prefers-reduced-motion",
"opening-curtain", "is-entering", "@keyframes reveal", "bridge().Quit()",
"Asia/Shanghai", "Asia/Hong_Kong", "Asia/Macau", "Asia/Taipei",
} {
if !strings.Contains(page, marker) {
t.Errorf("page lacks %q", marker)
if !strings.Contains(text, marker) {
t.Errorf("embedded desktop interface lacks %q", marker)
}
}
if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("Cache-Control") != "no-store" {
t.Fatalf("security headers=%v", response.Header)
for _, obsolete := range []string{"fetch(", "/api/", "bd2-session", "X-BD2-Session", "{{.", "window_darwin.js"} {
if strings.Contains(text, obsolete) {
t.Errorf("embedded desktop interface retains obsolete browser bridge %q", obsolete)
}
}
}
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 TestStudioInitializeLoadsSavedSettings(t *testing.T) {
dir := makeTestClient(t)
studio := NewStudio(Options{InitialGameDir: dir, Versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}, NativeHost{})
if state := studio.Initialize(); state.AutoOpen {
t.Fatal("missing settings enabled automatic workspace")
}
if _, err := clientconfig.Save(dir, clientconfig.Settings{ServerOrigin: "https://play.example.com", CDNMode: clientconfig.CDNOfficial}); err != nil {
t.Fatal(err)
}
state := studio.Initialize()
if !state.AutoOpen || state.GameDirectory != dir || state.ServerOrigin != "https://play.example.com" {
t.Fatalf("state=%+v", state)
}
}
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 TestStudioSaveRemembersOnlyValidatedSettings(t *testing.T) {
dir := makeTestClient(t)
studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}, NativeHost{})
var remembered string
studio.savePreferences = func(value string) error { remembered = value; return nil }
input := Request{GameDirectory: dir, ServerOrigin: "https://play.example.com", CDNMode: clientconfig.CDNOfficial}
result, err := studio.Save(input)
if err != nil || !result.OK || remembered != dir {
t.Fatalf("result=%+v remembered=%q err=%v", result, remembered, err)
}
remembered = ""
input.ServerOrigin = "http://untrusted.example.com"
if _, err := studio.Save(input); err == nil || remembered != "" {
t.Fatalf("invalid settings remembered=%q err=%v", remembered, err)
}
if _, err := studio.Launch(Request{GameDirectory: dir}); err == nil {
t.Fatal("unpatched game launch accepted")
}
}
func TestInspectAPI(t *testing.T) {
func TestStudioContextCancellationAndPickerCancellation(t *testing.T) {
studio := NewStudio(Options{}, NativeHost{BrowseDirectory: func(context.Context, string) (string, error) { return "", nil }})
for _, browse := range []func(Request) (Response, error){studio.Browse, studio.BrowseResources} {
result, err := browse(Request{})
if err != nil || !result.OK || result.Message != "Selection cancelled" || result.Data != nil {
t.Fatalf("result=%+v err=%v", result, err)
}
}
ctx, cancel := context.WithCancel(context.Background())
studio.Startup(ctx)
cancel()
if _, err := studio.Resources(Request{ServerOrigin: "http://127.0.0.1:8080", CDNMode: clientconfig.CDNOfficial}); !errors.Is(err, context.Canceled) {
t.Fatalf("err=%v", err)
}
}
func TestStudioShutdownCancelsOwnedContext(t *testing.T) {
studio := NewStudio(Options{}, NativeHost{})
studio.Startup(context.Background())
owned := studio.context()
studio.Shutdown()
if !errors.Is(owned.Err(), context.Canceled) {
t.Fatalf("owned context err=%v", owned.Err())
}
}
func TestStudioInitializeDefaultsWithoutGameDirectory(t *testing.T) {
studio := NewStudio(Options{LogPath: "test.log"}, NativeHost{})
studio.Startup(context.Background())
state := studio.Initialize()
if state.Platform == "" || state.LogPath != "test.log" || state.GameDirectory != "" {
t.Fatalf("state=%+v", state)
}
if state.ServerOrigin != "http://127.0.0.1:8080" || state.CDNMode != clientconfig.CDNOfficial || state.AutoOpen {
t.Fatalf("defaults=%+v", state)
}
}
func TestStudioBrowseUsesNativeDirectoryPicker(t *testing.T) {
dir := makeTestClient(t)
var title string
studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}, NativeHost{
BrowseDirectory: func(_ context.Context, requested string) (string, error) {
title = requested
return dir, nil
},
})
studio.Startup(context.Background())
result, err := studio.Browse(Request{UILanguage: "zh-CN"})
if err != nil {
t.Fatal(err)
}
if !result.OK || result.Data == nil || title != "选择 Brown Dust II 安装目录" {
t.Fatalf("result=%+v title=%q", result, title)
}
}
func TestStudioQuitUsesNativeLifecycleOnce(t *testing.T) {
quits := 0
studio := NewStudio(Options{}, NativeHost{Quit: func(context.Context) { quits++ }})
studio.Startup(context.Background())
studio.Quit()
studio.Quit()
if quits != 1 {
t.Fatalf("quits=%d", quits)
}
}
func TestStudioPropagatesOperationErrors(t *testing.T) {
want := errors.New("picker failed")
studio := NewStudio(Options{}, NativeHost{BrowseDirectory: func(context.Context, string) (string, error) {
return "", want
}})
studio.Startup(context.Background())
if _, err := studio.Browse(Request{}); !errors.Is(err, want) {
t.Fatalf("err=%v", err)
}
}
func TestDesktopSecurityHeaders(t *testing.T) {
called := false
handler := desktopSecurityHeaders(httpHandlerFunc(func(header map[string]string) {
called = true
if header["Cache-Control"] != "no-store" || header["Content-Security-Policy"] == "" {
t.Fatalf("headers=%v", header)
}
}))
response := &headerRecorder{header: make(map[string][]string)}
handler.ServeHTTP(response, nil)
if !called {
t.Fatal("asset middleware did not call the next handler")
}
}
// Small local adapters keep this middleware test independent of httptest's
// network-shaped helpers.
type httpHandlerFunc func(map[string]string)
func (f httpHandlerFunc) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
headers := make(map[string]string)
for name, values := range w.Header() {
if len(values) != 0 {
headers[name] = values[0]
}
}
f(headers)
}
type headerRecorder struct{ header http.Header }
func (r *headerRecorder) Header() http.Header { return r.header }
func (*headerRecorder) Write([]byte) (int, error) { return 0, nil }
func (*headerRecorder) WriteHeader(int) {}
func makeTestClient(t *testing.T) string {
t.Helper()
dir := t.TempDir()
for path, data := range map[string][]byte{
filepath.Join(dir, "BrownDust II.exe"): []byte("exe"),
@@ -109,21 +203,5 @@ func TestInspectAPI(t *testing.T) {
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)
}
return dir
}
+82
View File
@@ -0,0 +1,82 @@
package app
import (
"context"
"fmt"
"io/fs"
"net/http"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/mac"
"github.com/wailsapp/wails/v2/pkg/options/windows"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// Run starts one native desktop window. Wails uses WebView2 on Windows and
// WKWebView on macOS; no loopback listener or external browser is involved.
func Run(runOptions Options) error {
assets, err := fs.Sub(webFS, "web")
if err != nil {
return fmt.Errorf("load embedded client interface: %w", err)
}
host := NativeHost{
BrowseDirectory: func(ctx context.Context, title string) (string, error) {
return wailsruntime.OpenDirectoryDialog(ctx, wailsruntime.OpenDialogOptions{Title: title})
},
Quit: wailsruntime.Quit,
}
studio := NewStudio(runOptions, host)
studio.log().Info("starting native client window", "windows_engine", "WebView2", "macos_engine", "WKWebView")
err = wails.Run(&options.App{
Title: "BD2 Client Studio",
Width: 1080,
Height: 720,
MinWidth: 860,
MinHeight: 580,
BackgroundColour: options.NewRGB(231, 220, 199),
AssetServer: &assetserver.Options{
Assets: assets,
Middleware: desktopSecurityHeaders,
},
OnStartup: studio.Startup,
OnShutdown: func(context.Context) {
studio.Shutdown()
studio.log().Info("native client window closed")
},
Bind: []interface{}{studio},
EnableDefaultContextMenu: false,
DragAndDrop: &options.DragAndDrop{
DisableWebViewDrop: true,
},
Windows: &windows.Options{
Theme: windows.Light,
BackdropType: windows.Mica,
DisablePinchZoom: true,
IsZoomControlEnabled: false,
EnableSwipeGestures: false,
},
Mac: &mac.Options{
TitleBar: mac.TitleBarDefault(),
Appearance: mac.NSAppearanceNameAqua,
DisableZoom: true,
},
})
if err != nil {
studio.log().Error("native client window stopped with an error", "error", err)
return fmt.Errorf("run native client window: %w", err)
}
return nil
}
func desktopSecurityHeaders(next http.Handler) http.Handler {
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")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'")
next.ServeHTTP(w, r)
})
}
-147
View File
@@ -1,147 +0,0 @@
//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))
}
+4 -30
View File
@@ -2,12 +2,10 @@ package app
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"unicode"
"unicode/utf8"
@@ -40,13 +38,9 @@ func TestOpenPersistentLoggerUsesExecutableLogDirectory(t *testing.T) {
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)
studio := NewStudio(Options{Logger: logger}, NativeHost{})
studio.Startup(context.Background())
_, _ = studio.Inspect(Request{GameDirectory: `Z:\missing`, UILanguage: "zh-CN"})
for len(output.Bytes()) > 0 {
r, size := utf8.DecodeRune(output.Bytes())
if unicode.Is(unicode.Han, r) {
@@ -85,23 +79,3 @@ func TestRollingLogRetainsOneBackup(t *testing.T) {
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)
}
}
}
+1 -37
View File
@@ -12,45 +12,9 @@ import (
)
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)
_, _ = fmt.Fprintln(os.Stderr, "BD2 Client Studio:", err)
}
return strings.TrimSpace(string(output)), nil
}
func launchGame(target string) error {
-17
View File
@@ -6,8 +6,6 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"runtime"
)
func ShowFatalError(err error) {
@@ -16,21 +14,6 @@ func ShowFatalError(err error) {
}
}
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")
}
@@ -4,7 +4,6 @@ package app
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -68,44 +67,6 @@ func visibleCommand(name string, args ...string) *exec.Cmd {
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
@@ -29,12 +29,3 @@ func TestVisibleCommandDoesNotHideGUIWindow(t *testing.T) {
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")
}
}
+19 -25
View File
@@ -3,10 +3,6 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="bd2-session" content="{{.Token}}">
<meta name="bd2-platform" content="{{.Platform}}">
<meta name="bd2-initial-cdn" content="{{.CDNMode}}">
<meta name="bd2-auto-open" content="{{.AutoOpen}}">
<meta name="theme-color" content="#e8decc">
<title>BD2 Client Studio</title>
<style>
@@ -34,11 +30,11 @@ main{max-width:1324px;margin:auto;padding:0 58px}.scene{position:relative}.intro
<main>
<section class="scene intro-scene is-entering" id="directoryScene" aria-labelledby="directoryTitle">
<div><div class="eyebrow reveal" style="--i:1">01 / GAME DIRECTORY</div><h1 class="reveal" style="--i:2" id="directoryTitle" tabindex="-1">先从游戏目录<br>开始。</h1><p class="lead reveal" style="--i:3">选择已安装的游戏文件夹。<br>我们会检查游戏程序和资源文件,再继续设置连接。</p><div class="intro-label reveal" style="--i:4"><i></i>BrownDust II · 客户端配置</div></div>
<div class="form-card reveal" style="--i:4"><div class="card-number" aria-hidden="true">01</div><h2>选择游戏目录</h2><p class="description">请选择包含 <code>BrownDust II.exe</code> 的文件夹。</p><div id="directoryHome"><div id="directoryFields"><label class="field-label" for="gameDir">游戏安装目录</label><div class="input-row"><input id="gameDir" type="text" value="{{.GameDirectory}}" placeholder="例如 E:\Games\BrownDust II" autocomplete="off" spellcheck="false"><button class="btn" id="browse">浏览</button></div><div class="form-tools"><span id="directoryHint">可直接填写路径,也可以浏览目录。</span><button class="text-btn" id="inspect">检查目录</button></div><p class="inline-result" id="directoryResult" role="status"></p></div></div><button class="btn primary wide" id="directoryNext"><span>继续设置服务器</span><span class="arrow" aria-hidden="true">→</span></button><p class="small-note">此步骤只检查文件,不会修改游戏资源。</p></div>
<div class="form-card reveal" style="--i:4"><div class="card-number" aria-hidden="true">01</div><h2>选择游戏目录</h2><p class="description">请选择包含 <code>BrownDust II.exe</code> 的文件夹。</p><div id="directoryHome"><div id="directoryFields"><label class="field-label" for="gameDir">游戏安装目录</label><div class="input-row"><input id="gameDir" type="text" value="" placeholder="例如 E:\Games\BrownDust II" autocomplete="off" spellcheck="false"><button class="btn" id="browse">浏览</button></div><div class="form-tools"><span id="directoryHint">可直接填写路径,也可以浏览目录。</span><button class="text-btn" id="inspect">检查目录</button></div><p class="inline-result" id="directoryResult" role="status"></p></div></div><button class="btn primary wide" id="directoryNext"><span>继续设置服务器</span><span class="arrow" aria-hidden="true">→</span></button><p class="small-note">此步骤只检查文件,不会修改游戏资源。</p></div>
</section>
<section class="scene intro-scene" id="serverScene" hidden aria-labelledby="serverTitle">
<div><div class="eyebrow reveal" style="--i:1">02 / SERVER CONNECTION</div><h1 class="reveal" style="--i:2" id="serverTitle" tabindex="-1">接下来,<br>设置服务器。</h1><p class="lead reveal" style="--i:3">填写要连接的服务端地址。<br>进入工作台后,你可以选择资源来源、应用补丁和安装插件。</p><div class="intro-label reveal" style="--i:4"><i></i>目录已选择 · 之后仍可修改</div></div>
<div class="form-card reveal" style="--i:4"><div class="card-number" aria-hidden="true">02</div><h2>服务器地址</h2><p class="description">填写完整地址,可以包含端口。</p><div id="serverHome"><div id="serverFields"><label class="field-label" for="origin">地址与端口</label><input id="origin" type="text" value="{{.ServerOrigin}}" placeholder="https://play.example.com" autocomplete="url" spellcheck="false" aria-describedby="originHint"><p class="small-note" id="originHint">例如 <code>http://192.168.1.8:8080</code><br>不要添加 <code>/game/</code> 或其他路径。真实连接地址由 LI 插件读取。</p><p class="inline-result" id="serverResult" role="status"></p></div></div><button class="btn primary wide" id="serverNext"><span>进入工作台</span><span class="arrow" aria-hidden="true">→</span></button><div class="server-back"><button class="text-btn" id="serverBack">← 返回目录设置</button></div></div>
<div class="form-card reveal" style="--i:4"><div class="card-number" aria-hidden="true">02</div><h2>服务器地址</h2><p class="description">填写完整地址,可以包含端口。</p><div id="serverHome"><div id="serverFields"><label class="field-label" for="origin">地址与端口</label><input id="origin" type="text" value="" placeholder="https://play.example.com" autocomplete="url" spellcheck="false" aria-describedby="originHint"><p class="small-note" id="originHint">例如 <code>http://192.168.1.8:8080</code><br>不要添加 <code>/game/</code> 或其他路径。真实连接地址由 LI 插件读取。</p><p class="inline-result" id="serverResult" role="status"></p></div></div><button class="btn primary wide" id="serverNext"><span>进入工作台</span><span class="arrow" aria-hidden="true">→</span></button><div class="server-back"><button class="text-btn" id="serverBack">← 返回目录设置</button></div></div>
</section>
<section class="scene desk-scene" id="deskScene" hidden aria-labelledby="deskTitle">
<header class="desk-header reveal" style="--i:0"><div><div class="eyebrow">03 / WORKSPACE</div><h1 id="deskTitle" tabindex="-1">客户端工作台</h1><p>管理连接设置,完成游戏启动前的准备。</p></div><div class="desk-actions"><div class="connection-chip"><span class="dot" id="pulse"></span><span id="overallStatus">等待检查</span></div><button class="btn primary" id="launch">启动游戏 <span aria-hidden="true">▶</span></button></div></header>
@@ -48,9 +44,9 @@ main{max-width:1324px;margin:auto;padding:0 58px}.scene{position:relative}.intro
<label class="route"><input type="radio" name="cdn" value="official" checked><span class="route-content"><span class="route-top"><span class="route-code">OFFICIAL</span><span class="radio-dot"></span></span><strong>官方 CDN</strong><small>从官方节点下载资源,不占用服主的大文件流量。</small></span></label>
<label class="route"><input type="radio" name="cdn" value="local"><span class="route-content"><span class="route-top"><span class="route-code">LOCAL</span><span class="radio-dot"></span></span><strong>本机已下载资源</strong><small>直接读取电脑上的 ServerData 和 GameData,不再下载相同文件。</small></span></label>
<label class="route"><input type="radio" name="cdn" value="server"><span class="route-content"><span class="route-top"><span class="route-code">SERVER</span><span class="radio-dot"></span></span><strong>服主资源</strong><small>由服务器提供资源地址;服主可自建 CDN,也可以反代官方 CDN。</small></span></label>
</div><div class="local-resource" id="localResource" hidden><label class="field-label" for="localResourceDir">本机 CDN 根目录</label><div class="input-row"><input id="localResourceDir" type="text" value="{{.LocalResourceDirectory}}" placeholder="例如 E:\bd2\dl" autocomplete="off" spellcheck="false"><button class="btn" id="browseResources">浏览</button></div><p class="small-note">所选目录内必须同时包含 <code>ServerData</code> 和 <code>GameData</code>。</p></div><p class="route-hint" id="routeHint">官方模式由客户端直连版本锁定的官方 CDN。</p><div class="route-actions"><button class="text-btn" id="checkResources">验证资源来源 ↗</button><button class="btn" id="save">保存连接设置</button></div><p class="unsaved" id="dirtyNotice">当前设置尚未保存。</p><div class="policy" id="policy" role="status" hidden></div></section>
</div><div class="local-resource" id="localResource" hidden><label class="field-label" for="localResourceDir">本机 CDN 根目录</label><div class="input-row"><input id="localResourceDir" type="text" value="" placeholder="例如 E:\bd2\dl" autocomplete="off" spellcheck="false"><button class="btn" id="browseResources">浏览</button></div><p class="small-note">所选目录内必须同时包含 <code>ServerData</code> 和 <code>GameData</code>。</p></div><p class="route-hint" id="routeHint">官方模式由客户端直连版本锁定的官方 CDN。</p><div class="route-actions"><button class="text-btn" id="checkResources">验证资源来源 ↗</button><button class="btn" id="save">保存连接设置</button></div><p class="unsaved" id="dirtyNotice">当前设置尚未保存。</p><div class="policy" id="policy" role="status" hidden></div></section>
<section class="panel-section"><div class="section-title"><span class="index">03</span><h2>补丁与插件</h2></div><p class="prepare-note">操作前请先关闭游戏。补丁只修改内置 Intro 地址,并保留 <code>resources.assets.bak</code> 备份。安装插件前,请先手动安装 BepInEx。</p><div class="prepare-actions"><button class="btn rust" id="patch">应用客户端补丁 <span aria-hidden="true">↗</span></button><button class="btn primary" id="install">安装 / 更新插件 <span aria-hidden="true">↗</span></button></div></section>
</div><aside class="status-panel reveal" style="--i:3" aria-label="客户端状态"><div class="status-title"><h2>当前状态</h2><span>LOCAL STATUS</span></div><div><div class="check"><span>游戏程序</span><span id="sExe">等待检查</span></div><div class="check"><span>资源文件</span><span id="sAssets">等待检查</span></div><div class="check"><span>BepInEx</span><span id="sBep">等待检查</span></div><div class="check"><span>连接配置</span><span id="sConfig">尚未写入</span></div><p class="status-foot">状态来自本机文件检查,不代表服务器在线。<br>诊断日志:<code id="persistentLogPath">{{.LogPath}}</code></p><button class="text-btn" style="font-size:11px" id="refresh">重新检查 ↻</button></div><div class="log-wrap"><div class="log-heading"><span>最近操作</span><time id="logTime">—</time></div><div class="log" id="log" role="status"><strong>等待操作</strong><span>选择游戏目录后即可开始检查。</span></div><details class="history"><summary>查看操作记录</summary><ol id="historyList"></ol></details></div></aside></div>
</div><aside class="status-panel reveal" style="--i:3" aria-label="客户端状态"><div class="status-title"><h2>当前状态</h2><span>LOCAL STATUS</span></div><div><div class="check"><span>游戏程序</span><span id="sExe">等待检查</span></div><div class="check"><span>资源文件</span><span id="sAssets">等待检查</span></div><div class="check"><span>BepInEx</span><span id="sBep">等待检查</span></div><div class="check"><span>连接配置</span><span id="sConfig">尚未写入</span></div><p class="status-foot">状态来自本机文件检查,不代表服务器在线。<br>诊断日志:<code id="persistentLogPath"></code></p><button class="text-btn" style="font-size:11px" id="refresh">重新检查 ↻</button></div><div class="log-wrap"><div class="log-heading"><span>最近操作</span><time id="logTime">—</time></div><div class="log" id="log" role="status"><strong>等待操作</strong><span>选择游戏目录后即可开始检查。</span></div><details class="history"><summary>查看操作记录</summary><ol id="historyList"></ol></details></div></aside></div>
<footer class="desk-footer reveal" style="--i:4"><span>BD2 CLIENT STUDIO <span aria-hidden="true">/</span> 配置仅保存在你的电脑上</span><span>不会保存账号凭据或服主密钥</span></footer>
</section>
<section class="scene quit-scene" id="quitScene" hidden aria-labelledby="quitTitle"><div class="eyebrow reveal">CLIENT STUDIO</div><h1 class="reveal" id="quitTitle" tabindex="-1">工具已退出</h1><p class="reveal">现在可以关闭这个页面。</p></section>
@@ -61,10 +57,7 @@ main{max-width:1324px;margin:auto;padding:0 58px}.scene{position:relative}.intro
<noscript><p style="padding:20px">请启用 JavaScript 以使用客户端设置工具。</p></noscript>
<script>
'use strict';
const SESSION=document.querySelector('meta[name="bd2-session"]').content;
const HOST_PLATFORM=document.querySelector('meta[name="bd2-platform"]').content;
const INITIAL_CDN=document.querySelector('meta[name="bd2-initial-cdn"]').content;
const AUTO_OPEN=document.querySelector('meta[name="bd2-auto-open"]').content==='true';
let HOST_PLATFORM='',INITIAL_CDN='official',AUTO_OPEN=false;
const $=id=>document.getElementById(id);
const reduced=window.matchMedia('(prefers-reduced-motion: reduce)');
const CHINA_TIME_ZONES=new Set(['Asia/Shanghai','Asia/Chongqing','Asia/Harbin','Asia/Urumqi','Asia/Hong_Kong','Asia/Macau','Asia/Taipei']);
@@ -82,14 +75,15 @@ if(!zhCN){
document.querySelectorAll('[aria-label]').forEach(element=>{let value=element.getAttribute('aria-label');for(const [source,target] of translations)value=value.replaceAll(source,target);element.setAttribute('aria-label',value)});
$('gameDir').placeholder='Example: E:\\Games\\Brown Dust II';$('origin').placeholder='https://play.example.com';$('localResourceDir').placeholder='Example: E:\\bd2\\dl';
}
if(HOST_PLATFORM==='darwin'){$('gameDir').placeholder=localized('例如 /Applications/BrownDust II.app','Example: /Applications/BrownDust II.app');$('directoryScene').querySelector('.description').textContent=localized('请选择 BrownDust II.app 或它所在的文件夹。','Select BrownDust II.app or its parent folder.')}
let scene='directory',busy=false,transitioning=false,toastTimer,editorKind=null,savedSignature=null;
const status={executable:null,resources:null,bepinex:null,config:null};
function payload(){const mode=document.querySelector('input[name="cdn"]:checked').value;return{game_directory:$('gameDir').value.trim(),server_origin:$('origin').value.trim(),cdn_mode:mode,local_resource_directory:mode==='local'?$('localResourceDir').value.trim():'',ui_language:zhCN?'zh-CN':'en'}}
function signature(){return JSON.stringify(payload())}
const backendMessages={'Selection cancelled':'已取消选择','Game client found':'已找到游戏客户端','Local resource directory found':'已找到本机资源目录','Game directory is valid':'游戏目录有效','The client will use the release-locked official CDN':'将由客户端直接连接版本锁定的官方 CDN','Local resources verified':'本机资源已验证','Server resource policy verified':'服务器资源策略已确认','Connection settings saved':'连接设置已保存','Client entry point patched; the original backup was retained':'客户端入口补丁完成;原文件备份已保留','Client patch is already complete; no asset file was rewritten':'客户端补丁已经完成,未再次改写资源文件','BD2 client plugins installed or updated':'BD2 客户端插件已安装或更新','BD2 client plugins are already up to date; no DLL was rewritten':'BD2 客户端插件已经是最新版本,未再次改写 DLL','Brown Dust II started':'Brown Dust II 已启动','Brown Dust II is already running':'Brown Dust II 已经在运行','Client tool exited':'客户端工具已退出'};
const localizeMessage=message=>zhCN?(backendMessages[message]||message):message;
async function api(path,body={}){const res=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json','X-BD2-Session':SESSION},body:JSON.stringify(body)});let data;try{data=await res.json()}catch{throw new Error(localized('客户端工具返回了无法解析的响应','The client tool returned an unreadable response'))}if(!res.ok||!data.ok)throw new Error(localizeMessage(data.message)||localized(`操作失败(HTTP ${res.status})`,`Operation failed (HTTP ${res.status})`));if(data.message)data.message=localizeMessage(data.message);return data}
const nativeMethods={browse:'Browse',browseResources:'BrowseResources',inspect:'Inspect',resources:'Resources',save:'Save',patch:'Patch',install:'Install',launch:'Launch'};
function bridge(){const bindings=window.go&&window.go.app&&window.go.app.Studio;if(!bindings)throw new Error(localized('客户端原生接口尚未就绪','The native client interface is unavailable'));return bindings}
async function api(operation,body={}){let data;try{data=await bridge()[nativeMethods[operation]](body)}catch(error){throw new Error(localizeMessage(error.message||String(error)))}if(!data||!data.ok)throw new Error(localizeMessage(data&&data.message)||localized('操作失败','Operation failed'));if(data.message)data.message=localizeMessage(data.message);return data}
function show(message,error=false){message=message||(error?localized('操作未完成','Operation failed'):localized('操作已完成','Operation completed'));clearTimeout(toastTimer);const t=$('toast');t.textContent=message;t.className='toast show'+(error?' error':'');toastTimer=setTimeout(()=>t.className='toast',5200);$('log').replaceChildren();const title=document.createElement('strong');title.textContent=error?localized('操作未完成','Operation failed'):localized('操作已完成','Operation completed');const desc=document.createElement('span');desc.textContent=message;$('log').append(title,desc);const now=new Date(),time=now.toLocaleTimeString(zhCN?'zh-CN':'en-US',{hour:'2-digit',minute:'2-digit',second:'2-digit'});$('logTime').textContent=time;const row=document.createElement('li');if(error)row.className='error';const stamp=document.createElement('time');stamp.dateTime=now.toISOString();stamp.textContent=time;row.append(stamp,document.createTextNode(message));$('historyList').prepend(row);while($('historyList').children.length>20)$('historyList').lastElementChild.remove()}
function mark(id,value,yes=localized('已就绪','Ready'),no=localized('未检测到','Not found')){const e=$(id);e.textContent=value===null?localized('等待检查','Waiting'):value?yes:no;e.className=value===null?'':value?'ok':'warn'}
function renderStatus(){mark('sExe',status.executable);mark('sAssets',status.resources);mark('sBep',status.bepinex,localized('已安装','Installed'),localized('需手动安装','Manual install required'));mark('sConfig',status.config,localized('已写入','Written'),localized('尚未写入','Not written'));const checked=status.executable!==null&&status.resources!==null;const ready=status.executable&&status.resources;$('pulse').className='dot'+(checked?(ready?' good':' bad'):'');$('overallStatus').textContent=checked?(ready?localized('游戏文件已就绪','Game files ready'):localized('游戏文件待检查','Game files need attention')):localized('等待检查','Waiting');if(savedSignature&&signature()!==savedSignature){$('sConfig').textContent=localized('有未保存修改','Unsaved changes');$('sConfig').className='warn'}}
@@ -103,7 +97,7 @@ function setBusy(value){busy=value;document.querySelectorAll('button,input').for
async function action(id,fn){if(busy||transitioning)return;const b=$(id),old=b.innerHTML;setBusy(true);b.textContent=localized('请稍候…','Please wait…');try{await fn()}catch(e){const message=e.message||String(e);show(message,true);if(scene==='directory'||editorKind==='directory'){$('directoryResult').textContent=message;$('directoryResult').className='inline-result error'}if(scene==='server'||editorKind==='server'){$('serverResult').textContent=message;$('serverResult').className='inline-result error'}}finally{b.innerHTML=old;setBusy(false)}}
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
async function go(next){if(transitioning||scene===next)return;transitioning=true;const old=$(scene+'Scene'),target=$(next+'Scene');if(!reduced.matches){old.classList.add('is-leaving');$('veil').classList.add('play');await delay(570)}old.hidden=true;old.classList.remove('is-leaving','is-entering');scene=next;document.body.dataset.scene=next;target.hidden=false;target.classList.add('is-entering');$('journey').hidden=next==='desk'||next==='quit';document.querySelectorAll('[data-step]').forEach(e=>{const active=e.dataset.step===next;e.classList.toggle('active',active);if(active)e.setAttribute('aria-current','step');else e.removeAttribute('aria-current')});window.scrollTo({top:0,behavior:'instant'});$(next==='directory'?'directoryTitle':next==='server'?'serverTitle':next==='desk'?'deskTitle':'quitTitle').focus({preventScroll:true});if(!reduced.matches)await delay(680);$('veil').classList.remove('play');transitioning=false}
async function inspect(){requireDirectory();const r=await api('/api/inspect',payload());applyStatus(r.data);show(r.message);return r}
async function inspect(){requireDirectory();const r=await api('inspect',payload());applyStatus(r.data);show(r.message);return r}
function openEditor(kind){if(busy||transitioning)return;editorKind=kind;$('editorTitle').textContent=kind==='directory'?localized('修改游戏目录','Change game directory'):localized('修改服务器地址','Change server address');$('editorContent').append($(kind+'Fields'));$('editor').showModal();$(kind==='directory'?'gameDir':'origin').focus()}
function closeEditor(){if(busy)return;$('editor').close()}
$('editor').addEventListener('close',()=>{if(editorKind)$(editorKind+'Home').append($(editorKind+'Fields'));editorKind=null;sync()});
@@ -112,22 +106,22 @@ $('closeEditor').onclick=closeEditor;
$('doneEditor').onclick=()=>{try{if(editorKind==='server')requireOrigin();else requireDirectory();closeEditor()}catch(e){show(e.message,true)}};
$('editDirectory').onclick=()=>openEditor('directory');$('editServer').onclick=()=>openEditor('server');
$('gameDir').addEventListener('input',()=>invalidate(true));$('origin').addEventListener('input',()=>{$('serverResult').textContent='';invalidate()});$('localResourceDir').addEventListener('input',()=>invalidate());document.querySelectorAll('input[name="cdn"]').forEach(e=>e.addEventListener('change',()=>invalidate()));
$('browse').onclick=()=>action('browse',async()=>{const previous=payload().game_directory;const r=await api('/api/browse',payload());if(r.data){applyStatus(r.data);if(payload().game_directory!==previous)$('policy').hidden=true}show(r.message)});
$('browseResources').onclick=()=>action('browseResources',async()=>{const r=await api('/api/browse-resources',payload());if(r.data){$('localResourceDir').value=r.data.local_directory||'';invalidate()}show(r.message)});
$('browse').onclick=()=>action('browse',async()=>{const previous=payload().game_directory;const r=await api('browse',payload());if(r.data){applyStatus(r.data);if(payload().game_directory!==previous)$('policy').hidden=true}show(r.message)});
$('browseResources').onclick=()=>action('browseResources',async()=>{const r=await api('browseResources',payload());if(r.data){$('localResourceDir').value=r.data.local_directory||'';invalidate()}show(r.message)});
$('inspect').onclick=()=>action('inspect',inspect);$('refresh').onclick=()=>action('refresh',inspect);
$('directoryNext').onclick=()=>action('directoryNext',async()=>{await inspect();sync();await go('server')});
$('serverBack').onclick=()=>{if(!busy)go('directory')};
$('serverNext').onclick=()=>action('serverNext',async()=>{requireOrigin();sync();await go('desk')});
$('checkResources').onclick=()=>action('checkResources',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('/api/resources',payload()),p=r.data||{},missing=localized('未返回','Missing');$('policy').hidden=false;$('policy').textContent=p.mode==='official'?localized('OFFICIAL · 客户端使用版本锁定的官方地址','OFFICIAL · The client uses release-locked official URLs'):p.mode==='local'?`LOCAL · ${p.local_directory||payload().local_resource_directory}\nServerData · ${p.server_data_url||missing}\nGameData · ${p.game_data_url||missing}`:`SERVER · ${localized('由服务器提供资源地址','Resource URLs supplied by the server')}\nServerData · ${p.server_data_url||missing}\nGameData · ${p.game_data_url||missing}\nBundle · ${p.bundle_version||missing}\nGameData · ${p.game_data_version||missing}`;show(r.message)});
$('checkResources').onclick=()=>action('checkResources',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('resources',payload()),p=r.data||{},missing=localized('未返回','Missing');$('policy').hidden=false;$('policy').textContent=p.mode==='official'?localized('OFFICIAL · 客户端使用版本锁定的官方地址','OFFICIAL · The client uses release-locked official URLs'):p.mode==='local'?`LOCAL · ${p.local_directory||payload().local_resource_directory}\nServerData · ${p.server_data_url||missing}\nGameData · ${p.game_data_url||missing}`:`SERVER · ${localized('由服务器提供资源地址','Resource URLs supplied by the server')}\nServerData · ${p.server_data_url||missing}\nGameData · ${p.game_data_url||missing}\nBundle · ${p.bundle_version||missing}\nGameData · ${p.game_data_version||missing}`;show(r.message)});
function saved(){savedSignature=signature();status.config=true;sync()}
$('save').onclick=()=>action('save',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('/api/save',payload());saved();show(r.message)});
$('patch').onclick=()=>action('patch',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('/api/patch',payload());saved();show(r.message)});
$('install').onclick=()=>action('install',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('/api/install',payload());status.bepinex=true;saved();show(r.message)});
$('launch').onclick=()=>action('launch',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('/api/launch',payload());saved();show(r.message)});
$('quit').onclick=()=>action('quit',async()=>{await api('/api/quit');clearTimeout(toastTimer);$('toast').className='toast';await go('quit');$('quit').hidden=true});
$('save').onclick=()=>action('save',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('save',payload());saved();show(r.message)});
$('patch').onclick=()=>action('patch',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('patch',payload());saved();show(r.message)});
$('install').onclick=()=>action('install',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('install',payload());status.bepinex=true;saved();show(r.message)});
$('launch').onclick=()=>action('launch',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('launch',payload());saved();show(r.message)});
$('quit').onclick=()=>{if(!busy){clearTimeout(toastTimer);bridge().Quit().catch(error=>show(error.message||String(error),true))}};
$('gameDir').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();(editorKind?$('inspect'):$('directoryNext')).click()}});$('origin').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();(editorKind?$('doneEditor'):$('serverNext')).click()}});
const initialRadio=document.querySelector(`input[name="cdn"][value="${CSS.escape(INITIAL_CDN)}"]`);if(initialRadio)initialRadio.checked=true;
sync();if($('gameDir').value.trim()){action('inspect',async()=>{await inspect();if(AUTO_OPEN){saved();await go('desk')}})}
async function initialize(){setBusy(true);try{const initial=await bridge().Initialize();HOST_PLATFORM=initial.platform;INITIAL_CDN=initial.cdn_mode;AUTO_OPEN=initial.auto_open;$('gameDir').value=initial.game_directory||'';$('origin').value=initial.server_origin||'http://127.0.0.1:8080';$('localResourceDir').value=initial.local_resource_directory||'';$('persistentLogPath').textContent=initial.log_path||'';const initialRadio=document.querySelector(`input[name="cdn"][value="${CSS.escape(INITIAL_CDN)}"]`);if(initialRadio)initialRadio.checked=true;if(HOST_PLATFORM==='darwin'){$('gameDir').placeholder=localized('例如 /Applications/BrownDust II.app','Example: /Applications/BrownDust II.app');$('directoryScene').querySelector('.description').textContent=localized('请选择 BrownDust II.app 或它所在的文件夹。','Select BrownDust II.app or its parent folder.')}sync();if($('gameDir').value.trim()){await inspect();if(AUTO_OPEN){saved();await go('desk')}}}catch(error){show(error.message||String(error),true)}finally{setBusy(false)}}
initialize();
</script>
</body>
</html>