github.com/nextlinux/gosbom@v0.81.1-0.20230627115839-1ff50c281391/internal/ui/ephemeral_terminal_ui.go (about)

     1  //go:build linux || darwin || netbsd
     2  // +build linux darwin netbsd
     3  
     4  package ui
     5  
     6  import (
     7  	"bytes"
     8  	"context"
     9  	"fmt"
    10  	"io"
    11  	"os"
    12  	"sync"
    13  
    14  	gosbomEvent "github.com/nextlinux/gosbom/gosbom/event"
    15  	"github.com/nextlinux/gosbom/internal/log"
    16  	"github.com/nextlinux/gosbom/ui"
    17  	"github.com/wagoodman/go-partybus"
    18  	"github.com/wagoodman/jotframe/pkg/frame"
    19  
    20  	"github.com/anchore/go-logger"
    21  )
    22  
    23  // ephemeralTerminalUI provides an "ephemeral" terminal user interface to display the application state dynamically.
    24  // The terminal is placed into raw mode and the cursor is manipulated to allow for a dynamic, multi-line
    25  // UI (provided by the jotframe lib), for this reason all other application mechanisms that write to the screen
    26  // must be suppressed before starting (such as logs); since bytes in the device and in application memory combine to make
    27  // a shared state, bytes coming from elsewhere to the screen will disrupt this state.
    28  //
    29  // This UI is primarily driven off of events from the event bus, creating single-line terminal widgets to represent a
    30  // published element on the event bus, typically polling the element for the latest state. This allows for the UI to
    31  // control update frequency to the screen, provide "liveness" indications that are interpolated between bus events,
    32  // and overall loosely couple the bus events from screen interactions.
    33  //
    34  // By convention, all elements published on the bus should be treated as read-only, and publishers on the bus should
    35  // attempt to enforce this when possible by wrapping complex objects with interfaces to prescribe interactions. Also by
    36  // convention, each new event that the UI should respond to should be added either in this package as a handler function,
    37  // or in the shared ui package as a function on the main handler object. All handler functions should be completed
    38  // processing an event before the ETUI exits (coordinated with a sync.WaitGroup)
    39  type ephemeralTerminalUI struct {
    40  	unsubscribe func() error
    41  	handler     *ui.Handler
    42  	waitGroup   *sync.WaitGroup
    43  	frame       *frame.Frame
    44  	logBuffer   *bytes.Buffer
    45  	uiOutput    *os.File
    46  }
    47  
    48  // NewEphemeralTerminalUI writes all events to a TUI and writes the final report to the given writer.
    49  func NewEphemeralTerminalUI() UI {
    50  	return &ephemeralTerminalUI{
    51  		handler:   ui.NewHandler(),
    52  		waitGroup: &sync.WaitGroup{},
    53  		uiOutput:  os.Stderr,
    54  	}
    55  }
    56  
    57  func (h *ephemeralTerminalUI) Setup(unsubscribe func() error) error {
    58  	h.unsubscribe = unsubscribe
    59  	hideCursor(h.uiOutput)
    60  
    61  	// prep the logger to not clobber the screen from now on (logrus only)
    62  	h.logBuffer = bytes.NewBufferString("")
    63  	logController, ok := log.Log.(logger.Controller)
    64  	if ok {
    65  		logController.SetOutput(h.logBuffer)
    66  	}
    67  
    68  	return h.openScreen()
    69  }
    70  
    71  func (h *ephemeralTerminalUI) Handle(event partybus.Event) error {
    72  	ctx := context.Background()
    73  	switch {
    74  	case h.handler.RespondsTo(event):
    75  		if err := h.handler.Handle(ctx, h.frame, event, h.waitGroup); err != nil {
    76  			log.Errorf("unable to show %s event: %+v", event.Type, err)
    77  		}
    78  
    79  	case event.Type == gosbomEvent.AppUpdateAvailable:
    80  		if err := handleAppUpdateAvailable(ctx, h.frame, event, h.waitGroup); err != nil {
    81  			log.Errorf("unable to show %s event: %+v", event.Type, err)
    82  		}
    83  
    84  	case event.Type == gosbomEvent.Exit:
    85  		// we need to close the screen now since signaling the sbom is ready means that we
    86  		// are about to write bytes to stdout, so we should reset the terminal state first
    87  		h.closeScreen(false)
    88  
    89  		if err := handleExit(event); err != nil {
    90  			log.Errorf("unable to show %s event: %+v", event.Type, err)
    91  		}
    92  
    93  		// this is the last expected event, stop listening to events
    94  		return h.unsubscribe()
    95  	}
    96  	return nil
    97  }
    98  
    99  func (h *ephemeralTerminalUI) openScreen() error {
   100  	config := frame.Config{
   101  		PositionPolicy: frame.PolicyFloatForward,
   102  		// only report output to stderr, reserve report output for stdout
   103  		Output: h.uiOutput,
   104  	}
   105  
   106  	fr, err := frame.New(config)
   107  	if err != nil {
   108  		return fmt.Errorf("failed to create the screen object: %w", err)
   109  	}
   110  	h.frame = fr
   111  
   112  	return nil
   113  }
   114  
   115  func (h *ephemeralTerminalUI) closeScreen(force bool) {
   116  	// we may have other background processes still displaying progress, wait for them to
   117  	// finish before discontinuing dynamic content and showing the final report
   118  	if !h.frame.IsClosed() {
   119  		if !force {
   120  			h.waitGroup.Wait()
   121  		}
   122  		h.frame.Close()
   123  		// TODO: there is a race condition within frame.Close() that sometimes leads to an extra blank line being output
   124  		frame.Close()
   125  
   126  		// only flush the log on close
   127  		h.flushLog()
   128  	}
   129  }
   130  
   131  func (h *ephemeralTerminalUI) flushLog() {
   132  	// flush any errors to the screen before the report
   133  	logController, ok := log.Log.(logger.Controller)
   134  	if ok {
   135  		fmt.Fprint(logController.GetOutput(), h.logBuffer.String())
   136  		logController.SetOutput(h.uiOutput)
   137  	} else {
   138  		fmt.Fprint(h.uiOutput, h.logBuffer.String())
   139  	}
   140  }
   141  
   142  func (h *ephemeralTerminalUI) Teardown(force bool) error {
   143  	h.closeScreen(force)
   144  	showCursor(h.uiOutput)
   145  	return nil
   146  }
   147  
   148  func hideCursor(output io.Writer) {
   149  	fmt.Fprint(output, "\x1b[?25l")
   150  }
   151  
   152  func showCursor(output io.Writer) {
   153  	fmt.Fprint(output, "\x1b[?25h")
   154  }