diff --git a/cmd/cairde/main.go b/cmd/cairde/main.go index 90a3846..33e0bdc 100644 --- a/cmd/cairde/main.go +++ b/cmd/cairde/main.go @@ -91,7 +91,7 @@ func main() { cairdeHomeDir := path.Join(user.HomeDir, ".cairde") cairdeLogsDir := path.Join(cairdeHomeDir, "logs") for _, baseDir := range []string{cairdeHomeDir, cairdeLogsDir} { - if err := os.Mkdir(baseDir, 0764); err != nil { + if err := os.Mkdir(baseDir, 0760); err != nil { if !os.IsExist(err) { log.Fatalf("main: unable to create directory %s: %s", baseDir, err) } diff --git a/makefile b/makefile index 56a2740..bd3d61d 100644 --- a/makefile +++ b/makefile @@ -16,11 +16,7 @@ check: ci: @golangci-lint run ./... -clean: - @go clean && \ - find ~/.cairde -type f -name "*.log" -exec rm '{}' \; - -build: clean +build: @go build -ldflags=$(DIST_LDFLAGS) ./cmd/cairde run: build diff --git a/ui/acn.go b/ui/acn.go index c6fde57..ef9deca 100644 --- a/ui/acn.go +++ b/ui/acn.go @@ -103,7 +103,7 @@ func turnAcnOn(m *model) tea.Msg { func turnAcnOff(m *model, quitProgram bool) tea.Msg { if m.acnState != offline { - logToFile(*m, "Shutting down application and ACN now") + logDebug(*m, "Shutting down application and ACN now") m.app.Shutdown() m.acn.Close() } diff --git a/ui/buffer.go b/ui/buffer.go new file mode 100644 index 0000000..5d92ee7 --- /dev/null +++ b/ui/buffer.go @@ -0,0 +1,205 @@ +package ui + +import ( + "bufio" + "fmt" + "os" + "path" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +const ( + // Status is the status buffer. + Status = iota + // Profile is the profile buffer. + Profile +) + +// BufferHandler is the concrete functionality of a Buffer. +type BufferHandler interface { + open(m *model) error // gracefully open a new buffer + validate(m model, input string) error // validate incoming input + handle(input, hiddenInput string) tea.Msg // handle and dispatch messages from input + persist(m *model, input string) // store buffer input for persistence + close(m model) error // gracefully destroy a buffer +} + +// Buffer is a user-facing interactive buffer in the TUI. +type Buffer struct { + name string // user-friendly buffer name for the menu + inputChannel chan string // channel for passing input to handlers + viewport viewport.Model // viewport for rendering lines + viewportLines []string // lines for viewport renderer + viewportIsReady bool // whether or not the viewport is ready to render + inputHistory []string // buffer input history for optional persistence + inputHistoryCursor int // where we are in the input history + persistence bool // whether or not to persist buffer input to file + + BufferHandler +} + +// StatusBuffer is the privileged status buffer where e.g. you input +// program-wide commands. It can also report general status information, e.g. +// the status of ACN connectivity. +type StatusBufferHandler Buffer + +func (s StatusBufferHandler) open(m *model) error { + buffer := m.buffers[m.menuState] + cairdeLogsDir := path.Join(m.userDir, "logs") + bufferLogsPath := path.Join(cairdeLogsDir, buffer.name) + + f, err := os.OpenFile(bufferLogsPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0664) + if err != nil { + return fmt.Errorf("unable to create/open file %s: %s", bufferLogsPath, err) + } + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + buffer.inputHistory = append(buffer.inputHistory, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("unable to read lines from %s : %s", bufferLogsPath, err) + } + + if err := f.Close(); err != nil { + return fmt.Errorf("unable to close %s file handler: %s", bufferLogsPath, err) + } + + buffer.inputHistoryCursor = 0 + if len(buffer.inputHistory) > 0 { + buffer.inputHistoryCursor = len(buffer.inputHistory) - 1 + } + + m.buffers[m.menuState] = buffer + m.statusBuffer = buffer + + return nil +} + +func (s StatusBufferHandler) validate(m model, input string) error { + if string(input[0]) != "/" { + return fmt.Errorf("Woops, this is not a chat buffer. Only commands are allowed") + } + return nil +} + +func (s StatusBufferHandler) handle(input, hiddenInput string) tea.Msg { + cmds := strings.Split(input, " ") + + switch { + case cmds[0] == "help": + return cmdMsg{output: strings.Split(cmdHelp, "\n")} + + case cmds[0] == "quit": + return turnAcnOffMsg{quitProgram: true} + + case cmds[0] == "start": + return showGettingStartedGuideMsg{} + + case cmds[0] == "profile": + if cmds[1] != "unlock" && cmds[1] != "info" && cmds[1] != "create" { + profileHelp := `Unknown "profile" command? +/profile create | Create a new profile +/profile info | Show profile information +/profile unlock | Unlock profile(s)` + return cmdMsg{output: strings.Split(profileHelp, "\n")} + } + + switch { + case cmds[1] == "unlock": + if len(cmds) != 3 { + return cmdMsg{output: strings.Split(cmdHelp, "\n")} + } + + return profileUnlockMsg{ + password: strings.TrimSpace(hiddenInput), + } + + case cmds[1] == "info": + return profileInfoMsg{} + + case cmds[1] == "create": + if len(cmds) != 5 { + return cmdMsg{output: strings.Split(cmdHelp, "\n")} + } + + passwords := strings.Split(strings.TrimSpace(hiddenInput), " ") + if len(passwords) != 2 { + return cmdMsg{output: []string{"Profile create: unable to parse hidden input"}} + } + + if passwords[0] != passwords[1] { + return cmdMsg{output: []string{"Profile create: passwords do not match?"}} + } + + return profileCreateMsg{ + name: cmds[2], + password: passwords[1], + } + } + + case cmds[0] == "acn": + if cmds[1] != "on" && cmds[1] != "off" { + acnHelp := `Unknown "acn" command? +/acn on | Turn on the Tor ACN +/acn off | Turn off the Tor ACN` + return cmdMsg{output: strings.Split(acnHelp, "\n")} + } + + switch { + case cmds[1] == "on": + return turnAcnOnMsg{} + case cmds[1] == "off": + return turnAcnOffMsg{} + } + + case cmds[0] == "clear": + return clearScreenMsg{} + } + + return cmdMsg{output: []string{unknownCmdHelp}} +} + +func (s StatusBufferHandler) persist(m *model, input string) { + buffer := m.buffers[m.menuState] + if buffer.persistence { + buffer.inputHistory = append(buffer.inputHistory, input) + buffer.inputHistoryCursor++ + m.buffers[m.menuState] = buffer + } +} + +func (s StatusBufferHandler) close(m model) error { + buffer := m.buffers[m.menuState] + cairdeLogsDir := path.Join(m.userDir, "logs") + bufferLogsPath := path.Join(cairdeLogsDir, buffer.name) + + if !buffer.persistence { + return nil + } + + f, err := os.OpenFile(bufferLogsPath, os.O_RDWR|os.O_CREATE, 0664) + if err != nil { + return fmt.Errorf("unable to create/open file %s: %s", bufferLogsPath, err) + } + + for _, cmd := range buffer.inputHistory { + if _, err := f.WriteString(cmd + "\n"); err != nil { + return fmt.Errorf("unable to append input to %s: %s", bufferLogsPath, err) + } + } + + if err := f.Close(); err != nil { + return fmt.Errorf("unable to close %s file handler: %s", bufferLogsPath, err) + } + + return nil +} + +// ProfileBuffer is the profile buffer. There can be several profile buffers +// which correspond to several profiles. Profile buffers take input and report +// output for profile-specific information, e.g. invites from a new contact. +type ProfileBuffer Buffer diff --git a/ui/input.go b/ui/input.go index 40faef2..ac4ef88 100644 --- a/ui/input.go +++ b/ui/input.go @@ -2,8 +2,6 @@ package ui import ( "strings" - - tea "github.com/charmbracelet/bubbletea" ) const unknownCmdHelp = "Unknown command? Run /help to see available commands" @@ -18,85 +16,8 @@ const cmdHelp = `/acn on | Turn on the Tor /start | Show getting started guide /quit | Quit the program` -func handleCmd(cmd, hiddenInput string) tea.Msg { - cmds := strings.Split(cmd, " ") - - switch { - case cmds[0] == "help": - return cmdMsg{output: strings.Split(cmdHelp, "\n")} - - case cmds[0] == "quit": - return turnAcnOffMsg{quitProgram: true} - - case cmds[0] == "start": - return showGettingStartedGuideMsg{} - - case cmds[0] == "profile": - if cmds[1] != "unlock" && cmds[1] != "info" && cmds[1] != "create" { - profileHelp := `Unknown "profile" command? -/profile create | Create a new profile -/profile info | Show profile information -/profile unlock | Unlock profile(s)` - return cmdMsg{output: strings.Split(profileHelp, "\n")} - } - - switch { - case cmds[1] == "unlock": - if len(cmds) != 3 { - return cmdMsg{output: strings.Split(cmdHelp, "\n")} - } - - return profileUnlockMsg{ - password: strings.TrimSpace(hiddenInput), - } - - case cmds[1] == "info": - return profileInfoMsg{} - - case cmds[1] == "create": - if len(cmds) != 5 { - return cmdMsg{output: strings.Split(cmdHelp, "\n")} - } - - passwords := strings.Split(strings.TrimSpace(hiddenInput), " ") - if len(passwords) != 2 { - return cmdMsg{output: []string{"Profile create: unable to parse hidden input"}} - } - - if passwords[0] != passwords[1] { - return cmdMsg{output: []string{"Profile create: passwords do not match?"}} - } - - return profileCreateMsg{ - name: cmds[2], - password: passwords[1], - } - } - - case cmds[0] == "acn": - if cmds[1] != "on" && cmds[1] != "off" { - acnHelp := `Unknown "acn" command? -/acn on | Turn on the Tor ACN -/acn off | Turn off the Tor ACN` - return cmdMsg{output: strings.Split(acnHelp, "\n")} - } - - switch { - case cmds[1] == "on": - return turnAcnOnMsg{} - case cmds[1] == "off": - return turnAcnOffMsg{} - } - - case cmds[0] == "clear": - return clearScreenMsg{} - } - - return cmdMsg{output: []string{unknownCmdHelp}} -} - func hidePasswordInput(m *model) { - val := strings.TrimSpace(m.input.Value()) + val := m.input.Value() if len(val) == 0 { return @@ -114,8 +35,8 @@ func hidePasswordInput(m *model) { if cmds[1] == "create" && len(cmds) > 3 || cmds[1] == "unlock" && len(cmds) > 2 { lastChar := string(val[len(val)-1]) + m.hiddenInput += lastChar if lastChar != " " { - m.hiddenInput += lastChar newCmd := []rune(val) newCmd[len(val)-1] = '*' m.input.SetValue(string(newCmd)) diff --git a/ui/logger.go b/ui/logger.go index 31b8684..15215df 100644 --- a/ui/logger.go +++ b/ui/logger.go @@ -4,8 +4,8 @@ import ( "log" ) -// LogToFile logs a message to a log file. -func logToFile(m model, msg string) { +// logDebug logs debug messages. +func logDebug(m model, msg string) { if m.debug { log.Printf("debug: %s", msg) } diff --git a/ui/message.go b/ui/message.go index 3fa5936..63abe5d 100644 --- a/ui/message.go +++ b/ui/message.go @@ -68,3 +68,5 @@ type renderStatusMsg struct{ line string } type cmdMsg struct{ output []string } type clearScreenMsg struct{} + +type gracefulShutdownMsg struct{} diff --git a/ui/model.go b/ui/model.go index fb935aa..748774a 100644 --- a/ui/model.go +++ b/ui/model.go @@ -56,38 +56,26 @@ network, initialise Cwtch and create your first profile. It's simple! ` ) +// model is the core data structure for the entire programme type model struct { - username string - userDir string - - acnState int - - menuState int - menuBar []string - - app app.Application - acn connectivity.ACN - - width int - height int - - showWelcomeMessage bool - - statusViewport viewport.Model - statusViewportLines []string - statusViewportReady bool - - profiles profiles - profileState int - - input textinput.Model - hiddenInput string - - version string - - statusBuffer chan string - - debug bool + username string // system username + userDir string // system user home directory + acnState int // ACN state - online, offline (or in between...) + menuState int // which buffer is currently selected + menuBar []string // list of buffers in use (e.g. status, profile1) + app app.Application // cwtch application manager + acn connectivity.ACN // cwtch ACN connectivity manager + width int // width of the TUI + height int // height of the TUI + showWelcomeMessage bool // whether or not we show the welcome message when turning on + buffers []Buffer // all buffers + statusBuffer Buffer // the central status buffer + profiles profiles // all unlocked cwtch profiles + profileState int // which profile is currently selected + input textinput.Model // the central user input buffer + hiddenInput string // the hidden input storage for e.g. passwords + version string // the version of cairde + debug bool // whether or not to run under debug mode } func NewModel(username, homeDir, version string, debug bool) model { // nolint:revive @@ -99,24 +87,28 @@ func NewModel(username, homeDir, version string, debug bool) model { // nolint:r input.TextStyle = inputTextStyle input.Focus() + statusBuffer := Buffer{ + BufferHandler: StatusBufferHandler{}, + name: "status", + inputChannel: make(chan string), + viewportIsReady: false, + persistence: true, // TODO: configurable + } + return model{ username: username, userDir: path.Join(homeDir, "/.cairde/"), - acnState: offline, - - menuBar: []string{"STATUS"}, - menuState: 0, - - input: input, - - version: version, - - statusBuffer: make(chan string), - + menuBar: []string{ + strings.ToUpper(statusBuffer.name), + }, + menuState: 0, + statusBuffer: statusBuffer, + buffers: []Buffer{statusBuffer}, + input: input, + version: version, showWelcomeMessage: true, - - debug: debug, + debug: debug, } } @@ -124,7 +116,7 @@ func NewModel(username, homeDir, version string, debug bool) model { // nolint:r // the statusViewport. This command is repeatedly called by the logic which // responds to a renderStatusMsg in Update. func (m model) receiveStatusCmd() tea.Msg { - return renderStatusMsg{line: <-m.statusBuffer} + return renderStatusMsg{line: <-m.statusBuffer.inputChannel} } // sendStatusCmd delivers a tea.Msg to bubbletea for rendering lines from the @@ -141,8 +133,8 @@ func (m model) sendStatusCmd(lines ...string) tea.Cmd { // we want to render messages to the status buffer but still have work to do. func (m model) sendStatus(lines ...string) { for _, line := range lines { - logToFile(m, line) - m.statusBuffer <- line + logDebug(m, line) + m.statusBuffer.inputChannel <- line } } @@ -179,6 +171,12 @@ func (m model) Init() tea.Cmd { return tea.Batch( textinput.Blink, m.receiveStatusCmd, + func() tea.Msg { + if err := m.statusBuffer.open(&m); err != nil { + return cmdMsg{output: []string{err.Error()}} + } + return nil + }, ) } @@ -191,7 +189,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.input, cmd = m.input.Update(msg) cmds = append(cmds, cmd) - m.statusViewport, cmd = m.statusViewport.Update(msg) + m.statusBuffer.viewport, cmd = m.statusBuffer.viewport.Update(msg) cmds = append(cmds, cmd) for _, p := range m.profiles { @@ -218,12 +216,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.input.Width = m.width - 5 - if !m.statusViewportReady { - m.statusViewport = newViewport(m.width, m.height-3) - m.statusViewportReady = true + if !m.statusBuffer.viewportIsReady { + m.statusBuffer.viewport = newViewport(m.width, m.height-3) + m.statusBuffer.viewportIsReady = true } else { - m.statusViewport.Width = m.width - m.statusViewport.Height = m.height - 3 + m.statusBuffer.viewport.Width = m.width + m.statusBuffer.viewport.Height = m.height - 3 } for _, p := range m.profiles { @@ -233,6 +231,41 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { + + case "down": + buffer := m.buffers[m.menuState] + if len(buffer.inputHistory) == 0 { + break + } + + m.input.Reset() + + if buffer.inputHistoryCursor < len(buffer.inputHistory)-1 { + buffer.inputHistoryCursor++ + } + + m.buffers[m.menuState] = buffer + + lastCmd := buffer.inputHistory[buffer.inputHistoryCursor] + m.input.SetValue(lastCmd) + + case "up": + buffer := m.buffers[m.menuState] + if len(buffer.inputHistory) == 0 { + break + } + + m.input.Reset() + + if buffer.inputHistoryCursor > 0 { + buffer.inputHistoryCursor-- + } + + m.buffers[m.menuState] = buffer + + lastCmd := buffer.inputHistory[buffer.inputHistoryCursor] + m.input.SetValue(lastCmd) + case "ctrl+c": cmds = append(cmds, func() tea.Msg { return turnAcnOff(&m, true) @@ -244,16 +277,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } - if string(cmdInput[0]) != "/" && m.menuState == 0 { + buffer := m.buffers[Status] + + if err := buffer.validate(m, cmdInput); err != nil { cmds = append(cmds, func() tea.Msg { - errMsg := "Woops, this is not a chat buffer. Only commands are allowed" - return cmdMsg{output: []string{errMsg}} + return cmdMsg{output: []string{err.Error()}} }) break } + buffer.persist(&m, cmdInput) + cmds = append(cmds, func() tea.Msg { - return handleCmd(cmdInput[1:], m.hiddenInput) + return buffer.handle(cmdInput[1:], m.hiddenInput) }) case "ctrl+n": @@ -298,9 +334,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return turnAcnOff(&m, msg.quitProgram) }) + case gracefulShutdownMsg: + if err := m.statusBuffer.close(m); err != nil { + panic(err) // TODO: ??? + } + return m, tea.Quit + case acnOffMsg: if msg.quitProgram { - return m, tea.Quit + return m, func() tea.Msg { + return gracefulShutdownMsg{} + } } cmds = append(cmds, m.sendStatusCmd("ACN successfully turned off")) m.acnState = offline @@ -431,23 +475,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case sendStatusMsg: cmds = append(cmds, func() tea.Msg { for _, line := range msg.lines { - logToFile(m, line) - m.statusBuffer <- line + logDebug(m, line) + m.statusBuffer.inputChannel <- line } return nil }) case renderStatusMsg: - m.statusViewportLines = append(m.statusViewportLines, renderLines(m.width, msg.line)...) - m.statusViewport.SetContent(strings.Join(m.statusViewportLines, "\n")) - m.statusViewport.GotoBottom() + m.statusBuffer.viewportLines = append(m.statusBuffer.viewportLines, renderLines(m.width, msg.line)...) + m.statusBuffer.viewport.SetContent(strings.Join(m.statusBuffer.viewportLines, "\n")) + m.statusBuffer.viewport.GotoBottom() cmds = append(cmds, m.receiveStatusCmd) case clearScreenMsg: m.input.Reset() - m.statusViewportLines = []string{} - m.statusViewport.SetContent("") - m.statusViewport.GotoTop() + m.statusBuffer.viewportLines = []string{} + m.statusBuffer.viewport.SetContent("") + m.statusBuffer.viewport.GotoTop() } return m, tea.Batch(cmds...) @@ -459,10 +503,10 @@ func (m model) View() string { var chosenViewport viewport.Model switch m.menuState { case 0: - chosenViewport = m.statusViewport + chosenViewport = m.statusBuffer.viewport default: if m.menuState > len(m.profiles) { - chosenViewport = m.statusViewport + chosenViewport = m.statusBuffer.viewport break } chosenViewport = m.profiles[m.menuState-1].statusViewport