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 }