feat(all): add verified gacha flows and native client studio
This commit is contained in:
+241
-427
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user