Files
sshchat/utils/client.go
2025-10-14 10:16:18 +09:00

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를 비워둡니다.