9 "github.com/gdamore/tcell/v2"
10 "github.com/rivo/tview"
11 "github.com/stretchr/testify/mock"
23 trackerListPage = "trackerList"
24 serverUIPage = "serverUI"
27 //go:embed banners/*.txt
28 var bannerDir embed.FS
30 type Bookmark struct {
31 Name string `yaml:"Name"`
32 Addr string `yaml:"Addr"`
33 Login string `yaml:"Login"`
34 Password string `yaml:"Password"`
37 type ClientPrefs struct {
38 Username string `yaml:"Username"`
39 IconID int `yaml:"IconID"`
40 Bookmarks []Bookmark `yaml:"Bookmarks"`
41 Tracker string `yaml:"Tracker"`
42 EnableBell bool `yaml:"EnableBell"`
45 func (cp *ClientPrefs) IconBytes() []byte {
46 iconBytes := make([]byte, 2)
47 binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID))
51 func (cp *ClientPrefs) AddBookmark(name, addr, login, pass string) error {
52 cp.Bookmarks = append(cp.Bookmarks, Bookmark{Addr: addr, Login: login, Password: pass})
57 func readConfig(cfgPath string) (*ClientPrefs, error) {
58 fh, err := os.Open(cfgPath)
63 prefs := ClientPrefs{}
64 decoder := yaml.NewDecoder(fh)
65 if err := decoder.Decode(&prefs); err != nil {
83 Logger *zap.SugaredLogger
84 activeTasks map[uint32]*Transaction
89 Handlers map[uint16]clientTHandler
93 Inbox chan *Transaction
96 func NewClient(cfgPath string, logger *zap.SugaredLogger) *Client {
100 activeTasks: make(map[uint32]*Transaction),
101 Handlers: clientHandlers,
105 prefs, err := readConfig(cfgPath)
107 logger.Fatal(fmt.Sprintf("unable to read config file %s\n", cfgPath))
114 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
115 type DebugBuffer struct {
116 TextView *tview.TextView
119 func (db *DebugBuffer) Write(p []byte) (int, error) {
120 return db.TextView.Write(p)
123 // Sync is a noop function that dataFile to satisfy the zapcore.WriteSyncer interface
124 func (db *DebugBuffer) Sync() error {
128 func randomBanner() string {
129 rand.Seed(time.Now().UnixNano())
131 bannerFiles, _ := bannerDir.ReadDir("banners")
132 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
134 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
137 type clientTransaction struct {
139 Handler func(*Client, *Transaction) ([]Transaction, error)
142 func (ch clientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
143 return ch.Handler(cc, t)
146 type clientTHandler interface {
147 Handle(*Client, *Transaction) ([]Transaction, error)
150 type mockClientHandler struct {
154 func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
155 args := mh.Called(cc, t)
156 return args.Get(0).([]Transaction), args.Error(1)
159 var clientHandlers = map[uint16]clientTHandler{
161 tranChatMsg: clientTransaction{
163 Handler: handleClientChatMsg,
165 tranLogin: clientTransaction{
167 Handler: handleClientTranLogin,
169 tranShowAgreement: clientTransaction{
170 Name: "tranShowAgreement",
171 Handler: handleClientTranShowAgreement,
173 tranUserAccess: clientTransaction{
174 Name: "tranUserAccess",
175 Handler: handleClientTranUserAccess,
177 tranGetUserNameList: clientTransaction{
178 Name: "tranGetUserNameList",
179 Handler: handleClientGetUserNameList,
181 tranNotifyChangeUser: clientTransaction{
182 Name: "tranNotifyChangeUser",
183 Handler: handleNotifyChangeUser,
185 tranNotifyDeleteUser: clientTransaction{
186 Name: "tranNotifyDeleteUser",
187 Handler: handleNotifyDeleteUser,
189 tranGetMsgs: clientTransaction{
190 Name: "tranNotifyDeleteUser",
191 Handler: handleGetMsgs,
193 tranGetFileNameList: clientTransaction{
194 Name: "tranGetFileNameList",
195 Handler: handleGetFileNameList,
197 tranServerMsg: clientTransaction{
198 Name: "tranServerMsg",
199 Handler: handleTranServerMsg,
201 tranKeepAlive: clientTransaction{
202 Name: "tranKeepAlive",
203 Handler: func(client *Client, transaction *Transaction) (t []Transaction, err error) {
209 func handleTranServerMsg(c *Client, t *Transaction) (res []Transaction, err error) {
210 time := time.Now().Format(time.RFC850)
212 msg := strings.ReplaceAll(string(t.GetField(fieldData).Data), "\r", "\n")
213 msg += "\n\nAt " + time
214 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(fieldUserName).Data)
216 msgBox := tview.NewTextView().SetScrollable(true)
217 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
218 msgBox.SetTitle(title).SetBorder(true)
219 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
221 case tcell.KeyEscape:
222 c.UI.Pages.RemovePage("serverMsgModal" + time)
227 centeredFlex := tview.NewFlex().
228 AddItem(nil, 0, 1, false).
229 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
230 AddItem(nil, 0, 1, false).
231 AddItem(msgBox, 0, 2, true).
232 AddItem(nil, 0, 1, false), 0, 2, true).
233 AddItem(nil, 0, 1, false)
235 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
236 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
241 func (c *Client) showErrMsg(msg string) {
242 time := time.Now().Format(time.RFC850)
246 msgBox := tview.NewTextView().SetScrollable(true)
247 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkRed)
248 msgBox.SetTitle(title).SetBorder(true)
249 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
251 case tcell.KeyEscape:
252 c.UI.Pages.RemovePage("serverMsgModal" + time)
257 centeredFlex := tview.NewFlex().
258 AddItem(nil, 0, 1, false).
259 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
260 AddItem(nil, 0, 1, false).
261 AddItem(msgBox, 0, 2, true).
262 AddItem(nil, 0, 1, false), 0, 2, true).
263 AddItem(nil, 0, 1, false)
265 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
266 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
269 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
271 c.showErrMsg(string(t.GetField(fieldError).Data))
272 c.Logger.Infof("Error: %s", t.GetField(fieldError).Data)
276 fTree := tview.NewTreeView().SetTopLevel(1)
277 root := tview.NewTreeNode("Root")
278 fTree.SetRoot(root).SetCurrentNode(root)
279 fTree.SetBorder(true).SetTitle("| Files |")
280 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
282 case tcell.KeyEscape:
283 c.UI.Pages.RemovePage("files")
284 c.filePath = []string{}
286 selectedNode := fTree.GetCurrentNode()
288 if selectedNode.GetText() == "<- Back" {
289 c.filePath = c.filePath[:len(c.filePath)-1]
290 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
292 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
293 c.UI.HLClient.Logger.Errorw("err", "err", err)
298 entry := selectedNode.GetReference().(*FileNameWithInfo)
300 if bytes.Equal(entry.Type[:], []byte("fldr")) {
301 c.Logger.Infow("get new directory listing", "name", string(entry.name))
303 c.filePath = append(c.filePath, string(entry.name))
304 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
306 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
307 c.UI.HLClient.Logger.Errorw("err", "err", err)
310 // TODO: initiate file download
311 c.Logger.Infow("download file", "name", string(entry.name))
318 if len(c.filePath) > 0 {
319 node := tview.NewTreeNode("<- Back")
323 for _, f := range t.Fields {
324 var fn FileNameWithInfo
325 err = fn.UnmarshalBinary(f.Data)
330 if bytes.Equal(fn.Type[:], []byte("fldr")) {
331 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name))
332 node.SetReference(&fn)
335 size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024
337 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.name, size))
338 node.SetReference(&fn)
344 centerFlex := tview.NewFlex().
345 AddItem(nil, 0, 1, false).
346 AddItem(tview.NewFlex().
347 SetDirection(tview.FlexRow).
348 AddItem(nil, 0, 1, false).
349 AddItem(fTree, 20, 1, true).
350 AddItem(nil, 0, 1, false), 60, 1, true).
351 AddItem(nil, 0, 1, false)
353 c.UI.Pages.AddPage("files", centerFlex, true, true)
359 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
360 newsText := string(t.GetField(fieldData).Data)
361 newsText = strings.ReplaceAll(newsText, "\r", "\n")
363 newsTextView := tview.NewTextView().
365 SetDoneFunc(func(key tcell.Key) {
366 c.UI.Pages.SwitchToPage(serverUIPage)
367 c.UI.App.SetFocus(c.UI.chatInput)
369 newsTextView.SetBorder(true).SetTitle("News")
371 c.UI.Pages.AddPage("news", newsTextView, true, true)
372 // c.UI.Pages.SwitchToPage("news")
373 // c.UI.App.SetFocus(newsTextView)
379 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
381 ID: t.GetField(fieldUserID).Data,
382 Name: string(t.GetField(fieldUserName).Data),
383 Icon: t.GetField(fieldUserIconID).Data,
384 Flags: t.GetField(fieldUserFlags).Data,
388 // user is new to the server
389 // user is already on the server but has a new name
392 var newUserList []User
394 for _, u := range c.UserList {
395 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
396 if bytes.Equal(newUser.ID, u.ID) {
398 u.Name = newUser.Name
399 if u.Name != newUser.Name {
400 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
404 newUserList = append(newUserList, u)
408 newUserList = append(newUserList, newUser)
411 c.UserList = newUserList
418 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
419 exitUser := t.GetField(fieldUserID).Data
421 var newUserList []User
422 for _, u := range c.UserList {
423 if !bytes.Equal(exitUser, u.ID) {
424 newUserList = append(newUserList, u)
428 c.UserList = newUserList
435 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
437 for _, field := range t.Fields {
438 // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300)
439 // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected
440 // field type. Probably a good idea to do everywhere.
441 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
442 u, err := ReadUser(field.Data)
446 users = append(users, *u)
456 func (c *Client) renderUserList() {
457 c.UI.userList.Clear()
458 for _, u := range c.UserList {
459 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
460 if flagBitmap.Bit(userFlagAdmin) == 1 {
461 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
463 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
465 // TODO: fade if user is away
469 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
470 if c.pref.EnableBell {
474 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
479 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
480 c.UserAccess = t.GetField(fieldUserAccess).Data
485 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
486 agreement := string(t.GetField(fieldData).Data)
487 agreement = strings.ReplaceAll(agreement, "\r", "\n")
489 agreeModal := tview.NewModal().
491 AddButtons([]string{"Agree", "Disagree"}).
492 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
493 if buttonIndex == 0 {
497 NewField(fieldUserName, []byte(c.pref.Username)),
498 NewField(fieldUserIconID, c.pref.IconBytes()),
499 NewField(fieldUserFlags, []byte{0x00, 0x00}),
500 NewField(fieldOptions, []byte{0x00, 0x00}),
503 c.UI.Pages.HidePage("agreement")
504 c.UI.App.SetFocus(c.UI.chatInput)
507 c.UI.Pages.SwitchToPage("home")
512 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
517 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
518 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
519 errMsg := string(t.GetField(fieldError).Data)
520 errModal := tview.NewModal()
521 errModal.SetText(errMsg)
522 errModal.AddButtons([]string{"Oh no"})
523 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
524 c.UI.Pages.RemovePage("errModal")
526 c.UI.Pages.RemovePage("joinServer")
527 c.UI.Pages.AddPage("errModal", errModal, false, true)
529 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
531 c.Logger.Error(string(t.GetField(fieldError).Data))
532 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
534 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
535 c.UI.App.SetFocus(c.UI.chatInput)
537 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
538 c.Logger.Errorw("err", "err", err)
543 // JoinServer connects to a Hotline server and completes the login flow
544 func (c *Client) JoinServer(address, login, passwd string) error {
545 // Establish TCP connection to server
546 if err := c.connect(address); err != nil {
550 // Send handshake sequence
551 if err := c.Handshake(); err != nil {
555 // Authenticate (send tranLogin 107)
556 if err := c.LogIn(login, passwd); err != nil {
560 // start keepalive go routine
561 go func() { _ = c.keepalive() }()
566 func (c *Client) keepalive() error {
568 time.Sleep(300 * time.Second)
569 _ = c.Send(*NewTransaction(tranKeepAlive, nil))
570 c.Logger.Infow("Sent keepalive ping")
574 // connect establishes a connection with a Server by sending handshake sequence
575 func (c *Client) connect(address string) error {
577 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
584 var ClientHandshake = []byte{
585 0x54, 0x52, 0x54, 0x50, // TRTP
586 0x48, 0x4f, 0x54, 0x4c, // HOTL
591 var ServerHandshake = []byte{
592 0x54, 0x52, 0x54, 0x50, // TRTP
593 0x00, 0x00, 0x00, 0x00, // ErrorCode
596 func (c *Client) Handshake() error {
597 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
598 // Sub-protocol ID 4 User defined
599 // Version 2 1 Currently 1
600 // Sub-version 2 User defined
601 if _, err := c.Connection.Write(ClientHandshake); err != nil {
602 return fmt.Errorf("handshake write err: %s", err)
605 replyBuf := make([]byte, 8)
606 _, err := c.Connection.Read(replyBuf)
611 if bytes.Equal(replyBuf, ServerHandshake) {
615 // In the case of an error, client and server close the connection.
616 return fmt.Errorf("handshake response err: %s", err)
619 func (c *Client) LogIn(login string, password string) error {
623 NewField(fieldUserName, []byte(c.pref.Username)),
624 NewField(fieldUserIconID, c.pref.IconBytes()),
625 NewField(fieldUserLogin, negateString([]byte(login))),
626 NewField(fieldUserPassword, negateString([]byte(password))),
631 func (c *Client) Send(t Transaction) error {
632 requestNum := binary.BigEndian.Uint16(t.Type)
633 tID := binary.BigEndian.Uint32(t.ID)
635 // handler := TransactionHandlers[requestNum]
637 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
639 c.activeTasks[tID] = &t
644 b, err := t.MarshalBinary()
648 if n, err = c.Connection.Write(b); err != nil {
651 c.Logger.Debugw("Sent Transaction",
652 "IsReply", t.IsReply,
659 func (c *Client) HandleTransaction(t *Transaction) error {
660 var origT Transaction
662 requestID := binary.BigEndian.Uint32(t.ID)
663 origT = *c.activeTasks[requestID]
667 requestNum := binary.BigEndian.Uint16(t.Type)
668 c.Logger.Debugw("Received Transaction", "RequestType", requestNum)
670 if handler, ok := c.Handlers[requestNum]; ok {
671 outT, _ := handler.Handle(c, t)
672 for _, t := range outT {
677 "Unimplemented transaction type received",
678 "RequestID", requestNum,
679 "TransactionID", t.ID,
686 func (c *Client) Disconnect() error {
687 return c.Connection.Close()