Package standalone state tool and client plugin
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
package clientplugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
FileName = "BD2LocalIdentity.dll"
|
||||
BepInExReleasesURL = "https://github.com/BepInEx/BepInEx/releases"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Destination string
|
||||
Changed bool
|
||||
}
|
||||
|
||||
func ResolvePackaged(explicit string) (string, error) {
|
||||
if explicit != "" {
|
||||
return filepath.Clean(explicit), nil
|
||||
}
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("clientplugin: resolve server executable: %w", err)
|
||||
}
|
||||
return filepath.Join(filepath.Dir(executable), "plugins", FileName), nil
|
||||
}
|
||||
|
||||
// Install verifies that the user installed BepInEx, then atomically stages the
|
||||
// packaged local-identity plugin into its plugins directory. It never installs
|
||||
// or downloads BepInEx itself.
|
||||
func Install(gameDir, source string) (Result, error) {
|
||||
if gameDir == "" || source == "" {
|
||||
return Result{}, errors.New("clientplugin: game directory and plugin source are required")
|
||||
}
|
||||
gameDir = filepath.Clean(gameDir)
|
||||
source = filepath.Clean(source)
|
||||
gameExecutable := filepath.Join(gameDir, "BrownDust II.exe")
|
||||
if info, err := os.Stat(gameExecutable); err != nil || info.IsDir() {
|
||||
return Result{}, fmt.Errorf("clientplugin: game executable is unavailable at %q", gameExecutable)
|
||||
}
|
||||
bepInEx := filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll")
|
||||
if info, err := os.Stat(bepInEx); err != nil || info.IsDir() {
|
||||
return Result{}, fmt.Errorf("clientplugin: BepInEx is not installed; install it manually from %s, then restart the server; %s was not copied", BepInExReleasesURL, FileName)
|
||||
}
|
||||
sourceData, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("clientplugin: read packaged %s: %w", FileName, err)
|
||||
}
|
||||
if len(sourceData) == 0 {
|
||||
return Result{}, fmt.Errorf("clientplugin: packaged %s is empty", FileName)
|
||||
}
|
||||
pluginDir := filepath.Join(gameDir, "BepInEx", "plugins")
|
||||
destination := filepath.Join(pluginDir, FileName)
|
||||
if installed, err := os.ReadFile(destination); err == nil {
|
||||
if bytes.Equal(hash(installed), hash(sourceData)) {
|
||||
return Result{Destination: destination}, nil
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return Result{}, fmt.Errorf("clientplugin: inspect installed plugin: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
||||
return Result{}, fmt.Errorf("clientplugin: create plugin directory: %w", err)
|
||||
}
|
||||
temporary, err := os.CreateTemp(pluginDir, ".BD2LocalIdentity-*.tmp")
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("clientplugin: create temporary plugin: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if _, err = io.Copy(temporary, bytes.NewReader(sourceData)); err == nil {
|
||||
err = temporary.Sync()
|
||||
}
|
||||
if closeErr := temporary.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("clientplugin: stage plugin: %w", err)
|
||||
}
|
||||
if err := replaceFile(temporaryPath, destination); err != nil {
|
||||
return Result{}, fmt.Errorf("clientplugin: install plugin (close the game client first): %w", err)
|
||||
}
|
||||
return Result{Destination: destination, Changed: true}, nil
|
||||
}
|
||||
|
||||
func hash(data []byte) []byte {
|
||||
sum := sha256.Sum256(data)
|
||||
return sum[:]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package clientplugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) {
|
||||
gameDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(gameDir, "BrownDust II.exe"), []byte("game"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := filepath.Join(t.TempDir(), FileName)
|
||||
if err := os.WriteFile(source, []byte("plugin"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := Install(gameDir, source)
|
||||
if err == nil || !strings.Contains(err.Error(), BepInExReleasesURL) {
|
||||
t.Fatalf("missing BepInEx error=%v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(gameDir, "BepInEx", "plugins", FileName)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("plugin was copied without BepInEx: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
|
||||
gameDir := t.TempDir()
|
||||
for path, data := range map[string][]byte{
|
||||
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
|
||||
filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
source := filepath.Join(t.TempDir(), FileName)
|
||||
if err := os.WriteFile(source, []byte("v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := Install(gameDir, source)
|
||||
if err != nil || !first.Changed {
|
||||
t.Fatalf("first install=%+v err=%v", first, err)
|
||||
}
|
||||
second, err := Install(gameDir, source)
|
||||
if err != nil || second.Changed {
|
||||
t.Fatalf("idempotent install=%+v err=%v", second, err)
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("v2"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
third, err := Install(gameDir, source)
|
||||
if err != nil || !third.Changed {
|
||||
t.Fatalf("update=%+v err=%v", third, err)
|
||||
}
|
||||
got, err := os.ReadFile(third.Destination)
|
||||
if err != nil || string(got) != "v2" {
|
||||
t.Fatalf("installed=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package clientplugin
|
||||
|
||||
import "os"
|
||||
|
||||
func replaceFile(source, destination string) error {
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build windows
|
||||
|
||||
package clientplugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var moveFileEx = syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW")
|
||||
|
||||
func replaceFile(source, destination string) error {
|
||||
sourcePtr, err := syscall.UTF16PtrFromString(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destinationPtr, err := syscall.UTF16PtrFromString(destination)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, _, callErr := moveFileEx.Call(
|
||||
uintptr(unsafe.Pointer(sourcePtr)),
|
||||
uintptr(unsafe.Pointer(destinationPtr)),
|
||||
0x1|0x8, // MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH
|
||||
)
|
||||
if result == 0 {
|
||||
if callErr != syscall.Errno(0) {
|
||||
return callErr
|
||||
}
|
||||
return os.ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user