mirror of
https://github.com/dalbodeule/sshchat.git
synced 2025-12-07 22:55:44 +09:00
411 lines
9.3 KiB
Go
411 lines
9.3 KiB
Go
package utils
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/gliderlabs/ssh"
|
|
)
|
|
|
|
type Message struct {
|
|
Timestamp time.Time
|
|
Username string
|
|
Content string
|
|
}
|
|
|
|
type Input struct {
|
|
Buffer []rune
|
|
MaxLen int
|
|
}
|
|
|
|
type Client struct {
|
|
session ssh.Session
|
|
|
|
mu sync.Mutex
|
|
width int
|
|
height int
|
|
input Input
|
|
messages []Message
|
|
|
|
wg sync.WaitGroup
|
|
username string
|
|
ip string
|
|
|
|
// Event channels
|
|
RenderCh chan struct{}
|
|
EnterCh chan struct{}
|
|
WinSizeChangedCh chan struct{}
|
|
CloseCh chan struct{}
|
|
|
|
// Debounce
|
|
renderDebounceTimer *time.Timer
|
|
renderDebounceDur time.Duration
|
|
|
|
// internals
|
|
cancel context.CancelFunc
|
|
once sync.Once
|
|
}
|
|
|
|
// NewClient creates a Client bound to an ssh.Session and initial state.
|
|
// It starts background goroutines to watch input, window-size changes, and session close.
|
|
func NewClient(s ssh.Session, w int, h int, username string, ip string) *Client {
|
|
input := Input{
|
|
Buffer: make([]rune, 0, 128),
|
|
MaxLen: 128,
|
|
}
|
|
c := &Client{
|
|
session: s,
|
|
width: w,
|
|
height: h,
|
|
username: username,
|
|
ip: ip,
|
|
input: input,
|
|
messages: make([]Message, 0),
|
|
RenderCh: make(chan struct{}, 1),
|
|
EnterCh: make(chan struct{}, 1),
|
|
WinSizeChangedCh: make(chan struct{}, 1),
|
|
CloseCh: make(chan struct{}, 1),
|
|
renderDebounceDur: 50 * time.Millisecond,
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
c.cancel = cancel
|
|
|
|
// Input watcher (Enter, Ctrl+C, Ctrl+D) and render trigger
|
|
c.wg.Add(1)
|
|
go func() {
|
|
defer c.wg.Done()
|
|
reader := bufio.NewReader(s)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
r, size, err := reader.ReadRune()
|
|
if err != nil {
|
|
if err == io.EOF || isSessionClosedErr(err) {
|
|
c.emitClose()
|
|
}
|
|
return
|
|
}
|
|
|
|
if size == 0 {
|
|
continue
|
|
}
|
|
|
|
c.mu.Lock()
|
|
switch r {
|
|
case '\r', '\n': // **[수정] \r과 \n을 함께 처리**
|
|
if len(c.input.Buffer) > 0 {
|
|
c.messages = append(c.messages, Message{
|
|
Timestamp: time.Now(),
|
|
Username: c.username,
|
|
Content: string(c.input.Buffer),
|
|
})
|
|
c.input.Buffer = c.input.Buffer[:0]
|
|
}
|
|
c.mu.Unlock()
|
|
c.trySend(c.EnterCh)
|
|
c.TrySendRender()
|
|
case 0x03: // Ctrl+C
|
|
c.mu.Unlock()
|
|
c.emitClose()
|
|
return
|
|
case 0x04: // Ctrl+D
|
|
c.mu.Unlock()
|
|
c.emitClose()
|
|
return
|
|
case '\b', 0x7f: // Backspace (0x08) 또는 Delete (0x7f)
|
|
if len(c.input.Buffer) > 0 {
|
|
c.input.Buffer = c.input.Buffer[:len(c.input.Buffer)-1]
|
|
c.mu.Unlock()
|
|
c.TrySendRender()
|
|
} else {
|
|
c.mu.Unlock()
|
|
}
|
|
default:
|
|
// 출력 불가능한 문자 (제어 문자 등)는 무시
|
|
if r < 0x20 && r != '\t' {
|
|
c.mu.Unlock()
|
|
continue
|
|
}
|
|
|
|
if len(c.input.Buffer) < c.input.MaxLen {
|
|
// 룬(rune)을 []rune 슬라이스에 추가
|
|
c.input.Buffer = append(c.input.Buffer, r)
|
|
c.mu.Unlock()
|
|
c.TrySendRender()
|
|
} else {
|
|
// 버퍼가 꽉 찬 경우 렌더링 요청을 보내지 않습니다.
|
|
c.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Window size change watcher
|
|
c.wg.Add(1)
|
|
go func() {
|
|
defer c.wg.Done()
|
|
_, winCh, _ := s.Pty()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case win, ok := <-winCh:
|
|
if !ok {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
c.width = win.Width
|
|
c.height = win.Height
|
|
c.mu.Unlock()
|
|
c.trySend(c.WinSizeChangedCh)
|
|
c.TrySendRender()
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Session close watcher (fallback)
|
|
c.wg.Add(1)
|
|
go func() {
|
|
defer c.wg.Done()
|
|
<-s.Context().Done()
|
|
c.emitClose()
|
|
}()
|
|
|
|
return c
|
|
}
|
|
|
|
// Word wrap을 위한 헬퍼 함수
|
|
// 반환되는 각 문자열은 한 줄의 내용이며, 줄 바꿈 문자는 포함하지 않습니다.
|
|
func calculateMessageLines(header string, content []rune, w int) []string {
|
|
lines := make([]string, 0)
|
|
currentLine := ""
|
|
lineWidth := w
|
|
|
|
// 1. 헤더 처리
|
|
if utf8.RuneCountInString(header) > 0 {
|
|
currentLine = header
|
|
lineWidth -= utf8.RuneCountInString(header)
|
|
}
|
|
|
|
// 2. 내용 처리
|
|
contentIdx := 0
|
|
for contentIdx < len(content) {
|
|
remainingContent := content[contentIdx:]
|
|
spaceLeft := lineWidth - utf8.RuneCountInString(currentLine)
|
|
|
|
if len(remainingContent) <= spaceLeft {
|
|
// 남은 내용이 현재 줄에 모두 들어갈 경우
|
|
currentLine += string(remainingContent)
|
|
contentIdx = len(content)
|
|
} else if spaceLeft > 0 {
|
|
// 현재 줄에 일부만 넣을 경우
|
|
toAdd := remainingContent[:spaceLeft]
|
|
currentLine += string(toAdd)
|
|
contentIdx += len(toAdd)
|
|
|
|
// 줄이 꽉 찼으므로 다음 줄로 넘깁니다.
|
|
lines = append(lines, currentLine)
|
|
currentLine = ""
|
|
lineWidth = w // 다음 줄은 전체 너비
|
|
} else {
|
|
// 현재 줄이 꽉 찼거나 (spaceLeft <= 0), 헤더만 있는 경우
|
|
// 현재 줄을 저장하고 다음 줄로 넘깁니다.
|
|
lines = append(lines, currentLine)
|
|
currentLine = ""
|
|
lineWidth = w // 다음 줄은 전체 너비
|
|
}
|
|
}
|
|
|
|
// 마지막 줄이 비어있지 않으면 추가
|
|
if currentLine != "" {
|
|
lines = append(lines, currentLine)
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
// handleRender는 화면 렌더링을 처리합니다.
|
|
func (c *Client) handleRender() {
|
|
w, h := c.Size()
|
|
s := c.Session()
|
|
|
|
// 1. 화면을 지우고 커서를 맨 위로 이동 (화면을 새로 그릴 준비)
|
|
fmt.Fprint(s, "\x1b[2J\x1b[H")
|
|
|
|
// **[수정] 2. 입력 프롬프트 렌더링 영역 계산**
|
|
promptLine := h // 프롬프트가 위치할 맨 아래 행
|
|
|
|
// **[수정] 3. 메시지 렌더링 (아래에서 위로 스크롤)**
|
|
maxMessageHeight := promptLine - 1 // 메시지가 출력될 수 있는 최대 행
|
|
|
|
messages := c.messages
|
|
currentY := maxMessageHeight // 현재 출력할 행 (bottom-up)
|
|
|
|
// 메시지 인덱스를 역순으로 순회 (최신 메시지가 화면 아래에 위치)
|
|
for i := len(messages) - 1; i >= 0; i-- {
|
|
msg := messages[i]
|
|
|
|
header := fmt.Sprintf("[%s %s] ", msg.Timestamp.Format("2006-01-02 15:04:05"), msg.Username)
|
|
content := []rune(msg.Content)
|
|
|
|
lines := calculateMessageLines(header, content, w)
|
|
numLines := len(lines)
|
|
|
|
// 현재 행이 출력될 공간이 부족하면 중단
|
|
if currentY-numLines < 0 {
|
|
break
|
|
}
|
|
|
|
// 커서를 메시지가 시작될 행으로 이동 (currentY + 1은 1-based index)
|
|
currentY -= numLines
|
|
fmt.Fprintf(s, "\x1b[%dH", currentY+1)
|
|
|
|
// 메시지 출력 (줄 바꿈 포함)
|
|
for _, line := range lines {
|
|
fmt.Fprintln(s, line)
|
|
}
|
|
if currentY < 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
// 4. 입력 프롬프트 렌더링 (화면 맨 아래 행에 출력)
|
|
fmt.Fprintf(s, "\x1b[%dH", promptLine)
|
|
promptRunes := append([]rune("> "), c.input.Buffer...)
|
|
|
|
// 프롬프트를 화면 너비 w에 맞게 출력 (줄 바꿈은 고려하지 않음)
|
|
if len(promptRunes) > w {
|
|
fmt.Fprint(s, string(promptRunes[:w]))
|
|
} else {
|
|
fmt.Fprint(s, string(promptRunes))
|
|
}
|
|
|
|
// 5. 마지막으로 커서를 입력 위치로 재배치 (promptLine 행, 프롬프트 문자열 끝)
|
|
cursorX := utf8.RuneCountInString("> ") + len(c.input.Buffer) + 1
|
|
if cursorX > w {
|
|
cursorX = w
|
|
}
|
|
fmt.Fprintf(s, "\x1b[%d;%dH", promptLine, cursorX)
|
|
}
|
|
|
|
func (c *Client) handleClose() {
|
|
c.emitClose()
|
|
}
|
|
|
|
// Accessors
|
|
|
|
func (c *Client) Session() ssh.Session { return c.session }
|
|
|
|
func (c *Client) Username() string { return c.username }
|
|
|
|
func (c *Client) IP() string { return c.ip }
|
|
|
|
func (c *Client) Size() (int, int) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.width, c.height
|
|
}
|
|
|
|
// Close shuts down watchers and closes event channels.
|
|
func (c *Client) Close() {
|
|
c.emitClose()
|
|
c.wg.Wait()
|
|
c.once.Do(func() {
|
|
close(c.RenderCh)
|
|
close(c.EnterCh)
|
|
close(c.WinSizeChangedCh)
|
|
close(c.CloseCh)
|
|
})
|
|
}
|
|
|
|
// Internal utilities
|
|
func (c *Client) emitClose() {
|
|
c.once.Do(func() {
|
|
// **[추가]** SSH 세션 자체를 닫습니다.
|
|
if c.session != nil {
|
|
_ = c.session.Close() // 오류 처리는 간단히 무시합니다.
|
|
}
|
|
|
|
// drain cancel after a short delay to allow pending events
|
|
go func() {
|
|
time.Sleep(10 * time.Millisecond)
|
|
if c.cancel != nil {
|
|
c.cancel()
|
|
}
|
|
}()
|
|
c.trySend(c.CloseCh)
|
|
})
|
|
}
|
|
|
|
func (c *Client) trySend(ch chan struct{}) {
|
|
select {
|
|
case ch <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (c *Client) TrySendRender() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.renderDebounceTimer != nil {
|
|
c.renderDebounceTimer.Reset(c.renderDebounceDur)
|
|
return
|
|
}
|
|
|
|
c.renderDebounceTimer = time.AfterFunc(c.renderDebounceDur, func() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.trySend(c.RenderCh)
|
|
|
|
c.renderDebounceTimer.Stop()
|
|
c.renderDebounceTimer = nil
|
|
})
|
|
}
|
|
|
|
func (c *Client) HandleRender() {
|
|
c.handleRender()
|
|
}
|
|
|
|
func (c *Client) EventLoop() {
|
|
for {
|
|
select {
|
|
case <-c.RenderCh:
|
|
c.HandleRender()
|
|
|
|
case <-c.EnterCh:
|
|
// Input watcher가 메시지 버퍼를 변경하고 EnterCh를 보냈습니다.
|
|
// 상태가 변경되었으므로 렌더링을 요청합니다.
|
|
c.HandleRender()
|
|
|
|
case <-c.WinSizeChangedCh:
|
|
// WinSize watcher가 이미 c.width, c.height를 업데이트했습니다.
|
|
c.HandleRender()
|
|
|
|
case <-c.CloseCh:
|
|
c.handleClose()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func isSessionClosedErr(err error) bool {
|
|
// Conservative check; specific implementations may vary.
|
|
return err == io.EOF
|
|
}
|
|
|
|
// handleEnter 함수가 더 이상 EventLoop에서 필요하지 않으므로,
|
|
// Client 구조체에서 관련 메서드인 handleEnter는 삭제하는 것이 깔끔하지만,
|
|
// 현재 코드를 유지하기 위해 body를 비워둡니다.
|