package main import ( "encoding/xml" "fmt" "os" "os/exec" "path/filepath" "strings" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/widget" "git.leaktechnologies.dev/stu/VideoTools/internal/ui" "git.leaktechnologies.dev/stu/VideoTools/internal/utils" ) func buildAuthorView(state *appState) fyne.CanvasObject { state.stopPreview() state.lastModule = state.active state.active = "author" if state.authorOutputType == "" { state.authorOutputType = "dvd" } if state.authorRegion == "" { state.authorRegion = "AUTO" } if state.authorAspectRatio == "" { state.authorAspectRatio = "AUTO" } authorColor := moduleColor("author") backBtn := widget.NewButton("< BACK", func() { state.showMainMenu() }) backBtn.Importance = widget.LowImportance queueBtn := widget.NewButton("View Queue", func() { state.showQueue() }) state.queueBtn = queueBtn state.updateQueueButtonLabel() topBar := ui.TintedBar(authorColor, container.NewHBox(backBtn, layout.NewSpacer(), queueBtn)) bottomBar := moduleFooter(authorColor, layout.NewSpacer(), state.statsBar) tabs := container.NewAppTabs( container.NewTabItem("Videos", buildVideoClipsTab(state)), container.NewTabItem("Chapters", buildChaptersTab(state)), container.NewTabItem("Subtitles", buildSubtitlesTab(state)), container.NewTabItem("Settings", buildAuthorSettingsTab(state)), container.NewTabItem("Generate", buildAuthorDiscTab(state)), ) tabs.SetTabLocation(container.TabLocationTop) return container.NewBorder(topBar, bottomBar, nil, nil, tabs) } func buildVideoClipsTab(state *appState) fyne.CanvasObject { list := container.NewVBox() listScroll := container.NewVScroll(list) var rebuildList func() var emptyOverlay *fyne.Container rebuildList = func() { list.Objects = nil if len(state.authorClips) == 0 { if emptyOverlay != nil { emptyOverlay.Show() } list.Refresh() return } if emptyOverlay != nil { emptyOverlay.Hide() } for i, clip := range state.authorClips { idx := i nameLabel := widget.NewLabel(clip.DisplayName) nameLabel.TextStyle = fyne.TextStyle{Bold: true} durationLabel := widget.NewLabel(fmt.Sprintf("%.2fs", clip.Duration)) durationLabel.TextStyle = fyne.TextStyle{Italic: true} durationLabel.Alignment = fyne.TextAlignTrailing removeBtn := widget.NewButton("Remove", func() { state.authorClips = append(state.authorClips[:idx], state.authorClips[idx+1:]...) rebuildList() state.updateAuthorSummary() }) removeBtn.Importance = widget.MediumImportance row := container.NewBorder( nil, nil, nil, container.NewVBox(durationLabel, removeBtn), container.NewVBox(nameLabel), ) cardBg := canvas.NewRectangle(utils.MustHex("#171C2A")) cardBg.CornerRadius = 6 cardBg.SetMinSize(fyne.NewSize(0, nameLabel.MinSize().Height+durationLabel.MinSize().Height+12)) list.Add(container.NewPadded(container.NewMax(cardBg, row))) } list.Refresh() } addBtn := widget.NewButton("Add Files", func() { dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { if err != nil || reader == nil { return } defer reader.Close() state.addAuthorFiles([]string{reader.URI().Path()}) rebuildList() }, state.window) }) addBtn.Importance = widget.HighImportance clearBtn := widget.NewButton("Clear All", func() { state.authorClips = []authorClip{} rebuildList() state.updateAuthorSummary() }) clearBtn.Importance = widget.MediumImportance compileBtn := widget.NewButton("COMPILE TO DVD", func() { if len(state.authorClips) == 0 { dialog.ShowInformation("No Clips", "Please add video clips first", state.window) return } state.startAuthorGeneration() }) compileBtn.Importance = widget.HighImportance dropTarget := ui.NewDroppable(listScroll, func(items []fyne.URI) { var paths []string for _, uri := range items { if uri.Scheme() == "file" { paths = append(paths, uri.Path()) } } if len(paths) > 0 { state.addAuthorFiles(paths) rebuildList() } }) emptyLabel := widget.NewLabel("Drag and drop video files here\nor click 'Add Files' to select videos") emptyLabel.Alignment = fyne.TextAlignCenter emptyOverlay = container.NewCenter(emptyLabel) listArea := container.NewMax(dropTarget, emptyOverlay) controls := container.NewBorder( widget.NewLabel("Videos:"), container.NewHBox(addBtn, clearBtn, compileBtn), nil, nil, listArea, ) rebuildList() return container.NewPadded(controls) } func buildChaptersTab(state *appState) fyne.CanvasObject { var fileLabel *widget.Label if state.authorFile != nil { fileLabel = widget.NewLabel(fmt.Sprintf("File: %s", filepath.Base(state.authorFile.Path))) fileLabel.TextStyle = fyne.TextStyle{Bold: true} } else { fileLabel = widget.NewLabel("Select a single video file or use clips from Video Clips tab") } selectBtn := widget.NewButton("Select Video", func() { dialog.ShowFileOpen(func(uc fyne.URIReadCloser, err error) { if err != nil || uc == nil { return } defer uc.Close() path := uc.URI().Path() src, err := probeVideo(path) if err != nil { dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window) return } state.authorFile = src fileLabel.SetText(fmt.Sprintf("File: %s", filepath.Base(src.Path))) }, state.window) }) thresholdLabel := widget.NewLabel(fmt.Sprintf("Detection Sensitivity: %.2f", state.authorSceneThreshold)) thresholdSlider := widget.NewSlider(0.1, 0.9) thresholdSlider.Value = state.authorSceneThreshold thresholdSlider.Step = 0.05 thresholdSlider.OnChanged = func(v float64) { state.authorSceneThreshold = v thresholdLabel.SetText(fmt.Sprintf("Detection Sensitivity: %.2f", v)) } detectBtn := widget.NewButton("Detect Scenes", func() { if state.authorFile == nil && len(state.authorClips) == 0 { dialog.ShowInformation("No File", "Please select a video file first", state.window) return } dialog.ShowInformation("Scene Detection", "Scene detection will be implemented", state.window) }) detectBtn.Importance = widget.HighImportance chapterList := widget.NewLabel("No chapters detected yet") addChapterBtn := widget.NewButton("+ Add Chapter", func() { dialog.ShowInformation("Add Chapter", "Manual chapter addition will be implemented", state.window) }) exportBtn := widget.NewButton("Export Chapters", func() { dialog.ShowInformation("Export", "Chapter export will be implemented", state.window) }) controls := container.NewVBox( fileLabel, selectBtn, widget.NewSeparator(), widget.NewLabel("Scene Detection:"), thresholdLabel, thresholdSlider, detectBtn, widget.NewSeparator(), widget.NewLabel("Chapters:"), container.NewScroll(chapterList), container.NewHBox(addChapterBtn, exportBtn), ) return container.NewPadded(controls) } func buildSubtitlesTab(state *appState) fyne.CanvasObject { list := container.NewVBox() listScroll := container.NewVScroll(list) var buildSubList func() var emptyOverlay *fyne.Container buildSubList = func() { list.Objects = nil if len(state.authorSubtitles) == 0 { if emptyOverlay != nil { emptyOverlay.Show() } list.Refresh() return } if emptyOverlay != nil { emptyOverlay.Hide() } for i, path := range state.authorSubtitles { idx := i card := widget.NewCard(filepath.Base(path), "", nil) removeBtn := widget.NewButton("Remove", func() { state.authorSubtitles = append(state.authorSubtitles[:idx], state.authorSubtitles[idx+1:]...) buildSubList() state.updateAuthorSummary() }) removeBtn.Importance = widget.MediumImportance cardContent := container.NewVBox(removeBtn) card.SetContent(cardContent) list.Add(card) } list.Refresh() } addBtn := widget.NewButton("Add Subtitles", func() { dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) { if err != nil || reader == nil { return } defer reader.Close() state.authorSubtitles = append(state.authorSubtitles, reader.URI().Path()) buildSubList() state.updateAuthorSummary() }, state.window) }) addBtn.Importance = widget.HighImportance clearBtn := widget.NewButton("Clear All", func() { state.authorSubtitles = []string{} buildSubList() state.updateAuthorSummary() }) clearBtn.Importance = widget.MediumImportance dropTarget := ui.NewDroppable(listScroll, func(items []fyne.URI) { var paths []string for _, uri := range items { if uri.Scheme() == "file" { paths = append(paths, uri.Path()) } } if len(paths) > 0 { state.authorSubtitles = append(state.authorSubtitles, paths...) buildSubList() state.updateAuthorSummary() } }) emptyLabel := widget.NewLabel("Drag and drop subtitle files here\nor click 'Add Subtitles' to select") emptyLabel.Alignment = fyne.TextAlignCenter emptyOverlay = container.NewCenter(emptyLabel) listArea := container.NewMax(dropTarget, emptyOverlay) controls := container.NewBorder( widget.NewLabel("Subtitle Tracks:"), container.NewHBox(addBtn, clearBtn), nil, nil, listArea, ) buildSubList() return container.NewPadded(controls) } func buildAuthorSettingsTab(state *appState) fyne.CanvasObject { outputType := widget.NewSelect([]string{"DVD (VIDEO_TS)", "ISO Image"}, func(value string) { if value == "DVD (VIDEO_TS)" { state.authorOutputType = "dvd" } else { state.authorOutputType = "iso" } state.updateAuthorSummary() }) if state.authorOutputType == "iso" { outputType.SetSelected("ISO Image") } else { outputType.SetSelected("DVD (VIDEO_TS)") } regionSelect := widget.NewSelect([]string{"AUTO", "NTSC", "PAL"}, func(value string) { state.authorRegion = value state.updateAuthorSummary() }) if state.authorRegion == "" { regionSelect.SetSelected("AUTO") } else { regionSelect.SetSelected(state.authorRegion) } aspectSelect := widget.NewSelect([]string{"AUTO", "4:3", "16:9"}, func(value string) { state.authorAspectRatio = value state.updateAuthorSummary() }) if state.authorAspectRatio == "" { aspectSelect.SetSelected("AUTO") } else { aspectSelect.SetSelected(state.authorAspectRatio) } titleEntry := widget.NewEntry() titleEntry.SetPlaceHolder("DVD Title") titleEntry.SetText(state.authorTitle) titleEntry.OnChanged = func(value string) { state.authorTitle = value state.updateAuthorSummary() } createMenuCheck := widget.NewCheck("Create DVD Menu", func(checked bool) { state.authorCreateMenu = checked state.updateAuthorSummary() }) createMenuCheck.SetChecked(state.authorCreateMenu) info := widget.NewLabel("Requires: ffmpeg, dvdauthor, and mkisofs/genisoimage (for ISO).") info.Wrapping = fyne.TextWrapWord controls := container.NewVBox( widget.NewLabel("Output Settings:"), widget.NewSeparator(), widget.NewLabel("Output Type:"), outputType, widget.NewLabel("Region:"), regionSelect, widget.NewLabel("Aspect Ratio:"), aspectSelect, widget.NewLabel("DVD Title:"), titleEntry, createMenuCheck, widget.NewSeparator(), info, ) return container.NewPadded(controls) } func buildAuthorDiscTab(state *appState) fyne.CanvasObject { generateBtn := widget.NewButton("GENERATE DVD", func() { if len(state.authorClips) == 0 && state.authorFile == nil { dialog.ShowInformation("No Content", "Please add video clips or select a single video file", state.window) return } state.startAuthorGeneration() }) generateBtn.Importance = widget.HighImportance summaryLabel := widget.NewLabel(authorSummary(state)) summaryLabel.Wrapping = fyne.TextWrapWord state.authorSummaryLabel = summaryLabel controls := container.NewVBox( widget.NewLabel("Generate DVD/ISO:"), widget.NewSeparator(), summaryLabel, widget.NewSeparator(), generateBtn, ) return container.NewPadded(controls) } func authorSummary(state *appState) string { summary := "Ready to generate:\n\n" if len(state.authorClips) > 0 { summary += fmt.Sprintf("Videos: %d\n", len(state.authorClips)) for i, clip := range state.authorClips { summary += fmt.Sprintf(" %d. %s (%.2fs)\n", i+1, clip.DisplayName, clip.Duration) } } else if state.authorFile != nil { summary += fmt.Sprintf("Video File: %s\n", filepath.Base(state.authorFile.Path)) } if len(state.authorSubtitles) > 0 { summary += fmt.Sprintf("Subtitle Tracks: %d\n", len(state.authorSubtitles)) for i, path := range state.authorSubtitles { summary += fmt.Sprintf(" %d. %s\n", i+1, filepath.Base(path)) } } summary += fmt.Sprintf("Output Type: %s\n", state.authorOutputType) summary += fmt.Sprintf("Region: %s\n", state.authorRegion) summary += fmt.Sprintf("Aspect Ratio: %s\n", state.authorAspectRatio) if state.authorTitle != "" { summary += fmt.Sprintf("DVD Title: %s\n", state.authorTitle) } return summary } func (s *appState) addAuthorFiles(paths []string) { for _, path := range paths { src, err := probeVideo(path) if err != nil { dialog.ShowError(fmt.Errorf("failed to load video %s: %w", filepath.Base(path), err), s.window) continue } clip := authorClip{ Path: path, DisplayName: filepath.Base(path), Duration: src.Duration, Chapters: []authorChapter{}, } s.authorClips = append(s.authorClips, clip) } s.updateAuthorSummary() } func (s *appState) updateAuthorSummary() { if s.authorSummaryLabel == nil { return } s.authorSummaryLabel.SetText(authorSummary(s)) } func (s *appState) startAuthorGeneration() { paths, primary, err := s.authorSourcePaths() if err != nil { dialog.ShowError(err, s.window) return } region := resolveAuthorRegion(s.authorRegion, primary) aspect := resolveAuthorAspect(s.authorAspectRatio, primary) title := strings.TrimSpace(s.authorTitle) if title == "" { title = defaultAuthorTitle(paths) } warnings := authorWarnings(s) continuePrompt := func() { s.promptAuthorOutput(paths, region, aspect, title) } if len(warnings) > 0 { dialog.ShowConfirm("Authoring Notes", strings.Join(warnings, "\n")+"\n\nContinue?", func(ok bool) { if ok { continuePrompt() } }, s.window) return } continuePrompt() } func (s *appState) promptAuthorOutput(paths []string, region, aspect, title string) { outputType := strings.ToLower(strings.TrimSpace(s.authorOutputType)) if outputType == "" { outputType = "dvd" } if outputType == "iso" { dialog.ShowFileSave(func(writer fyne.URIWriteCloser, err error) { if err != nil || writer == nil { return } path := writer.URI().Path() writer.Close() if !strings.HasSuffix(strings.ToLower(path), ".iso") { path += ".iso" } s.generateAuthoring(paths, region, aspect, title, path, true) }, s.window) return } dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) { if err != nil || uri == nil { return } discRoot := filepath.Join(uri.Path(), authorOutputFolderName(title, paths)) s.generateAuthoring(paths, region, aspect, title, discRoot, false) }, s.window) } func authorWarnings(state *appState) []string { var warnings []string if state.authorCreateMenu { warnings = append(warnings, "DVD menus are not implemented yet; the disc will play titles directly.") } if len(state.authorSubtitles) > 0 { warnings = append(warnings, "Subtitle tracks are not authored yet; they will be ignored.") } if len(state.authorAudioTracks) > 0 { warnings = append(warnings, "Additional audio tracks are not authored yet; they will be ignored.") } return warnings } func (s *appState) authorSourcePaths() ([]string, *videoSource, error) { if len(s.authorClips) > 0 { paths := make([]string, 0, len(s.authorClips)) for _, clip := range s.authorClips { paths = append(paths, clip.Path) } primary, err := probeVideo(paths[0]) if err != nil { return nil, nil, fmt.Errorf("failed to probe source: %w", err) } return paths, primary, nil } if s.authorFile != nil { return []string{s.authorFile.Path}, s.authorFile, nil } return nil, nil, fmt.Errorf("no authoring content selected") } func resolveAuthorRegion(pref string, src *videoSource) string { pref = strings.ToUpper(strings.TrimSpace(pref)) if pref == "NTSC" || pref == "PAL" { return pref } if src != nil { if src.FrameRate > 0 { if src.FrameRate <= 26 { return "PAL" } return "NTSC" } if src.Height == 576 { return "PAL" } if src.Height == 480 { return "NTSC" } } return "NTSC" } func resolveAuthorAspect(pref string, src *videoSource) string { pref = strings.TrimSpace(pref) if pref == "4:3" || pref == "16:9" { return pref } if src != nil && src.Width > 0 && src.Height > 0 { ratio := float64(src.Width) / float64(src.Height) if ratio >= 1.55 { return "16:9" } return "4:3" } return "16:9" } func defaultAuthorTitle(paths []string) string { if len(paths) == 0 { return "DVD" } base := filepath.Base(paths[0]) return strings.TrimSuffix(base, filepath.Ext(base)) } func authorOutputFolderName(title string, paths []string) string { name := strings.TrimSpace(title) if name == "" { name = defaultAuthorTitle(paths) } name = sanitizeForPath(name) if name == "" { name = "dvd_output" } return name } func (s *appState) generateAuthoring(paths []string, region, aspect, title, outputPath string, makeISO bool) { if err := ensureAuthorDependencies(makeISO); err != nil { dialog.ShowError(err, s.window) return } progress := dialog.NewProgressInfinite("Authoring DVD", "Encoding sources...", s.window) progress.Show() go func() { err := s.runAuthoringPipeline(paths, region, aspect, title, outputPath, makeISO) message := "DVD authoring complete." if makeISO { message = fmt.Sprintf("ISO image created:\n%s", outputPath) } else { message = fmt.Sprintf("DVD folders created:\n%s", outputPath) } runOnUI(func() { progress.Hide() if err != nil { dialog.ShowError(err, s.window) return } dialog.ShowInformation("Authoring Complete", message, s.window) }) }() } func (s *appState) runAuthoringPipeline(paths []string, region, aspect, title, outputPath string, makeISO bool) error { workDir, err := os.MkdirTemp(utils.TempDir(), "videotools-author-") if err != nil { return fmt.Errorf("failed to create temp directory: %w", err) } defer os.RemoveAll(workDir) discRoot := outputPath var cleanup func() if makeISO { tempRoot, err := os.MkdirTemp(utils.TempDir(), "videotools-dvd-") if err != nil { return fmt.Errorf("failed to create DVD output directory: %w", err) } discRoot = tempRoot cleanup = func() { _ = os.RemoveAll(tempRoot) } } if cleanup != nil { defer cleanup() } if err := prepareDiscRoot(discRoot); err != nil { return err } mpgPaths, err := encodeAuthorSources(paths, region, aspect, workDir) if err != nil { return err } xmlPath := filepath.Join(workDir, "dvd.xml") if err := writeDVDAuthorXML(xmlPath, mpgPaths, region, aspect); err != nil { return err } if err := runCommand("dvdauthor", []string{"-o", discRoot, "-x", xmlPath}); err != nil { return err } if err := runCommand("dvdauthor", []string{"-o", discRoot, "-T"}); err != nil { return err } if err := os.MkdirAll(filepath.Join(discRoot, "AUDIO_TS"), 0755); err != nil { return fmt.Errorf("failed to create AUDIO_TS: %w", err) } if makeISO { tool, args, err := buildISOCommand(outputPath, discRoot, title) if err != nil { return err } if err := runCommand(tool, args); err != nil { return err } } return nil } func prepareDiscRoot(path string) error { if err := os.MkdirAll(path, 0755); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } entries, err := os.ReadDir(path) if err != nil { return fmt.Errorf("failed to read output directory: %w", err) } if len(entries) > 0 { return fmt.Errorf("output folder must be empty: %s", path) } return nil } func encodeAuthorSources(paths []string, region, aspect, workDir string) ([]string, error) { var mpgPaths []string for i, path := range paths { idx := i + 1 outPath := filepath.Join(workDir, fmt.Sprintf("title_%02d.mpg", idx)) src, err := probeVideo(path) if err != nil { return nil, fmt.Errorf("failed to probe %s: %w", filepath.Base(path), err) } args := buildAuthorFFmpegArgs(path, outPath, region, aspect, src.IsProgressive()) if err := runCommand(platformConfig.FFmpegPath, args); err != nil { return nil, err } mpgPaths = append(mpgPaths, outPath) } return mpgPaths, nil } func buildAuthorFFmpegArgs(inputPath, outputPath, region, aspect string, progressive bool) []string { width := 720 height := 480 fps := "30000/1001" gop := "15" bitrate := "6000k" maxrate := "9000k" if region == "PAL" { height = 576 fps = "25" gop = "12" bitrate = "8000k" maxrate = "9500k" } vf := []string{ fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", width, height), fmt.Sprintf("pad=%d:%d:(ow-iw)/2:(oh-ih)/2", width, height), fmt.Sprintf("setdar=%s", aspect), "setsar=1", fmt.Sprintf("fps=%s", fps), } args := []string{ "-y", "-hide_banner", "-loglevel", "error", "-i", inputPath, "-vf", strings.Join(vf, ","), "-c:v", "mpeg2video", "-r", fps, "-b:v", bitrate, "-maxrate", maxrate, "-bufsize", "1835k", "-g", gop, "-pix_fmt", "yuv420p", } if !progressive { args = append(args, "-flags", "+ilme+ildct") } args = append(args, "-c:a", "ac3", "-b:a", "192k", "-ar", "48000", "-ac", "2", outputPath, ) return args } func writeDVDAuthorXML(path string, mpgPaths []string, region, aspect string) error { format := strings.ToLower(region) if format != "pal" { format = "ntsc" } var b strings.Builder b.WriteString("\n") b.WriteString(" \n") b.WriteString(" \n") b.WriteString(" \n") b.WriteString(fmt.Sprintf(" \n") b.WriteString(" \n") b.WriteString("\n") if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil { return fmt.Errorf("failed to write dvdauthor XML: %w", err) } return nil } func escapeXMLAttr(value string) string { var b strings.Builder if err := xml.EscapeText(&b, []byte(value)); err != nil { return strings.ReplaceAll(value, "\"", """) } escaped := b.String() return strings.ReplaceAll(escaped, "\"", """) } func ensureAuthorDependencies(makeISO bool) error { if err := ensureExecutable(platformConfig.FFmpegPath, "ffmpeg"); err != nil { return err } if _, err := exec.LookPath("dvdauthor"); err != nil { return fmt.Errorf("dvdauthor not found in PATH") } if makeISO { if _, _, err := buildISOCommand("output.iso", "output", "VIDEO_TOOLS"); err != nil { return err } } return nil } func ensureExecutable(path, label string) error { if filepath.IsAbs(path) { if _, err := os.Stat(path); err == nil { return nil } } if _, err := exec.LookPath(path); err == nil { return nil } return fmt.Errorf("%s not found (%s)", label, path) } func buildISOCommand(outputISO, discRoot, title string) (string, []string, error) { tool, prefixArgs, err := findISOTool() if err != nil { return "", nil, err } label := isoVolumeLabel(title) args := append([]string{}, prefixArgs...) args = append(args, "-dvd-video", "-V", label, "-o", outputISO, discRoot) return tool, args, nil } func findISOTool() (string, []string, error) { if path, err := exec.LookPath("mkisofs"); err == nil { return path, nil, nil } if path, err := exec.LookPath("genisoimage"); err == nil { return path, nil, nil } if path, err := exec.LookPath("xorriso"); err == nil { return path, []string{"-as", "mkisofs"}, nil } return "", nil, fmt.Errorf("mkisofs, genisoimage, or xorriso not found in PATH") } func isoVolumeLabel(title string) string { label := strings.ToUpper(strings.TrimSpace(title)) if label == "" { label = "VIDEO_TOOLS" } var b strings.Builder for _, r := range label { switch { case r >= 'A' && r <= 'Z': b.WriteRune(r) case r >= '0' && r <= '9': b.WriteRune(r) case r == '_' || r == '-': b.WriteRune('_') default: b.WriteRune('_') } } clean := strings.Trim(b.String(), "_") if clean == "" { clean = "VIDEO_TOOLS" } if len(clean) > 32 { clean = clean[:32] } return clean } func runCommand(name string, args []string) error { cmd := exec.Command(name, args...) utils.ApplyNoWindow(cmd) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%s failed: %s", name, strings.TrimSpace(string(output))) } return nil } func runOnUI(fn func()) { fn() }