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