10 "github.com/gdamore/tcell/v2"
11 "github.com/rivo/tview"
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) {
52 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 if err := decoder.Decode(&prefs); err != nil {
76 Logger *zap.SugaredLogger
77 activeTasks map[uint32]*Transaction
82 Handlers map[uint16]ClientHandler
86 Inbox chan *Transaction
89 type ClientHandler func(*Client, *Transaction) ([]Transaction, error)
91 func (c *Client) HandleFunc(transactionID uint16, handler ClientHandler) {
92 c.Handlers[transactionID] = handler
95 func NewClient(username string, logger *zap.SugaredLogger) *Client {
98 activeTasks: make(map[uint32]*Transaction),
99 Handlers: make(map[uint16]ClientHandler),
101 c.Pref = &ClientPrefs{Username: username}
106 func NewUIClient(cfgPath string, logger *zap.SugaredLogger) *Client {
110 activeTasks: make(map[uint32]*Transaction),
111 Handlers: clientHandlers,
115 prefs, err := readConfig(cfgPath)
117 logger.Fatal(fmt.Sprintf("unable to read config file %s\n", cfgPath))
124 // DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
125 type DebugBuffer struct {
126 TextView *tview.TextView
129 func (db *DebugBuffer) Write(p []byte) (int, error) {
130 return db.TextView.Write(p)
133 // Sync is a noop function that dataFile to satisfy the zapcore.WriteSyncer interface
134 func (db *DebugBuffer) Sync() error {
138 func randomBanner() string {
139 rand.Seed(time.Now().UnixNano())
141 bannerFiles, _ := bannerDir.ReadDir("banners")
142 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
144 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
147 type ClientTransaction struct {
149 Handler func(*Client, *Transaction) ([]Transaction, error)
152 func (ch ClientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
153 return ch.Handler(cc, t)
156 type ClientTHandler interface {
157 Handle(*Client, *Transaction) ([]Transaction, error)
160 var clientHandlers = map[uint16]ClientHandler{
161 TranChatMsg: handleClientChatMsg,
162 TranLogin: handleClientTranLogin,
163 TranShowAgreement: handleClientTranShowAgreement,
164 TranUserAccess: handleClientTranUserAccess,
165 TranGetUserNameList: handleClientGetUserNameList,
166 TranNotifyChangeUser: handleNotifyChangeUser,
167 TranNotifyDeleteUser: handleNotifyDeleteUser,
168 TranGetMsgs: handleGetMsgs,
169 TranGetFileNameList: handleGetFileNameList,
170 TranServerMsg: handleTranServerMsg,
171 TranKeepAlive: func(client *Client, transaction *Transaction) (t []Transaction, err error) {
176 func handleTranServerMsg(c *Client, t *Transaction) (res []Transaction, err error) {
177 now := time.Now().Format(time.RFC850)
179 msg := strings.ReplaceAll(string(t.GetField(FieldData).Data), "\r", "\n")
180 msg += "\n\nAt " + now
181 title := fmt.Sprintf("| Private Message From: %s |", t.GetField(FieldUserName).Data)
183 msgBox := tview.NewTextView().SetScrollable(true)
184 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue)
185 msgBox.SetTitle(title).SetBorder(true)
186 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
188 case tcell.KeyEscape:
189 c.UI.Pages.RemovePage("serverMsgModal" + now)
194 centeredFlex := tview.NewFlex().
195 AddItem(nil, 0, 1, false).
196 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
197 AddItem(nil, 0, 1, false).
198 AddItem(msgBox, 0, 2, true).
199 AddItem(nil, 0, 1, false), 0, 2, true).
200 AddItem(nil, 0, 1, false)
202 c.UI.Pages.AddPage("serverMsgModal"+now, centeredFlex, true, true)
203 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
208 func (c *Client) showErrMsg(msg string) {
209 t := time.Now().Format(time.RFC850)
213 msgBox := tview.NewTextView().SetScrollable(true)
214 msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkRed)
215 msgBox.SetTitle(title).SetBorder(true)
216 msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
218 case tcell.KeyEscape:
219 c.UI.Pages.RemovePage("serverMsgModal" + t)
224 centeredFlex := tview.NewFlex().
225 AddItem(nil, 0, 1, false).
226 AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
227 AddItem(nil, 0, 1, false).
228 AddItem(msgBox, 0, 2, true).
229 AddItem(nil, 0, 1, false), 0, 2, true).
230 AddItem(nil, 0, 1, false)
232 c.UI.Pages.AddPage("serverMsgModal"+t, centeredFlex, true, true)
233 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
236 func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
238 c.showErrMsg(string(t.GetField(FieldError).Data))
239 c.Logger.Infof("Error: %s", t.GetField(FieldError).Data)
243 fTree := tview.NewTreeView().SetTopLevel(1)
244 root := tview.NewTreeNode("Root")
245 fTree.SetRoot(root).SetCurrentNode(root)
246 fTree.SetBorder(true).SetTitle("| Files |")
247 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
249 case tcell.KeyEscape:
250 c.UI.Pages.RemovePage("files")
251 c.filePath = []string{}
253 selectedNode := fTree.GetCurrentNode()
255 if selectedNode.GetText() == "<- Back" {
256 c.filePath = c.filePath[:len(c.filePath)-1]
257 f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
259 if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil {
260 c.UI.HLClient.Logger.Errorw("err", "err", err)
265 entry := selectedNode.GetReference().(*FileNameWithInfo)
267 if bytes.Equal(entry.Type[:], []byte("fldr")) {
268 c.Logger.Infow("get new directory listing", "name", string(entry.name))
270 c.filePath = append(c.filePath, string(entry.name))
271 f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
273 if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil {
274 c.UI.HLClient.Logger.Errorw("err", "err", err)
277 // TODO: initiate file download
278 c.Logger.Infow("download file", "name", string(entry.name))
285 if len(c.filePath) > 0 {
286 node := tview.NewTreeNode("<- Back")
290 for _, f := range t.Fields {
291 var fn FileNameWithInfo
292 err = fn.UnmarshalBinary(f.Data)
297 if bytes.Equal(fn.Type[:], []byte("fldr")) {
298 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name))
299 node.SetReference(&fn)
302 size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024
304 node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.name, size))
305 node.SetReference(&fn)
310 centerFlex := tview.NewFlex().
311 AddItem(nil, 0, 1, false).
312 AddItem(tview.NewFlex().
313 SetDirection(tview.FlexRow).
314 AddItem(nil, 0, 1, false).
315 AddItem(fTree, 20, 1, true).
316 AddItem(nil, 0, 1, false), 60, 1, true).
317 AddItem(nil, 0, 1, false)
319 c.UI.Pages.AddPage("files", centerFlex, true, true)
325 func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
326 newsText := string(t.GetField(FieldData).Data)
327 newsText = strings.ReplaceAll(newsText, "\r", "\n")
329 newsTextView := tview.NewTextView().
331 SetDoneFunc(func(key tcell.Key) {
332 c.UI.Pages.SwitchToPage(serverUIPage)
333 c.UI.App.SetFocus(c.UI.chatInput)
335 newsTextView.SetBorder(true).SetTitle("News")
337 c.UI.Pages.AddPage("news", newsTextView, true, true)
338 // c.UI.Pages.SwitchToPage("news")
339 // c.UI.App.SetFocus(newsTextView)
345 func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
347 ID: t.GetField(FieldUserID).Data,
348 Name: string(t.GetField(FieldUserName).Data),
349 Icon: t.GetField(FieldUserIconID).Data,
350 Flags: t.GetField(FieldUserFlags).Data,
354 // user is new to the server
355 // user is already on the server but has a new name
358 var newUserList []User
360 for _, u := range c.UserList {
361 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
362 if bytes.Equal(newUser.ID, u.ID) {
364 u.Name = newUser.Name
365 if u.Name != newUser.Name {
366 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
370 newUserList = append(newUserList, u)
374 newUserList = append(newUserList, newUser)
377 c.UserList = newUserList
384 func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
385 exitUser := t.GetField(FieldUserID).Data
387 var newUserList []User
388 for _, u := range c.UserList {
389 if !bytes.Equal(exitUser, u.ID) {
390 newUserList = append(newUserList, u)
394 c.UserList = newUserList
401 func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
403 for _, field := range t.Fields {
404 // The Hotline protocol docs say that ClientGetUserNameList should only return FieldUsernameWithInfo (300)
405 // fields, but shxd sneaks in FieldChatSubject (115) so it's important to filter explicitly for the expected
406 // field type. Probably a good idea to do everywhere.
407 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
408 u, err := ReadUser(field.Data)
412 users = append(users, *u)
422 func (c *Client) renderUserList() {
423 c.UI.userList.Clear()
424 for _, u := range c.UserList {
425 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
426 if flagBitmap.Bit(UserFlagAdmin) == 1 {
427 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
429 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
431 // TODO: fade if user is away
435 func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
436 if c.Pref.EnableBell {
440 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(FieldData).Data)
445 func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
446 c.UserAccess = t.GetField(FieldUserAccess).Data
451 func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
452 agreement := string(t.GetField(FieldData).Data)
453 agreement = strings.ReplaceAll(agreement, "\r", "\n")
455 agreeModal := tview.NewModal().
457 AddButtons([]string{"Agree", "Disagree"}).
458 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
459 if buttonIndex == 0 {
463 NewField(FieldUserName, []byte(c.Pref.Username)),
464 NewField(FieldUserIconID, c.Pref.IconBytes()),
465 NewField(FieldUserFlags, []byte{0x00, 0x00}),
466 NewField(FieldOptions, []byte{0x00, 0x00}),
469 c.UI.Pages.HidePage("agreement")
470 c.UI.App.SetFocus(c.UI.chatInput)
473 c.UI.Pages.SwitchToPage("home")
478 c.UI.Pages.AddPage("agreement", agreeModal, false, true)
483 func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
484 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
485 errMsg := string(t.GetField(FieldError).Data)
486 errModal := tview.NewModal()
487 errModal.SetText(errMsg)
488 errModal.AddButtons([]string{"Oh no"})
489 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
490 c.UI.Pages.RemovePage("errModal")
492 c.UI.Pages.RemovePage("joinServer")
493 c.UI.Pages.AddPage("errModal", errModal, false, true)
495 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
497 c.Logger.Error(string(t.GetField(FieldError).Data))
498 return nil, errors.New("login error: " + string(t.GetField(FieldError).Data))
500 c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true)
501 c.UI.App.SetFocus(c.UI.chatInput)
503 if err := c.Send(*NewTransaction(TranGetUserNameList, nil)); err != nil {
504 c.Logger.Errorw("err", "err", err)
509 // JoinServer connects to a Hotline server and completes the login flow
510 func (c *Client) Connect(address, login, passwd string) (err error) {
511 // Establish TCP connection to server
512 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
517 // Send handshake sequence
518 if err := c.Handshake(); err != nil {
522 // Authenticate (send TranLogin 107)
523 if err := c.LogIn(login, passwd); err != nil {
527 // start keepalive go routine
528 go func() { _ = c.keepalive() }()
533 const keepaliveInterval = 300 * time.Second
535 func (c *Client) keepalive() error {
537 time.Sleep(keepaliveInterval)
538 _ = c.Send(*NewTransaction(TranKeepAlive, nil))
539 c.Logger.Debugw("Sent keepalive ping")
543 var ClientHandshake = []byte{
544 0x54, 0x52, 0x54, 0x50, // TRTP
545 0x48, 0x4f, 0x54, 0x4c, // HOTL
550 var ServerHandshake = []byte{
551 0x54, 0x52, 0x54, 0x50, // TRTP
552 0x00, 0x00, 0x00, 0x00, // ErrorCode
555 func (c *Client) Handshake() error {
556 // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
557 // Sub-protocol ID 4 User defined
558 // Version 2 1 Currently 1
559 // Sub-version 2 User defined
560 if _, err := c.Connection.Write(ClientHandshake); err != nil {
561 return fmt.Errorf("handshake write err: %s", err)
564 replyBuf := make([]byte, 8)
565 _, err := c.Connection.Read(replyBuf)
570 if bytes.Equal(replyBuf, ServerHandshake) {
574 // In the case of an error, client and server close the connection.
575 return fmt.Errorf("handshake response err: %s", err)
578 func (c *Client) LogIn(login string, password string) error {
582 NewField(FieldUserName, []byte(c.Pref.Username)),
583 NewField(FieldUserIconID, c.Pref.IconBytes()),
584 NewField(FieldUserLogin, negateString([]byte(login))),
585 NewField(FieldUserPassword, negateString([]byte(password))),
590 func (c *Client) Send(t Transaction) error {
591 requestNum := binary.BigEndian.Uint16(t.Type)
593 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
595 c.activeTasks[binary.BigEndian.Uint32(t.ID)] = &t
598 b, err := t.MarshalBinary()
604 if n, err = c.Connection.Write(b); err != nil {
607 c.Logger.Debugw("Sent Transaction",
608 "IsReply", t.IsReply,
615 func (c *Client) HandleTransaction(t *Transaction) error {
616 var origT Transaction
618 requestID := binary.BigEndian.Uint32(t.ID)
619 origT = *c.activeTasks[requestID]
623 if handler, ok := c.Handlers[binary.BigEndian.Uint16(t.Type)]; ok {
624 outT, _ := handler(c, t)
625 for _, t := range outT {
626 if err := c.Send(t); err != nil {
632 "Unimplemented transaction type received",
634 "TransactionID", t.ID,
641 func (c *Client) Disconnect() error {
642 return c.Connection.Close()
645 func (c *Client) HandleTransactions() error {
646 // Create a new scanner for parsing incoming bytes into transaction tokens
647 scanner := bufio.NewScanner(c.Connection)
648 scanner.Split(transactionScanner)
650 // Scan for new transactions and handle them as they come in.
652 // Make a new []byte slice and copy the scanner bytes to it. This is critical to avoid a data race as the
653 // scanner re-uses the buffer for subsequent scans.
654 buf := make([]byte, len(scanner.Bytes()))
655 copy(buf, scanner.Bytes())
658 _, err := t.Write(buf)
662 if err := c.HandleTransaction(&t); err != nil {
663 c.Logger.Errorw("Error handling transaction", "err", err)
667 if scanner.Err() == nil {