Refactored client side to comply with protocol changes, implemnted tos

screen model, still missing accept/decline and authentication
This commit is contained in:
2025-07-08 17:53:36 +03:00
parent 8362d835ee
commit a34919da2f
3 changed files with 489 additions and 63 deletions

View File

@@ -14,7 +14,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/kyren223/eko/certs"
"github.com/kyren223/eko/embeds"
"github.com/kyren223/eko/internal/client/config"
"github.com/kyren223/eko/internal/client/ui"
"github.com/kyren223/eko/internal/packet"
@@ -30,36 +30,36 @@ var (
)
type (
ConnectionEstablished snowflake.ID
ConnectionFailed error
ConnectionLost error
ConnectionClosed struct{}
AuthenticationEstablished snowflake.ID
ConnectionEstablished struct{}
ConnectionFailed error
ConnectionLost error
ConnectionClosed struct{}
)
func Connect(privKey ed25519.PrivateKey, timeout time.Duration) tea.Cmd {
func Connect(timeout time.Duration) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
id, err := connect(ctx, privKey)
err := connect(ctx)
if err != nil {
return ConnectionFailed(err)
}
return ConnectionEstablished(id)
return ConnectionEstablished{}
}
}
func connect(ctx context.Context, privKey ed25519.PrivateKey) (snowflake.ID, error) {
func connect(ctx context.Context) error {
assert.Assert(conn == nil, "cannot connect, connection is active")
closed = false
var id snowflake.ID
connChan := make(chan net.Conn, 1)
errChan := make(chan error, 1)
go func() {
framer = packet.NewFramer()
certPool := x509.NewCertPool()
if !certPool.AppendCertsFromPEM(certs.CertPEM) {
if !certPool.AppendCertsFromPEM(embeds.ServerCertificate) {
log.Fatalln("failed to append server certificate")
}
@@ -85,11 +85,6 @@ func connect(ctx context.Context, privKey ed25519.PrivateKey) (snowflake.ID, err
}
log.Println("established connection with the server")
if id, err = handleAuth(ctx, connection, privKey); err != nil {
errChan <- err
return
}
log.Println("successfully authenticated with the server")
connChan <- connection
}()
@@ -97,15 +92,15 @@ func connect(ctx context.Context, privKey ed25519.PrivateKey) (snowflake.ID, err
case connection := <-connChan:
conn = connection
case err := <-errChan:
return 0, err
return err
case <-ctx.Done():
return 0, ctx.Err()
return ctx.Err()
}
go readForever(conn)
go handlePacketStream()
return id, nil
return nil
}
func handleAuth(ctx context.Context, conn net.Conn, privKey ed25519.PrivateKey) (snowflake.ID, error) {

View File

@@ -33,6 +33,7 @@ import (
"github.com/kyren223/eko/internal/client/ui/core/state"
"github.com/kyren223/eko/internal/client/ui/core/usersettings"
"github.com/kyren223/eko/internal/client/ui/loadscreen"
"github.com/kyren223/eko/internal/client/ui/tosscreen"
"github.com/kyren223/eko/internal/client/ui/viminput"
"github.com/kyren223/eko/internal/data"
"github.com/kyren223/eko/internal/packet"
@@ -40,12 +41,12 @@ import (
"github.com/kyren223/eko/pkg/snowflake"
)
var (
connectingToServer = "Connecting to server.."
connectionFailed = "Connection failed - retrying in %d sec..."
connectionTimeout = 5 * time.Second
initialTimeout = 3750 * time.Millisecond
timerInterval = 50 * time.Millisecond
const (
ConnectingToServer = "Connecting to server.."
ConnectionFailed = "Connection failed - retrying in %d sec..."
ConnectionTimeout = 5 * time.Second
InitialTimeout = 3750 * time.Millisecond
TimerInterval = 50 * time.Millisecond
)
const (
@@ -62,14 +63,25 @@ const (
FocusMax
)
type State int
const (
Disconnected State = iota
Connected
ConnectedReceivedTos
ConnectedAcceptedTos
Authenticated
)
type Model struct {
name string
privKey ed25519.PrivateKey
loading loadscreen.Model
timer timer.Model
timeout time.Duration
connected bool
loading loadscreen.Model
tos *tosscreen.Model
timer timer.Model
timeout time.Duration
state State
helpPopup *HelpPopup
userSettingsPopup *usersettings.Model
@@ -94,10 +106,11 @@ func New(privKey ed25519.PrivateKey, name string) Model {
m := Model{
name: name,
privKey: privKey,
loading: loadscreen.New(connectingToServer),
timer: newTimer(initialTimeout),
timeout: initialTimeout,
connected: false,
loading: loadscreen.New(ConnectingToServer),
tos: nil,
timer: newTimer(InitialTimeout),
timeout: InitialTimeout,
state: Disconnected,
helpPopup: nil,
userSettingsPopup: nil,
networkCreationPopup: nil,
@@ -108,6 +121,7 @@ func New(privKey ed25519.PrivateKey, name string) Model {
banReasonPopup: nil,
banViewPopup: nil,
signalAddPopup: nil,
profilePopup: nil,
networkList: networklist.New(),
signalList: signallist.New(),
frequencyList: frequencylist.New(),
@@ -121,12 +135,29 @@ func New(privKey ed25519.PrivateKey, name string) Model {
}
func (m Model) Init() tea.Cmd {
return tea.Batch(gateway.Connect(m.privKey, connectionTimeout), m.loading.Init())
return tea.Batch(gateway.Connect(ConnectionTimeout), m.loading.Init())
}
func (m Model) View() string {
if !m.connected {
switch m.state {
case Disconnected:
return m.loading.View()
case Connected:
return m.loading.View()
case ConnectedReceivedTos:
assert.NotNil(m.tos, "TOS is expected to not be nil if received TOS already")
return m.tos.View()
case ConnectedAcceptedTos:
// TODO: auth
return m.loading.View()
case Authenticated:
// Continue
default:
panic(fmt.Sprintf("unexpected core.State: %#v", m.state))
}
if m.HasPopup() {
@@ -188,36 +219,52 @@ func (m Model) View() string {
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.connected {
switch m.state {
case Disconnected:
cmd := m.updateDisconnected(msg)
return m, cmd
case Connected:
fallthrough
case ConnectedReceivedTos:
fallthrough
case ConnectedAcceptedTos:
cmd := m.updateConnected(msg)
return m, cmd
} else {
cmd := m.updateNotConnected(msg)
case Authenticated:
cmd := m.updateAuthenticated(msg)
return m, cmd
default:
panic(fmt.Sprintf("unexpected core.State: %#v", m.state))
}
}
func (m *Model) updateNotConnected(msg tea.Msg) tea.Cmd {
func (m *Model) updateDisconnected(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case gateway.ConnectionEstablished:
state.UserID = (*snowflake.ID)(&msg)
m.connected = true
m.timeout = initialTimeout
m.state = Connected
m.timeout = InitialTimeout
var setName tea.Cmd
if m.name != "" {
setName = gateway.Send(&packet.SetUserData{
Data: nil,
User: &data.User{
Name: m.name,
Description: "",
IsPublicDM: true,
},
})
m.name = ""
}
return tea.Batch(m.timer.Stop(), setName)
// TODO:
// // state.UserID = (*snowflake.ID)(&msg)
// var setName tea.Cmd
// if m.name != "" {
// setName = gateway.Send(&packet.SetUserData{
// Data: nil,
// User: &data.User{
// Name: m.name,
// Description: "",
// IsPublicDM: true,
// },
// })
// m.name = ""
// }
//
// return tea.Batch(m.timer.Stop(), setName)
return m.timer.Stop()
case gateway.ConnectionFailed:
log.Println("failed to connect:", msg)
@@ -227,8 +274,8 @@ func (m *Model) updateNotConnected(msg tea.Msg) tea.Cmd {
case timer.TimeoutMsg:
m.timeout = min(m.timeout*2, time.Minute)
m.loading.SetContent(connectingToServer)
return gateway.Connect(m.privKey, connectionTimeout)
m.loading.SetContent(ConnectingToServer)
return gateway.Connect(ConnectionTimeout)
case timer.StartStopMsg:
var cmd tea.Cmd
@@ -252,6 +299,54 @@ func (m *Model) updateNotConnected(msg tea.Msg) tea.Cmd {
}
func (m *Model) updateConnected(message tea.Msg) tea.Cmd {
switch msg := message.(type) {
case gateway.ConnectionLost:
m.state = Disconnected
m.timeout = InitialTimeout
return tea.Batch(gateway.Connect(ConnectionTimeout), m.loading.Init())
case *packet.TosInfo:
m.state = ConnectedReceivedTos
combined := msg.Tos + "\n" + msg.PrivacyPolicy
tos := tosscreen.New(combined)
m.tos = &tos
// var setName tea.Cmd
// if m.name != "" {
// setName = gateway.Send(&packet.SetUserData{
// Data: nil,
// User: &data.User{
// Name: m.name,
// Description: "",
// IsPublicDM: true,
// },
// })
// m.name = ""
// }
//
// return tea.Batch(m.timer.Stop(), setName)
case tea.KeyMsg:
switch msg.String() {
case "enter":
if m.state == ConnectedReceivedTos {
// TODO:
m.state = ConnectedAcceptedTos
}
}
}
if m.state == ConnectedReceivedTos {
tos, cmd := m.tos.Update(message)
m.tos = &tos
return cmd
}
return nil
}
func (m *Model) updateAuthenticated(message tea.Msg) tea.Cmd {
totalWidth := max(ui.Width, ui.MinWidth)
totalWidth -= NetworkWidth
sidebarWidth := int(math.Round(float64(totalWidth) * SidebarPercentage))
@@ -274,9 +369,9 @@ func (m *Model) updateConnected(message tea.Msg) tea.Cmd {
case gateway.ConnectionLost:
state.UserID = nil
m.connected = false
m.timeout = initialTimeout
return tea.Batch(gateway.Connect(m.privKey, connectionTimeout), m.loading.Init())
m.state = Disconnected
m.timeout = InitialTimeout
return tea.Batch(gateway.Connect(ConnectionTimeout), m.loading.Init())
case *packet.Error:
err := "new connection from another location, closing this one"
@@ -619,11 +714,11 @@ func (m *Model) updateConnected(message tea.Msg) tea.Cmd {
func (m *Model) updateLoadScreenContent() {
seconds := m.timer.Timeout.Round(time.Second) / time.Second
m.loading.SetContent(fmt.Sprintf(connectionFailed, seconds))
m.loading.SetContent(fmt.Sprintf(ConnectionFailed, seconds))
}
func newTimer(timeout time.Duration) timer.Model {
return timer.NewWithInterval(timeout.Truncate(time.Second)+(time.Second/2), timerInterval)
return timer.NewWithInterval(timeout.Truncate(time.Second)+(time.Second/2), TimerInterval)
}
func (m *Model) move(direction int) {
@@ -814,3 +909,7 @@ func getSignalNotification(signal snowflake.ID) int {
return pings
}
func (m Model) ViewTos() string {
return "TODO TOS"
}

View File

@@ -0,0 +1,332 @@
package tosscreen
import (
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/ansi"
"github.com/charmbracelet/lipgloss"
// "github.com/charmbracelet/glamour"
"github.com/kyren223/eko/internal/client/ui"
"github.com/kyren223/eko/internal/client/ui/colors"
"github.com/kyren223/eko/pkg/assert"
)
const MarginPercentage = 0.25
var style = func() lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.ThickBorder()).
BorderBackground(colors.Background).
Background(colors.Background).
MarginBackground(colors.BackgroundDimmer).
Padding(0, 3).
Margin(1, int(float32(ui.Width)*MarginPercentage), 0)
}
type Model struct {
content string
vp viewport.Model
}
func New(content string) Model {
m := Model{
content: content,
vp: viewport.New(ui.Width, ui.Height),
}
m.SetContent(content)
return m
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) View() string {
footer := "-- 󰳝 Accept | ^C Decline | ↑↓ or J/K Scroll | ^D/^U PgDn/PgUp --"
fWidth := lipgloss.Width(footer)
paddingLeft := (ui.Width - fWidth) / 2
paddingRight := ui.Width - fWidth - paddingLeft
footerStyle := lipgloss.NewStyle().
Padding(0, paddingLeft, 0, paddingRight).
Background(colors.BackgroundDimmer).
MarginBackground(colors.BackgroundDimmer)
footer = footerStyle.Render(footer)
view := m.vp.View()
return lipgloss.JoinVertical(lipgloss.Center, view, footer)
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmd tea.Cmd
if m.vp.Width != ui.Width {
m.vp.Width = ui.Width
m.updateContent()
}
if m.vp.Height != ui.Height-1 {
m.vp.Height = ui.Height - 1
m.updateContent()
}
m.vp.Style = style()
m.vp, cmd = m.vp.Update(msg)
return m, cmd
}
func (m *Model) updateContent() {
style := style()
margin := style.GetMarginLeft() + style.GetMarginRight()
padding := style.GetPaddingLeft() + style.GetPaddingRight()
border := 2
lineWidth := ui.Width - margin - padding - border
r, _ := glamour.NewTermRenderer(
glamour.WithStyles(MdStyle()),
glamour.WithWordWrap(lineWidth),
)
content := m.content
content = strings.ReplaceAll(content, "@", "") // HACK: workaround to remove mailto links
content, err := r.Render(content)
assert.NoError(err, "this should never error")
content = strings.ReplaceAll(content, "", "@") // HACK: workaround to remove mailto links
m.vp.SetContent(content)
}
func (m *Model) SetContent(content string) {
m.content = content
m.updateContent()
}
func MdStyle() ansi.StyleConfig {
mdStyle := ansi.StyleConfig{
Document: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "\n",
BlockSuffix: "\n",
Color: stringPtr(colors.White),
BackgroundColor: stringPtr(colors.Background),
},
Margin: uintPtr(defaultMargin),
},
BlockQuote: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{},
Indent: uintPtr(1),
IndentToken: stringPtr("│ "),
},
List: ansi.StyleList{
LevelIndent: defaultListIndent,
},
Heading: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockSuffix: "\n",
Color: stringPtr(colors.LightBlue),
Bold: boolPtr(true),
},
},
H1: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: " ",
Suffix: " ",
Color: stringPtr(colors.Black),
BackgroundColor: stringPtr(colors.LightBlue),
Bold: boolPtr(true),
},
},
H2: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "## ",
},
},
H3: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "### ",
},
},
H4: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "#### ",
},
},
H5: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "##### ",
},
},
H6: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "###### ",
Bold: boolPtr(false),
},
},
Strikethrough: ansi.StylePrimitive{
CrossedOut: boolPtr(true),
},
Emph: ansi.StylePrimitive{
Italic: boolPtr(true),
},
Strong: ansi.StylePrimitive{
Color: stringPtr(colors.Orange),
Bold: boolPtr(true),
},
HorizontalRule: ansi.StylePrimitive{
Color: stringPtr(colors.Gray),
Format: "\n--------\n",
},
Item: ansi.StylePrimitive{
BlockPrefix: " ",
},
Enumeration: ansi.StylePrimitive{
BlockPrefix: ". ",
},
Task: ansi.StyleTask{
StylePrimitive: ansi.StylePrimitive{},
Ticked: "[✓] ",
Unticked: "[ ] ",
},
Link: ansi.StylePrimitive{
// Format: "Bozo",
},
LinkText: ansi.StylePrimitive{
Color: stringPtr(colors.Gold),
Bold: boolPtr(true),
Conceal: boolPtr(true),
Faint: boolPtr(true),
},
Image: ansi.StylePrimitive{
Color: stringPtr(colors.Purple),
Underline: boolPtr(true),
},
ImageText: ansi.StylePrimitive{
Color: stringPtr(colors.LightGray),
Format: "Image: {{.text}} →",
},
Code: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: " ",
Suffix: " ",
Color: stringPtr(colors.Purple),
BackgroundColor: stringPtr(colors.BackgroundHighlight),
},
},
CodeBlock: ansi.StyleCodeBlock{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: stringPtr(colors.LightGray),
},
Margin: uintPtr(defaultMargin),
},
Chroma: &ansi.Chroma{
Text: ansi.StylePrimitive{
Color: stringPtr(colors.White),
},
Error: ansi.StylePrimitive{
Color: stringPtr(colors.White),
BackgroundColor: stringPtr(colors.Red),
},
Comment: ansi.StylePrimitive{
Color: stringPtr(colors.Gray),
},
CommentPreproc: ansi.StylePrimitive{
Color: stringPtr(colors.Orange),
},
Keyword: ansi.StylePrimitive{
Color: stringPtr(colors.Red),
},
KeywordReserved: ansi.StylePrimitive{
Color: stringPtr(colors.Red),
},
KeywordNamespace: ansi.StylePrimitive{
Color: stringPtr(colors.Red),
},
KeywordType: ansi.StylePrimitive{
Color: stringPtr(colors.Turquoise),
},
Operator: ansi.StylePrimitive{
Color: stringPtr(colors.Red),
},
Punctuation: ansi.StylePrimitive{
Color: stringPtr(colors.White),
},
Name: ansi.StylePrimitive{
Color: stringPtr(colors.Purple),
},
NameBuiltin: ansi.StylePrimitive{
Color: stringPtr(colors.Red),
},
NameTag: ansi.StylePrimitive{
Color: stringPtr(colors.Purple),
},
NameAttribute: ansi.StylePrimitive{
Color: stringPtr(colors.LightBlue),
},
NameClass: ansi.StylePrimitive{
Color: stringPtr(colors.White),
Underline: boolPtr(true),
Bold: boolPtr(true),
},
NameDecorator: ansi.StylePrimitive{
Color: stringPtr(colors.Gold),
},
NameFunction: ansi.StylePrimitive{
Color: stringPtr(colors.LightBlue),
},
LiteralNumber: ansi.StylePrimitive{
Color: stringPtr(colors.Purple),
},
LiteralString: ansi.StylePrimitive{
Color: stringPtr(colors.Orange),
},
LiteralStringEscape: ansi.StylePrimitive{
Color: stringPtr(colors.Purple),
},
GenericDeleted: ansi.StylePrimitive{
Color: stringPtr(colors.Red),
},
GenericEmph: ansi.StylePrimitive{
Italic: boolPtr(true),
},
GenericInserted: ansi.StylePrimitive{
Color: stringPtr(colors.Turquoise),
},
GenericStrong: ansi.StylePrimitive{
Bold: boolPtr(true),
},
GenericSubheading: ansi.StylePrimitive{
Color: stringPtr(colors.LightGray),
},
Background: ansi.StylePrimitive{
BackgroundColor: stringPtr(colors.BackgroundDim),
},
},
},
Table: ansi.StyleTable{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{},
},
},
DefinitionDescription: ansi.StylePrimitive{
BlockPrefix: "\n🠶 ",
},
}
return mdStyle
}
const (
defaultListIndent = 2
defaultListLevelIndent = 4
defaultMargin = 0
)
func stringPtr(c lipgloss.Color) *string { return (*string)(&c) }
func boolPtr(b bool) *bool { return &b }
func uintPtr(u uint) *uint { return &u }