9 "github.com/gdamore/tcell/v2"
10 "github.com/rivo/tview"
11 "github.com/stretchr/testify/mock"
23 trackerListPage = "trackerList"
26 //go:embed banners/*.txt
27 var bannerDir embed.FS
29 type Bookmark struct {
30 Name string `yaml:"Name"`
31 Addr string `yaml:"Addr"`
32 Login string `yaml:"Login"`
33 Password string `yaml:"Password"`
36 type ClientPrefs struct {
37 Username string `yaml:"Username"`
38 IconID int `yaml:"IconID"`
39 Bookmarks []Bookmark `yaml:"Bookmarks"`
40 Tracker string `yaml:"Tracker"`
43 func (cp *ClientPrefs) IconBytes() []byte {
44 iconBytes := make([]byte, 2)
45 binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID))
49 func (cp *ClientPrefs) AddBookmark(name, addr, login, pass string) error {
50 cp.Bookmarks = append(cp.Bookmarks, Bookmark{Addr: addr, Login: login, Password: pass})
55 func readConfig(cfgPath string) (*ClientPrefs, error) {
56 fh, err := os.Open(cfgPath)
61 prefs := ClientPrefs{}
62 decoder := yaml.NewDecoder(fh)
63 decoder.SetStrict(true)
64 if err := decoder.Decode(&prefs); err != nil {
82 Logger *zap.SugaredLogger
83 activeTasks map[uint32]*Transaction
87 Handlers map[uint16]clientTHandler
91 outbox chan *Transaction
92 Inbox chan *Transaction
95 func NewClient(cfgPath string, logger *zap.SugaredLogger) *Client {
99 activeTasks: make(map[uint32]*Transaction),
100 Handlers: clientHandlers,
104 prefs, err := readConfig(cfgPath)
106 fmt.Printf("unable to read config file %s", 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 exists 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,
203 func handleTranServerMsg(c *Client, t *Transaction) (res []Transaction, err error) {
204 time := time.Now().Format(time.RFC850)
206 msg := strings.ReplaceAll(string(t.GetField(fieldData).Data), "\r", "\n")
207 msg += "\n\nAt " + time
208 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(fieldUserName).Data)
210 msgBox := tview.NewTextView().SetScrollable(true)
211 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
212 msgBox.SetTitle(title).SetBorder(true)
213 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
215 case tcell.KeyEscape:
216 c.UI.Pages.RemovePage("serverMsgModal" + time)
221 centeredFlex := tview.NewFlex().
222 AddItem(nil, 0, 1, false).
223 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
224 AddItem(nil, 0, 1, false).
225 AddItem(msgBox, 0, 2, true).
226 AddItem(nil, 0, 1, false), 0, 2, true).
227 AddItem(nil, 0, 1, false)
230 c.UI.Pages.AddPage("serverMsgModal" + time, centeredFlex, true, true)
231 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
236 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
237 fTree := tview.NewTreeView().SetTopLevel(1)
238 root := tview.NewTreeNode("Root")
239 fTree.SetRoot(root).SetCurrentNode(root)
240 fTree.SetBorder(true).SetTitle("| Files |")
241 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
243 case tcell.KeyEscape:
244 c.UI.Pages.RemovePage("files")
245 c.filePath = []string{}
247 selectedNode := fTree.GetCurrentNode()
249 if selectedNode.GetText() == "<- Back" {
250 c.filePath = c.filePath[:len(c.filePath)-1]
251 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
253 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
254 c.UI.HLClient.Logger.Errorw("err", "err", err)
259 entry := selectedNode.GetReference().(*FileNameWithInfo)
261 if bytes.Equal(entry.Type, []byte("fldr")) {
262 c.Logger.Infow("get new directory listing", "name", string(entry.Name))
264 c.filePath = append(c.filePath, string(entry.Name))
265 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
267 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
268 c.UI.HLClient.Logger.Errorw("err", "err", err)
271 // TODO: initiate file download
272 c.Logger.Infow("download file", "name", string(entry.Name))
279 if len(c.filePath) > 0 {
280 node := tview.NewTreeNode("<- Back")
284 var fileList []FileNameWithInfo
285 for _, f := range t.Fields {
286 var fn FileNameWithInfo
287 _, _ = fn.Read(f.Data)
288 fileList = append(fileList, fn)
290 if bytes.Equal(fn.Type, []byte("fldr")) {
291 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.Name))
292 node.SetReference(&fn)
295 size := binary.BigEndian.Uint32(fn.FileSize) / 1024
297 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.Name, size))
298 node.SetReference(&fn)
304 centerFlex := tview.NewFlex().
305 AddItem(nil, 0, 1, false).
306 AddItem(tview.NewFlex().
307 SetDirection(tview.FlexRow).
308 AddItem(nil, 0, 1, false).
309 AddItem(fTree, 20, 1, true).
310 AddItem(nil, 0, 1, false), 60, 1, true).
311 AddItem(nil, 0, 1, false)
313 c.UI.Pages.AddPage("files", centerFlex, true, true)
319 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
320 newsText := string(t.GetField(fieldData).Data)
321 newsText = strings.ReplaceAll(newsText, "\r", "\n")
323 newsTextView := tview.NewTextView().
325 SetDoneFunc(func(key tcell.Key) {
326 c.UI.Pages.SwitchToPage("serverUI")
327 c.UI.App.SetFocus(c.UI.chatInput)
329 newsTextView.SetBorder(true).SetTitle("News")
331 c.UI.Pages.AddPage("news", newsTextView, true, true)
332 //c.UI.Pages.SwitchToPage("news")
333 //c.UI.App.SetFocus(newsTextView)
339 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
341 ID: t.GetField(fieldUserID).Data,
342 Name: string(t.GetField(fieldUserName).Data),
343 Icon: t.GetField(fieldUserIconID).Data,
344 Flags: t.GetField(fieldUserFlags).Data,
348 // user is new to the server
349 // user is already on the server but has a new name
352 var newUserList []User
354 for _, u := range c.UserList {
355 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
356 if bytes.Equal(newUser.ID, u.ID) {
358 u.Name = newUser.Name
359 if u.Name != newUser.Name {
360 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
364 newUserList = append(newUserList, u)
368 newUserList = append(newUserList, newUser)
371 c.UserList = newUserList
378 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
379 exitUser := t.GetField(fieldUserID).Data
381 var newUserList []User
382 for _, u := range c.UserList {
383 if !bytes.Equal(exitUser, u.ID) {
384 newUserList = append(newUserList, u)
388 c.UserList = newUserList
395 const readBuffSize = 1024000 // 1KB - TODO: what should this be?
397 func (c *Client) ReadLoop() error {
398 tranBuff := make([]byte, 0)
400 // Infinite loop where take action on incoming client requests until the connection is closed
402 buf := make([]byte, readBuffSize)
403 tranBuff = tranBuff[tReadlen:]
405 readLen, err := c.Connection.Read(buf)
409 tranBuff = append(tranBuff, buf[:readLen]...)
411 // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them
412 // into a slice of transactions
413 var transactions []Transaction
414 if transactions, tReadlen, err = readTransactions(tranBuff); err != nil {
415 c.Logger.Errorw("Error handling transaction", "err", err)
418 // iterate over all of the transactions that were parsed from the byte slice and handle them
419 for _, t := range transactions {
420 if err := c.HandleTransaction(&t); err != nil {
421 c.Logger.Errorw("Error handling transaction", "err", err)
427 func (c *Client) GetTransactions() error {
428 tranBuff := make([]byte, 0)
431 buf := make([]byte, readBuffSize)
432 tranBuff = tranBuff[tReadlen:]
434 readLen, err := c.Connection.Read(buf)
438 tranBuff = append(tranBuff, buf[:readLen]...)
443 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
445 for _, field := range t.Fields {
446 // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300)
447 // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected
448 // field type. Probably a good idea to do everywhere.
449 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
450 u, err := ReadUser(field.Data)
454 users = append(users, *u)
464 func (c *Client) renderUserList() {
465 c.UI.userList.Clear()
466 for _, u := range c.UserList {
467 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
468 if flagBitmap.Bit(userFlagAdmin) == 1 {
469 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
471 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
473 // TODO: fade if user is away
477 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
478 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
483 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
484 c.UserAccess = t.GetField(fieldUserAccess).Data
489 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
490 agreement := string(t.GetField(fieldData).Data)
491 agreement = strings.ReplaceAll(agreement, "\r", "\n")
493 c.UI.agreeModal = tview.NewModal().
495 AddButtons([]string{"Agree", "Disagree"}).
496 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
497 if buttonIndex == 0 {
501 NewField(fieldUserName, []byte(c.pref.Username)),
502 NewField(fieldUserIconID, c.pref.IconBytes()),
503 NewField(fieldUserFlags, []byte{0x00, 0x00}),
504 NewField(fieldOptions, []byte{0x00, 0x00}),
507 c.UI.Pages.HidePage("agreement")
508 c.UI.App.SetFocus(c.UI.chatInput)
511 c.UI.Pages.SwitchToPage("home")
516 c.Logger.Debug("show agreement page")
517 c.UI.Pages.AddPage("agreement", c.UI.agreeModal, false, true)
518 c.UI.Pages.ShowPage("agreement ")
524 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
525 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
526 errMsg := string(t.GetField(fieldError).Data)
527 errModal := tview.NewModal()
528 errModal.SetText(errMsg)
529 errModal.AddButtons([]string{"Oh no"})
530 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
531 c.UI.Pages.RemovePage("errModal")
533 c.UI.Pages.RemovePage("joinServer")
534 c.UI.Pages.AddPage("errModal", errModal, false, true)
536 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
538 c.Logger.Error(string(t.GetField(fieldError).Data))
539 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
541 c.UI.Pages.AddAndSwitchToPage("serverUI", c.UI.renderServerUI(), true)
542 c.UI.App.SetFocus(c.UI.chatInput)
544 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
545 c.Logger.Errorw("err", "err", err)
550 // JoinServer connects to a Hotline server and completes the login flow
551 func (c *Client) JoinServer(address, login, passwd string) error {
552 // Establish TCP connection to server
553 if err := c.connect(address); err != nil {
557 // Send handshake sequence
558 if err := c.Handshake(); err != nil {
562 // Authenticate (send tranLogin 107)
563 if err := c.LogIn(login, passwd); err != nil {
570 // connect establishes a connection with a Server by sending handshake sequence
571 func (c *Client) connect(address string) error {
573 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
580 var ClientHandshake = []byte{
581 0x54, 0x52, 0x54, 0x50, // TRTP
582 0x48, 0x4f, 0x54, 0x4c, // HOTL
587 var ServerHandshake = []byte{
588 0x54, 0x52, 0x54, 0x50, // TRTP
589 0x00, 0x00, 0x00, 0x00, // ErrorCode
592 func (c *Client) Handshake() error {
593 //Protocol ID 4 ‘TRTP’ 0x54 52 54 50
594 //Sub-protocol ID 4 User defined
595 //Version 2 1 Currently 1
596 //Sub-version 2 User defined
597 if _, err := c.Connection.Write(ClientHandshake); err != nil {
598 return fmt.Errorf("handshake write err: %s", err)
601 replyBuf := make([]byte, 8)
602 _, err := c.Connection.Read(replyBuf)
607 if bytes.Compare(replyBuf, ServerHandshake) == 0 {
611 // In the case of an error, client and server close the connection.
612 return fmt.Errorf("handshake response err: %s", err)
615 func (c *Client) LogIn(login string, password string) error {
619 NewField(fieldUserName, []byte(c.pref.Username)),
620 NewField(fieldUserIconID, c.pref.IconBytes()),
621 NewField(fieldUserLogin, negateString([]byte(login))),
622 NewField(fieldUserPassword, negateString([]byte(password))),
623 NewField(fieldVersion, []byte{0, 2}),
628 func (c *Client) Send(t Transaction) error {
629 requestNum := binary.BigEndian.Uint16(t.Type)
630 tID := binary.BigEndian.Uint32(t.ID)
632 //handler := TransactionHandlers[requestNum]
634 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
636 c.activeTasks[tID] = &t
641 if n, err = c.Connection.Write(t.Payload()); err != nil {
644 c.Logger.Debugw("Sent Transaction",
645 "IsReply", t.IsReply,
652 func (c *Client) HandleTransaction(t *Transaction) error {
653 var origT Transaction
655 requestID := binary.BigEndian.Uint32(t.ID)
656 origT = *c.activeTasks[requestID]
660 requestNum := binary.BigEndian.Uint16(t.Type)
662 "Received Transaction",
663 "RequestType", requestNum,
666 if handler, ok := c.Handlers[requestNum]; ok {
667 outT, _ := handler.Handle(c, t)
668 for _, t := range outT {
673 "Unimplemented transaction type received",
674 "RequestID", requestNum,
675 "TransactionID", t.ID,
682 func (c *Client) Connected() bool {
683 // c.Agreed == true &&
684 if c.UserAccess != nil {
690 func (c *Client) Disconnect() error {
691 err := c.Connection.Close()