package main import ( "crypto/rand" "encoding/base64" "flag" "fmt" "log" mrand "math/rand" "os" "os/exec" "os/user" "path" "path/filepath" "strings" "time" "cwtch.im/cwtch/app" "cwtch.im/cwtch/model/attr" "cwtch.im/cwtch/model/constants" "cwtch.im/cwtch/protocol/connections" "cwtch.im/cwtch/settings" "git.openprivacy.ca/openprivacy/connectivity" "git.openprivacy.ca/openprivacy/connectivity/tor" openPrivacyLog "git.openprivacy.ca/openprivacy/log" _ "github.com/mutecomm/go-sqlcipher/v4" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) const ( offline = iota connecting connected help = `cairde [options] A text-based user interface for metadata resistant online chat. Options: -h output help ` cmdHelp = `/clear /connect /help /profile create /profile info /profile unlock /quit` ) func main() { var helpFlag bool flag.BoolVar(&helpFlag, "h", false, "output help") flag.Parse() if helpFlag { fmt.Print(help) os.Exit(0) } // NOTE(d1): pending https://git.coopcloud.tech/decentral1se/cairde/issues/1 _, err := exec.LookPath("tor") if err != nil { log.Fatal("could not find 'tor' command, is it installed?") } f, err := tea.LogToFile("cairde.log", "debug") if err != nil { log.Fatal(err) } defer f.Close() filelogger, err := openPrivacyLog.NewFile(openPrivacyLog.LevelInfo, "cwtch.log") if err == nil { openPrivacyLog.SetStd(filelogger) } user, err := user.Current() if err != nil { log.Fatalf("unable to determine current user: %s", err) } p := tea.NewProgram( newModel(user.Username, user.HomeDir), tea.WithAltScreen(), tea.WithMouseCellMotion(), ) if err := p.Start(); err != nil { log.Fatal(err) } } var ( mainStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()) viewportStyle = lipgloss.NewStyle() profileNameButtonStyle = lipgloss.NewStyle() channelBarStyle = lipgloss.NewStyle() inputStyle = lipgloss.NewStyle() ) type profile struct { name string onion string } type model struct { username string userDir string connState int app app.Application acn connectivity.ACN width int height int viewport viewport.Model profiles []profile profileState int input textinput.Model hiddenInput string statusBuffer chan string } func newModel(username, homeDir string) model { input := textinput.New() input.Prompt = "" input.SetCursorMode(textinput.CursorStatic) input.Focus() return model{ username: username, userDir: path.Join(homeDir, "/.cairde/"), connState: offline, input: input, statusBuffer: make(chan string), } } type errMsg struct{ Err error } func (e errMsg) Error() string { return e.Err.Error() } type appInitialisedMsg struct { app app.Application acn connectivity.ACN } func (m model) initApp() tea.Cmd { return func() tea.Msg { mrand.Seed(int64(time.Now().Nanosecond())) port := mrand.Intn(1000) + 9600 controlPort := port + 1 key := make([]byte, 64) _, err := rand.Read(key) if err != nil { return errMsg{Err: fmt.Errorf("unable to generate control port password: %s", err)} } if err := os.MkdirAll(path.Join(m.userDir, "/.tor", "tor"), 0700); err != nil { return errMsg{Err: fmt.Errorf("unable to create tor directory: %s", err)} } if err := os.MkdirAll(path.Join(m.userDir, "profiles"), 0700); err != nil { return errMsg{Err: fmt.Errorf("unable to create profiles directory: %s", err)} } if err := tor.NewTorrc(). WithSocksPort(port). WithOnionTrafficOnly(). WithControlPort(controlPort). WithHashedPassword(base64.StdEncoding.EncodeToString(key)). Build(filepath.Join(m.userDir, ".tor", "tor", "torrc")); err != nil { return errMsg{Err: fmt.Errorf("unable to initialise torrc builder: %s", err)} } m.statusBuffer <- "initialising Tor ACN..." acn, err := tor.NewTorACNWithAuth( path.Join(m.userDir, "/.tor"), "", path.Join(m.userDir, "/.tor", "data"), controlPort, tor.HashedPasswordAuthenticator{ Password: base64.StdEncoding.EncodeToString(key), }, ) if err != nil { return errMsg{Err: fmt.Errorf("unable to bootstrap tor: %s", err)} } m.statusBuffer <- "waiting for Tor ACN to bootstrap..." if err := acn.WaitTillBootstrapped(); err != nil { return errMsg{Err: fmt.Errorf("unable to initialise tor: %s", err)} } settingsFile, err := settings.InitGlobalSettingsFile(m.userDir, "") if err != nil { return errMsg{Err: fmt.Errorf("unable to initialise settings: %s", err)} } gSettings := settingsFile.ReadGlobalSettings() gSettings.ExperimentsEnabled = false gSettings.DownloadPath = "./" settingsFile.WriteGlobalSettings(gSettings) app := app.NewApp(acn, m.userDir, settingsFile) engineHooks := connections.DefaultEngineHooks{} app.InstallEngineHooks(engineHooks) m.statusBuffer <- "the Tor ACN is up" return appInitialisedMsg{ app: app, acn: acn, } } } type statusMsg struct{ msg string } func (m model) handleStatusMessage() tea.Cmd { return func() tea.Msg { return statusMsg{msg: <-m.statusBuffer} } } func (m model) Init() tea.Cmd { return tea.Batch( textinput.Blink, m.handleStatusMessage(), ) } func (m model) isConnecting() bool { if m.connState == connecting { return true } return false } func (m model) isOffline() bool { if m.connState == offline { return true } return false } func (m model) shutdown() bool { if m.isConnecting() { return false } if m.isOffline() { return true } m.app.Shutdown() m.acn.Close() close(m.statusBuffer) return true } type cmdMsg struct { output string } type shutdownMsg struct{} type createProfileMsg struct { name string password string } type profileUnlockMsg struct { password string } type connectMsg struct{} type clearMsg struct{} type profileInfoMsg struct{} func isHelpCmd(cmds []string) bool { if cmds[0] == "help" { return true } return false } func isQuitCmd(cmds []string) bool { if cmds[0] == "quit" { return true } return false } func isProfileCreateCmd(cmds []string) bool { if cmds[0] == "profile" && cmds[1] == "create" { return true } return false } func isProfileUnlockCmd(cmds []string) bool { if cmds[0] == "profile" && cmds[1] == "unlock" { return true } return false } func isProfileInfoCmd(cmds []string) bool { if cmds[0] == "profile" && cmds[1] == "info" { return true } return false } func isConnectCmd(cmds []string) bool { if cmds[0] == "connect" { return true } return false } func isClearCmd(cmds []string) bool { if cmds[0] == "clear" { return true } return false } func handleCommand(cmd, hiddenInput string) tea.Msg { cmds := strings.Split(cmd, " ") switch { case isHelpCmd(cmds): return cmdMsg{output: cmdHelp} case isQuitCmd(cmds): return shutdownMsg{} case isProfileCreateCmd(cmds): if len(cmds) != 5 { return cmdMsg{output: cmdHelp} } passwords := strings.Split(strings.TrimSpace(hiddenInput), " ") if passwords[0] != passwords[1] { return cmdMsg{output: "profile create: passwords do not match?"} } return createProfileMsg{ name: cmds[2], password: passwords[1], } case isProfileUnlockCmd(cmds): if len(cmds) != 3 { return cmdMsg{output: cmdHelp} } return profileUnlockMsg{ password: strings.TrimSpace(hiddenInput), } case isProfileInfoCmd(cmds): return profileInfoMsg{} case isConnectCmd(cmds): return connectMsg{} case isClearCmd(cmds): return clearMsg{} default: return cmdMsg{output: "unknown command"} } } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( cmd tea.Cmd cmds []tea.Cmd ) switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width - 2 m.height = msg.Height - 2 m.viewport = viewport.New( m.width, m.height-3, ) case tea.KeyMsg: switch msg.String() { case "ctrl+c": if m.shutdown() { return m, tea.Quit } case "enter": val := m.input.Value() if len(val) == 0 { break } if string(val[0]) == "/" { cmds = append(cmds, func() tea.Msg { return handleCommand(val[1:], m.hiddenInput) }) } default: val := m.input.Value() if len(val) != 0 { if string(val[0]) == "/" { cmds := strings.Split(val[1:], " ") if cmds[0] == "profile" && len(cmds) >= 2 { if cmds[1] == "create" && len(cmds) > 3 || cmds[1] == "unlock" && len(cmds) > 2 { lastChar := string(val[len(val)-1]) m.hiddenInput += lastChar if lastChar != " " { newCmd := []rune(val) newCmd[len(val)-1] = '*' m.input.SetValue(string(newCmd)) } } } } } } case cmdMsg: m.input.Reset() m.statusBuffer <- msg.output case shutdownMsg: m.input.Reset() if m.shutdown() { return m, tea.Quit } case createProfileMsg: m.hiddenInput = "" m.input.Reset() m.app.CreateProfile(msg.name, msg.password, false) m.statusBuffer <- fmt.Sprintf("created new profile: %s", msg.name) profiles := m.app.ListProfiles() // TODO m.profiles = append(m.profiles, profile{ name: msg.name, onion: profiles[len(profiles)-1], }) case profileUnlockMsg: m.hiddenInput = "" m.input.Reset() switch m.connState { case offline: m.statusBuffer <- "offline, run /connect first?" break case connecting: m.statusBuffer <- "still connecting, hold on a sec..." break } m.app.LoadProfiles(msg.password) var unlocked []profile for _, onion := range m.app.ListProfiles() { peer := m.app.GetPeer(onion) name, _ := peer.GetScopedZonedAttribute(attr.PublicScope, attr.ProfileZone, constants.Name) unlocked = append(unlocked, profile{name: name, onion: onion}) } if len(unlocked) > 0 { var names []string for _, p := range unlocked { names = append(names, p.name) } m.statusBuffer <- fmt.Sprintf("unlocked profile(s): %s", strings.Join(names, " ")) } m.profiles = unlocked case profileInfoMsg: m.input.Reset() if m.connState != connected { m.statusBuffer <- "offline, run /connect first?" break } if len(m.profiles) == 0 { m.statusBuffer <- "no profiles loaded" break } profile := m.profiles[m.profileState] m.statusBuffer <- fmt.Sprintf("profile onion: %s", profile.onion) case statusMsg: existing := m.viewport.View() m.viewport.SetContent(existing + "\n" + msg.msg) m.viewport.GotoBottom() cmds = append(cmds, m.handleStatusMessage()) case clearMsg: m.input.Reset() m.viewport.SetContent("") m.viewport.GotoBottom() case connectMsg: m.input.Reset() cmds = append(cmds, m.initApp()) case appInitialisedMsg: m.app = msg.app m.acn = msg.acn m.connState = connected m.statusBuffer <- "create or unlock a profile to get started" m.statusBuffer <- "run /help to see the command listing" case errMsg: m.statusBuffer <- msg.Error() } m.input, cmd = m.input.Update(msg) cmds = append(cmds, cmd) m.viewport, cmd = m.viewport.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m model) View() string { body := strings.Builder{} channelBar := channelBarStyle. Width(m.width). AlignVertical(lipgloss.Center). Render(lipgloss.NewStyle().Background(lipgloss.Color("14")).Foreground(lipgloss.Color("#fffff")).Render("N/A")) statusButton := profileNameButtonStyle. Width(m.width). AlignVertical(lipgloss.Center). Render(lipgloss.NewStyle().Background(lipgloss.Color("26")).Foreground(lipgloss.Color("#fffff")).Render("status")) var profileNames []string for _, profile := range m.profiles { rendered := profileNameButtonStyle.Render(profile.name) profileNames = append(profileNames, rendered) } buttonBar := []string{statusButton} buttonBar = append(buttonBar, profileNames...) body.WriteString( mainStyle. Width(m.width). Height(m.height). Render( lipgloss.JoinVertical( lipgloss.Top, viewportStyle. Width(m.width). Render(m.viewport.View()), lipgloss.JoinHorizontal( lipgloss.Left, channelBar, ), lipgloss.JoinHorizontal( lipgloss.Left, buttonBar..., ), lipgloss.PlaceVertical( 1, lipgloss.Bottom, inputStyle. Width(m.width). Render(m.input.View()), ), ), ), ) return body.String() }