xK/xA/xA.go
Přemysl Eric Janouch 7ba17a0161
All checks were successful
Alpine 3.21 Success
Arch Linux AUR Success
OpenBSD 7.6 Success
Make the relay acknowledge all received commands
To that effect, bump liberty and the xC relay protocol version.
Relay events have been reordered to improve forward compatibility.

Also prevent use-after-free when serialization fails.

xP now slightly throttles activity notifications,
and indicates when there are unacknowledged commands.
2025-05-10 12:08:51 +02:00

1652 lines
37 KiB
Go

// Copyright (c) 2024 - 2025, Přemysl Eric Janouch <p@janouch.name>
// SPDX-License-Identifier: 0BSD
package main
import (
"bufio"
"bytes"
"context"
_ "embed"
"encoding/binary"
"errors"
"flag"
"fmt"
"image/color"
"io"
"log"
"net"
"net/url"
"os"
"regexp"
"slices"
"strings"
"sync"
"time"
"github.com/ebitengine/oto/v3"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/driver/mobile"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
var (
debug = flag.Bool("debug", false, "enable debug output")
projectName = "xA"
projectVersion = "?"
//go:embed xA.png
iconNormal []byte
//go:embed xA-highlighted.png
iconHighlighted []byte
//go:embed beep.raw
beepSample []byte
resourceIconNormal = fyne.NewStaticResource(
"xA.png", iconNormal)
resourceIconHighlighted = fyne.NewStaticResource(
"xA-highlighted.png", iconHighlighted)
)
// --- Theme -------------------------------------------------------------------
type customTheme struct{}
const (
colorNameRenditionError fyne.ThemeColorName = "renditionError"
colorNameRenditionJoin fyne.ThemeColorName = "renditionJoin"
colorNameRenditionPart fyne.ThemeColorName = "renditionPart"
colorNameRenditionAction fyne.ThemeColorName = "renditionAction"
colorNameBufferTimestamp fyne.ThemeColorName = "bufferTimestamp"
colorNameBufferLeaked fyne.ThemeColorName = "bufferLeaked"
)
func convertColor(c int) color.Color {
base16 := []uint16{
0x000, 0x800, 0x080, 0x880, 0x008, 0x808, 0x088, 0xccc,
0x888, 0xf00, 0x0f0, 0xff0, 0x00f, 0xf0f, 0x0ff, 0xfff,
}
if c < 16 {
r := 0xf & uint8(base16[c]>>8)
g := 0xf & uint8(base16[c]>>4)
b := 0xf & uint8(base16[c])
return color.RGBA{r * 0x11, g * 0x11, b * 0x11, 0xff}
}
if c >= 216 {
return color.Gray{8 + uint8(c-216)*10}
}
var (
i = uint8(c - 16)
r = i / 36 >> 0
g = (i / 6 >> 0) % 6
b = i % 6
)
if r != 0 {
r = 55 + 40*r
}
if g != 0 {
g = 55 + 40*g
}
if b != 0 {
b = 55 + 40*b
}
return color.RGBA{r, g, b, 0xff}
}
var ircColors = make(map[fyne.ThemeColorName]color.Color)
func ircColorName(color int) fyne.ThemeColorName {
return fyne.ThemeColorName(fmt.Sprintf("irc%02x", color))
}
func init() {
for color := 0; color < 256; color++ {
ircColors[ircColorName(color)] = convertColor(color)
}
}
func (t *customTheme) Color(
name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
/*
// Fyne may use a dark background with the Light variant,
// which makes the UI unusable.
if runtime.GOOS == "android" {
variant = theme.VariantDark
}
*/
/*
// Fyne 2.6.0 has a different bug, the Light variant is not applied:
variant = theme.VariantLight
*/
// Fuck this low contrast shit, text must be black.
if name == theme.ColorNameForeground &&
variant == theme.VariantLight {
return color.Black
}
switch name {
case colorNameRenditionError:
return color.RGBA{0xff, 0x00, 0x00, 0xff}
case colorNameRenditionJoin:
return color.RGBA{0x00, 0x88, 0x00, 0xff}
case colorNameRenditionPart:
return color.RGBA{0x88, 0x00, 0x00, 0xff}
case colorNameRenditionAction:
return color.RGBA{0x88, 0x00, 0x00, 0xff}
case colorNameBufferTimestamp, colorNameBufferLeaked:
return color.RGBA{0x88, 0x88, 0x88, 0xff}
}
if c, ok := ircColors[name]; ok {
return c
}
return theme.DefaultTheme().Color(name, variant)
}
func (t *customTheme) Font(style fyne.TextStyle) fyne.Resource {
return theme.DefaultTheme().Font(style)
}
func (t *customTheme) Icon(i fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(i)
}
func (t *customTheme) Size(s fyne.ThemeSizeName) float32 {
switch s {
case theme.SizeNameInnerPadding:
return 2
default:
return theme.DefaultTheme().Size(s)
}
}
// --- Relay state -------------------------------------------------------------
type server struct {
state RelayServerState
user string
userModes string
}
type bufferLineItem struct {
format fyne.TextStyle
// For RichTextStyle.ColorName.
color fyne.ThemeColorName
// XXX: Fyne's RichText doesn't support background colours.
background fyne.ThemeColorName
text string
link *url.URL
}
type bufferLine struct {
/// Leaked from another buffer, but temporarily staying in another one.
leaked bool
isUnimportant bool
isHighlight bool
rendition RelayRendition
when time.Time
items []bufferLineItem
}
type buffer struct {
bufferName string
hideUnimportant bool
kind RelayBufferKind
serverName string
lines []bufferLine
// Channel:
topic []bufferLineItem
modes string
// Stats:
newMessages int
newUnimportantMessages int
highlighted bool
// Input:
input string
inputRow, inputColumn int
history []string
historyAt int
}
type callback func(err string, response *RelayResponseData)
const (
preferenceAddress = "address"
)
var (
backendAddress string
backendContext context.Context
backendCancel context.CancelFunc
backendConn net.Conn
backendLock sync.Mutex
// Connection state:
commandSeq uint32
commandCallbacks = make(map[uint32]callback)
buffers []buffer
bufferCurrent string
bufferLast string
servers = make(map[string]*server)
// Sound:
otoContext *oto.Context
otoReady chan struct{}
// Widgets:
inForeground = true
wConnect *dialog.FormDialog
wWindow fyne.Window
wTopic *widget.RichText
wBufferList *widget.List
wRichText *widget.RichText
wRichScroll *container.Scroll
wLog *logEntry
wPrompt *widget.Label
wDown *widget.Icon
wStatus *widget.Label
wEntry *inputEntry
)
// -----------------------------------------------------------------------------
func showErrorMessage(text string) {
dialog.ShowError(errors.New(text), wWindow)
}
func beep() {
if otoContext == nil {
return
}
go func() {
<-otoReady
p := otoContext.NewPlayer(bytes.NewReader(beepSample))
p.Play()
for p.IsPlaying() {
time.Sleep(time.Second)
}
}()
}
// --- Networking --------------------------------------------------------------
func relayReadMessage(r io.Reader) (m RelayEventMessage, ok bool) {
var length uint32
if err := binary.Read(r, binary.BigEndian, &length); err != nil {
log.Println("Event receive failed: " + err.Error())
return
}
b := make([]byte, length)
if _, err := io.ReadFull(r, b); err != nil {
log.Println("Event receive failed: " + err.Error())
return
}
if after, ok2 := m.ConsumeFrom(b); !ok2 {
log.Println("Event deserialization failed")
return
} else if len(after) != 0 {
log.Println("Event deserialization failed: trailing data")
return
}
if *debug {
log.Printf("<? %v\n", b)
j, err := m.MarshalJSON()
if err != nil {
log.Println("Event marshalling failed: " + err.Error())
return
}
log.Printf("<- %s\n", j)
}
return m, true
}
func relaySend(data RelayCommandData, callback callback) bool {
backendLock.Lock()
defer backendLock.Unlock()
m := RelayCommandMessage{
CommandSeq: commandSeq,
Data: data,
}
if callback == nil {
callback = func(err string, response *RelayResponseData) {
if response == nil {
showErrorMessage(err)
}
}
}
commandCallbacks[m.CommandSeq] = callback
commandSeq++
// TODO(p): Handle errors better.
b, ok := m.AppendTo(make([]byte, 4))
if !ok {
log.Println("Command serialization failed")
return false
}
binary.BigEndian.PutUint32(b[:4], uint32(len(b)-4))
if _, err := backendConn.Write(b); err != nil {
log.Println("Command send failed: " + err.Error())
return false
}
if *debug {
log.Printf("-> %v\n", b)
}
return true
}
// --- Buffers -----------------------------------------------------------------
func bufferByName(name string) *buffer {
for i := range buffers {
if buffers[i].bufferName == name {
return &buffers[i]
}
}
return nil
}
func bufferAtBottom() bool {
return wRichScroll.Offset.Y >=
wRichScroll.Content.Size().Height-wRichScroll.Size().Height
}
func bufferScrollToBottom() {
// XXX: Doing it once is not reliable, something's amiss.
// (In particular, nothing happens when we switch from an empty buffer
// to a buffer than needs scrolling.)
wRichScroll.ScrollToBottom()
wRichScroll.ScrollToBottom()
refreshStatus()
}
func bufferPushLine(b *buffer, line bufferLine) {
b.lines = append(b.lines, line)
// Fyne's text layouting is extremely slow.
// The limit could be made configurable,
// and we could use a ring buffer approach to storing the lines.
if len(b.lines) > 100 {
b.lines = slices.Delete(b.lines, 0, 1)
}
}
// --- UI state refresh --------------------------------------------------------
func refreshIcon() {
resource := resourceIconNormal
for _, b := range buffers {
if b.highlighted {
resource = resourceIconHighlighted
break
}
}
wWindow.SetIcon(resource)
}
func refreshTopic(topic []bufferLineItem) {
wTopic.Segments = nil
for _, item := range topic {
if item.link != nil {
wTopic.Segments = append(wTopic.Segments,
&widget.HyperlinkSegment{Text: item.text, URL: item.link})
continue
}
wTopic.Segments = append(wTopic.Segments, &widget.TextSegment{
Text: item.text,
Style: widget.RichTextStyle{
Alignment: fyne.TextAlignLeading,
ColorName: item.color,
Inline: true,
SizeName: theme.SizeNameText,
TextStyle: item.format,
},
})
}
wTopic.Refresh()
}
func refreshBufferList() {
// This seems to be enough, even for removals.
for i := range buffers {
wBufferList.RefreshItem(widget.ListItemID(i))
}
}
func refreshPrompt() {
var prompt string
if b := bufferByName(bufferCurrent); b == nil {
prompt = "Synchronizing..."
} else if server, ok := servers[b.serverName]; ok {
prompt = server.user
if server.userModes != "" {
prompt += "(" + server.userModes + ")"
}
if prompt == "" {
prompt = "(" + server.state.String() + ")"
}
}
wPrompt.SetText(prompt)
}
func refreshStatus() {
if bufferAtBottom() {
wDown.Hide()
} else {
wDown.Show()
}
status := bufferCurrent
if b := bufferByName(bufferCurrent); b != nil {
if b.modes != "" {
status += "(+" + b.modes + ")"
}
if b.hideUnimportant {
status += "<H>"
}
}
wStatus.SetText(status)
}
func recheckHighlighted() {
// Corresponds to the logic toggling the bool on.
if b := bufferByName(bufferCurrent); b != nil &&
b.highlighted && bufferAtBottom() &&
inForeground && !wLog.Visible() {
b.highlighted = false
refreshIcon()
refreshBufferList()
}
}
// --- Buffer actions ----------------------------------------------------------
func bufferActivate(name string) {
relaySend(RelayCommandData{
Variant: &RelayCommandDataBufferActivate{BufferName: name},
}, nil)
}
func bufferToggleUnimportant(name string) {
relaySend(RelayCommandData{
Variant: &RelayCommandDataBufferToggleUnimportant{BufferName: name},
}, nil)
}
func bufferToggleLogFinish(err string, response *RelayResponseDataBufferLog) {
if response == nil {
showErrorMessage(err)
return
}
wLog.SetText(string(response.Log))
wLog.Show()
wRichScroll.Hide()
}
func bufferToggleLog() {
if wLog.Visible() {
wRichScroll.Show()
wLog.Hide()
wLog.SetText("")
recheckHighlighted()
return
}
name := bufferCurrent
relaySend(RelayCommandData{Variant: &RelayCommandDataBufferLog{
BufferName: name,
}}, func(err string, response *RelayResponseData) {
if bufferCurrent == name {
bufferToggleLogFinish(
err, response.Variant.(*RelayResponseDataBufferLog))
}
})
}
// --- RichText formatting -----------------------------------------------------
func defaultBufferLineItem() bufferLineItem { return bufferLineItem{} }
func convertItemFormatting(
item RelayItemData, cf *bufferLineItem, inverse *bool) {
switch data := item.Variant.(type) {
case *RelayItemDataReset:
*cf = defaultBufferLineItem()
case *RelayItemDataFlipBold:
cf.format.Bold = !cf.format.Bold
case *RelayItemDataFlipItalic:
cf.format.Italic = !cf.format.Italic
case *RelayItemDataFlipUnderline:
cf.format.Underline = !cf.format.Underline
case *RelayItemDataFlipCrossedOut:
// https://github.com/fyne-io/fyne/issues/1084
case *RelayItemDataFlipInverse:
*inverse = !*inverse
case *RelayItemDataFlipMonospace:
cf.format.Monospace = !cf.format.Monospace
case *RelayItemDataFgColor:
if data.Color < 0 {
cf.color = ""
} else {
cf.color = ircColorName(int(data.Color))
}
case *RelayItemDataBgColor:
if data.Color < 0 {
cf.background = ""
} else {
cf.background = ircColorName(int(data.Color))
}
}
}
var linkRE = regexp.MustCompile(`https?://` +
`(?:[^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+` +
`(?:[^\[\](){}<>"'\s,.:]|\([^\[\](){}<>"'\s]*\))`)
func convertLinks(
item bufferLineItem, items []bufferLineItem) []bufferLineItem {
end, matches := 0, linkRE.FindAllStringIndex(item.text, -1)
for _, m := range matches {
url, _ := url.Parse(item.text[m[0]:m[1]])
if url == nil {
continue
}
if end < m[0] {
subitem := item
subitem.text = item.text[end:m[0]]
items = append(items, subitem)
}
subitem := item
subitem.text = item.text[m[0]:m[1]]
subitem.link = url
items = append(items, subitem)
end = m[1]
}
if end < len(item.text) {
subitem := item
subitem.text = item.text[end:]
items = append(items, subitem)
}
return items
}
func convertItems(items []RelayItemData) []bufferLineItem {
result := []bufferLineItem{}
cf, inverse := defaultBufferLineItem(), false
for _, it := range items {
text, ok := it.Variant.(*RelayItemDataText)
if !ok {
convertItemFormatting(it, &cf, &inverse)
continue
}
item := cf
item.text = text.Text
if inverse {
item.color, item.background = item.background, item.color
}
result = convertLinks(item, result)
}
return result
}
// --- Buffer output -----------------------------------------------------------
func convertBufferLine(m *RelayEventDataBufferLine) bufferLine {
return bufferLine{
items: convertItems(m.Items),
isUnimportant: m.IsUnimportant,
isHighlight: m.IsHighlight,
rendition: m.Rendition,
when: time.UnixMilli(int64(m.When)),
}
}
func bufferPrintDateChange(last, current time.Time) {
last, current = last.Local(), current.Local()
if last.Year() == current.Year() &&
last.Month() == current.Month() &&
last.Day() == current.Day() {
return
}
wRichText.Segments = append(wRichText.Segments, &widget.TextSegment{
Style: widget.RichTextStyle{
Alignment: fyne.TextAlignLeading,
ColorName: "",
Inline: false,
SizeName: theme.SizeNameText,
TextStyle: fyne.TextStyle{Bold: true},
},
Text: current.Format(time.DateOnly),
})
}
func bufferPrintAndWatchTrailingDateChanges() {
current := time.Now()
b := bufferByName(bufferCurrent)
if b != nil && len(b.lines) != 0 {
last := b.lines[len(b.lines)-1].when
bufferPrintDateChange(last, current)
}
// TODO(p): The watching part.
}
func bufferPrintLine(lines []bufferLine, index int) {
line := &lines[index]
last, current := time.Time{}, line.when
if index == 0 {
last = time.Now()
} else {
last = lines[index-1].when
}
bufferPrintDateChange(last, current)
texts := []widget.RichTextSegment{&widget.TextSegment{
Text: line.when.Format("15:04:05 "),
Style: widget.RichTextStyle{
Alignment: fyne.TextAlignLeading,
ColorName: colorNameBufferTimestamp,
Inline: true,
SizeName: theme.SizeNameText,
TextStyle: fyne.TextStyle{},
}}}
// Tabstops won't quite help us here, since we need it centred.
prefix := ""
pcf := widget.RichTextStyle{
Alignment: fyne.TextAlignLeading,
Inline: true,
SizeName: theme.SizeNameText,
TextStyle: fyne.TextStyle{Monospace: true},
}
switch line.rendition {
case RelayRenditionBare:
case RelayRenditionIndent:
prefix = " "
case RelayRenditionStatus:
prefix = " - "
case RelayRenditionError:
prefix = "=!= "
pcf.ColorName = colorNameRenditionError
case RelayRenditionJoin:
prefix = "--> "
pcf.ColorName = colorNameRenditionJoin
case RelayRenditionPart:
prefix = "<-- "
pcf.ColorName = colorNameRenditionPart
case RelayRenditionAction:
prefix = " * "
pcf.ColorName = colorNameRenditionAction
}
if prefix != "" {
style := pcf
if line.leaked {
style.ColorName = colorNameBufferLeaked
}
texts = append(texts, &widget.TextSegment{
Text: prefix,
Style: style,
})
}
for _, item := range line.items {
if item.link != nil {
texts = append(texts,
&widget.HyperlinkSegment{Text: item.text, URL: item.link})
continue
}
style := widget.RichTextStyle{
Alignment: fyne.TextAlignLeading,
ColorName: item.color,
Inline: true,
SizeName: theme.SizeNameText,
TextStyle: item.format,
}
if line.leaked {
style.ColorName = colorNameBufferLeaked
}
texts = append(texts, &widget.TextSegment{
Text: item.text,
Style: style,
})
}
wRichText.Segments = append(wRichText.Segments,
&widget.ParagraphSegment{Texts: texts},
&widget.TextSegment{Style: widget.RichTextStyleParagraph})
}
func bufferPrintSeparator() {
// TODO(p): Implement our own, so that it can use the primary colour.
wRichText.Segments = append(wRichText.Segments,
&widget.SeparatorSegment{})
}
func refreshBuffer(b *buffer) {
wRichText.Segments = nil
markBefore := len(b.lines) - b.newMessages - b.newUnimportantMessages
for i, line := range b.lines {
if i == markBefore {
bufferPrintSeparator()
}
if !line.isUnimportant || !b.hideUnimportant {
bufferPrintLine(b.lines, i)
}
}
bufferPrintAndWatchTrailingDateChanges()
wRichText.Refresh()
bufferScrollToBottom()
recheckHighlighted()
}
// --- Event processing --------------------------------------------------------
func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) {
line := convertBufferLine(m)
// Initial sync: skip all other processing, let highlights be.
bc := bufferByName(bufferCurrent)
if bc == nil {
bufferPushLine(b, line)
return
}
// Retained mode is complicated.
display := (!m.IsUnimportant || !bc.hideUnimportant) &&
(b.bufferName == bufferCurrent || m.LeakToActive)
toBottom := display && bufferAtBottom()
visible := display && toBottom && inForeground && !wLog.Visible()
separate := display &&
!visible && bc.newMessages == 0 && bc.newUnimportantMessages == 0
bufferPushLine(b, line)
if !(visible || m.LeakToActive) ||
b.newMessages != 0 || b.newUnimportantMessages != 0 {
if line.isUnimportant || m.LeakToActive {
b.newUnimportantMessages++
} else {
b.newMessages++
}
}
if m.LeakToActive {
leakedLine := line
leakedLine.leaked = true
bufferPushLine(bc, leakedLine)
if !visible || bc.newMessages != 0 || bc.newUnimportantMessages != 0 {
if line.isUnimportant {
bc.newUnimportantMessages++
} else {
bc.newMessages++
}
}
}
if separate {
bufferPrintSeparator()
}
if display {
bufferPrintLine(bc.lines, len(bc.lines)-1)
wRichText.Refresh()
}
if toBottom {
bufferScrollToBottom()
}
// TODO(p): On mobile, we should probably send notifications.
// Though we probably can't run in the background.
if line.isHighlight || (!visible && !line.isUnimportant &&
b.kind == RelayBufferKindPrivateMessage) {
beep()
if !visible {
b.highlighted = true
refreshIcon()
}
}
refreshBufferList()
}
func relayProcessCallbacks(
commandSeq uint32, err string, response *RelayResponseData) {
if handler, ok := commandCallbacks[commandSeq]; !ok {
if *debug {
log.Printf("Unawaited response: %+v\n", *response)
}
} else {
delete(commandCallbacks, commandSeq)
if handler != nil {
handler(err, response)
}
}
// We don't particularly care about wraparound issues.
for cs, handler := range commandCallbacks {
if cs <= commandSeq {
delete(commandCallbacks, cs)
if handler != nil {
handler("No response", nil)
}
}
}
}
func relayProcessMessage(m *RelayEventMessage) {
switch data := m.Data.Variant.(type) {
case *RelayEventDataError:
relayProcessCallbacks(data.CommandSeq, data.Error, nil)
case *RelayEventDataResponse:
relayProcessCallbacks(data.CommandSeq, "", &data.Data)
case *RelayEventDataPing:
relaySend(RelayCommandData{
Variant: &RelayCommandDataPingResponse{EventSeq: m.EventSeq},
}, nil)
case *RelayEventDataBufferLine:
b := bufferByName(data.BufferName)
if b == nil {
return
}
relayProcessBufferLine(b, data)
case *RelayEventDataBufferUpdate:
b := bufferByName(data.BufferName)
if b == nil {
buffers = append(buffers, buffer{bufferName: data.BufferName})
b = &buffers[len(buffers)-1]
refreshBufferList()
}
hidingToggled := b.hideUnimportant != data.HideUnimportant
b.hideUnimportant = data.HideUnimportant
b.kind = data.Context.Variant.Kind()
b.serverName = ""
switch context := data.Context.Variant.(type) {
case *RelayBufferContextServer:
b.serverName = context.ServerName
case *RelayBufferContextChannel:
b.serverName = context.ServerName
b.modes = context.Modes
b.topic = convertItems(context.Topic)
case *RelayBufferContextPrivateMessage:
b.serverName = context.ServerName
}
if b.bufferName == bufferCurrent {
refreshTopic(b.topic)
refreshStatus()
if hidingToggled {
refreshBuffer(b)
}
}
case *RelayEventDataBufferStats:
b := bufferByName(data.BufferName)
if b == nil {
return
}
b.newMessages = int(data.NewMessages)
b.newUnimportantMessages = int(data.NewUnimportantMessages)
b.highlighted = data.Highlighted
refreshIcon()
case *RelayEventDataBufferRename:
b := bufferByName(data.BufferName)
if b == nil {
return
}
b.bufferName = data.New
if data.BufferName == bufferCurrent {
bufferCurrent = data.New
refreshStatus()
}
refreshBufferList()
if data.BufferName == bufferLast {
bufferLast = data.New
}
case *RelayEventDataBufferRemove:
buffers = slices.DeleteFunc(buffers, func(b buffer) bool {
return b.bufferName == data.BufferName
})
refreshBufferList()
refreshIcon()
case *RelayEventDataBufferActivate:
old := bufferByName(bufferCurrent)
bufferLast = bufferCurrent
bufferCurrent = data.BufferName
b := bufferByName(data.BufferName)
if b == nil {
return
}
if old != nil {
old.newMessages = 0
old.newUnimportantMessages = 0
old.highlighted = false
old.input = wEntry.Text
old.inputRow = wEntry.CursorRow
old.inputColumn = wEntry.CursorColumn
// Note that we effectively overwrite the newest line
// with the current textarea contents, and jump there.
old.historyAt = len(old.history)
}
if wLog.Visible() {
bufferToggleLog()
}
if inForeground {
b.highlighted = false
}
for i := range buffers {
if buffers[i].bufferName == bufferCurrent {
wBufferList.Select(widget.ListItemID(i))
break
}
}
refreshIcon()
refreshTopic(b.topic)
refreshBufferList()
refreshBuffer(b)
refreshPrompt()
refreshStatus()
wEntry.SetText(b.input)
wEntry.CursorRow = b.inputRow
wEntry.CursorColumn = b.inputColumn
wEntry.Refresh()
wWindow.Canvas().Focus(wEntry)
case *RelayEventDataBufferInput:
b := bufferByName(data.BufferName)
if b == nil {
return
}
if b.historyAt == len(b.history) {
b.historyAt++
}
b.history = append(b.history, data.Text)
case *RelayEventDataBufferClear:
b := bufferByName(data.BufferName)
if b == nil {
return
}
b.lines = nil
if b.bufferName == bufferCurrent {
refreshBuffer(b)
}
case *RelayEventDataServerUpdate:
s, existed := servers[data.ServerName]
if !existed {
s = &server{}
servers[data.ServerName] = s
}
s.state = data.Data.Variant.State()
switch state := data.Data.Variant.(type) {
case *RelayServerDataRegistered:
s.user = state.User
s.userModes = state.UserModes
default:
s.user = ""
s.userModes = ""
}
refreshPrompt()
case *RelayEventDataServerRename:
servers[data.New] = servers[data.ServerName]
delete(servers, data.ServerName)
case *RelayEventDataServerRemove:
delete(servers, data.ServerName)
}
}
// --- Networking --------------------------------------------------------------
func relayMakeReceiver(
ctx context.Context, conn net.Conn) <-chan RelayEventMessage {
// The usual event message rarely gets above 1 kilobyte,
// thus this is set to buffer up at most 1 megabyte or so.
p := make(chan RelayEventMessage, 1000)
r := bufio.NewReaderSize(conn, 65536)
go func() {
defer close(p)
for {
m, ok := relayReadMessage(r)
if !ok {
return
}
select {
case p <- m:
case <-ctx.Done():
return
}
}
}()
return p
}
func relayResetState() {
commandSeq = 0
commandCallbacks = make(map[uint32]callback)
buffers = nil
bufferCurrent = ""
bufferLast = ""
servers = make(map[string]*server)
refreshIcon()
refreshTopic(nil)
refreshBufferList()
wRichText.ParseMarkdown("")
refreshPrompt()
refreshStatus()
}
func relayRun() {
fyne.CurrentApp().Preferences().SetString(preferenceAddress, backendAddress)
backendLock.Lock()
fyne.DoAndWait(func() {
relayResetState()
})
backendContext, backendCancel = context.WithCancel(context.Background())
defer backendCancel()
var err error
backendConn, err = net.Dial("tcp", backendAddress)
backendLock.Unlock()
if err != nil {
fyne.DoAndWait(func() {
wConnect.Show()
showErrorMessage("Connection failed: " + err.Error())
})
return
}
defer backendConn.Close()
// TODO(p): Figure out locking.
// - Messages are currently sent (semi-)synchronously, directly.
// - Is the net.Conn actually async-safe?
relaySend(RelayCommandData{
Variant: &RelayCommandDataHello{Version: RelayVersion},
}, nil)
relayMessages := relayMakeReceiver(backendContext, backendConn)
Loop:
for {
select {
case m, ok := <-relayMessages:
if !ok {
break Loop
}
fyne.DoAndWait(func() {
relayProcessMessage(&m)
})
}
}
fyne.DoAndWait(func() {
wConnect.Show()
showErrorMessage("Disconnected")
})
}
// --- Input line --------------------------------------------------------------
func inputSetContents(input string) {
wEntry.SetText(input)
}
func inputSubmit(text string) bool {
b := bufferByName(bufferCurrent)
if b == nil {
return false
}
b.history = append(b.history, text)
b.historyAt = len(b.history)
inputSetContents("")
relaySend(RelayCommandData{Variant: &RelayCommandDataBufferInput{
BufferName: b.bufferName,
Text: text,
}}, nil)
return true
}
type inputStamp struct {
cursorRow, cursorColumn int
input string
}
func inputGetStamp() inputStamp {
return inputStamp{
cursorRow: wEntry.CursorRow,
cursorColumn: wEntry.CursorColumn,
input: wEntry.Text,
}
}
func inputCompleteFinish(state inputStamp,
err string, response *RelayResponseDataBufferComplete) {
if response == nil {
showErrorMessage(err)
return
}
if len(response.Completions) > 0 {
insert := response.Completions[0]
if len(response.Completions) == 1 {
insert += " "
}
inputSetContents(state.input[:response.Start] + insert)
}
if len(response.Completions) != 1 {
beep()
}
// TODO(p): Show all completion options.
}
func inputComplete() bool {
if wEntry.SelectedText() != "" {
return false
}
// XXX: Fyne's Entry widget makes it impossible to handle this properly.
state := inputGetStamp()
relaySend(RelayCommandData{Variant: &RelayCommandDataBufferComplete{
BufferName: bufferCurrent,
Text: state.input,
Position: uint32(len(state.input)),
}}, func(err string, response *RelayResponseData) {
if stamp := inputGetStamp(); state == stamp {
inputCompleteFinish(state,
err, response.Variant.(*RelayResponseDataBufferComplete))
}
})
return true
}
func inputUp() bool {
b := bufferByName(bufferCurrent)
if b == nil || b.historyAt < 1 {
return false
}
if b.historyAt == len(b.history) {
b.input = wEntry.Text
}
b.historyAt--
inputSetContents(b.history[b.historyAt])
return true
}
func inputDown() bool {
b := bufferByName(bufferCurrent)
if b == nil || b.historyAt >= len(b.history) {
return false
}
b.historyAt++
if b.historyAt == len(b.history) {
inputSetContents(b.input)
} else {
inputSetContents(b.history[b.historyAt])
}
return true
}
// --- General UI --------------------------------------------------------------
type inputEntry struct {
widget.Entry
// selectKeyDown is a hack to exactly invert widget.Entry's behaviour,
// which groups both Shift keys together.
selectKeyDown bool
}
func newInputEntry() *inputEntry {
e := &inputEntry{}
e.MultiLine = true
e.Wrapping = fyne.TextWrap(fyne.TextTruncateClip)
e.ExtendBaseWidget(e)
return e
}
func (e *inputEntry) FocusLost() {
e.selectKeyDown = false
e.Entry.FocusLost()
}
func (e *inputEntry) KeyDown(key *fyne.KeyEvent) {
// TODO(p): And perhaps on other actions, too.
relaySend(RelayCommandData{Variant: &RelayCommandDataActive{}}, nil)
// Modified events are eaten somewhere, not reaching TypedKey or Shortcuts.
if dd, ok := fyne.CurrentApp().Driver().(desktop.Driver); ok {
modifiedKey := desktop.CustomShortcut{
KeyName: key.Name, Modifier: dd.CurrentKeyModifiers()}
if handler := shortcuts[modifiedKey]; handler != nil {
handler()
return
}
switch {
case modifiedKey.Modifier == fyne.KeyModifierControl &&
modifiedKey.KeyName == fyne.KeyP:
inputUp()
return
case modifiedKey.Modifier == fyne.KeyModifierControl &&
modifiedKey.KeyName == fyne.KeyN:
inputDown()
return
}
}
if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight {
e.selectKeyDown = true
}
e.Entry.KeyDown(key)
}
func (e *inputEntry) KeyUp(key *fyne.KeyEvent) {
if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight {
e.selectKeyDown = false
}
e.Entry.KeyUp(key)
}
func (e *inputEntry) TypedKey(key *fyne.KeyEvent) {
if e.Disabled() {
return
}
// Invert the Shift key behaviour here.
// Notice that this will never work on mobile.
shift := &fyne.KeyEvent{Name: desktop.KeyShiftLeft}
switch key.Name {
case fyne.KeyReturn, fyne.KeyEnter:
if e.selectKeyDown {
e.Entry.KeyUp(shift)
e.Entry.TypedKey(key)
e.Entry.KeyDown(shift)
} else if e.OnSubmitted != nil {
e.OnSubmitted(e.Text)
}
case fyne.KeyTab:
if e.selectKeyDown {
// This could also go through completion lists.
wWindow.Canvas().FocusPrevious()
} else {
inputComplete()
}
default:
e.Entry.TypedKey(key)
}
}
func (e *inputEntry) SetText(text string) {
e.Entry.SetText(text)
if text != "" {
e.Entry.TypedKey(&fyne.KeyEvent{Name: fyne.KeyPageDown})
}
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
type logEntry struct {
// XXX: Sadly, we can't seem to make it actually read-only.
// https://github.com/fyne-io/fyne/issues/5263
widget.Entry
}
func newLogEntry() *logEntry {
e := &logEntry{}
e.MultiLine = true
e.Wrapping = fyne.TextWrapWord
e.ExtendBaseWidget(e)
return e
}
func (e *logEntry) SetText(text string) {
e.OnChanged = nil
e.Entry.SetText(text)
e.OnChanged = func(string) { e.Entry.SetText(text) }
}
func (e *logEntry) AcceptsTab() bool {
return false
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
type customLayout struct{}
func (l *customLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
var w, h float32 = 0, 0
for _, o := range objects {
size := o.MinSize()
if w < size.Width {
w = size.Width
}
if h < size.Height {
h = size.Height
}
}
return fyne.NewSize(w, h)
}
func (l *customLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
// It is not otherwise possible to be notified of resizes.
// Embedding container.Scroll either directly or as a pointer
// to override its Resize method results in brokenness.
toBottom := bufferAtBottom()
for _, o := range objects {
o.Move(fyne.NewPos(0, 0))
o.Resize(size)
}
if toBottom {
bufferScrollToBottom()
} else {
recheckHighlighted()
refreshStatus()
}
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// rotatedBuffers returns buffer indexes starting with the current buffer.
func rotatedBuffers() []int {
r, start := make([]int, len(buffers)), 0
for i := range buffers {
if buffers[i].bufferName == bufferCurrent {
start = i
break
}
}
for i := range r {
start++
r[i] = start % len(r)
}
return r
}
var shortcuts = map[desktop.CustomShortcut]func(){
{
KeyName: fyne.KeyPageUp,
Modifier: fyne.KeyModifierControl,
}: func() {
if r := rotatedBuffers(); len(r) <= 0 {
} else if i := r[len(r)-1]; i == 0 {
bufferActivate(buffers[len(buffers)-1].bufferName)
} else {
bufferActivate(buffers[i-1].bufferName)
}
},
{
KeyName: fyne.KeyPageDown,
Modifier: fyne.KeyModifierControl,
}: func() {
if r := rotatedBuffers(); len(r) <= 0 {
} else {
bufferActivate(buffers[r[0]].bufferName)
}
},
{
KeyName: fyne.KeyTab,
Modifier: fyne.KeyModifierAlt,
}: func() {
if bufferLast != "" {
bufferActivate(bufferLast)
}
},
{
// XXX: This makes an assumption on the keyboard layout (we want '!').
KeyName: fyne.Key1,
Modifier: fyne.KeyModifierAlt | fyne.KeyModifierShift,
}: func() {
for _, i := range rotatedBuffers() {
if buffers[i].highlighted {
bufferActivate(buffers[i].bufferName)
break
}
}
},
{
KeyName: fyne.KeyA,
Modifier: fyne.KeyModifierAlt,
}: func() {
for _, i := range rotatedBuffers() {
if buffers[i].newMessages != 0 {
bufferActivate(buffers[i].bufferName)
break
}
}
},
{
KeyName: fyne.KeyH,
Modifier: fyne.KeyModifierAlt | fyne.KeyModifierShift,
}: func() {
if b := bufferByName(bufferCurrent); b != nil {
bufferToggleUnimportant(b.bufferName)
}
},
{
KeyName: fyne.KeyH,
Modifier: fyne.KeyModifierAlt,
}: func() {
if b := bufferByName(bufferCurrent); b != nil {
bufferToggleLog()
}
},
}
func main() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(),
"Usage: %s [OPTION...] [CONNECT]\n\n", os.Args[0])
flag.PrintDefaults()
}
flag.Parse()
if flag.NArg() > 1 {
flag.Usage()
os.Exit(1)
}
var err error
otoContext, otoReady, err = oto.NewContext(&oto.NewContextOptions{
SampleRate: 44100,
ChannelCount: 1,
Format: oto.FormatSignedInt16LE,
})
if err != nil {
log.Println(err)
}
a := app.New()
a.Settings().SetTheme(&customTheme{})
a.SetIcon(resourceIconNormal)
wWindow = a.NewWindow(projectName)
wWindow.Resize(fyne.NewSize(640, 480))
a.Lifecycle().SetOnEnteredForeground(func() {
// TODO(p): Does this need locking?
inForeground = true
recheckHighlighted()
})
a.Lifecycle().SetOnExitedForeground(func() {
inForeground = false
})
// TODO(p): Consider using data bindings.
wBufferList = widget.NewList(func() int { return len(buffers) },
func() fyne.CanvasObject {
return widget.NewLabel(strings.Repeat(" ", 16))
},
func(id widget.ListItemID, item fyne.CanvasObject) {
label, b := item.(*widget.Label), &buffers[int(id)]
label.TextStyle.Italic = b.bufferName == bufferCurrent
label.TextStyle.Bold = false
text := b.bufferName
if b.bufferName != bufferCurrent && b.newMessages != 0 {
label.TextStyle.Bold = true
text += fmt.Sprintf(" (%d)", b.newMessages)
}
label.Importance = widget.MediumImportance
if b.highlighted {
label.Importance = widget.HighImportance
}
label.SetText(text)
})
wBufferList.HideSeparators = true
wBufferList.OnSelected = func(id widget.ListItemID) {
// TODO(p): See if we can deselect it now without consequences.
request := buffers[int(id)].bufferName
if request != bufferCurrent {
bufferActivate(request)
}
}
wTopic = widget.NewRichText()
wTopic.Truncation = fyne.TextTruncateEllipsis
wRichText = widget.NewRichText()
wRichText.Wrapping = fyne.TextWrapWord
wRichScroll = container.NewVScroll(wRichText)
wRichScroll.OnScrolled = func(position fyne.Position) {
recheckHighlighted()
refreshStatus()
}
wLog = newLogEntry()
wLog.Wrapping = fyne.TextWrapWord
wLog.Hide()
wPrompt = widget.NewLabelWithStyle(
"", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
wDown = widget.NewIcon(theme.MoveDownIcon())
wStatus = widget.NewLabelWithStyle(
"", fyne.TextAlignTrailing, fyne.TextStyle{})
wEntry = newInputEntry()
wEntry.OnSubmitted = func(text string) { inputSubmit(text) }
top := container.NewVBox(
wTopic,
widget.NewSeparator(),
)
split := container.NewHSplit(wBufferList,
container.New(&customLayout{}, wRichScroll, wLog))
split.SetOffset(0.25)
bottom := container.NewVBox(
widget.NewSeparator(),
container.NewBorder(nil, nil,
wPrompt, container.NewHBox(wDown, wStatus)),
wEntry,
)
wWindow.SetContent(container.NewBorder(top, bottom, nil, nil, split))
canvas := wWindow.Canvas()
for s, handler := range shortcuts {
canvas.AddShortcut(&s, func(fyne.Shortcut) { handler() })
}
// ---
connect := false
backendAddress = a.Preferences().String(preferenceAddress)
if flag.NArg() >= 1 {
backendAddress = flag.Arg(0)
connect = true
}
connectAddress := widget.NewEntry()
connectAddress.SetPlaceHolder("host:port")
connectAddress.SetText(backendAddress)
connectAddress.TypedKey(&fyne.KeyEvent{Name: fyne.KeyPageDown})
connectAddress.Validator = func(text string) error {
_, _, err := net.SplitHostPort(text)
return err
}
// TODO(p): Mobile should not have the option to cancel at all.
// The GoBack just makes us go to the background, staying useless.
wConnect = dialog.NewForm("Connect to relay", "Connect", "Exit",
[]*widget.FormItem{
{Text: "Address:", Widget: connectAddress},
}, func(ok bool) {
if ok {
backendAddress = connectAddress.Text
go relayRun()
} else if md, ok := a.Driver().(mobile.Driver); ok {
md.GoBack()
wConnect.Show()
} else {
a.Quit()
}
}, wWindow)
if connect {
go relayRun()
} else {
wConnect.Show()
}
wWindow.ShowAndRun()
}