]> git.r.bdr.sh - rbdr/mobius/blame - hotline/client.go
Fix intermittent tracker read panic
[rbdr/mobius] / hotline / client.go
CommitLineData
6988a057
JH
1package hotline
2
3import (
4 "bytes"
5 "embed"
6 "encoding/binary"
7 "errors"
8 "fmt"
9 "github.com/davecgh/go-spew/spew"
10 "github.com/gdamore/tcell/v2"
11 "github.com/rivo/tview"
12 "github.com/stretchr/testify/mock"
13 "go.uber.org/zap"
14 "gopkg.in/yaml.v2"
15 "io/ioutil"
16 "math/big"
17 "math/rand"
18 "net"
19 "os"
4f3c459c 20 "strconv"
6988a057
JH
21 "strings"
22 "time"
23)
24
25const clientConfigPath = "/usr/local/etc/mobius-client-config.yaml"
4f3c459c
JH
26const (
27 trackerListPage = "trackerList"
28)
6988a057 29
22c599ab 30//go:embed banners/*.txt
6988a057
JH
31var bannerDir embed.FS
32
33type Bookmark struct {
34 Name string `yaml:"Name"`
35 Addr string `yaml:"Addr"`
36 Login string `yaml:"Login"`
37 Password string `yaml:"Password"`
38}
39
40type ClientPrefs struct {
41 Username string `yaml:"Username"`
42 IconID int `yaml:"IconID"`
43 Bookmarks []Bookmark `yaml:"Bookmarks"`
4f3c459c 44 Tracker string `yaml:"Tracker"`
6988a057
JH
45}
46
f7e36225
JH
47func (cp *ClientPrefs) IconBytes() []byte {
48 iconBytes := make([]byte, 2)
49 binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID))
50 return iconBytes
51}
52
6988a057
JH
53func readConfig(cfgPath string) (*ClientPrefs, error) {
54 fh, err := os.Open(cfgPath)
55 if err != nil {
56 return nil, err
57 }
58
59 prefs := ClientPrefs{}
60 decoder := yaml.NewDecoder(fh)
61 decoder.SetStrict(true)
62 if err := decoder.Decode(&prefs); err != nil {
63 return nil, err
64 }
65 return &prefs, nil
66}
67
68type Client struct {
69 DebugBuf *DebugBuffer
70 Connection net.Conn
6988a057
JH
71 Login *[]byte
72 Password *[]byte
6988a057
JH
73 Flags *[]byte
74 ID *[]byte
75 Version []byte
76 UserAccess []byte
77 Agreed bool
78 UserList []User
79 Logger *zap.SugaredLogger
80 activeTasks map[uint32]*Transaction
81
82 pref *ClientPrefs
83
84 Handlers map[uint16]clientTHandler
85
86 UI *UI
87
88 outbox chan *Transaction
89 Inbox chan *Transaction
90}
91
92type UI struct {
93 chatBox *tview.TextView
94 chatInput *tview.InputField
95 App *tview.Application
96 Pages *tview.Pages
97 userList *tview.TextView
98 agreeModal *tview.Modal
99 trackerList *tview.List
100 settingsPage *tview.Box
101 HLClient *Client
102}
103
104func NewUI(c *Client) *UI {
105 app := tview.NewApplication()
106 chatBox := tview.NewTextView().
107 SetScrollable(true).
6988a057
JH
108 SetDynamicColors(true).
109 SetWordWrap(true).
110 SetChangedFunc(func() {
111 app.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render??
112 })
113 chatBox.Box.SetBorder(true).SetTitle("Chat")
114
115 chatInput := tview.NewInputField()
116 chatInput.
117 SetLabel("> ").
118 SetFieldBackgroundColor(tcell.ColorDimGray).
6988a057
JH
119 SetDoneFunc(func(key tcell.Key) {
120 // skip send if user hit enter with no other text
121 if len(chatInput.GetText()) == 0 {
122 return
123 }
124
125 c.Send(
126 *NewTransaction(tranChatSend, nil,
127 NewField(fieldData, []byte(chatInput.GetText())),
128 ),
129 )
130 chatInput.SetText("") // clear the input field after chat send
131 })
132
133 chatInput.Box.SetBorder(true).SetTitle("Send")
134
27e918a2
JH
135 userList := tview.
136 NewTextView().
137 SetDynamicColors(true).
138 SetChangedFunc(func() {
139 app.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render??
140 })
6988a057
JH
141 userList.Box.SetBorder(true).SetTitle("Users")
142
143 return &UI{
144 App: app,
145 chatBox: chatBox,
146 Pages: tview.NewPages(),
147 chatInput: chatInput,
148 userList: userList,
149 trackerList: tview.NewList(),
150 agreeModal: tview.NewModal(),
151 HLClient: c,
152 }
153}
154
6988a057
JH
155func (ui *UI) showBookmarks() *tview.List {
156 list := tview.NewList()
157 list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
158 if event.Key() == tcell.KeyEsc {
159 ui.Pages.SwitchToPage("home")
160 }
161 return event
162 })
163 list.Box.SetBorder(true).SetTitle("| Bookmarks |")
164
165 shortcut := 97 // rune for "a"
166 for i, srv := range ui.HLClient.pref.Bookmarks {
167 addr := srv.Addr
168 login := srv.Login
169 pass := srv.Password
170 list.AddItem(srv.Name, srv.Addr, rune(shortcut+i), func() {
171 ui.Pages.RemovePage("joinServer")
172
173 newJS := ui.renderJoinServerForm(addr, login, pass, "bookmarks", true, true)
174
175 ui.Pages.AddPage("joinServer", newJS, true, true)
176 })
177 }
178
179 return list
180}
181
182func (ui *UI) getTrackerList() *tview.List {
4f3c459c 183 listing, err := GetListing(ui.HLClient.pref.Tracker)
6988a057
JH
184 if err != nil {
185 spew.Dump(err)
186 }
187
188 list := tview.NewList()
189 list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
190 if event.Key() == tcell.KeyEsc {
191 ui.Pages.SwitchToPage("home")
192 }
193 return event
194 })
195 list.Box.SetBorder(true).SetTitle("| Servers |")
196
197 shortcut := 97 // rune for "a"
198 for i, srv := range listing {
199 addr := srv.Addr()
200 list.AddItem(string(srv.Name), string(srv.Description), rune(shortcut+i), func() {
201 ui.Pages.RemovePage("joinServer")
202
203 newJS := ui.renderJoinServerForm(addr, GuestAccount, "", trackerListPage, false, true)
204
205 ui.Pages.AddPage("joinServer", newJS, true, true)
206 ui.Pages.ShowPage("joinServer")
207 })
208 }
209
210 return list
211}
212
213func (ui *UI) renderSettingsForm() *tview.Flex {
4f3c459c 214 iconStr := strconv.Itoa(ui.HLClient.pref.IconID)
6988a057 215 settingsForm := tview.NewForm()
4f3c459c 216 settingsForm.AddInputField("Your Name", ui.HLClient.pref.Username, 0, nil, nil)
bf290335 217 settingsForm.AddInputField("IconID", iconStr, 0, func(idStr string, _ rune) bool {
4f3c459c
JH
218 _, err := strconv.Atoi(idStr)
219 return err == nil
220 }, nil)
221 settingsForm.AddInputField("Tracker", ui.HLClient.pref.Tracker, 0, nil, nil)
6988a057 222 settingsForm.AddButton("Save", func() {
028d97e7
JH
223 usernameInput := settingsForm.GetFormItem(0).(*tview.InputField).GetText()
224 if len(usernameInput) == 0 {
225 usernameInput = "unnamed"
226 }
227 ui.HLClient.pref.Username = usernameInput
4f3c459c
JH
228 iconStr = settingsForm.GetFormItem(1).(*tview.InputField).GetText()
229 ui.HLClient.pref.IconID, _ = strconv.Atoi(iconStr)
230 ui.HLClient.pref.Tracker = settingsForm.GetFormItem(2).(*tview.InputField).GetText()
231
6988a057
JH
232 out, err := yaml.Marshal(&ui.HLClient.pref)
233 if err != nil {
234 // TODO: handle err
235 }
236 // TODO: handle err
237 _ = ioutil.WriteFile(clientConfigPath, out, 0666)
238 ui.Pages.RemovePage("settings")
239 })
240 settingsForm.SetBorder(true)
241 settingsForm.SetCancelFunc(func() {
242 ui.Pages.RemovePage("settings")
243 })
244 settingsPage := tview.NewFlex().SetDirection(tview.FlexRow)
245 settingsPage.Box.SetBorder(true).SetTitle("Settings")
246 settingsPage.AddItem(settingsForm, 0, 1, true)
247
248 centerFlex := tview.NewFlex().
249 AddItem(nil, 0, 1, false).
250 AddItem(tview.NewFlex().
251 SetDirection(tview.FlexRow).
252 AddItem(nil, 0, 1, false).
253 AddItem(settingsForm, 15, 1, true).
254 AddItem(nil, 0, 1, false), 40, 1, true).
255 AddItem(nil, 0, 1, false)
256
257 return centerFlex
258}
259
6988a057
JH
260// DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
261type DebugBuffer struct {
262 TextView *tview.TextView
263}
264
265func (db *DebugBuffer) Write(p []byte) (int, error) {
266 return db.TextView.Write(p)
267}
268
269// Sync is a noop function that exists to satisfy the zapcore.WriteSyncer interface
270func (db *DebugBuffer) Sync() error {
271 return nil
272}
273
274func (ui *UI) joinServer(addr, login, password string) error {
275 if err := ui.HLClient.JoinServer(addr, login, password); err != nil {
276 return errors.New(fmt.Sprintf("Error joining server: %v\n", err))
277 }
278
279 go func() {
280 err := ui.HLClient.ReadLoop()
281 if err != nil {
282 ui.HLClient.Logger.Errorw("read error", "err", err)
283 }
284 }()
285 return nil
286}
287
288func (ui *UI) renderJoinServerForm(server, login, password, backPage string, save, defaultConnect bool) *tview.Flex {
6988a057
JH
289 joinServerForm := tview.NewForm()
290 joinServerForm.
bf290335
JH
291 AddInputField("Server", server, 0, nil, nil).
292 AddInputField("Login", login, 0, nil, nil).
293 AddPasswordField("Password", password, 0, '*', nil).
6988a057 294 AddCheckbox("Save", save, func(checked bool) {
bf290335 295 // TODO: Implement bookmark saving
6988a057
JH
296 }).
297 AddButton("Cancel", func() {
298 ui.Pages.SwitchToPage(backPage)
299 }).
300 AddButton("Connect", func() {
301 err := ui.joinServer(
302 joinServerForm.GetFormItem(0).(*tview.InputField).GetText(),
303 joinServerForm.GetFormItem(1).(*tview.InputField).GetText(),
304 joinServerForm.GetFormItem(2).(*tview.InputField).GetText(),
305 )
306 if err != nil {
307 ui.HLClient.Logger.Errorw("login error", "err", err)
308 loginErrModal := tview.NewModal().
309 AddButtons([]string{"Oh no"}).
310 SetText(err.Error()).
311 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
312 ui.Pages.SwitchToPage(backPage)
313 })
314
315 ui.Pages.AddPage("loginErr", loginErrModal, false, true)
316 }
317
318 // Save checkbox
319 if joinServerForm.GetFormItem(3).(*tview.Checkbox).IsChecked() {
320 // TODO: implement bookmark saving
321 }
322 })
323
324 joinServerForm.Box.SetBorder(true).SetTitle("| Connect |")
325 joinServerForm.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
326 if event.Key() == tcell.KeyEscape {
327 ui.Pages.SwitchToPage(backPage)
328 }
329 return event
330 })
331
332 if defaultConnect {
333 joinServerForm.SetFocus(5)
334 }
335
336 joinServerPage := tview.NewFlex().
337 AddItem(nil, 0, 1, false).
338 AddItem(tview.NewFlex().
339 SetDirection(tview.FlexRow).
340 AddItem(nil, 0, 1, false).
341 AddItem(joinServerForm, 14, 1, true).
342 AddItem(nil, 0, 1, false), 40, 1, true).
343 AddItem(nil, 0, 1, false)
344
345 return joinServerPage
346}
347
348func randomBanner() string {
349 rand.Seed(time.Now().UnixNano())
350
351 bannerFiles, _ := bannerDir.ReadDir("client/banners")
8b53c840 352 file, _ := bannerDir.ReadFile("client/banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
6988a057
JH
353
354 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
355}
356
357func (ui *UI) renderServerUI() *tview.Flex {
358 commandList := tview.NewTextView().SetDynamicColors(true)
359 commandList.
08daa287 360 SetText("[yellow]^n[-::]: Read News [yellow]^p[-::]: Post News\n[yellow]^l[-::]: View Logs\n").
6988a057
JH
361 SetBorder(true).
362 SetTitle("Keyboard Shortcuts")
363
364 modal := tview.NewModal().
365 SetText("Disconnect from the server?").
366 AddButtons([]string{"Cancel", "Exit"}).
367 SetFocus(1)
368 modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
369 if buttonIndex == 1 {
370 _ = ui.HLClient.Disconnect()
371 ui.Pages.SwitchToPage("home")
372 } else {
373 ui.Pages.HidePage("modal")
374 }
375 })
376
377 serverUI := tview.NewFlex().
378 AddItem(tview.NewFlex().
379 SetDirection(tview.FlexRow).
380 AddItem(commandList, 4, 0, false).
381 AddItem(ui.chatBox, 0, 8, false).
382 AddItem(ui.chatInput, 3, 0, true), 0, 1, true).
383 AddItem(ui.userList, 25, 1, false)
384 serverUI.SetBorder(true).SetTitle("| Mobius - Connected to " + "TODO" + " |").SetTitleAlign(tview.AlignLeft)
385 serverUI.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
386 if event.Key() == tcell.KeyEscape {
387 ui.Pages.AddPage("modal", modal, false, true)
388 }
389
390 // Show News
391 if event.Key() == tcell.KeyCtrlN {
392 if err := ui.HLClient.Send(*NewTransaction(tranGetMsgs, nil)); err != nil {
393 ui.HLClient.Logger.Errorw("err", "err", err)
394 }
395 }
396
08daa287
JH
397 // Post news
398 if event.Key() == tcell.KeyCtrlP {
399
400 newsFlex := tview.NewFlex()
401
402 newsPostTextArea := tview.NewTextView()
403 newsPostTextArea.SetBackgroundColor(tcell.ColorDimGray)
404 newsPostTextArea.SetChangedFunc(func() {
405 ui.App.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render??
406 })
407 //newsPostTextArea.SetBorderPadding(0, 0, 1, 1)
408
409 newsPostForm := tview.NewForm().
410 SetButtonsAlign(tview.AlignRight).
411 AddButton("Post", nil)
412 newsPostForm.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
413 switch event.Key() {
414 case tcell.KeyTab:
415 ui.App.SetFocus(newsPostTextArea)
416 case tcell.KeyEnter:
9c30c1b9 417 newsText := strings.ReplaceAll(newsPostTextArea.GetText(true), "\n", "\r")
08daa287
JH
418 err := ui.HLClient.Send(
419 *NewTransaction(tranOldPostNews, nil,
9c30c1b9 420 NewField(fieldData, []byte(newsText)),
08daa287
JH
421 ),
422 )
423 if err != nil {
424 ui.HLClient.Logger.Errorw("Error posting news", "err", err)
425 // TODO: display errModal to user
426 }
427 //newsInput.SetText("") // clear the input field after chat send
428 ui.Pages.RemovePage("newsInput")
429 }
430
431 return event
432 })
433
434 newsFlex.
435 SetDirection(tview.FlexRow).
436 SetBorder(true).
437 SetTitle("News Post")
438
439 newsPostTextArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
440 ui.HLClient.Logger.Infow("key", "key", event.Key(), "rune", event.Rune())
441 switch event.Key() {
442 case tcell.KeyEscape:
443 ui.Pages.RemovePage("newsInput")
444 case tcell.KeyTab:
445 ui.App.SetFocus(newsPostForm)
446 case tcell.KeyEnter:
447 fmt.Fprintf(newsPostTextArea, "\n")
448 default:
449 switch event.Rune() {
9c30c1b9 450 case 127: // backspace
08daa287
JH
451 curTxt := newsPostTextArea.GetText(true)
452 if len(curTxt) > 0 {
453 curTxt = curTxt[:len(curTxt)-1]
454 newsPostTextArea.SetText(curTxt)
455 }
456 default:
457 fmt.Fprintf(newsPostTextArea, string(event.Rune()))
458 }
459 }
460
461 return event
462 })
463
464 newsFlex.AddItem(newsPostTextArea, 10, 0, true)
465 newsFlex.AddItem(newsPostForm, 3, 0, false)
466
467 newsPostPage := tview.NewFlex().
468 AddItem(nil, 0, 1, false).
469 AddItem(tview.NewFlex().
470 SetDirection(tview.FlexRow).
471 AddItem(nil, 0, 1, false).
472 AddItem(newsFlex, 15, 1, true).
473 //AddItem(newsPostForm, 3, 0, false).
474 AddItem(nil, 0, 1, false), 40, 1, false).
475 AddItem(nil, 0, 1, false)
476
477 ui.Pages.AddPage("newsInput", newsPostPage, true, true)
478 ui.App.SetFocus(newsPostTextArea)
479 }
480
6988a057
JH
481 return event
482 })
483 return serverUI
484}
485
486func (ui *UI) Start() {
487 home := tview.NewFlex().SetDirection(tview.FlexRow)
488 home.Box.SetBorder(true).SetTitle("| Mobius v" + VERSION + " |").SetTitleAlign(tview.AlignLeft)
489 mainMenu := tview.NewList()
490
491 bannerItem := tview.NewTextView().
492 SetText(randomBanner()).
493 SetDynamicColors(true).
494 SetTextAlign(tview.AlignCenter)
495
496 home.AddItem(
497 tview.NewFlex().AddItem(bannerItem, 0, 1, false),
bed4796f 498 14, 1, false)
6988a057
JH
499 home.AddItem(tview.NewFlex().
500 AddItem(nil, 0, 1, false).
501 AddItem(mainMenu, 0, 1, true).
502 AddItem(nil, 0, 1, false),
503 0, 1, true,
504 )
505
6988a057 506 mainMenu.AddItem("Join Server", "", 'j', func() {
27e918a2 507 joinServerPage := ui.renderJoinServerForm("", GuestAccount, "", "home", false, false)
6988a057
JH
508 ui.Pages.AddPage("joinServer", joinServerPage, true, true)
509 }).
510 AddItem("Bookmarks", "", 'b', func() {
511 ui.Pages.AddAndSwitchToPage("bookmarks", ui.showBookmarks(), true)
512 }).
513 AddItem("Browse Tracker", "", 't', func() {
514 ui.trackerList = ui.getTrackerList()
515 ui.Pages.AddAndSwitchToPage("trackerList", ui.trackerList, true)
516 }).
517 AddItem("Settings", "", 's', func() {
6988a057
JH
518 ui.Pages.AddPage("settings", ui.renderSettingsForm(), true, true)
519 }).
520 AddItem("Quit", "", 'q', func() {
521 ui.App.Stop()
522 })
523
524 ui.Pages.AddPage("home", home, true, true)
525
526 // App level input capture
527 ui.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
528 if event.Key() == tcell.KeyCtrlC {
529 ui.HLClient.Logger.Infow("Exiting")
530 ui.App.Stop()
531 os.Exit(0)
532 }
533 // Show Logs
534 if event.Key() == tcell.KeyCtrlL {
6988a057
JH
535 ui.HLClient.DebugBuf.TextView.ScrollToEnd()
536 ui.HLClient.DebugBuf.TextView.SetBorder(true).SetTitle("Logs")
537 ui.HLClient.DebugBuf.TextView.SetDoneFunc(func(key tcell.Key) {
538 if key == tcell.KeyEscape {
6988a057
JH
539 ui.Pages.RemovePage("logs")
540 }
541 })
542
543 ui.Pages.AddAndSwitchToPage("logs", ui.HLClient.DebugBuf.TextView, true)
544 }
545 return event
546 })
547
548 if err := ui.App.SetRoot(ui.Pages, true).SetFocus(ui.Pages).Run(); err != nil {
12ace83f
JH
549 ui.App.Stop()
550 os.Exit(1)
6988a057
JH
551 }
552}
553
554func NewClient(username string, logger *zap.SugaredLogger) *Client {
555 c := &Client{
6988a057
JH
556 Logger: logger,
557 activeTasks: make(map[uint32]*Transaction),
558 Handlers: clientHandlers,
559 }
560 c.UI = NewUI(c)
561
562 prefs, err := readConfig(clientConfigPath)
563 if err != nil {
564 return c
565 }
566 c.pref = prefs
567
568 return c
569}
570
571type clientTransaction struct {
572 Name string
573 Handler func(*Client, *Transaction) ([]Transaction, error)
574}
575
576func (ch clientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
577 return ch.Handler(cc, t)
578}
579
580type clientTHandler interface {
581 Handle(*Client, *Transaction) ([]Transaction, error)
582}
583
584type mockClientHandler struct {
585 mock.Mock
586}
587
588func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
589 args := mh.Called(cc, t)
590 return args.Get(0).([]Transaction), args.Error(1)
591}
592
593var clientHandlers = map[uint16]clientTHandler{
594 // Server initiated
595 tranChatMsg: clientTransaction{
596 Name: "tranChatMsg",
597 Handler: handleClientChatMsg,
598 },
599 tranLogin: clientTransaction{
600 Name: "tranLogin",
601 Handler: handleClientTranLogin,
602 },
603 tranShowAgreement: clientTransaction{
604 Name: "tranShowAgreement",
605 Handler: handleClientTranShowAgreement,
606 },
607 tranUserAccess: clientTransaction{
608 Name: "tranUserAccess",
609 Handler: handleClientTranUserAccess,
610 },
611 tranGetUserNameList: clientTransaction{
612 Name: "tranGetUserNameList",
613 Handler: handleClientGetUserNameList,
614 },
615 tranNotifyChangeUser: clientTransaction{
616 Name: "tranNotifyChangeUser",
617 Handler: handleNotifyChangeUser,
618 },
619 tranNotifyDeleteUser: clientTransaction{
620 Name: "tranNotifyDeleteUser",
621 Handler: handleNotifyDeleteUser,
622 },
623 tranGetMsgs: clientTransaction{
624 Name: "tranNotifyDeleteUser",
625 Handler: handleGetMsgs,
626 },
627}
628
629func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
630 newsText := string(t.GetField(fieldData).Data)
631 newsText = strings.ReplaceAll(newsText, "\r", "\n")
632
633 newsTextView := tview.NewTextView().
634 SetText(newsText).
635 SetDoneFunc(func(key tcell.Key) {
636 c.UI.Pages.SwitchToPage("serverUI")
637 c.UI.App.SetFocus(c.UI.chatInput)
638 })
639 newsTextView.SetBorder(true).SetTitle("News")
640
641 c.UI.Pages.AddPage("news", newsTextView, true, true)
642 c.UI.Pages.SwitchToPage("news")
643 c.UI.App.SetFocus(newsTextView)
644
645 c.UI.App.Draw()
646
647 return res, err
648}
649
650func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
651 newUser := User{
652 ID: t.GetField(fieldUserID).Data,
653 Name: string(t.GetField(fieldUserName).Data),
654 Icon: t.GetField(fieldUserIconID).Data,
655 Flags: t.GetField(fieldUserFlags).Data,
656 }
657
658 // Possible cases:
659 // user is new to the server
660 // user is already on the server but has a new name
661
662 var oldName string
663 var newUserList []User
664 updatedUser := false
665 for _, u := range c.UserList {
666 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
667 if bytes.Equal(newUser.ID, u.ID) {
668 oldName = u.Name
669 u.Name = newUser.Name
670 if u.Name != newUser.Name {
671 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
672 }
673 updatedUser = true
674 }
675 newUserList = append(newUserList, u)
676 }
677
678 if !updatedUser {
679 newUserList = append(newUserList, newUser)
680 }
681
682 c.UserList = newUserList
683
684 c.renderUserList()
685
686 return res, err
687}
688
689func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
690 exitUser := t.GetField(fieldUserID).Data
691
692 var newUserList []User
693 for _, u := range c.UserList {
694 if !bytes.Equal(exitUser, u.ID) {
695 newUserList = append(newUserList, u)
696 }
697 }
698
699 c.UserList = newUserList
700
701 c.renderUserList()
702
703 return res, err
704}
705
706const readBuffSize = 1024000 // 1KB - TODO: what should this be?
707
708func (c *Client) ReadLoop() error {
709 tranBuff := make([]byte, 0)
710 tReadlen := 0
711 // Infinite loop where take action on incoming client requests until the connection is closed
712 for {
713 buf := make([]byte, readBuffSize)
714 tranBuff = tranBuff[tReadlen:]
715
716 readLen, err := c.Connection.Read(buf)
717 if err != nil {
718 return err
719 }
720 tranBuff = append(tranBuff, buf[:readLen]...)
721
722 // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them
723 // into a slice of transactions
724 var transactions []Transaction
725 if transactions, tReadlen, err = readTransactions(tranBuff); err != nil {
726 c.Logger.Errorw("Error handling transaction", "err", err)
727 }
728
729 // iterate over all of the transactions that were parsed from the byte slice and handle them
730 for _, t := range transactions {
731 if err := c.HandleTransaction(&t); err != nil {
732 c.Logger.Errorw("Error handling transaction", "err", err)
733 }
734 }
735 }
736}
737
738func (c *Client) GetTransactions() error {
739 tranBuff := make([]byte, 0)
740 tReadlen := 0
741
742 buf := make([]byte, readBuffSize)
743 tranBuff = tranBuff[tReadlen:]
744
745 readLen, err := c.Connection.Read(buf)
746 if err != nil {
747 return err
748 }
749 tranBuff = append(tranBuff, buf[:readLen]...)
750
751 return nil
752}
753
754func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
755 var users []User
756 for _, field := range t.Fields {
71c56068
JH
757 // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300)
758 // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected
759 // field type. Probably a good idea to do everywhere.
760 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
761 u, err := ReadUser(field.Data)
762 if err != nil {
763 return res, err
764 }
765 users = append(users, *u)
766 }
6988a057
JH
767 }
768 c.UserList = users
769
770 c.renderUserList()
771
772 return res, err
773}
774
775func (c *Client) renderUserList() {
776 c.UI.userList.Clear()
777 for _, u := range c.UserList {
778 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
779 if flagBitmap.Bit(userFlagAdmin) == 1 {
5dd57308 780 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
6988a057 781 } else {
5dd57308 782 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
6988a057
JH
783 }
784 }
785}
786
787func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
5dd57308 788 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
6988a057
JH
789
790 return res, err
791}
792
793func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
794 c.UserAccess = t.GetField(fieldUserAccess).Data
795
796 return res, err
797}
798
799func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
800 agreement := string(t.GetField(fieldData).Data)
801 agreement = strings.ReplaceAll(agreement, "\r", "\n")
802
803 c.UI.agreeModal = tview.NewModal().
804 SetText(agreement).
805 AddButtons([]string{"Agree", "Disagree"}).
806 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
807 if buttonIndex == 0 {
808 res = append(res,
809 *NewTransaction(
810 tranAgreed, nil,
811 NewField(fieldUserName, []byte(c.pref.Username)),
f7e36225 812 NewField(fieldUserIconID, c.pref.IconBytes()),
6988a057
JH
813 NewField(fieldUserFlags, []byte{0x00, 0x00}),
814 NewField(fieldOptions, []byte{0x00, 0x00}),
815 ),
816 )
817 c.Agreed = true
818 c.UI.Pages.HidePage("agreement")
819 c.UI.App.SetFocus(c.UI.chatInput)
820 } else {
f7e36225 821 _ = c.Disconnect()
6988a057
JH
822 c.UI.Pages.SwitchToPage("home")
823 }
824 },
825 )
826
827 c.Logger.Debug("show agreement page")
828 c.UI.Pages.AddPage("agreement", c.UI.agreeModal, false, true)
829
830 c.UI.Pages.ShowPage("agreement ")
831
832 c.UI.App.Draw()
833 return res, err
834}
835
836func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
837 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
838 errMsg := string(t.GetField(fieldError).Data)
839 errModal := tview.NewModal()
840 errModal.SetText(errMsg)
841 errModal.AddButtons([]string{"Oh no"})
842 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
843 c.UI.Pages.RemovePage("errModal")
844 })
845 c.UI.Pages.RemovePage("joinServer")
846 c.UI.Pages.AddPage("errModal", errModal, false, true)
847
848 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
849
850 c.Logger.Error(string(t.GetField(fieldError).Data))
851 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
852 }
853 c.UI.Pages.AddAndSwitchToPage("serverUI", c.UI.renderServerUI(), true)
854 c.UI.App.SetFocus(c.UI.chatInput)
855
856 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
857 c.Logger.Errorw("err", "err", err)
858 }
859 return res, err
860}
861
862// JoinServer connects to a Hotline server and completes the login flow
863func (c *Client) JoinServer(address, login, passwd string) error {
864 // Establish TCP connection to server
865 if err := c.connect(address); err != nil {
866 return err
867 }
868
869 // Send handshake sequence
870 if err := c.Handshake(); err != nil {
871 return err
872 }
873
874 // Authenticate (send tranLogin 107)
875 if err := c.LogIn(login, passwd); err != nil {
876 return err
877 }
878
879 return nil
880}
881
882// connect establishes a connection with a Server by sending handshake sequence
883func (c *Client) connect(address string) error {
884 var err error
885 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
886 if err != nil {
887 return err
888 }
889 return nil
890}
891
892var ClientHandshake = []byte{
893 0x54, 0x52, 0x54, 0x50, // TRTP
894 0x48, 0x4f, 0x54, 0x4c, // HOTL
895 0x00, 0x01,
896 0x00, 0x02,
897}
898
899var ServerHandshake = []byte{
900 0x54, 0x52, 0x54, 0x50, // TRTP
901 0x00, 0x00, 0x00, 0x00, // ErrorCode
902}
903
904func (c *Client) Handshake() error {
905 //Protocol ID 4 ‘TRTP’ 0x54 52 54 50
906 //Sub-protocol ID 4 User defined
907 //Version 2 1 Currently 1
908 //Sub-version 2 User defined
909 if _, err := c.Connection.Write(ClientHandshake); err != nil {
910 return fmt.Errorf("handshake write err: %s", err)
911 }
912
913 replyBuf := make([]byte, 8)
914 _, err := c.Connection.Read(replyBuf)
915 if err != nil {
916 return err
917 }
918
919 //spew.Dump(replyBuf)
920 if bytes.Compare(replyBuf, ServerHandshake) == 0 {
921 return nil
922 }
923 // In the case of an error, client and server close the connection.
924
925 return fmt.Errorf("handshake response err: %s", err)
926}
927
928func (c *Client) LogIn(login string, password string) error {
929 return c.Send(
930 *NewTransaction(
931 tranLogin, nil,
932 NewField(fieldUserName, []byte(c.pref.Username)),
f7e36225 933 NewField(fieldUserIconID, c.pref.IconBytes()),
6988a057
JH
934 NewField(fieldUserLogin, []byte(NegatedUserString([]byte(login)))),
935 NewField(fieldUserPassword, []byte(NegatedUserString([]byte(password)))),
936 NewField(fieldVersion, []byte{0, 2}),
937 ),
938 )
939}
940
6988a057
JH
941func (c *Client) Send(t Transaction) error {
942 requestNum := binary.BigEndian.Uint16(t.Type)
943 tID := binary.BigEndian.Uint32(t.ID)
944
945 //handler := TransactionHandlers[requestNum]
946
947 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
948 if t.IsReply == 0 {
949 c.activeTasks[tID] = &t
950 }
951
952 var n int
953 var err error
954 if n, err = c.Connection.Write(t.Payload()); err != nil {
955 return err
956 }
957 c.Logger.Debugw("Sent Transaction",
958 "IsReply", t.IsReply,
959 "type", requestNum,
960 "sentBytes", n,
961 )
962 return nil
963}
964
965func (c *Client) HandleTransaction(t *Transaction) error {
966 var origT Transaction
967 if t.IsReply == 1 {
968 requestID := binary.BigEndian.Uint32(t.ID)
969 origT = *c.activeTasks[requestID]
970 t.Type = origT.Type
971 }
972
973 requestNum := binary.BigEndian.Uint16(t.Type)
974 c.Logger.Infow(
975 "Received Transaction",
976 "RequestType", requestNum,
977 )
978
979 if handler, ok := c.Handlers[requestNum]; ok {
980 outT, _ := handler.Handle(c, t)
981 for _, t := range outT {
982 c.Send(t)
983 }
984 } else {
985 c.Logger.Errorw(
986 "Unimplemented transaction type received",
987 "RequestID", requestNum,
988 "TransactionID", t.ID,
989 )
990 }
991
992 return nil
993}
994
995func (c *Client) Connected() bool {
996 fmt.Printf("Agreed: %v UserAccess: %v\n", c.Agreed, c.UserAccess)
997 // c.Agreed == true &&
998 if c.UserAccess != nil {
999 return true
1000 }
1001 return false
1002}
1003
1004func (c *Client) Disconnect() error {
1005 err := c.Connection.Close()
1006 if err != nil {
1007 return err
1008 }
1009 return nil
1010}