package ui import ( "context" "fmt" "io" "strings" "sync" "time" "github.com/Ullaakut/cameradar/v6" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type state int const ( statePending state = iota stateActive stateDone stateError ) type logLevel int const ( logInfo logLevel = iota logDebug logError ) type stepMsg struct { step cameradar.Step state state message string } type logMsg struct { level logLevel step cameradar.Step message string } type progressMsg struct { step cameradar.Step total int increment int } type closeMsg struct{} type summaryMsg struct { streams []cameradar.Stream final bool } type summaryTable struct { table table.Model } const ( summaryMinHeight = 8 summaryMaxHeight = 10 summaryColumnCount = 8 ) // TUIReporter renders a Bubble Tea based UI. type TUIReporter struct { program *tea.Program debug bool once sync.Once closed chan struct{} mu sync.Mutex last []cameradar.Stream } // NewTUIReporter creates a new Bubble Tea reporter. func NewTUIReporter(debug bool, out io.Writer, buildInfo BuildInfo, cancel context.CancelFunc) (*TUIReporter, error) { spin := spinner.New() spin.Spinner = spinner.Dot spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) prog := progress.New( progress.WithDefaultGradient(), progress.WithFillCharacters('━', '·'), progress.WithoutPercentage(), progress.WithWidth(28), ) initial := &modelState{ steps: cameradar.Steps(), status: make(map[cameradar.Step]state), debug: debug, buildInfo: buildInfo, cancel: cancel, spinner: spin, progress: prog, progressTotals: make(map[cameradar.Step]int), progressCounts: make(map[cameradar.Step]int), } p := tea.NewProgram(initial, tea.WithInputTTY(), tea.WithOutput(out), tea.WithAltScreen()) reporter := &TUIReporter{program: p, debug: debug, closed: make(chan struct{})} go func() { model, err := p.Run() if err != nil { _, _ = fmt.Fprintf(out, "Error running TUI: %v\n", err) close(reporter.closed) return } if rendered, ok := model.(*modelState); ok { output := rendered.FinalView() if len(rendered.summaryStreams) == 0 { fallback := reporter.snapshotSummary() if len(fallback) > 0 { tmp := &modelState{ summaryStreams: fallback, width: rendered.width, status: summaryStatusAllDone(), } output = tmp.FinalView() } } _, _ = fmt.Fprintln(out, output) } close(reporter.closed) }() return reporter, nil } // Start implements Reporter. func (r *TUIReporter) Start(step cameradar.Step, message string) { r.send(stepMsg{step: step, state: stateActive, message: message}) } // Done implements Reporter. func (r *TUIReporter) Done(step cameradar.Step, message string) { r.send(stepMsg{step: step, state: stateDone, message: message}) } // Progress implements Reporter. func (r *TUIReporter) Progress(step cameradar.Step, message string) { if kind, value, ok := cameradar.ParseProgressMessage(message); ok { msg := progressMsg{step: step} if kind == "total" { msg.total = value } if kind == "tick" { msg.increment = value } r.send(msg) return } r.send(logMsg{level: logInfo, step: step, message: message}) } // Debug implements Reporter. func (r *TUIReporter) Debug(step cameradar.Step, message string) { if !r.debug { return } r.send(logMsg{level: logDebug, step: step, message: message}) } // Error implements Reporter. func (r *TUIReporter) Error(step cameradar.Step, err error) { if err == nil { return } r.send(stepMsg{step: step, state: stateError, message: err.Error()}) } // Summary implements Reporter. func (r *TUIReporter) Summary(streams []cameradar.Stream, _ error) { cloned := copyStreams(streams) r.recordSummary(cloned) r.send(summaryMsg{streams: cloned, final: true}) } // UpdateSummary updates the summary section with partial results. func (r *TUIReporter) UpdateSummary(streams []cameradar.Stream) { cloned := copyStreams(streams) r.recordSummary(cloned) r.send(summaryMsg{streams: cloned, final: false}) } func (r *TUIReporter) recordSummary(streams []cameradar.Stream) { r.mu.Lock() defer r.mu.Unlock() r.last = streams } func (r *TUIReporter) snapshotSummary() []cameradar.Stream { r.mu.Lock() defer r.mu.Unlock() return copyStreams(r.last) } // Close implements Reporter. func (r *TUIReporter) Close() { r.once.Do(func() { r.send(closeMsg{}) }) // Timeout after 2 seconds to avoid hanging forever. select { case <-r.closed: case <-time.After(2 * time.Second): } } func (r *TUIReporter) send(msg tea.Msg) { if r.program == nil { return } r.program.Send(msg) } func renderStep(step cameradar.Step, state state, spinnerView string) string { label := cameradar.StepLabel(step) symbol := "·" style := dimStyle switch state { case stateActive: symbol = spinnerView style = activeStyle case stateDone: symbol = "✓" style = successStyle case stateError: symbol = "✗" style = errorStyle } return style.Render(fmt.Sprintf("%s %s", symbol, label)) } func renderLog(entry logMsg) string { prefix := "INFO" style := infoStyle if entry.level == logDebug { prefix = "DEBUG" style = debugStyle } if entry.level == logError { prefix = "ERROR" style = errorStyle } return style.Render(fmt.Sprintf("[%s] %s: %s", prefix, cameradar.StepLabel(entry.step), entry.message)) } func renderProgress(m *modelState) string { completed, total := progressCounts(m.steps, m.status) percent := progressPercent(m.steps, m.status, m.progressTotals, m.progressCounts) countLabel := dimStyle.Render(fmt.Sprintf("%3.0f%% %d/%d complete", percent*100, completed, total)) return fmt.Sprintf("%s %s", m.progress.ViewAs(m.progressVisible), countLabel) } func progressCounts(steps []cameradar.Step, status map[cameradar.Step]state) (int, int) { if len(steps) == 0 { return 0, 0 } completed := 0 for _, step := range steps { switch status[step] { case stateDone, stateError: completed++ } } return completed, len(steps) } func progressPercent(steps []cameradar.Step, status map[cameradar.Step]state, totals, counts map[cameradar.Step]int) float64 { weights := stepWeights() percent := 0.0 for _, step := range steps { weight := weights[step] if weight <= 0 { continue } percent += weight * stepProgress(step, status, totals, counts) } if percent > 1 { return 1 } return percent } func stepWeights() map[cameradar.Step]float64 { return map[cameradar.Step]float64{ cameradar.StepScan: 0.15, cameradar.StepAttackRoutes: 0.25, cameradar.StepDetectAuth: 0.05, cameradar.StepAttackCredentials: 0.35, cameradar.StepValidateStreams: 0.2, cameradar.StepSummary: 0.0, } } func stepProgress(step cameradar.Step, status map[cameradar.Step]state, totals, counts map[cameradar.Step]int) float64 { if total := totals[step]; total > 0 { count := counts[step] if count >= total { return 1 } return float64(count) / float64(total) } switch status[step] { case stateDone, stateError: return 1 default: return 0 } } func queueProgressUpdate(m *modelState) { desired := progressPercent(m.steps, m.status, m.progressTotals, m.progressCounts) if desired <= m.progressTarget { return } m.progressTarget = desired } func advanceProgress(m *modelState) { if m.progressVisible >= m.progressTarget { return } remaining := m.progressTarget - m.progressVisible step := remaining * 0.2 if step < 0.02 { step = 0.02 } if m.quitting && step < 0.08 { step = 0.08 } if remaining < step { m.progressVisible = m.progressTarget return } m.progressVisible += step } func progressComplete(m modelState) bool { return m.progressVisible >= m.progressTarget } func markStepComplete(m *modelState, step cameradar.Step) { if m.progressTotals[step] == 0 { m.progressTotals[step] = 1 } if m.progressCounts[step] < m.progressTotals[step] { m.progressCounts[step] = m.progressTotals[step] } } func progressWidth(width int) int { if width <= 0 { return 28 } if width < 60 { return 20 } if width < 100 { return 28 } return 36 } func buildSummaryTables(streams []cameradar.Stream, width int, status map[cameradar.Step]state, maxRows int) []summaryTable { visibility := summaryVisibility(status) accessible, others := partitionStreams(streams) rows := append(buildSummaryRows(accessible, visibility), buildSummaryRows(others, visibility)...) if len(rows) == 0 { rows = []table.Row{emptySummaryRow()} } if maxRows > 0 { switch { case len(rows) > maxRows: if maxRows == 1 { rows = []table.Row{summaryOverflowRow(len(rows))} } else { visibleRows := maxRows - 1 hidden := len(rows) - visibleRows rows = append(rows[:visibleRows], summaryOverflowRow(hidden)) } case len(rows) < maxRows: rows = padSummaryRows(rows, maxRows) } } columns := summaryColumns(width, rows) model := table.New( table.WithColumns(columns), table.WithRows(rows), table.WithFocused(false), table.WithHeight(len(rows)), ) model.SetStyles(summaryTableStyles()) return []summaryTable{{table: model}} } func renderSummaryTitle(streams []cameradar.Stream) string { accessible, _ := partitionStreams(streams) return fmt.Sprintf("Summary - Streams (%d accessible / %d total)", len(accessible), len(streams)) } func summaryStatusAllDone() map[cameradar.Step]state { status := make(map[cameradar.Step]state) for _, step := range cameradar.Steps() { status[step] = stateDone } return status } const emptyEntry = "—" func emptySummaryRow() table.Row { row := make(table.Row, summaryColumnCount) for i := range row { row[i] = emptyEntry } return row } func padSummaryRows(rows []table.Row, maxRows int) []table.Row { for len(rows) < maxRows { rows = append(rows, emptySummaryRow()) } return rows } func summaryOverflowRow(hidden int) table.Row { row := emptySummaryRow() if hidden <= 0 { return row } label := "\u2026 1 more stream" if hidden > 1 { label = fmt.Sprintf("\u2026 %d more streams", hidden) } row[0] = label return row } func renderSummaryTablePlain(columns []table.Column, rows []table.Row) string { colWidths := make([]int, len(columns)) for i, col := range columns { colWidths[i] = max(col.Width, len([]rune(col.Title))) } var builder strings.Builder builder.WriteString(renderSummaryBorder("┌", "┬", "┐", colWidths)) builder.WriteString("\n") builder.WriteString(renderSummaryRow(columnTitles(columns), colWidths)) builder.WriteString("\n") builder.WriteString(renderSummaryBorder("├", "┼", "┤", colWidths)) for _, row := range rows { builder.WriteString("\n") builder.WriteString(renderSummaryRow(row, colWidths)) } builder.WriteString("\n") builder.WriteString(renderSummaryBorder("└", "┴", "┘", colWidths)) return builder.String() } func renderSummaryBorder(left, middle, right string, widths []int) string { parts := make([]string, 0, len(widths)) for _, width := range widths { parts = append(parts, strings.Repeat("─", width+2)) } return left + strings.Join(parts, middle) + right } func renderSummaryRow(cells []string, widths []int) string { var builder strings.Builder builder.WriteString("│") for i, width := range widths { value := "" if i < len(cells) { value = cells[i] } builder.WriteString(" ") builder.WriteString(padAndTrim(value, width)) builder.WriteString(" │") } return builder.String() } func padAndTrim(value string, width int) string { if width <= 0 { return "" } runes := []rune(value) if len(runes) > width { return string(runes[:width]) } if len(runes) < width { return string(runes) + strings.Repeat(" ", width-len(runes)) } return value } func columnTitles(columns []table.Column) []string { if len(columns) == 0 { return nil } titles := make([]string, len(columns)) for i, col := range columns { titles[i] = col.Title } return titles } func buildSummaryRows(streams []cameradar.Stream, visibility summaryVisibilityState) []table.Row { rows := make([]table.Row, 0, len(streams)) for _, stream := range streams { target := fmt.Sprintf("%s:%d", stream.Address.String(), stream.Port) device := emptyEntry if visibility.showDevice && stream.Device != "" { device = stream.Device } routes := emptyEntry if visibility.showRoutes && len(stream.Routes) > 0 { routes = strings.Join(stream.Routes, ", ") } credentials := emptyEntry if visibility.showCredentials && stream.CredentialsFound { credentials = formatCredentials(stream) } available := emptyEntry if visibility.showAvailable { available = "no" if stream.Available { available = "yes" } } rtspURL := emptyEntry if visibility.showCredentials && stream.RouteFound && stream.CredentialsFound { rtspURL = formatRTSPURL(stream) } authType := emptyEntry if visibility.showAuth { authType = authTypeLabel(stream.AuthenticationType) } rows = append(rows, table.Row{ target, device, authType, routes, credentials, available, rtspURL, adminPanelLabel(stream, visibility), }) } return rows } func summaryColumns(width int, rows []table.Row) []table.Column { columns := []table.Column{ {Title: "Target", Width: 18}, {Title: "Device", Width: 14}, {Title: "Auth", Width: 8}, {Title: "Routes", Width: 18}, {Title: "Credentials", Width: 16}, {Title: "Available", Width: 9}, {Title: "RTSP URL", Width: 30}, {Title: "Admin", Width: 24}, } columns[6].Width = maxColumnWidth(columns[6].Title, rows, 6, columns[6].Width) columns[7].Width = maxColumnWidth(columns[7].Title, rows, 7, columns[7].Width) if width <= 0 { return columns } columns = clampColumns(columns, max(width-2, 60)) return columns } func clampColumns(columns []table.Column, maxWidth int) []table.Column { padding := 2 * len(columns) contentWidth := 0 for _, col := range columns { contentWidth += col.Width } contentWidth += padding if contentWidth <= maxWidth { return columns } over := contentWidth - maxWidth shrinkOrder := []int{7, 3, 4, 1} minWidths := map[int]int{ 7: 10, 3: 10, 4: 10, 1: 10, } for over > 0 { changed := false for _, idx := range shrinkOrder { minWidth := minWidths[idx] if columns[idx].Width > minWidth { columns[idx].Width-- over-- changed = true if over == 0 { break } } } if !changed { break } } return columns } func summaryTableStyles() table.Styles { styles := table.DefaultStyles() styles.Header = styles.Header. BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("240")). BorderBottom(true). Bold(true) styles.Selected = lipgloss.NewStyle() styles.Cell = styles.Cell.Padding(0, 1) return styles } func maxColumnWidth(title string, rows []table.Row, idx, minWidth int) int { width := max(len(title), minWidth) for _, row := range rows { if idx >= len(row) { continue } if len(row[idx]) > width { width = len(row[idx]) } } return width } func adminPanelLabel(stream cameradar.Stream, visibility summaryVisibilityState) string { if !visibility.showCredentials || !stream.CredentialsFound { return emptyEntry } return formatAdminPanelURL(stream) } type summaryVisibilityState struct { showDevice bool showRoutes bool showAuth bool showCredentials bool showAvailable bool } func summaryVisibility(status map[cameradar.Step]state) summaryVisibilityState { return summaryVisibilityState{ showDevice: stepComplete(status, cameradar.StepScan), showRoutes: stepComplete(status, cameradar.StepAttackRoutes), showAuth: stepComplete(status, cameradar.StepDetectAuth), showCredentials: stepComplete(status, cameradar.StepAttackCredentials), showAvailable: stepComplete(status, cameradar.StepValidateStreams), } } func stepComplete(status map[cameradar.Step]state, step cameradar.Step) bool { if status == nil { return false } switch status[step] { case stateDone, stateError: return true default: return false } } func copyStreams(streams []cameradar.Stream) []cameradar.Stream { if len(streams) == 0 { return nil } cloned := make([]cameradar.Stream, len(streams)) copy(cloned, streams) return cloned }