Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ tcprcon-cli --profile="my_server" --port=27015

## Protocol Compliance

While `tcprcon-cli` follows the standard Source RCON Protocol, some game servers (like Rust) have non-standard implementations that might cause unexpected behavior, such as duplicated responses or incorrect packet IDs, the cli should still work, you might just have to deal with an overly chatty server.
While `tcprcon-cli` follows the standard Source RCON Protocol, some game servers (like Rust) have non-standard implementations that might introduce unexpected behaviors, such as duplicated responses or incorrect packet IDs, the cli should still work, you might just have to deal with an overly chatty server.

For a detailed breakdown of known server quirks and how they are handled, see the [Caveats section in the core library documentation](https://github.com/UltimateForm/tcprcon#caveats).

Expand Down
4 changes: 4 additions & 0 deletions internal/ansi/constants.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package ansi

const (
ArrowKeyUp = "\033[A"
ArrowKeyDown = "\033[B"
PageUpKey = "\033[5~"
PageDownKey = "\033[6~"
ClearScreen = "\033[2J"
CursorHome = "\033[H"
CursorToPos = "\033[%d;%dH" // use with fmt.Sprintf, the two ds are for the row and column coordinates
Expand Down
94 changes: 80 additions & 14 deletions internal/fullterm/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"os/signal"
"strings"
"sync"
"syscall"

Expand All @@ -20,10 +21,13 @@ type app struct {
stdinChannel chan byte
fd int
prevState *term.State
cmdLine []byte
content []string
commandSignature string
once sync.Once
ansiMachine stdinAnsi
history [][]byte
historyCursor int
scrollOffset int
}

func (src *app) Write(bytes []byte) (int, error) {
Expand All @@ -47,15 +51,29 @@ func (src *app) ListenStdin(context context.Context) {
}
}
}

func (src *app) setHistoryTail(b []byte) {
src.history[len(src.history)-1] = b
}

func (src *app) historyTail() []byte {
return src.history[len(src.history)-1]
}

func (src *app) currentCmd() []byte {
return src.history[src.historyCursor]
}

func (src *app) Submissions() <-chan string {
return src.submissionChan
}

func visibleContent(content []string, height int) []string {
func visibleContent(content []string, height int, scrollOffset int) []string {
currentRows := len(content)
endRow := max(currentRows-scrollOffset, 0)
// ngl i forgot why we adding plus 1.. oh well
startRow := max(currentRows-(height+1), 0)
return content[startRow:]
startRow := max(endRow-(height+1), 0)
return content[startRow:endRow]
}

func formatCommandEcho(cmd string) string {
Expand All @@ -70,7 +88,7 @@ func (src *app) DrawContent(finalDraw bool) error {
if !finalDraw {
fmt.Print(ansi.ClearScreen + ansi.CursorHome)
}
drawableRows := visibleContent(src.content, height)
drawableRows := visibleContent(src.content, height, src.scrollOffset)
for i := range drawableRows {
fmt.Print(drawableRows[i])
}
Expand All @@ -79,11 +97,18 @@ func (src *app) DrawContent(finalDraw bool) error {
return nil
}
ansi.MoveCursorTo(height, 0)
if src.scrollOffset > 0 {
fmt.Print(ansi.Format(fmt.Sprintf("[↑ %d] ", src.scrollOffset), ansi.Yellow, ansi.Bold))
}
fmt.Printf(ansi.Format("%v> ", ansi.Blue), src.commandSignature)
fmt.Print(string(src.cmdLine))
fmt.Print(string(src.currentCmd()))
return nil
}

func (src *app) traverseHistory(delta int) {
src.historyCursor = clamp(0, src.historyCursor+delta, len(src.history)-1)
}

func (src *app) Run(context context.Context) error {

// this could be an argument but i aint feeling yet
Expand Down Expand Up @@ -122,19 +147,57 @@ func (src *app) Run(context context.Context) error {
case <-context.Done():
return nil
case newStdinInput := <-src.stdinChannel:
newCmd, isSubmission := constructCmdLine(newStdinInput, src.cmdLine)
if isSubmission {
src.content = append(src.content, formatCommandEcho(string(newCmd)))
src.cmdLine = []byte{}
src.submissionChan <- string(newCmd)
} else {
src.cmdLine = newCmd
ansiSeq, ansiState := src.ansiMachine.handle(newStdinInput)
// src.content = append(src.content, fmt.Sprintf("ansi machine handling %v, at state %v\n", ansiSeq, ansiState))
switch ansiState {
case ansiStateIdle:
// no ansi sequence ongoing so its just presentation bytes
newCmd, isSubmission := constructCmdLine(newStdinInput, src.currentCmd())
if isSubmission {
src.content = append(src.content, formatCommandEcho(string(newCmd)))
if len(src.historyTail()) > 0 {
src.history = append(src.history, []byte{})
}
src.scrollOffset = 0
src.submissionChan <- string(newCmd)
} else {
src.setHistoryTail(newCmd)
}
// feel a bit awkward doing this every input stroke, we can get back to it later
src.historyCursor = len(src.history) - 1
case ansiStateCSITerm:
switch string(ansiSeq) {
case ansi.ArrowKeyUp:
src.traverseHistory(-1)
case ansi.ArrowKeyDown:
src.traverseHistory(1)
case ansi.PageUpKey:
if _, h, err := term.GetSize(src.fd); err == nil {
maxOffset := max(len(src.content)-(h+1), 0)
// substract one cuz we need to account for persistent command line
src.scrollOffset = min(src.scrollOffset+h-1, maxOffset)
} else {
// TODO: do something here idk
}
case ansi.PageDownKey:
if _, h, err := term.GetSize(src.fd); err == nil {
// ditto subtraction
src.scrollOffset = max(src.scrollOffset-(h-1), 0)
}
default:
// src.content = append(src.content, fmt.Sprintf("unhandled csi %v, %v\n", strconv.Itoa(int(ansiState)), ansiSeq))
}
default:
// src.content = append(src.content, fmt.Sprintf("unhandled state %v, %v\n", strconv.Itoa(int(ansiState)), ansiSeq))
}

if err := src.DrawContent(false); err != nil {
return err
}
case newDisplayInput := <-src.DisplayChannel:
src.content = append(src.content, newDisplayInput)
for line := range strings.Lines(newDisplayInput) {
src.content = append(src.content, line)
}
if err := src.DrawContent(false); err != nil {
return err
}
Expand Down Expand Up @@ -164,5 +227,8 @@ func CreateApp(commandSignature string) *app {
submissionChan: submissionChan,
content: make([]string, 0),
commandSignature: commandSignature,
ansiMachine: newStdinAnsi(),
history: [][]byte{[]byte{}},
historyCursor: 0,
}
}
49 changes: 44 additions & 5 deletions internal/fullterm/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (

func TestVisibleContentShorterThanWindow(t *testing.T) {
content := []string{"line1\n", "line2\n", "line3\n"}
result := visibleContent(content, 10)
result := visibleContent(content, 10, 0)
if len(result) != len(content) {
t.Fatalf("expected %d rows, got %d", len(content), len(result))
}
Expand All @@ -20,7 +20,7 @@ func TestVisibleContentShorterThanWindow(t *testing.T) {
func TestVisibleContentExactlyFitsWindow(t *testing.T) {
content := []string{"line1\n", "line2\n", "line3\n"}
// height+1 == len(content), so startRow == 0
result := visibleContent(content, len(content)-1)
result := visibleContent(content, len(content)-1, 0)
if len(result) != len(content) {
t.Fatalf("expected %d rows, got %d", len(content), len(result))
}
Expand All @@ -29,7 +29,7 @@ func TestVisibleContentExactlyFitsWindow(t *testing.T) {
func TestVisibleContentOverflowsWindow(t *testing.T) {
content := []string{"line1\n", "line2\n", "line3\n", "line4\n", "line5\n"}
height := 2
result := visibleContent(content, height)
result := visibleContent(content, height, 0)
// startRow = max(5 - 3, 0) = 2, so rows 2,3,4
expectedLen := height + 1
if len(result) != expectedLen {
Expand All @@ -44,15 +44,15 @@ func TestVisibleContentOverflowsWindow(t *testing.T) {
}

func TestVisibleContentEmpty(t *testing.T) {
result := visibleContent([]string{}, 10)
result := visibleContent([]string{}, 10, 0)
if len(result) != 0 {
t.Fatalf("expected empty result, got %d rows", len(result))
}
}

func TestVisibleContentZeroHeight(t *testing.T) {
content := []string{"line1\n", "line2\n", "line3\n"}
result := visibleContent(content, 0)
result := visibleContent(content, 0, 0)
// startRow = max(3 - 1, 0) = 2, so only last row
if len(result) != 1 {
t.Fatalf("expected 1 row, got %d", len(result))
Expand All @@ -61,3 +61,42 @@ func TestVisibleContentZeroHeight(t *testing.T) {
t.Errorf("expected 'line3\\n', got %q", result[0])
}
}

func TestVisibleContentScrolled(t *testing.T) {
content := []string{"line1\n", "line2\n", "line3\n", "line4\n", "line5\n"}
height := 2
// scrollOffset=2: endRow = 5-2 = 3, startRow = max(3-3, 0) = 0, so rows 0,1,2
result := visibleContent(content, height, 2)
expectedLen := height + 1
if len(result) != expectedLen {
t.Fatalf("expected %d rows, got %d", expectedLen, len(result))
}
if result[0] != "line1\n" {
t.Errorf("expected first visible row to be 'line1\\n', got %q", result[0])
}
if result[len(result)-1] != "line3\n" {
t.Errorf("expected last visible row to be 'line3\\n', got %q", result[len(result)-1])
}
}

func TestVisibleContentScrollOffsetPastTop(t *testing.T) {
content := []string{"line1\n", "line2\n", "line3\n"}
// scrollOffset larger than content — should return empty, not panic
result := visibleContent(content, 2, 100)
if len(result) != 0 {
t.Fatalf("expected empty result when scrolled past top, got %d rows", len(result))
}
}

func TestVisibleContentScrolledPartial(t *testing.T) {
content := []string{"line1\n", "line2\n", "line3\n", "line4\n", "line5\n"}
height := 10
// scrollOffset=1: endRow = 4, startRow = max(4-11, 0) = 0, all 4 rows visible
result := visibleContent(content, height, 1)
if len(result) != 4 {
t.Fatalf("expected 4 rows, got %d", len(result))
}
if result[len(result)-1] != "line4\n" {
t.Errorf("expected last row to be 'line4\\n', got %q", result[len(result)-1])
}
}
50 changes: 50 additions & 0 deletions internal/fullterm/stdin_ansi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package fullterm

type ansiState int

const (
ansiStateIdle ansiState = iota
ansiStateEscape
ansiStateCSI
ansiStateCSITerm
)

type stdinAnsi struct {
buf []byte
state ansiState
}

func newStdinAnsi() stdinAnsi {
return stdinAnsi{
buf: make([]byte, 0, 8),
state: ansiStateIdle,
}
}

func (src *stdinAnsi) reset() {
src.buf = make([]byte, 0, 8)
src.state = ansiStateIdle
}

func (src *stdinAnsi) handle(b byte) ([]byte, ansiState) {
switch {
case b == 27:
src.reset()
src.buf = append(src.buf, b)
src.state = ansiStateEscape
case b == 91 && src.state == ansiStateEscape:
src.buf = append(src.buf, b)
src.state = ansiStateCSI
case src.state == ansiStateCSI:
src.buf = append(src.buf, b)
if b > 63 && b < 127 {
defer func() {
src.state = ansiStateIdle
}()
src.state = ansiStateCSITerm
}
default:
src.reset()
}
return src.buf, src.state
}
4 changes: 4 additions & 0 deletions internal/fullterm/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ func constructCmdLine(newByte byte, cmdLine []byte) ([]byte, bool) {
}
return cmdLine, isSubmission
}

func clamp(minBound int, n int, maxBound int) int {
return max(minBound, min(maxBound, n))
}
30 changes: 30 additions & 0 deletions internal/fullterm/util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,33 @@ func TestConstructCmdLineSubmissionLF(t *testing.T) {
t.Errorf("expected 'test', got '%s'", newLine)
}
}

func TestClamp(t *testing.T) {
minBound := 4
maxBound := 23
n := 11
r := clamp(minBound, n, maxBound)
if r != n {
t.Fatalf("expect r to equal %v but instead it is %v", n, r)
}
}

func TestClampGreaterThanMax(t *testing.T) {
minBound := 2
maxBound := 5
n := 12
r := clamp(minBound, n, maxBound)
if r != maxBound {
t.Fatalf("expect r to equal %v but instead it is %v", maxBound, r)
}
}

func TestClampLesserThanMin(t *testing.T) {
minBound := 202
maxBound := 582
n := 105
r := clamp(minBound, n, maxBound)
if r != minBound {
t.Fatalf("expect r to equal %v but instead it is %v", minBound, r)
}
}
Loading