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"`
44 func (cp *ClientPrefs) IconBytes() []byte {
45 iconBytes := make([]byte, 2)
46 binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID))
50 func (cp *ClientPrefs) AddBookmark(name, addr, login, pass string) error {
51 cp.Bookmarks = append(cp.Bookmarks, Bookmark{Addr: addr, Login: login, Password: pass})
56 func readConfig(cfgPath string) (*ClientPrefs, error) {
57 fh, err := os.Open(cfgPath)
62 prefs := ClientPrefs{}
63 decoder := yaml.NewDecoder(fh)
64 decoder.SetStrict(true)
65 if err := decoder.Decode(&prefs); err != nil {
83 Logger *zap.SugaredLogger
84 activeTasks map[uint32]*Transaction
89 Handlers map[uint16]clientTHandler
93 outbox chan *Transaction
94 Inbox chan *Transaction
97 func NewClient(cfgPath string, logger *zap.SugaredLogger) *Client {
101 activeTasks: make(map[uint32]*Transaction),
102 Handlers: clientHandlers,
106 prefs, err := readConfig(cfgPath)
108 fmt.Printf("unable to read config file %s", cfgPath)
116 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
117 type DebugBuffer struct {
118 TextView *tview.TextView
121 func (db *DebugBuffer) Write(p []byte) (int, error) {
122 return db.TextView.Write(p)
125 // Sync is a noop function that exists to satisfy the zapcore.WriteSyncer interface
126 func (db *DebugBuffer) Sync() error {
130 func randomBanner() string {
131 rand.Seed(time.Now().UnixNano())
133 bannerFiles, _ := bannerDir.ReadDir("banners")
134 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
136 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
139 type clientTransaction struct {
141 Handler func(*Client, *Transaction) ([]Transaction, error)
144 func (ch clientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
145 return ch.Handler(cc, t)
148 type clientTHandler interface {
149 Handle(*Client, *Transaction) ([]Transaction, error)
152 type mockClientHandler struct {
156 func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
157 args := mh.Called(cc, t)
158 return args.Get(0).([]Transaction), args.Error(1)
161 var clientHandlers = map[uint16]clientTHandler{
163 tranChatMsg: clientTransaction{
165 Handler: handleClientChatMsg,
167 tranLogin: clientTransaction{
169 Handler: handleClientTranLogin,
171 tranShowAgreement: clientTransaction{
172 Name: "tranShowAgreement",
173 Handler: handleClientTranShowAgreement,
175 tranUserAccess: clientTransaction{
176 Name: "tranUserAccess",
177 Handler: handleClientTranUserAccess,
179 tranGetUserNameList: clientTransaction{
180 Name: "tranGetUserNameList",
181 Handler: handleClientGetUserNameList,
183 tranNotifyChangeUser: clientTransaction{
184 Name: "tranNotifyChangeUser",
185 Handler: handleNotifyChangeUser,
187 tranNotifyDeleteUser: clientTransaction{
188 Name: "tranNotifyDeleteUser",
189 Handler: handleNotifyDeleteUser,
191 tranGetMsgs: clientTransaction{
192 Name: "tranNotifyDeleteUser",
193 Handler: handleGetMsgs,
195 tranGetFileNameList: clientTransaction{
196 Name: "tranGetFileNameList",
197 Handler: handleGetFileNameList,
199 tranServerMsg: clientTransaction{
200 Name: "tranServerMsg",
201 Handler: handleTranServerMsg,
205 func handleTranServerMsg(c *Client, t *Transaction) (res []Transaction, err error) {
206 time := time.Now().Format(time.RFC850)
208 msg := strings.ReplaceAll(string(t.GetField(fieldData).Data), "\r", "\n")
209 msg += "\n\nAt " + time
210 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(fieldUserName).Data)
212 msgBox := tview.NewTextView().SetScrollable(true)
213 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
214 msgBox.SetTitle(title).SetBorder(true)
215 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
217 case tcell.KeyEscape:
218 c.UI.Pages.RemovePage("serverMsgModal" + time)
223 centeredFlex := tview.NewFlex().
224 AddItem(nil, 0, 1, false).
225 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
226 AddItem(nil, 0, 1, false).
227 AddItem(msgBox, 0, 2, true).
228 AddItem(nil, 0, 1, false), 0, 2, true).
229 AddItem(nil, 0, 1, false)
231 c.UI.Pages.AddPage("serverMsgModal"+time, centeredFlex, true, true)
232 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
237 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
238 fTree := tview.NewTreeView().SetTopLevel(1)
239 root := tview.NewTreeNode("Root")
240 fTree.SetRoot(root).SetCurrentNode(root)
241 fTree.SetBorder(true).SetTitle("| Files |")
242 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
244 case tcell.KeyEscape:
245 c.UI.Pages.RemovePage("files")
246 c.filePath = []string{}
248 selectedNode := fTree.GetCurrentNode()
250 if selectedNode.GetText() == "<- Back" {
251 c.filePath = c.filePath[:len(c.filePath)-1]
252 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
254 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
255 c.UI.HLClient.Logger.Errorw("err", "err", err)
260 entry := selectedNode.GetReference().(*FileNameWithInfo)
262 if bytes.Equal(entry.Type, []byte("fldr")) {
263 c.Logger.Infow("get new directory listing", "name", string(entry.Name))
265 c.filePath = append(c.filePath, string(entry.Name))
266 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
268 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
269 c.UI.HLClient.Logger.Errorw("err", "err", err)
272 // TODO: initiate file download
273 c.Logger.Infow("download file", "name", string(entry.Name))
280 if len(c.filePath) > 0 {
281 node := tview.NewTreeNode("<- Back")
285 for _, f := range t.Fields {
286 var fn FileNameWithInfo
287 _, _ = fn.Read(f.Data)
289 if bytes.Equal(fn.Type, []byte("fldr")) {
290 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.Name))
291 node.SetReference(&fn)
294 size := binary.BigEndian.Uint32(fn.FileSize) / 1024
296 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.Name, size))
297 node.SetReference(&fn)
303 centerFlex := tview.NewFlex().
304 AddItem(nil, 0, 1, false).
305 AddItem(tview.NewFlex().
306 SetDirection(tview.FlexRow).
307 AddItem(nil, 0, 1, false).
308 AddItem(fTree, 20, 1, true).
309 AddItem(nil, 0, 1, false), 60, 1, true).
310 AddItem(nil, 0, 1, false)
312 c.UI.Pages.AddPage("files", centerFlex, true, true)
318 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
319 newsText := string(t.GetField(fieldData).Data)
320 newsText = strings.ReplaceAll(newsText, "\r", "\n")
322 newsTextView := tview.NewTextView().
324 SetDoneFunc(func(key tcell.Key) {
325 c.UI.Pages.SwitchToPage(serverUIPage)
326 c.UI.App.SetFocus(c.UI.chatInput)
328 newsTextView.SetBorder(true).SetTitle("News")
330 c.UI.Pages.AddPage("news", newsTextView, true, true)
331 //c.UI.Pages.SwitchToPage("news")
332 //c.UI.App.SetFocus(newsTextView)
338 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
340 ID: t.GetField(fieldUserID).Data,
341 Name: string(t.GetField(fieldUserName).Data),
342 Icon: t.GetField(fieldUserIconID).Data,
343 Flags: t.GetField(fieldUserFlags).Data,
347 // user is new to the server
348 // user is already on the server but has a new name
351 var newUserList []User
353 for _, u := range c.UserList {
354 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
355 if bytes.Equal(newUser.ID, u.ID) {
357 u.Name = newUser.Name
358 if u.Name != newUser.Name {
359 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
363 newUserList = append(newUserList, u)
367 newUserList = append(newUserList, newUser)
370 c.UserList = newUserList
377 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
378 exitUser := t.GetField(fieldUserID).Data
380 var newUserList []User
381 for _, u := range c.UserList {
382 if !bytes.Equal(exitUser, u.ID) {
383 newUserList = append(newUserList, u)
387 c.UserList = newUserList
394 const readBuffSize = 1024000 // 1KB - TODO: what should this be?
396 func (c *Client) ReadLoop() error {
397 tranBuff := make([]byte, 0)
399 // Infinite loop where take action on incoming client requests until the connection is closed
401 buf := make([]byte, readBuffSize)
402 tranBuff = tranBuff[tReadlen:]
404 readLen, err := c.Connection.Read(buf)
408 tranBuff = append(tranBuff, buf[:readLen]...)
410 // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them
411 // into a slice of transactions
412 var transactions []Transaction
413 if transactions, tReadlen, err = readTransactions(tranBuff); err != nil {
414 c.Logger.Errorw("Error handling transaction", "err", err)
417 // iterate over all of the transactions that were parsed from the byte slice and handle them
418 for _, t := range transactions {
419 if err := c.HandleTransaction(&t); err != nil {
420 c.Logger.Errorw("Error handling transaction", "err", err)
426 func (c *Client) GetTransactions() error {
427 tranBuff := make([]byte, 0)
430 buf := make([]byte, readBuffSize)
431 tranBuff = tranBuff[tReadlen:]
433 readLen, err := c.Connection.Read(buf)
437 tranBuff = append(tranBuff, buf[:readLen]...)
442 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
444 for _, field := range t.Fields {
445 // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300)
446 // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected
447 // field type. Probably a good idea to do everywhere.
448 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
449 u, err := ReadUser(field.Data)
453 users = append(users, *u)
463 func (c *Client) renderUserList() {
464 c.UI.userList.Clear()
465 for _, u := range c.UserList {
466 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
467 if flagBitmap.Bit(userFlagAdmin) == 1 {
468 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
470 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
472 // TODO: fade if user is away
476 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
477 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
482 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
483 c.UserAccess = t.GetField(fieldUserAccess).Data
488 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
489 agreement := string(t.GetField(fieldData).Data)
490 agreement = strings.ReplaceAll(agreement, "\r", "\n")
492 c.UI.agreeModal = tview.NewModal().
494 AddButtons([]string{"Agree", "Disagree"}).
495 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
496 if buttonIndex == 0 {
500 NewField(fieldUserName, []byte(c.pref.Username)),
501 NewField(fieldUserIconID, c.pref.IconBytes()),
502 NewField(fieldUserFlags, []byte{0x00, 0x00}),
503 NewField(fieldOptions, []byte{0x00, 0x00}),
506 c.UI.Pages.HidePage("agreement")
507 c.UI.App.SetFocus(c.UI.chatInput)
510 c.UI.Pages.SwitchToPage("home")
515 c.Logger.Debug("show agreement page")
516 c.UI.Pages.AddPage("agreement", c.UI.agreeModal, false, true)
517 c.UI.Pages.ShowPage("agreement ")
523 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
524 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
525 errMsg := string(t.GetField(fieldError).Data)
526 errModal := tview.NewModal()
527 errModal.SetText(errMsg)
528 errModal.AddButtons([]string{"Oh no"})
529 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
530 c.UI.Pages.RemovePage("errModal")
532 c.UI.Pages.RemovePage("joinServer")
533 c.UI.Pages.AddPage("errModal", errModal, false, true)
535 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
537 c.Logger.Error(string(t.GetField(fieldError).Data))
538 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
540 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
541 c.UI.App.SetFocus(c.UI.chatInput)
543 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
544 c.Logger.Errorw("err", "err", err)
549 // JoinServer connects to a Hotline server and completes the login flow
550 func (c *Client) JoinServer(address, login, passwd string) error {
551 // Establish TCP connection to server
552 if err := c.connect(address); err != nil {
556 // Send handshake sequence
557 if err := c.Handshake(); err != nil {
561 // Authenticate (send tranLogin 107)
562 if err := c.LogIn(login, passwd); err != nil {
569 // connect establishes a connection with a Server by sending handshake sequence
570 func (c *Client) connect(address string) error {
572 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
579 var ClientHandshake = []byte{
580 0x54, 0x52, 0x54, 0x50, // TRTP
581 0x48, 0x4f, 0x54, 0x4c, // HOTL
586 var ServerHandshake = []byte{
587 0x54, 0x52, 0x54, 0x50, // TRTP
588 0x00, 0x00, 0x00, 0x00, // ErrorCode
591 func (c *Client) Handshake() error {
592 //Protocol ID 4 ‘TRTP’ 0x54 52 54 50
593 //Sub-protocol ID 4 User defined
594 //Version 2 1 Currently 1
595 //Sub-version 2 User defined
596 if _, err := c.Connection.Write(ClientHandshake); err != nil {
597 return fmt.Errorf("handshake write err: %s", err)
600 replyBuf := make([]byte, 8)
601 _, err := c.Connection.Read(replyBuf)
606 if bytes.Compare(replyBuf, ServerHandshake) == 0 {
610 // In the case of an error, client and server close the connection.
611 return fmt.Errorf("handshake response err: %s", err)
614 func (c *Client) LogIn(login string, password string) error {
618 NewField(fieldUserName, []byte(c.pref.Username)),
619 NewField(fieldUserIconID, c.pref.IconBytes()),
620 NewField(fieldUserLogin, negateString([]byte(login))),
621 NewField(fieldUserPassword, negateString([]byte(password))),
622 NewField(fieldVersion, []byte{0, 2}),
627 func (c *Client) Send(t Transaction) error {
628 requestNum := binary.BigEndian.Uint16(t.Type)
629 tID := binary.BigEndian.Uint32(t.ID)
631 //handler := TransactionHandlers[requestNum]
633 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
635 c.activeTasks[tID] = &t
640 if n, err = c.Connection.Write(t.Payload()); err != nil {
643 c.Logger.Debugw("Sent Transaction",
644 "IsReply", t.IsReply,
651 func (c *Client) HandleTransaction(t *Transaction) error {
652 var origT Transaction
654 requestID := binary.BigEndian.Uint32(t.ID)
655 origT = *c.activeTasks[requestID]
659 requestNum := binary.BigEndian.Uint16(t.Type)
661 "Received Transaction",
662 "RequestType", requestNum,
665 if handler, ok := c.Handlers[requestNum]; ok {
666 outT, _ := handler.Handle(c, t)
667 for _, t := range outT {
672 "Unimplemented transaction type received",
673 "RequestID", requestNum,
674 "TransactionID", t.ID,
681 func (c *Client) Connected() bool {
682 // c.Agreed == true &&
683 if c.UserAccess != nil {
689 func (c *Client) Disconnect() error {
690 err := c.Connection.Close()