mirror of
https://oauth2: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@github.com/m1ngsama/TNT.git
synced 2026-06-26 05:44:38 +08:00
Compare commits
81 commits
d3ebe25973
...
94b602613f
| Author | SHA1 | Date | |
|---|---|---|---|
| 94b602613f | |||
| 139715efb5 | |||
| cd49519058 | |||
| 6c8ea56e8d | |||
| ed92aeb1e6 | |||
| f196bfaf6d | |||
| aa2b8b1b23 | |||
| d1d44d0914 | |||
| 46f5780057 | |||
| 69d3b76512 | |||
| f99103ede6 | |||
| 155e535b8a | |||
| f2942e9c9e | |||
| 0aaba8e1f9 | |||
| 1391ddca07 | |||
| bfaafb4b35 | |||
| da0170d2c0 | |||
| e911a2d469 | |||
| 5eda6ed127 | |||
| 00fc944da8 | |||
| 01439507d5 | |||
| 8fbd789dfb | |||
| 06a10e2df8 | |||
| 1f1c2398b6 | |||
| 57bf3cfc67 | |||
| 8eb311e54b | |||
| a693d281f8 | |||
| 15aac7134f | |||
| e78989c7ce | |||
| 782d21eaae | |||
| 86e1ec8e32 | |||
| 1897a980d5 | |||
| ddf1242b17 | |||
| 84e26e3f74 | |||
| 998da4288f | |||
| fa16beb7a6 | |||
| cd170d3245 | |||
| f39f07b205 | |||
| 095491927a | |||
| 6d5c77b850 | |||
| 6ec86eb016 | |||
| 73655d0e70 | |||
| fd6cdbf627 | |||
| 81c3f45864 | |||
| 0cf8ac6759 | |||
| 4fb531771b | |||
| 8009887be9 | |||
| 07e47e65c8 | |||
| 1d8fcea3fa | |||
| aca68824ac | |||
| 9159586716 | |||
| 4c8ef99880 | |||
| 22ab85acef | |||
| 92123d208d | |||
| f535b928d1 | |||
| 2e69283e5c | |||
| 0c27976763 | |||
| 39f7f1c7c4 | |||
| 599cd690b8 | |||
| 4c7b72e7a0 | |||
| 2490262332 | |||
| 7da33951b0 | |||
| d819fd5324 | |||
| a4748cd902 | |||
| b4e714ed44 | |||
| 36dbe8d549 | |||
| 69ddcd2d95 | |||
| 169ba1a150 | |||
| 67d21ad0e9 | |||
| 87d6572156 | |||
| ddcecbea81 | |||
| 70718482f3 | |||
| 6a36cbcb82 | |||
| 585262fe4f | |||
| 0e03c4d216 | |||
| 0a013ed40f | |||
| ae1bc2f166 | |||
| c66491d4f8 | |||
| b1353d904b | |||
| f3217de36b | |||
| 94f3d28562 |
83 changed files with 6013 additions and 1100 deletions
70
.github/workflows/ci.yml
vendored
70
.github/workflows/ci.yml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libssh-dev
|
||||
sudo apt-get install -y expect libssh-dev
|
||||
|
||||
- name: Install dependencies (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
|
|
@ -34,17 +34,67 @@ jobs:
|
|||
run: make asan
|
||||
|
||||
- name: Run comprehensive tests
|
||||
run: |
|
||||
make test
|
||||
cd tests
|
||||
./test_security_features.sh
|
||||
# Skipping anonymous access test in CI as it requires interactive pty handling which might be flaky
|
||||
# ./test_anonymous_access.sh
|
||||
run: make ci-test
|
||||
|
||||
- name: Run release preflight
|
||||
run: make release-check
|
||||
|
||||
- name: Check for memory leaks
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
set -eu
|
||||
sudo apt-get install -y valgrind
|
||||
timeout 10 valgrind --leak-check=full --error-exitcode=1 ./tnt &
|
||||
sleep 5
|
||||
pkill tnt || true
|
||||
STATE_DIR=$(mktemp -d)
|
||||
SERVER_LOG="$STATE_DIR/server.log"
|
||||
VALGRIND_LOG="$STATE_DIR/valgrind.log"
|
||||
PORT=13990
|
||||
SERVER_PID=""
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$STATE_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
ssh-keygen -q -t rsa -b 4096 -m PEM -N "" -f "$STATE_DIR/host_key"
|
||||
chmod 600 "$STATE_DIR/host_key"
|
||||
|
||||
TNT_RATE_LIMIT=0 valgrind --leak-check=full --error-exitcode=99 --log-file="$VALGRIND_LOG" \
|
||||
./tnt -p "$PORT" -d "$STATE_DIR" >"$SERVER_LOG" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
READY=0
|
||||
for _ in $(seq 1 60); do
|
||||
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
OUT=$(ssh -n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-o BatchMode=yes -p "$PORT" localhost health 2>/dev/null || true)
|
||||
if [ "$OUT" = "ok" ]; then
|
||||
READY=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$READY" -ne 1 ]; then
|
||||
echo "::group::server log"
|
||||
cat "$SERVER_LOG" || true
|
||||
echo "::endgroup::"
|
||||
echo "::group::valgrind log"
|
||||
cat "$VALGRIND_LOG" || true
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
SERVER_PID=""
|
||||
|
||||
if ! grep -q "ERROR SUMMARY: 0 errors" "$VALGRIND_LOG"; then
|
||||
cat "$VALGRIND_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
|
|
|||
45
.github/workflows/deploy.yml
vendored
45
.github/workflows/deploy.yml
vendored
|
|
@ -1,45 +0,0 @@
|
|||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libssh-dev
|
||||
|
||||
- name: Build
|
||||
run: make
|
||||
|
||||
- name: Build with AddressSanitizer
|
||||
run: make asan
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
make test
|
||||
cd tests
|
||||
./test_security_features.sh
|
||||
|
||||
deploy:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to production
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||
script: |
|
||||
cd /home/admin/repo/tnt
|
||||
git pull origin main
|
||||
make clean && make release
|
||||
cp tnt /home/admin/tnt/tnt
|
||||
sudo systemctl restart tnt
|
||||
50
.github/workflows/release.yml
vendored
50
.github/workflows/release.yml
vendored
|
|
@ -12,16 +12,16 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
- os: ubuntu-24.04
|
||||
target: linux-amd64
|
||||
artifact: tnt-linux-amd64
|
||||
- os: ubuntu-latest
|
||||
- os: ubuntu-24.04-arm
|
||||
target: linux-arm64
|
||||
artifact: tnt-linux-arm64
|
||||
- os: macos-latest
|
||||
- os: macos-15-intel
|
||||
target: darwin-amd64
|
||||
artifact: tnt-darwin-amd64
|
||||
- os: macos-latest
|
||||
- os: macos-15
|
||||
target: darwin-arm64
|
||||
artifact: tnt-darwin-arm64
|
||||
|
||||
|
|
@ -34,20 +34,35 @@ jobs:
|
|||
sudo apt-get update
|
||||
sudo apt-get install -y libssh-dev
|
||||
|
||||
- name: Install cross-compilation tools (Ubuntu ARM64)
|
||||
if: matrix.target == 'linux-arm64'
|
||||
run: |
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
sudo dpkg --add-architecture arm64
|
||||
|
||||
- name: Install dependencies (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
brew install libssh
|
||||
|
||||
- name: Run release preflight
|
||||
run: make release-check
|
||||
|
||||
- name: Build release binary
|
||||
run: make release
|
||||
|
||||
- name: Verify artifact architecture
|
||||
run: |
|
||||
file tnt
|
||||
case "${{ matrix.target }}" in
|
||||
linux-amd64)
|
||||
file tnt | grep -E 'ELF 64-bit.*x86-64'
|
||||
;;
|
||||
linux-arm64)
|
||||
file tnt | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
|
||||
;;
|
||||
darwin-amd64)
|
||||
file tnt | grep -E 'Mach-O 64-bit.*x86_64'
|
||||
;;
|
||||
darwin-arm64)
|
||||
file tnt | grep -E 'Mach-O 64-bit.*arm64'
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Rename binary
|
||||
run: mv tnt ${{ matrix.artifact }}
|
||||
|
||||
|
|
@ -74,19 +89,18 @@ jobs:
|
|||
- name: Create checksums
|
||||
run: |
|
||||
cd artifacts
|
||||
for dir in */; do
|
||||
cd "$dir"
|
||||
sha256sum * > checksums.txt
|
||||
cd ..
|
||||
: > checksums.txt
|
||||
for artifact in */tnt-*; do
|
||||
sha256sum "$artifact" | sed "s# $artifact# $(basename "$artifact")#" >> checksums.txt
|
||||
done
|
||||
cd ..
|
||||
cat checksums.txt
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
artifacts/*/tnt-*
|
||||
artifacts/*/checksums.txt
|
||||
artifacts/checksums.txt
|
||||
body: |
|
||||
## Installation
|
||||
|
||||
|
|
@ -126,8 +140,8 @@ jobs:
|
|||
```
|
||||
|
||||
## What's Changed
|
||||
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/${{ github.ref_name }}/CHANGELOG.md)
|
||||
draft: false
|
||||
See [docs/CHANGELOG.md](https://github.com/${{ github.repository }}/blob/${{ github.ref_name }}/docs/CHANGELOG.md)
|
||||
draft: true
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -11,3 +11,13 @@ test.log
|
|||
tests/unit/test_utf8
|
||||
tests/unit/test_message
|
||||
tests/unit/test_chat_room
|
||||
tests/unit/test_history_view
|
||||
tests/unit/test_i18n
|
||||
tests/unit/test_system_message
|
||||
tests/unit/test_command_catalog
|
||||
tests/unit/test_exec_catalog
|
||||
tests/unit/test_help_text
|
||||
tests/unit/test_manual_text
|
||||
tests/unit/test_support_text
|
||||
tests/unit/test_cli_text
|
||||
tests/unit/test_ratelimit
|
||||
|
|
|
|||
75
Makefile
75
Makefile
|
|
@ -5,6 +5,7 @@ CC = gcc
|
|||
CFLAGS = -Wall -Wextra -O2 -std=c11 -D_XOPEN_SOURCE=700
|
||||
LDFLAGS = -pthread -lssh
|
||||
INCLUDES = -Iinclude
|
||||
DEPFLAGS = -MMD -MP
|
||||
|
||||
# Detect libssh location (homebrew on macOS)
|
||||
ifeq ($(shell uname), Darwin)
|
||||
|
|
@ -21,9 +22,16 @@ OBJ_DIR = obj
|
|||
|
||||
SOURCES = $(wildcard $(SRC_DIR)/*.c)
|
||||
OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)
|
||||
DEPS = $(OBJECTS:.o=.d)
|
||||
TARGET = tnt
|
||||
|
||||
.PHONY: all clean install uninstall debug release asan valgrind check info
|
||||
PREFIX ?= /usr/local
|
||||
BINDIR ?= $(PREFIX)/bin
|
||||
MANDIR ?= $(PREFIX)/share/man
|
||||
SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system
|
||||
CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222)
|
||||
|
||||
.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict asan valgrind check test test-advisory ci-test unit-test integration-test anonymous-access-test connection-limit-test security-test stress-test info
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
|
|
@ -32,7 +40,7 @@ $(TARGET): $(OBJECTS)
|
|||
@echo "Build complete: $(TARGET)"
|
||||
|
||||
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
|
||||
$(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) $(INCLUDES) -c $< -o $@
|
||||
|
||||
$(OBJ_DIR):
|
||||
mkdir -p $(OBJ_DIR)
|
||||
|
|
@ -43,14 +51,21 @@ clean:
|
|||
@echo "Clean complete"
|
||||
|
||||
install: $(TARGET)
|
||||
install -d $(DESTDIR)/usr/local/bin
|
||||
install -m 755 $(TARGET) $(DESTDIR)/usr/local/bin/
|
||||
install -d $(DESTDIR)/usr/local/share/man/man1
|
||||
install -m 644 tnt.1 $(DESTDIR)/usr/local/share/man/man1/
|
||||
install -d $(DESTDIR)$(BINDIR)
|
||||
install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/
|
||||
install -d $(DESTDIR)$(MANDIR)/man1
|
||||
install -m 644 tnt.1 $(DESTDIR)$(MANDIR)/man1/
|
||||
|
||||
install-systemd:
|
||||
install -d $(DESTDIR)$(SYSTEMD_UNIT_DIR)
|
||||
install -m 644 tnt.service $(DESTDIR)$(SYSTEMD_UNIT_DIR)/
|
||||
|
||||
uninstall:
|
||||
rm -f $(DESTDIR)/usr/local/bin/$(TARGET)
|
||||
rm -f $(DESTDIR)/usr/local/share/man/man1/tnt.1
|
||||
rm -f $(DESTDIR)$(BINDIR)/$(TARGET)
|
||||
rm -f $(DESTDIR)$(MANDIR)/man1/tnt.1
|
||||
|
||||
uninstall-systemd:
|
||||
rm -f $(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service
|
||||
|
||||
# Development targets
|
||||
debug: CFLAGS += -g -DDEBUG
|
||||
|
|
@ -60,6 +75,12 @@ release: CFLAGS += -O3 -DNDEBUG
|
|||
release: clean $(TARGET)
|
||||
strip $(TARGET)
|
||||
|
||||
release-check:
|
||||
./scripts/release_check.sh
|
||||
|
||||
release-check-strict:
|
||||
./scripts/release_check.sh --strict
|
||||
|
||||
asan: CFLAGS += -g -fsanitize=address -fno-omit-frame-pointer
|
||||
asan: LDFLAGS += -fsanitize=address
|
||||
asan: clean $(TARGET)
|
||||
|
|
@ -74,17 +95,51 @@ check:
|
|||
@command -v clang-tidy >/dev/null 2>&1 && clang-tidy src/*.c -- -Iinclude $(INCLUDES) || echo "clang-tidy not installed"
|
||||
|
||||
# Test
|
||||
test: all unit-test
|
||||
test: all unit-test integration-test
|
||||
|
||||
test-advisory: all unit-test
|
||||
@echo "Running integration tests..."
|
||||
@cd tests && ./test_basic.sh || echo "(integration tests are advisory)"
|
||||
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh || echo "(basic integration tests are advisory)"
|
||||
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh || echo "(exec mode tests are advisory)"
|
||||
@cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh || echo "(interactive input tests are advisory)"
|
||||
|
||||
unit-test:
|
||||
@echo "Running unit tests..."
|
||||
@$(MAKE) -C tests/unit run
|
||||
|
||||
integration-test: all
|
||||
@echo "Running integration tests..."
|
||||
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh
|
||||
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh
|
||||
@cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh
|
||||
|
||||
anonymous-access-test: all
|
||||
@echo "Running anonymous access tests..."
|
||||
@cd tests && PORT=$${PORT:-2222} ./test_anonymous_access.sh
|
||||
|
||||
connection-limit-test: all
|
||||
@echo "Running connection limit tests..."
|
||||
@cd tests && PORT=$${PORT:-2222} ./test_connection_limits.sh
|
||||
|
||||
security-test: all
|
||||
@echo "Running security feature tests..."
|
||||
@cd tests && PORT=$${PORT:-13600} ./test_security_features.sh
|
||||
|
||||
stress-test: all
|
||||
@echo "Running stress tests..."
|
||||
@cd tests && PORT=$${PORT:-2222} ./test_stress.sh $${CLIENTS:-10} $${DURATION:-30}
|
||||
|
||||
ci-test:
|
||||
@$(MAKE) test PORT=$(CI_TEST_PORT)
|
||||
@$(MAKE) anonymous-access-test PORT=$$(($(CI_TEST_PORT) + 5))
|
||||
@$(MAKE) connection-limit-test PORT=$$(($(CI_TEST_PORT) + 10))
|
||||
@$(MAKE) security-test PORT=$$(($(CI_TEST_PORT) + 20))
|
||||
|
||||
# Show build info
|
||||
info:
|
||||
@echo "Compiler: $(CC)"
|
||||
@echo "Flags: $(CFLAGS)"
|
||||
@echo "Sources: $(SOURCES)"
|
||||
@echo "Objects: $(OBJECTS)"
|
||||
|
||||
-include $(DEPS)
|
||||
|
|
|
|||
110
README.md
110
README.md
|
|
@ -21,6 +21,8 @@ A minimalist terminal chat server with Vim-style interface over SSH.
|
|||
```sh
|
||||
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
```
|
||||
The installer verifies the downloaded release binary against `checksums.txt`
|
||||
before installing it.
|
||||
|
||||
**From source:**
|
||||
```sh
|
||||
|
|
@ -45,7 +47,7 @@ PORT=3333 tnt # via env var
|
|||
### Connecting
|
||||
|
||||
```sh
|
||||
ssh -p 2222 chat.m1ng.space
|
||||
ssh -p 2222 chat.example.com
|
||||
```
|
||||
|
||||
**Anonymous access by default**: Users can connect with ANY username/password (or empty password). No SSH keys required. Perfect for public chat servers.
|
||||
|
|
@ -62,17 +64,25 @@ Backspace - Delete character
|
|||
Ctrl+W - Delete last word
|
||||
Ctrl+U - Delete line
|
||||
Ctrl+C - Enter NORMAL mode
|
||||
Paste - Multi-line paste stays in the input buffer
|
||||
```
|
||||
|
||||
The input line shows remaining bytes near the message limit. Extra input
|
||||
past the limit is ignored with a terminal bell.
|
||||
|
||||
**NORMAL mode**
|
||||
```
|
||||
Opens at latest messages
|
||||
Stays pinned to latest until you scroll up
|
||||
i - Return to INSERT mode
|
||||
: - Enter COMMAND mode
|
||||
j/k - Scroll down/up one line
|
||||
Ctrl+D/U - Scroll half page down/up
|
||||
Ctrl+F/B - Scroll full page down/up
|
||||
PgDn/PgUp - Scroll full page down/up
|
||||
End/Home - Jump to bottom/top
|
||||
g/G - Jump to top/bottom
|
||||
? - Show help
|
||||
? - Show full key reference
|
||||
Ctrl+C - Exit chat
|
||||
```
|
||||
|
||||
|
|
@ -80,12 +90,14 @@ Ctrl+C - Exit chat
|
|||
```
|
||||
:list, :users - Show online users
|
||||
:nick <name> - Change nickname
|
||||
:msg <user> <text> - Whisper to user
|
||||
:msg <user> <message> - Send private message
|
||||
:w <user> <text> - Short alias for :msg
|
||||
:inbox - Show private messages
|
||||
:last [N] - Show last N messages from history (max 50, default 10)
|
||||
:search <keyword> - Search full message history (case-insensitive)
|
||||
:mute-joins - Toggle join/leave system notifications
|
||||
:help - Show available commands
|
||||
:lang <en|zh> - Switch UI language for this session
|
||||
:help - Show concise manual
|
||||
:clear - Clear command output
|
||||
:q, :quit, :exit - Disconnect
|
||||
Up/Down - Browse command history
|
||||
|
|
@ -115,7 +127,10 @@ TNT_BIND_ADDR=192.168.1.100 tnt
|
|||
TNT_STATE_DIR=/var/lib/tnt tnt
|
||||
|
||||
# Show the public SSH endpoint in startup logs
|
||||
TNT_PUBLIC_HOST=chat.m1ng.space tnt
|
||||
TNT_PUBLIC_HOST=chat.example.com tnt
|
||||
|
||||
# Choose interactive UI language (en or zh; defaults from locale)
|
||||
TNT_LANG=zh tnt
|
||||
```
|
||||
|
||||
**Rate limiting:**
|
||||
|
|
@ -158,12 +173,12 @@ tnt -p 2222
|
|||
TNT also exposes a small non-interactive SSH surface for scripts:
|
||||
|
||||
```sh
|
||||
ssh -p 2222 chat.m1ng.space health
|
||||
ssh -p 2222 chat.m1ng.space stats --json
|
||||
ssh -p 2222 chat.m1ng.space users
|
||||
ssh -p 2222 chat.m1ng.space "tail -n 20"
|
||||
ssh -p 2222 operator@chat.m1ng.space post "service notice"
|
||||
ssh -p 2222 chat.m1ng.space post "/me deploys v2.0"
|
||||
ssh -p 2222 chat.example.com health
|
||||
ssh -p 2222 chat.example.com stats --json
|
||||
ssh -p 2222 chat.example.com users
|
||||
ssh -p 2222 chat.example.com "tail -n 20"
|
||||
ssh -p 2222 operator@chat.example.com post "service notice"
|
||||
ssh -p 2222 chat.example.com post "/me deploys v2.0"
|
||||
```
|
||||
|
||||
**`post` identity**: the message is attributed to the SSH login name (the `user@` part of the URL, falling back to `anonymous`). In the default anonymous-access configuration there is no identity check, so any client can post as any name. Set `TNT_ACCESS_TOKEN` if you need authenticated posting.
|
||||
|
|
@ -176,6 +191,7 @@ ssh -p 2222 chat.m1ng.space post "/me deploys v2.0"
|
|||
make # standard build
|
||||
make debug # debug build (with symbols)
|
||||
make asan # AddressSanitizer build
|
||||
make release-check # local release/package preflight
|
||||
make check # static analysis (cppcheck)
|
||||
make clean # clean build artifacts
|
||||
```
|
||||
|
|
@ -183,7 +199,13 @@ make clean # clean build artifacts
|
|||
### Testing
|
||||
|
||||
```sh
|
||||
make test # run comprehensive test suite
|
||||
make test # run comprehensive test suite and fail on regressions
|
||||
make test-advisory # run integration tests as advisory checks
|
||||
make anonymous-access-test # verify default anonymous login behavior
|
||||
make connection-limit-test # verify per-IP concurrency and rate limits
|
||||
make security-test # run security feature checks
|
||||
make stress-test # run configurable concurrent-client stress test
|
||||
make ci-test # run the same checks as GitHub Actions
|
||||
|
||||
# Individual tests
|
||||
cd tests
|
||||
|
|
@ -197,8 +219,8 @@ cd tests
|
|||
**Test coverage:**
|
||||
- Basic functionality: 3 tests
|
||||
- Anonymous access: 2 tests
|
||||
- Security features: 11 tests
|
||||
- Stress test: concurrent connections
|
||||
- Security features: 12 tests
|
||||
- Stress test: configurable concurrent clients (`CLIENTS=20 DURATION=60 make stress-test`)
|
||||
|
||||
### Dependencies
|
||||
|
||||
|
|
@ -227,14 +249,29 @@ sudo dnf install libssh-devel
|
|||
TNT/
|
||||
├── src/ # source code
|
||||
│ ├── main.c # entry point
|
||||
│ ├── cli_text.c # startup CLI help and option text
|
||||
│ ├── command_catalog.c # command metadata, usage, and argument shape
|
||||
│ ├── commands.c # COMMAND-mode command dispatch
|
||||
│ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape
|
||||
│ ├── exec.c # SSH exec command dispatch
|
||||
│ ├── ssh_server.c # SSH server implementation
|
||||
│ ├── bootstrap.c # SSH authentication and session bootstrap
|
||||
│ ├── chat_room.c # chat room logic
|
||||
│ ├── message.c # message persistence
|
||||
│ ├── history_view.c # message viewport and scroll state
|
||||
│ ├── help_text.c # full-screen key reference content
|
||||
│ ├── manual.c # concise manual panel rendering
|
||||
│ ├── manual_text.c # concise manual content
|
||||
│ ├── i18n.c # UI language and locale selection
|
||||
│ ├── i18n_text.c # shared UI text catalog
|
||||
│ ├── ratelimit.c # connection limits and rate limiting
|
||||
│ ├── tui.c # terminal UI rendering
|
||||
│ ├── tui_status.c # status/input line rendering
|
||||
│ └── utf8.c # UTF-8 character handling
|
||||
├── include/ # header files
|
||||
├── tests/ # test scripts
|
||||
├── docs/ # documentation
|
||||
├── packaging/ # package-manager drafts and release checklist
|
||||
├── scripts/ # operational scripts
|
||||
├── Makefile # build configuration
|
||||
└── README.md # this file
|
||||
|
|
@ -260,7 +297,7 @@ TNT_MAX_CONN_PER_IP=30
|
|||
TNT_MAX_CONN_RATE_PER_IP=60
|
||||
TNT_RATE_LIMIT=1
|
||||
TNT_SSH_LOG_LEVEL=0
|
||||
TNT_PUBLIC_HOST=chat.m1ng.space
|
||||
TNT_PUBLIC_HOST=chat.example.com
|
||||
EOF
|
||||
```
|
||||
|
||||
|
|
@ -276,6 +313,23 @@ CMD ["tnt"]
|
|||
|
||||
See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for details.
|
||||
|
||||
## Packaging
|
||||
|
||||
Package-manager drafts live in [packaging/](packaging/). Current targets are
|
||||
Arch/AUR (`tnt-chat`), Homebrew tap formula, and Ubuntu PPA notes.
|
||||
|
||||
Before preparing a release locally:
|
||||
|
||||
```sh
|
||||
make release-check
|
||||
```
|
||||
|
||||
Before publishing package recipes, replace placeholder checksums and run:
|
||||
|
||||
```sh
|
||||
make release-check-strict
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
|
|
@ -317,6 +371,32 @@ Delete `motd.txt` to disable the MOTD.
|
|||
- **Concurrency**: Supports 100+ concurrent connections
|
||||
- **Throughput**: 1000+ messages/second
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Connection closed by remote host" right after `ssh -p 2222 host`
|
||||
|
||||
TNT has very little it can say to the SSH client before disconnecting,
|
||||
so any pre-auth rejection just looks like a generic close. Common
|
||||
causes, fastest to slowest fix:
|
||||
|
||||
| Likely cause | Why | Fix |
|
||||
|---|---|---|
|
||||
| Per-IP concurrent limit | `TNT_MAX_CONN_PER_IP` (default 5) | Close other sessions, or raise the env var |
|
||||
| Per-IP connection rate | More than `TNT_MAX_CONN_RATE_PER_IP` attempts in 60 s | Wait 5 min (block window), or raise the limit |
|
||||
| Auth-failure ban | 5 wrong passwords / failed kex in a row | Wait 5 min |
|
||||
| Global cap | `TNT_MAX_CONNECTIONS` (default 64) is full | Wait for someone to leave |
|
||||
| Firewall | The host's ufw / iptables doesn't open 2222 | Open the port |
|
||||
|
||||
The server admin can confirm which by checking the systemd journal
|
||||
(`sudo journalctl -u tnt -n 50 --no-pager`) — the rejection reason is
|
||||
logged to stderr with the offending IP.
|
||||
|
||||
### Idle disconnect
|
||||
|
||||
After `TNT_IDLE_TIMEOUT` seconds (default 1800 = 30 min) of no
|
||||
keystrokes, TNT prints a localized idle-timeout notice and closes the
|
||||
channel. Set the env var to `0` to disable.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Single chat room (no multi-room support yet)
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
**用户体验:**
|
||||
```bash
|
||||
# 用户连接(零配置)
|
||||
ssh -p 2222 chat.m1ng.space
|
||||
ssh -p 2222 chat.example.com
|
||||
# 输入任意内容或直接按回车
|
||||
# 开始聊天!
|
||||
```
|
||||
|
|
@ -143,7 +143,7 @@ ssh -p 2222 chat.m1ng.space
|
|||
tnt
|
||||
|
||||
# 用户端(任何人)
|
||||
ssh -p 2222 chat.m1ng.space
|
||||
ssh -p 2222 chat.example.com
|
||||
# 输入任何内容作为密码或直接回车
|
||||
# 选择显示名称(可留空)
|
||||
# 开始聊天!
|
||||
|
|
|
|||
|
|
@ -1,5 +1,209 @@
|
|||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
- Split UI-language parsing from localized text lookup: `src/i18n.c` now owns
|
||||
locale/code parsing, while `src/i18n_text.c` owns the table-driven text
|
||||
catalog with coverage checks for every message ID.
|
||||
- Kept command placeholders stable across localized output: Chinese help and
|
||||
usage text now uses ASCII metavariables such as `<user>` and `<message>`.
|
||||
- Standardized user-facing `:msg` / `:inbox` terminology around "private
|
||||
message" / "私信" instead of mixing it with "whisper" wording.
|
||||
- Kept localized startup CLI syntax stable by using `用法: tnt [options]`
|
||||
instead of localizing the `[options]` metavariable.
|
||||
- Moved SSH exec help rows into an `exec_catalog` module so command metadata
|
||||
no longer lives as one large translated blob inside the shared i18n table.
|
||||
- Refreshed contributor and development guidance so new commands are added
|
||||
through `command_catalog`, `exec_catalog`, and `i18n_text` instead of stale
|
||||
`ssh_server.c` / inline-`strcmp` instructions.
|
||||
- `exec_catalog` now owns SSH exec command matching as well as help metadata,
|
||||
reducing duplicate command knowledge in `src/exec.c`.
|
||||
- Replaced hard-coded `chat.m1ng.space` examples with `chat.example.com` so
|
||||
public documentation does not imply a specific production host.
|
||||
- Moved SSH exec usage text and argument-shape checks into `exec_catalog`, so
|
||||
`src/exec.c` no longer duplicates `--json` and required-message validation.
|
||||
- Moved interactive command usage text and first-pass argument-shape checks
|
||||
into `command_catalog`, so known commands with bad arguments now show usage
|
||||
instead of unknown-command guidance.
|
||||
- Renamed the internal language state from help-oriented names to
|
||||
UI-language names (`ui_lang_t`, `client->ui_lang`, and
|
||||
`i18n_*_ui_lang`) so future i18n work has a correctly named seam.
|
||||
- Command names, aliases, help summaries, concise-manual command rows, and
|
||||
unknown-command suggestions now share a dedicated `command_catalog` module.
|
||||
- COMMAND-mode output is now a small scrollable pager with `j/k`, page
|
||||
movement, `g/G`, and `q`/Esc close controls, so long `:last` and `:search`
|
||||
results are readable instead of being cut off by the terminal height.
|
||||
- Collapsed the interactive help surface around a concise Unix-style `:help`
|
||||
manual and the `?` full key reference; `:support` is no longer a user-facing
|
||||
command.
|
||||
- First-use hints and unknown-command guidance now point users to `:help`
|
||||
instead of the removed support entry.
|
||||
- The concise manual module is now named `manual_text`, and the redundant
|
||||
interactive `:commands` entrypoint was removed.
|
||||
- The concise `:help` manual now stays within one command-output screen so it
|
||||
does not truncate on normal terminal sizes.
|
||||
- Language selection is limited to stable codes (`en`, `zh`) and
|
||||
locale-shaped environment values; natural-language labels are not accepted
|
||||
as command arguments.
|
||||
- Full-screen help now uses `l` to cycle the UI language through the i18n
|
||||
module instead of hard-coding one key per language.
|
||||
- Language parsing, language-code output, and help-language cycling now share
|
||||
one internal language registry.
|
||||
- Removed the unused `MODE_HELP` enum value and refreshed development-guide
|
||||
module descriptions for the split between language parsing and text lookup.
|
||||
- `i18n_text` now indexes localized strings by `UI_LANG_*` instead of storing
|
||||
English/Chinese as hard-coded struct fields.
|
||||
- `command_catalog` now uses the shared localized-string helper for help,
|
||||
manual, and usage text instead of per-field English/Chinese members.
|
||||
- `exec_catalog` now uses the same localized-string helper for exec help
|
||||
summaries.
|
||||
- Startup CLI help and option error formats now use the shared
|
||||
localized-string helper and English fallback path.
|
||||
- The concise `:help` manual text now uses the shared localized-string helper
|
||||
around the command-catalog rows.
|
||||
- The full-screen key reference now uses the shared localized-string helper
|
||||
around the command-catalog rows.
|
||||
- SSH exec help headers and usage prefixes now use the shared
|
||||
localized-string helper and English fallback path.
|
||||
- Documented i18n and user-facing text rules for English-first source text,
|
||||
stable command syntax, concise help copy, and translation-only localization.
|
||||
- Rewrote the quick setup guide as a concise English-first user lifecycle
|
||||
document with a short Chinese notes section.
|
||||
- The shared UI text catalog now uses the same localized-string initializer
|
||||
as the smaller text modules, avoiding GCC missing-braces warnings.
|
||||
|
||||
## 1.0.1 - 2026-05-24 - Release candidate hardening
|
||||
|
||||
### Added
|
||||
- Added a first i18n boundary: `TNT_LANG` / locale detection now chooses the
|
||||
default interactive UI language (`en` or `zh`) for username prompts, status
|
||||
hints, help output, and `:support`.
|
||||
- Added `:lang <en|zh>` so users can switch the interactive UI language for
|
||||
their current session.
|
||||
- COMMAND-mode `:help`, unknown-command guidance, language command output, and
|
||||
continuation prompts now follow the session UI language.
|
||||
- The full-screen help title and footer now follow the session UI language,
|
||||
with UTF-8-aware title padding for Chinese.
|
||||
- Common COMMAND-mode outputs now respect the session language, including
|
||||
`:users` headers and `:mute-joins` state text.
|
||||
- Command-output and MOTD screen chrome now use the session UI language.
|
||||
- Common command usage errors now stay in the session language, and bare
|
||||
`:search`, `:msg`, and `:nick` show usage instead of falling through to
|
||||
unknown-command guidance.
|
||||
- Command output text for common interactive commands is now centralized in
|
||||
the i18n table instead of being scattered through command flow logic.
|
||||
- TUI title-bar status labels, including online count, mute marker, and help
|
||||
hint, now follow the session UI language.
|
||||
- Join, leave, and nickname-change system messages now use a dedicated
|
||||
`system_message` module, follow the sender's session language, and keep
|
||||
`:mute-joins` filtering compatible with both Chinese and English logs.
|
||||
- The interactive welcome screen now follows the selected UI language,
|
||||
including the narrow-terminal fallback.
|
||||
- Full-screen help and COMMAND-mode help now live in a dedicated `help_text`
|
||||
module, keeping large bilingual help copy out of TUI and command flow code.
|
||||
- Session language, unknown-command, suggestion, and continuation prompts now
|
||||
use the shared i18n table instead of inline command-flow conditionals.
|
||||
- Interactive and exec support guide copy now lives in a dedicated
|
||||
`support_text` module, with focused language-selection unit coverage.
|
||||
- Exec-mode help, usage errors, unknown-command feedback, and post validation
|
||||
messages now follow `TNT_LANG` while preserving stable machine-readable
|
||||
command output.
|
||||
- Startup CLI help and option errors now live in a dedicated `cli_text` module
|
||||
and follow `TNT_LANG` / locale for English and Chinese users.
|
||||
- Idle-timeout disconnect notices now follow the session UI language.
|
||||
|
||||
### Changed
|
||||
- `make test` now fails on integration-test regressions; constrained local
|
||||
environments can use `make test-advisory` for the previous advisory behavior.
|
||||
- Removed the duplicate `deploy.yml` CI workflow so automated checks stay
|
||||
focused on CI while production deployment remains manual.
|
||||
- Fixed the per-IP connection-rate limit to allow the configured number of
|
||||
attempts before blocking, added unit coverage, and exposed
|
||||
`make connection-limit-test` for the black-box limit regression test.
|
||||
- Security feature checks now use isolated ports and temporary state
|
||||
directories, so they no longer require `timeout`/`gtimeout` or write
|
||||
`host_key` / `messages.log` into the test directory.
|
||||
- Added `make security-test` and `make ci-test` so local runs can use the same
|
||||
full verification path as GitHub Actions.
|
||||
- Anonymous access checks now use isolated state, wait for real SSH health,
|
||||
avoid external `timeout` helpers, and run through `make anonymous-access-test`
|
||||
as part of `make ci-test`.
|
||||
- Stress testing now uses isolated state, waits for real SSH health, avoids
|
||||
external `timeout` helpers, and is available through `make stress-test`.
|
||||
- Basic integration tests now wait for real SSH `health` responses instead of
|
||||
sleeping for a fixed startup delay.
|
||||
- Connection-limit tests now use shared SSH health readiness checks for both
|
||||
concurrent-session and connection-rate scenarios.
|
||||
- CI memory-leak smoke checks now use an isolated state directory, wait for
|
||||
real SSH readiness, and clean up the exact server PID instead of `pkill`.
|
||||
- CI memory-leak smoke checks now pre-generate the host key and use a longer
|
||||
valgrind readiness window, avoiding false failures during slow startup.
|
||||
- Language parsing now tolerates surrounding whitespace and accepts the
|
||||
`english` alias, improving `TNT_LANG` and `:lang` ergonomics.
|
||||
- Refreshed the development guide's command/keybinding instructions so they
|
||||
point at the current modular `commands`, `exec`, `i18n`, and help text files.
|
||||
- Refreshed README and quick-reference module maps to match the current
|
||||
`cli_text`, `help_text`, `support_text`, i18n, exec, and rate-limit modules.
|
||||
- NORMAL mode now opens at the latest visible messages instead of the oldest
|
||||
in-memory message. Use `k`/PageUp to browse older history and `G`/End to
|
||||
return to the latest messages.
|
||||
- NORMAL mode status now shows the visible message range and points users to
|
||||
`G latest` when new messages arrive while they are browsing.
|
||||
- NORMAL mode now keeps following the latest messages while the view is pinned
|
||||
to the bottom; scrolling upward switches into history browsing.
|
||||
- NORMAL mode now accepts arrow keys, PageUp/PageDown, and Home/End in addition
|
||||
to the existing Vim-style keys.
|
||||
- Message viewport and scroll-state rules now live in a focused
|
||||
`history_view` module instead of being split across input and rendering code.
|
||||
- Added unit coverage for `history_view` scroll boundaries, live-follow state,
|
||||
and date-divider-aware latest windows.
|
||||
- Status/input line rendering now lives in a focused `tui_status` module,
|
||||
keeping the main TUI renderer closer to layout orchestration.
|
||||
- Added `:support` / `support` quick guides so interactive users and SSH exec
|
||||
clients can discover common actions and troubleshooting paths in-product.
|
||||
- The GitHub workflow formerly named deploy now runs CI only; production
|
||||
deployment remains a manual operator action.
|
||||
- Command output rendering now truncates ANSI-styled UTF-8 text without
|
||||
counting escape sequences as visible width or cutting color codes.
|
||||
- Host-key generation now uses the non-deprecated libssh PKI API on libssh
|
||||
0.12+ while keeping compatibility with older libssh releases.
|
||||
- INSERT mode now shows a lightweight first-use hint for sending, browsing,
|
||||
and `:support`.
|
||||
- `:support` is now task-oriented around common user goals, and mistyped
|
||||
commands suggest the nearest known command when possible.
|
||||
- Added a local `make release-check` preflight for release/package validation
|
||||
without tagging, publishing, or deploying.
|
||||
- CI now installs `expect` on Ubuntu so interactive integration tests run
|
||||
instead of being skipped, and runs `make release-check` on every push/PR.
|
||||
- The tag-triggered release workflow now builds on native x64/arm64 runners,
|
||||
verifies artifact architecture, emits one checksum file, and creates a draft
|
||||
release for manual review instead of publishing immediately.
|
||||
- The one-line installer now downloads `checksums.txt`, verifies the selected
|
||||
binary before installation, and fails fast on missing release assets.
|
||||
- Added a Debian packaging metadata draft for the future Ubuntu PPA path, with
|
||||
lightweight validation in `make release-check`.
|
||||
- Added an Arch `.SRCINFO` draft and AUR maintainer notes, with version/package
|
||||
checks in `make release-check`.
|
||||
- Added Homebrew tap maintainer notes, and expanded `make release-check` to
|
||||
validate the formula class and `libssh` dependency.
|
||||
|
||||
## 2026-05-18 - Interactive input polish
|
||||
|
||||
### Added
|
||||
- Bracketed paste handling keeps multi-line pasted text in the input buffer
|
||||
until the user presses Enter, then sends it as one message.
|
||||
- Input and paste overflow now rings the terminal bell when the 1023-byte
|
||||
message limit is reached.
|
||||
- Added an interactive `expect` regression test for basic TTY input,
|
||||
bracketed paste, and overlong paste capping.
|
||||
- Added the exec-mode regression test to the main `make test` path.
|
||||
|
||||
### Fixed
|
||||
- SSH exec clients now survive stdin EOF long enough to flush stdout, exit
|
||||
status, EOF, and channel close. This fixes non-interactive commands such as
|
||||
`ssh localhost health` and `ssh user@host post "message"`.
|
||||
|
||||
## 2026-05-16 - Internal cleanup
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
83
docs/CICD.md
83
docs/CICD.md
|
|
@ -1,42 +1,67 @@
|
|||
CI/CD USAGE GUIDE
|
||||
=================
|
||||
CI / RELEASE GUIDE
|
||||
==================
|
||||
|
||||
AUTOMATIC TESTING
|
||||
-----------------
|
||||
Every push or PR automatically runs:
|
||||
- Build on Ubuntu and macOS
|
||||
- AddressSanitizer checks
|
||||
- Valgrind memory leak detection
|
||||
- Build on Ubuntu
|
||||
- AddressSanitizer build
|
||||
- `make ci-test` (strict integration, anonymous access, connection limits,
|
||||
and security feature checks)
|
||||
- Release/package preflight (`make release-check`)
|
||||
|
||||
Check status:
|
||||
https://github.com/m1ngsama/TNT/actions
|
||||
|
||||
Production deployment is intentionally manual. The CI workflow must not SSH
|
||||
into production or restart services on push.
|
||||
|
||||
|
||||
CREATING RELEASES
|
||||
-----------------
|
||||
1. Update version in CHANGELOG.md
|
||||
1. Update version metadata:
|
||||
- include/common.h
|
||||
- tnt.1
|
||||
- docs/CHANGELOG.md
|
||||
- packaging/arch/PKGBUILD
|
||||
- packaging/homebrew/tnt-chat.rb
|
||||
|
||||
2. Create and push tag:
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
2. Run the local preflight:
|
||||
make release-check
|
||||
|
||||
3. GitHub Actions automatically:
|
||||
3. Replace package checksum placeholders and run:
|
||||
make release-check-strict
|
||||
|
||||
4. Create and push tag:
|
||||
git tag v1.0.1
|
||||
git push origin v1.0.1
|
||||
|
||||
5. GitHub Actions automatically:
|
||||
- Builds binaries (Linux/macOS, AMD64/ARM64)
|
||||
- Creates release
|
||||
- Creates a draft release
|
||||
- Uploads binaries
|
||||
- Generates checksums
|
||||
- Generates one `checksums.txt` file
|
||||
- Verifies that artifact architecture matches the asset name
|
||||
|
||||
4. Release appears at:
|
||||
6. Review the draft release, smoke-test downloaded assets, then publish it
|
||||
manually from GitHub.
|
||||
|
||||
7. Release appears at:
|
||||
https://github.com/m1ngsama/TNT/releases
|
||||
|
||||
|
||||
DEPLOYING TO SERVERS
|
||||
--------------------
|
||||
Single command on any server:
|
||||
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
Deployments are operator-driven:
|
||||
1. Build and test locally or in a temporary server directory.
|
||||
2. Back up the installed binary.
|
||||
3. Install the new binary.
|
||||
4. Restart the service.
|
||||
5. Run black-box checks (`health`, `stats --json`, `users --json`,
|
||||
and a post/tail smoke test).
|
||||
|
||||
Or with specific version:
|
||||
VERSION=v1.0.0 curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
The installer can still be used manually on a server:
|
||||
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
|
||||
|
||||
PRODUCTION SETUP (systemd)
|
||||
|
|
@ -58,14 +83,11 @@ PRODUCTION SETUP (systemd)
|
|||
|
||||
UPDATING SERVERS
|
||||
----------------
|
||||
Stop service:
|
||||
sudo systemctl stop tnt
|
||||
|
||||
Run installer again:
|
||||
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
|
||||
Restart:
|
||||
sudo systemctl start tnt
|
||||
Manual binary replacement pattern:
|
||||
backup=/usr/local/bin/tnt.bak-$(date +%Y%m%d%H%M%S)
|
||||
sudo cp -a /usr/local/bin/tnt "$backup"
|
||||
sudo install -m 755 ./tnt /usr/local/bin/tnt
|
||||
sudo systemctl restart tnt
|
||||
|
||||
|
||||
PLATFORMS SUPPORTED
|
||||
|
|
@ -79,7 +101,7 @@ PLATFORMS SUPPORTED
|
|||
EXAMPLE WORKFLOW
|
||||
----------------
|
||||
# Local development
|
||||
make && make asan
|
||||
make && make asan && make release-check
|
||||
./tnt
|
||||
|
||||
# Create release
|
||||
|
|
@ -87,8 +109,7 @@ git tag v1.0.1
|
|||
git push origin v1.0.1
|
||||
# Wait 5 minutes for builds
|
||||
|
||||
# Deploy to production servers
|
||||
for server in server1 server2 server3; do
|
||||
ssh $server "curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | VERSION=v1.0.1 sh"
|
||||
ssh $server "sudo systemctl restart tnt"
|
||||
done
|
||||
# Deploy to production manually after validation
|
||||
ssh server "sudo install -m 755 /tmp/tnt-build/tnt /usr/local/bin/tnt"
|
||||
ssh server "sudo systemctl restart tnt"
|
||||
ssh -p 2222 server health
|
||||
|
|
|
|||
|
|
@ -7,13 +7,15 @@ make # normal build
|
|||
make debug # with symbols
|
||||
make asan # AddressSanitizer
|
||||
make release # optimized
|
||||
make release-check # release preflight
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```sh
|
||||
./test_basic.sh # functional tests
|
||||
./test_stress.sh 20 60 # 20 clients, 60 seconds
|
||||
make test # unit + integration tests
|
||||
make ci-test # local CI-equivalent checks
|
||||
make stress-test # concurrent-client stress test
|
||||
```
|
||||
|
||||
## Debug
|
||||
|
|
@ -34,10 +36,21 @@ make check
|
|||
|
||||
```
|
||||
main.c → entry point, signal handling
|
||||
ssh_server.c → SSH protocol, client threads
|
||||
cli_text.c → startup CLI text
|
||||
command_catalog.c → COMMAND-mode command metadata, usage, and argument shape
|
||||
commands.c → COMMAND-mode command dispatch
|
||||
exec_catalog.c → SSH exec command matching, usage, and argument shape
|
||||
exec.c → SSH exec command dispatch
|
||||
ssh_server.c → SSH listener setup
|
||||
bootstrap.c → SSH authentication/session bootstrap
|
||||
input.c → interactive session loop
|
||||
chat_room.c → client list, message broadcast
|
||||
history_view.c → message viewport and scroll state
|
||||
i18n.c → UI language and locale selection
|
||||
i18n_text.c → shared UI text catalog
|
||||
message.c → persistent storage
|
||||
tui.c → terminal rendering
|
||||
tui_status.c → status/input-line rendering
|
||||
utf8.c → UTF-8 string handling
|
||||
```
|
||||
|
||||
|
|
@ -70,9 +83,13 @@ utf8.c → UTF-8 string handling
|
|||
|
||||
## Adding Features
|
||||
|
||||
1. Add new command in `execute_command()` (ssh_server.c:190)
|
||||
2. Add new mode in `client_mode_t` enum (common.h:30)
|
||||
3. Add new vim key in `handle_key()` (ssh_server.c:220)
|
||||
1. Add interactive command metadata, usage text, and argument shape in
|
||||
`src/command_catalog.c`.
|
||||
2. Add interactive command behavior in `src/commands.c`.
|
||||
3. Add SSH exec metadata in `src/exec_catalog.c` and dispatch in `src/exec.c`
|
||||
only when the feature should be scriptable.
|
||||
4. Put shared localized strings in `src/i18n_text.c`.
|
||||
5. Add or update the narrowest unit/integration test for the behavior.
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
|||
|
||||
Specific version:
|
||||
```bash
|
||||
VERSION=v1.0.0 curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
VERSION=v1.0.1 curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
```
|
||||
|
||||
## Manual Install
|
||||
|
|
@ -54,7 +54,7 @@ TNT_MAX_CONN_PER_IP=30
|
|||
TNT_MAX_CONN_RATE_PER_IP=60
|
||||
TNT_RATE_LIMIT=1
|
||||
TNT_SSH_LOG_LEVEL=0
|
||||
TNT_PUBLIC_HOST=chat.m1ng.space
|
||||
TNT_PUBLIC_HOST=chat.example.com
|
||||
EOF
|
||||
|
||||
sudo systemctl restart tnt
|
||||
|
|
@ -97,7 +97,7 @@ Place a `motd.txt` file in the state directory. TNT displays it to each user on
|
|||
# Systemd deployment (state dir is /var/lib/tnt)
|
||||
sudo tee /var/lib/tnt/motd.txt <<'EOF'
|
||||
Welcome! Be respectful. No spam.
|
||||
Type :help for available commands.
|
||||
Type :help for a concise manual, or ? for the full key reference.
|
||||
EOF
|
||||
sudo chown tnt:tnt /var/lib/tnt/motd.txt
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ Complete guide for TNT developers and contributors.
|
|||
3. [Building and Testing](#building-and-testing)
|
||||
4. [Core Components](#core-components)
|
||||
5. [Adding Features](#adding-features)
|
||||
6. [Debugging](#debugging)
|
||||
7. [Performance Optimization](#performance-optimization)
|
||||
8. [Contributing Guidelines](#contributing-guidelines)
|
||||
6. [User-Facing Text and i18n](#user-facing-text-and-i18n)
|
||||
7. [Debugging](#debugging)
|
||||
8. [Performance Optimization](#performance-optimization)
|
||||
9. [Contributing Guidelines](#contributing-guidelines)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -67,11 +68,26 @@ TNT uses a multi-threaded architecture with a main accept loop and per-client th
|
|||
|
||||
```
|
||||
src/
|
||||
├── main.c - Entry point, signal handling
|
||||
├── ssh_server.c - SSH server, client threads, authentication
|
||||
├── chat_room.c - Chat room logic, message broadcasting
|
||||
├── main.c - CLI entry point and startup option parsing
|
||||
├── ssh_server.c - SSH listener setup and connection accept loop
|
||||
├── bootstrap.c - SSH authentication/session bootstrap
|
||||
├── input.c - Interactive session loop and key handling
|
||||
├── commands.c - COMMAND-mode command dispatch
|
||||
├── command_catalog.c - COMMAND-mode names, aliases, and help summaries
|
||||
├── exec_catalog.c - SSH exec command matching and help metadata
|
||||
├── exec.c - SSH exec command dispatch
|
||||
├── chat_room.c - Chat room logic and message broadcasting
|
||||
├── message.c - Message persistence (RFC3339 format)
|
||||
├── history_view.c - NORMAL-mode scroll window rules
|
||||
├── tui.c - Terminal UI rendering (ANSI escape codes)
|
||||
├── tui_status.c - Mode/status/input-line rendering
|
||||
├── i18n.c - UI language selection and locale parsing
|
||||
├── i18n_text.c - Shared UI text catalog
|
||||
├── help_text.c - Full-screen key reference text
|
||||
├── manual.c - Concise manual panel rendering
|
||||
├── manual_text.c - Concise manual text
|
||||
├── system_message.c - Localized join/leave/nick system messages
|
||||
├── ratelimit.c - Per-IP and global connection limits
|
||||
└── utf8.c - UTF-8 character handling
|
||||
```
|
||||
|
||||
|
|
@ -81,9 +97,17 @@ src/
|
|||
include/
|
||||
├── common.h - Common definitions, constants
|
||||
├── ssh_server.h - SSH server interface
|
||||
├── bootstrap.h - SSH session bootstrap interface
|
||||
├── chat_room.h - Chat room interface
|
||||
├── message.h - Message structure and persistence
|
||||
├── command_catalog.h - COMMAND-mode command metadata interface
|
||||
├── history_view.h - Scroll-state helpers
|
||||
├── tui.h - TUI rendering functions
|
||||
├── i18n.h - Language and shared text IDs
|
||||
├── help_text.h - Key reference text interface
|
||||
├── manual.h - Concise manual panel interface
|
||||
├── manual_text.h - Concise manual text interface
|
||||
├── ratelimit.h - Connection limit interface
|
||||
└── utf8.h - UTF-8 utilities
|
||||
```
|
||||
|
||||
|
|
@ -159,7 +183,13 @@ make install # Install to /usr/local/bin
|
|||
### Running Tests
|
||||
|
||||
```sh
|
||||
make test # Run all tests
|
||||
make test # Run all tests and fail on regressions
|
||||
make test-advisory # Run integration tests as advisory checks
|
||||
make anonymous-access-test # Verify default anonymous login behavior
|
||||
make connection-limit-test # Verify per-IP concurrency and rate limits
|
||||
make security-test # Run security feature checks
|
||||
make stress-test # Run configurable concurrent-client stress test
|
||||
make ci-test # Run the same checks as GitHub Actions
|
||||
|
||||
# Individual tests
|
||||
cd tests
|
||||
|
|
@ -328,28 +358,36 @@ void utf8_remove_last_word(char *str) {
|
|||
|
||||
### Adding a New Command
|
||||
|
||||
1. **Add to `execute_command()` in ssh_server.c:**
|
||||
1. **For interactive COMMAND mode, add command metadata in `src/command_catalog.c`:**
|
||||
```c
|
||||
if (strcmp(cmd, "newcmd") == 0) {
|
||||
pos += snprintf(output + pos, sizeof(output) - pos,
|
||||
"New command output\n");
|
||||
{
|
||||
{TNT_COMMAND_NEWCMD, "newcmd", {"newcmd", NULL}, false},
|
||||
":newcmd", ":newcmd",
|
||||
"Show new output", "显示新输出",
|
||||
":newcmd", ":newcmd", 3
|
||||
}
|
||||
```
|
||||
|
||||
2. **Update help text in tui.c:**
|
||||
```c
|
||||
"AVAILABLE COMMANDS:\n"
|
||||
" newcmd - Description of new command\n"
|
||||
```
|
||||
2. **Add interactive behavior in `src/commands.c` by switching on the command ID.**
|
||||
|
||||
3. **Add test in tests/test_basic.sh:**
|
||||
3. **For SSH exec mode, add help metadata in `src/exec_catalog.c` and the stable command path in `src/exec.c` if it should work non-interactively.**
|
||||
|
||||
4. **Move shared user-facing strings through `src/i18n_text.c` when they need localization or are reused. Keep command syntax and metavariables ASCII.**
|
||||
|
||||
5. **Update user help surfaces through their catalogs. Avoid duplicating command rows by hand.**
|
||||
|
||||
6. **Add tests in the narrowest target:**
|
||||
```sh
|
||||
echo ":newcmd" | timeout 5 ssh -p $PORT localhost
|
||||
tests/test_exec_mode.sh # exec command behavior
|
||||
tests/test_interactive_input.sh # COMMAND-mode/TUI behavior
|
||||
tests/unit/test_i18n.c # localized shared text
|
||||
tests/unit/test_command_catalog.c # interactive command metadata
|
||||
tests/unit/test_exec_catalog.c # exec command help metadata
|
||||
```
|
||||
|
||||
### Adding a New Keybinding
|
||||
|
||||
1. **Add to `handle_key()` in ssh_server.c:**
|
||||
1. **Add to the relevant mode handler in `src/input.c`:**
|
||||
```c
|
||||
case MODE_INSERT:
|
||||
if (key == 26) { /* Ctrl+Z */
|
||||
|
|
@ -358,12 +396,77 @@ case MODE_INSERT:
|
|||
}
|
||||
```
|
||||
|
||||
2. **Update help text in tui.c**
|
||||
2. **Update `src/help_text.c` and status hints in `src/i18n_text.c` /
|
||||
`src/tui_status.c` if the binding is user-visible.**
|
||||
|
||||
3. **Document in README.md**
|
||||
|
||||
---
|
||||
|
||||
## User-Facing Text and i18n
|
||||
|
||||
TNT should follow Unix/open-source conventions for user-facing text:
|
||||
English is the source language, command syntax is stable ASCII, and
|
||||
translations are presentation only. A localized interface must never create
|
||||
localized command names, localized option names, or localized configuration
|
||||
keys.
|
||||
|
||||
### Principles
|
||||
|
||||
1. **English-first source text**
|
||||
- Keep code identifiers, comments, command names, option names, and
|
||||
documentation source in English.
|
||||
- Treat English text as the canonical source text for future gettext-style
|
||||
catalogs.
|
||||
- Do not use translated text as a programmatic key.
|
||||
|
||||
2. **Stable language identifiers**
|
||||
- Interactive `:lang` accepts only stable language codes: `en` and `zh`.
|
||||
- Code should name this concept `ui_lang`, not `help_lang`; the same value
|
||||
controls prompts, status text, help, command output, MOTD chrome, and
|
||||
system messages.
|
||||
- Locale detection may accept locale-shaped values such as
|
||||
`en_US.UTF-8`, `zh_CN.UTF-8`, `C`, and `POSIX`.
|
||||
- Do not accept natural-language labels such as `english`, `chinese`,
|
||||
`中文`, or `英文` as command arguments.
|
||||
- If regional variants are added later, add explicit locale identifiers
|
||||
such as `zh_TW` instead of overloading `zh`.
|
||||
|
||||
3. **Concise writing**
|
||||
- Prefer imperative verbs: "Show", "Switch", "Disconnect".
|
||||
- Keep command descriptions noun-like or verb-like, not explanatory prose.
|
||||
- Avoid tutorial language in `:help`; put detailed behavior in `tnt(1)`.
|
||||
- Keep `:help` within one command-output screen. `?` is the full key
|
||||
reference.
|
||||
|
||||
4. **One behavior, one name**
|
||||
- Do not create parallel help commands for the same task.
|
||||
- Keep `:help` for the concise manual and `?` for the full key reference.
|
||||
- Keep SSH exec commands small, scriptable, and stable.
|
||||
|
||||
5. **Translation safety**
|
||||
- Use whole sentences or whole phrases; do not concatenate translated
|
||||
fragments.
|
||||
- Keep placeholders visible and stable, for example `%s`, `%d`,
|
||||
`<user>`, and `<message>`.
|
||||
- Every new user-facing string needs tests for at least English fallback
|
||||
and Chinese output while this project has two UI languages.
|
||||
|
||||
### Current Limitations
|
||||
|
||||
The current `src/i18n_text.c` implementation is a small-project translation
|
||||
table implemented in C, not a full gettext catalog. It is acceptable for two
|
||||
languages because message lookup is already split from language parsing in
|
||||
`src/i18n.c`, but adding more languages should move toward catalog-like
|
||||
storage instead of adding ad hoc branches for every locale.
|
||||
|
||||
Relevant conventions:
|
||||
- POSIX locale variables: `LANG`, `LC_ALL`, `LC_MESSAGES`.
|
||||
- GNU gettext source preparation: decent English, whole sentences, and
|
||||
format placeholders rather than string concatenation.
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### Enable Verbose SSH Logging
|
||||
|
|
|
|||
|
|
@ -1,278 +1,224 @@
|
|||
# TNT 匿名聊天室 - 快速部署指南 / TNT Anonymous Chat - Quick Setup Guide
|
||||
# TNT Quick Setup
|
||||
|
||||
[中文](#中文) | [English](#english)
|
||||
This guide gets a TNT server running and explains the first user session.
|
||||
For the full reference, see [README.md](../README.md), [tnt(1)](../tnt.1),
|
||||
and [Deployment](DEPLOYMENT.md).
|
||||
|
||||
---
|
||||
## Install
|
||||
|
||||
## 中文
|
||||
|
||||
### 一键安装
|
||||
|
||||
```bash
|
||||
```sh
|
||||
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
```
|
||||
|
||||
### 启动服务器
|
||||
Or build from source:
|
||||
|
||||
```bash
|
||||
tnt # 监听 2222 端口
|
||||
```sh
|
||||
git clone https://github.com/m1ngsama/TNT.git
|
||||
cd TNT
|
||||
make
|
||||
sudo make install
|
||||
```
|
||||
|
||||
就这么简单!服务器已经运行了。
|
||||
## Start A Server
|
||||
|
||||
### 用户如何连接
|
||||
|
||||
用户只需要一个SSH客户端即可,无需任何配置:
|
||||
|
||||
```bash
|
||||
ssh -p 2222 chat.m1ng.space
|
||||
```
|
||||
|
||||
**重要提示**:
|
||||
- ✅ 用户可以使用**任意用户名**连接
|
||||
- ✅ 用户可以输入**任意密码**(甚至直接按回车跳过)
|
||||
- ✅ **不需要SSH密钥**
|
||||
- ✅ 不需要提前注册账号
|
||||
- ✅ 完全匿名,零门槛
|
||||
|
||||
连接后,系统会提示输入显示名称(也可以留空使用默认名称)。
|
||||
|
||||
### 生产环境部署
|
||||
|
||||
使用 systemd 让服务器开机自启:
|
||||
|
||||
```bash
|
||||
# 1. 创建专用用户
|
||||
sudo useradd -r -s /bin/false tnt
|
||||
sudo mkdir -p /var/lib/tnt
|
||||
sudo chown tnt:tnt /var/lib/tnt
|
||||
|
||||
# 2. 安装服务
|
||||
sudo cp tnt.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable tnt
|
||||
sudo systemctl start tnt
|
||||
|
||||
# 3. 检查状态
|
||||
sudo systemctl status tnt
|
||||
```
|
||||
|
||||
### 防火墙设置
|
||||
|
||||
记得开放2222端口:
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo ufw allow 2222/tcp
|
||||
|
||||
# CentOS/RHEL
|
||||
sudo firewall-cmd --permanent --add-port=2222/tcp
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
### 可选配置
|
||||
|
||||
通过环境变量进行高级配置:
|
||||
|
||||
```bash
|
||||
# 修改端口
|
||||
PORT=3333 tnt
|
||||
|
||||
# 限制最大连接数
|
||||
TNT_MAX_CONNECTIONS=100 tnt
|
||||
|
||||
# 限制每个IP的最大连接数
|
||||
TNT_MAX_CONN_PER_IP=10 tnt
|
||||
|
||||
# 只允许本地访问
|
||||
TNT_BIND_ADDR=127.0.0.1 tnt
|
||||
|
||||
# 添加访问密码(所有用户共用一个密码)
|
||||
TNT_ACCESS_TOKEN="your_secret_password" tnt
|
||||
```
|
||||
|
||||
**注意**:设置 `TNT_ACCESS_TOKEN` 后,所有用户必须使用该密码才能连接,这会提高安全性但也会增加使用门槛。
|
||||
|
||||
### 特性
|
||||
|
||||
- 🚀 **零配置** - 开箱即用
|
||||
- 🔓 **完全匿名** - 无需注册,无需密钥
|
||||
- 🎨 **Vim风格界面** - 支持 INSERT/NORMAL/COMMAND 三种模式
|
||||
- 📜 **消息历史** - 自动保存聊天记录
|
||||
- 🌐 **UTF-8支持** - 完美支持中英文及其他语言
|
||||
- 🔒 **可选安全特性** - 支持限流、访问控制等
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
### One-Line Installation
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
```
|
||||
|
||||
### Start Server
|
||||
|
||||
```bash
|
||||
tnt # Listen on port 2222
|
||||
```
|
||||
|
||||
That's it! Your server is now running.
|
||||
|
||||
### How Users Connect
|
||||
|
||||
Users only need an SSH client, no configuration required:
|
||||
|
||||
```bash
|
||||
ssh -p 2222 chat.m1ng.space
|
||||
```
|
||||
|
||||
**Important**:
|
||||
- ✅ Users can use **ANY username**
|
||||
- ✅ Users can enter **ANY password** (or just press Enter to skip)
|
||||
- ✅ **No SSH keys required**
|
||||
- ✅ No registration needed
|
||||
- ✅ Completely anonymous, zero barrier
|
||||
|
||||
After connecting, the system will prompt for a display name (can be left empty for default name).
|
||||
|
||||
### Production Deployment
|
||||
|
||||
Use systemd for auto-start on boot:
|
||||
|
||||
```bash
|
||||
# 1. Create dedicated user
|
||||
sudo useradd -r -s /bin/false tnt
|
||||
sudo mkdir -p /var/lib/tnt
|
||||
sudo chown tnt:tnt /var/lib/tnt
|
||||
|
||||
# 2. Install service
|
||||
sudo cp tnt.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable tnt
|
||||
sudo systemctl start tnt
|
||||
|
||||
# 3. Check status
|
||||
sudo systemctl status tnt
|
||||
```
|
||||
|
||||
### Firewall Configuration
|
||||
|
||||
Remember to open port 2222:
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo ufw allow 2222/tcp
|
||||
|
||||
# CentOS/RHEL
|
||||
sudo firewall-cmd --permanent --add-port=2222/tcp
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
### Optional Configuration
|
||||
|
||||
Advanced configuration via environment variables:
|
||||
|
||||
```bash
|
||||
# Change port
|
||||
PORT=3333 tnt
|
||||
|
||||
# Limit max connections
|
||||
TNT_MAX_CONNECTIONS=100 tnt
|
||||
|
||||
# Limit concurrent sessions per IP
|
||||
TNT_MAX_CONN_PER_IP=10 tnt
|
||||
|
||||
# Limit new connections per IP per 60 seconds
|
||||
TNT_MAX_CONN_RATE_PER_IP=30 tnt
|
||||
|
||||
# Bind to localhost only
|
||||
TNT_BIND_ADDR=127.0.0.1 tnt
|
||||
|
||||
# Add password protection (shared password for all users)
|
||||
TNT_ACCESS_TOKEN="your_secret_password" tnt
|
||||
```
|
||||
|
||||
**Note**: Setting `TNT_ACCESS_TOKEN` requires all users to use that password to connect. This increases security but also raises the barrier to entry.
|
||||
|
||||
### Features
|
||||
|
||||
- 🚀 **Zero Configuration** - Works out of the box
|
||||
- 🔓 **Fully Anonymous** - No registration, no keys
|
||||
- 🎨 **Vim-Style Interface** - Supports INSERT/NORMAL/COMMAND modes
|
||||
- 📜 **Message History** - Automatic chat log persistence
|
||||
- 🌐 **UTF-8 Support** - Perfect for all languages
|
||||
- 🔒 **Optional Security** - Rate limiting, access control, etc.
|
||||
|
||||
---
|
||||
|
||||
## 使用示例 / Usage Examples
|
||||
|
||||
### 基本使用 / Basic Usage
|
||||
|
||||
```bash
|
||||
# 启动服务器
|
||||
```sh
|
||||
tnt
|
||||
|
||||
# 用户连接(从任何机器)
|
||||
ssh -p 2222 chat.m1ng.space
|
||||
# 输入任意密码或直接回车
|
||||
# 输入显示名称或留空
|
||||
# 开始聊天!
|
||||
```
|
||||
|
||||
### Vim风格操作 / Vim-Style Operations
|
||||
By default TNT listens on port `2222`, stores `host_key` and `messages.log`
|
||||
in the current directory, and allows anonymous SSH login.
|
||||
|
||||
连接后:
|
||||
Use explicit state and port settings for a long-running server:
|
||||
|
||||
- **INSERT 模式**(默认):直接输入消息,按 Enter 发送
|
||||
- **NORMAL 模式**:按 `ESC` 进入,使用 `j/k` 滚动历史,`g/G` 跳转顶部/底部
|
||||
- **COMMAND 模式**:按 `:` 进入,输入 `:list` 查看在线用户,`:help` 查看帮助
|
||||
```sh
|
||||
tnt -p 2222 -d /var/lib/tnt
|
||||
```
|
||||
|
||||
### 故障排除 / Troubleshooting
|
||||
## Connect
|
||||
|
||||
#### 问题:端口已被占用
|
||||
```sh
|
||||
ssh -p 2222 chat.example.com
|
||||
```
|
||||
|
||||
```bash
|
||||
# 更换端口
|
||||
Default access rules:
|
||||
|
||||
- Any SSH username is accepted.
|
||||
- Empty or arbitrary passwords are accepted.
|
||||
- SSH keys are not required.
|
||||
- TNT asks for a display name after the SSH session starts.
|
||||
|
||||
Set `TNT_ACCESS_TOKEN` when you want a shared password:
|
||||
|
||||
```sh
|
||||
TNT_ACCESS_TOKEN="change-this-password" tnt -p 2222 -d /var/lib/tnt
|
||||
```
|
||||
|
||||
## First Session
|
||||
|
||||
TNT opens in INSERT mode. Type a message and press `Enter`.
|
||||
|
||||
Common keys:
|
||||
|
||||
```text
|
||||
Esc enter NORMAL mode
|
||||
i return to INSERT mode
|
||||
: enter COMMAND mode
|
||||
? open the full key reference
|
||||
G or End jump to latest messages
|
||||
Ctrl+C disconnect from NORMAL mode
|
||||
```
|
||||
|
||||
Common commands:
|
||||
|
||||
```text
|
||||
:help concise manual
|
||||
:users online users
|
||||
:nick <name> change nickname
|
||||
:msg <user> <message> send private message
|
||||
:inbox show private messages
|
||||
:last [N] recent messages
|
||||
:search <keyword> search message history
|
||||
:lang en|zh switch UI language
|
||||
:q disconnect
|
||||
```
|
||||
|
||||
NORMAL mode opens at the latest messages and follows new messages until the
|
||||
user scrolls up. Use `G` or `End` to return to the live tail.
|
||||
|
||||
## Configure
|
||||
|
||||
```sh
|
||||
# Listening address and port
|
||||
TNT_BIND_ADDR=0.0.0.0 PORT=2222 tnt
|
||||
|
||||
# State directory
|
||||
TNT_STATE_DIR=/var/lib/tnt tnt
|
||||
|
||||
# Shared SSH password
|
||||
TNT_ACCESS_TOKEN="change-this-password" tnt
|
||||
|
||||
# Default UI language; unset means locale detection, then English fallback
|
||||
TNT_LANG=en tnt
|
||||
TNT_LANG=zh tnt
|
||||
|
||||
# Connection limits
|
||||
TNT_MAX_CONNECTIONS=200 tnt
|
||||
TNT_MAX_CONN_PER_IP=30 tnt
|
||||
TNT_MAX_CONN_RATE_PER_IP=60 tnt
|
||||
|
||||
# Idle timeout in seconds; 0 disables it
|
||||
TNT_IDLE_TIMEOUT=3600 tnt
|
||||
```
|
||||
|
||||
## Run Under systemd
|
||||
|
||||
```sh
|
||||
sudo useradd -r -s /bin/false tnt
|
||||
sudo mkdir -p /var/lib/tnt
|
||||
sudo chown tnt:tnt /var/lib/tnt
|
||||
sudo cp tnt.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now tnt
|
||||
sudo systemctl status tnt
|
||||
```
|
||||
|
||||
Put runtime overrides in `/etc/default/tnt`:
|
||||
|
||||
```sh
|
||||
PORT=2222
|
||||
TNT_BIND_ADDR=0.0.0.0
|
||||
TNT_STATE_DIR=/var/lib/tnt
|
||||
TNT_MAX_CONNECTIONS=200
|
||||
TNT_MAX_CONN_PER_IP=30
|
||||
TNT_MAX_CONN_RATE_PER_IP=60
|
||||
TNT_RATE_LIMIT=1
|
||||
TNT_SSH_LOG_LEVEL=1
|
||||
TNT_PUBLIC_HOST=chat.example.com
|
||||
```
|
||||
|
||||
Open the listening port in your firewall:
|
||||
|
||||
```sh
|
||||
sudo ufw allow 2222/tcp
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already In Use
|
||||
|
||||
```sh
|
||||
tnt -p 3333
|
||||
```
|
||||
|
||||
#### 问题:防火墙阻止连接
|
||||
### Cannot Connect
|
||||
|
||||
```bash
|
||||
# 检查防火墙状态
|
||||
Check the server process:
|
||||
|
||||
```sh
|
||||
systemctl status tnt
|
||||
sudo journalctl -u tnt -n 50 --no-pager
|
||||
```
|
||||
|
||||
Check the listening port:
|
||||
|
||||
```sh
|
||||
ss -ltnp | grep 2222
|
||||
```
|
||||
|
||||
Check the firewall:
|
||||
|
||||
```sh
|
||||
sudo ufw status
|
||||
sudo firewall-cmd --list-ports
|
||||
|
||||
# 确保已开放端口
|
||||
sudo ufw allow 2222/tcp
|
||||
```
|
||||
|
||||
#### 问题:连接超时
|
||||
### Connection Closes Immediately
|
||||
|
||||
```bash
|
||||
# 检查服务器是否运行
|
||||
ps aux | grep tnt
|
||||
The most common causes are per-IP connection limits, connection-rate limits,
|
||||
an auth-failure ban, a full room, or a closed firewall port. The server logs
|
||||
the rejection reason to stderr or the systemd journal.
|
||||
|
||||
# 检查端口监听
|
||||
sudo lsof -i:2222
|
||||
## Chinese Quick Notes
|
||||
|
||||
### 安装
|
||||
|
||||
```sh
|
||||
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
```
|
||||
|
||||
---
|
||||
### 启动
|
||||
|
||||
## 技术细节 / Technical Details
|
||||
```sh
|
||||
tnt
|
||||
```
|
||||
|
||||
- **语言**: C
|
||||
- **依赖**: libssh
|
||||
- **并发**: 多线程,支持数百个同时连接
|
||||
- **安全**: 可选限流、访问控制、密码保护
|
||||
- **存储**: 简单的文本日志(messages.log)
|
||||
- **配置**: 环境变量,无配置文件
|
||||
默认监听 `2222` 端口,并允许匿名 SSH 登录。
|
||||
|
||||
---
|
||||
### 连接
|
||||
|
||||
## 许可证 / License
|
||||
```sh
|
||||
ssh -p 2222 chat.example.com
|
||||
```
|
||||
|
||||
MIT License - 自由使用、修改、分发
|
||||
默认情况下,任意 SSH 用户名和空密码都可以连接。进入后 TNT 会询问显示名称。
|
||||
|
||||
### 常用操作
|
||||
|
||||
```text
|
||||
Enter 发送消息
|
||||
Esc 进入 NORMAL 模式
|
||||
i 回到 INSERT 模式
|
||||
: 输入命令
|
||||
? 查看完整按键参考
|
||||
G 或 End 回到最新消息
|
||||
:help 查看简明手册
|
||||
:lang en|zh 切换界面语言
|
||||
:q 断开连接
|
||||
```
|
||||
|
||||
### 常用配置
|
||||
|
||||
```sh
|
||||
TNT_ACCESS_TOKEN="change-this-password" tnt
|
||||
TNT_STATE_DIR=/var/lib/tnt tnt
|
||||
TNT_LANG=zh tnt
|
||||
```
|
||||
|
|
|
|||
|
|
@ -9,8 +9,13 @@ BUILD
|
|||
make clean remove artifacts
|
||||
|
||||
TEST
|
||||
./test_basic.sh basic functionality
|
||||
./test_stress.sh 20 60 stress test (20 clients, 60s)
|
||||
make test strict unit + integration tests
|
||||
make test-advisory unit tests + advisory integration checks
|
||||
make anonymous-access-test default anonymous login checks
|
||||
make connection-limit-test per-IP concurrency/rate-limit checks
|
||||
make security-test security feature checks
|
||||
make stress-test concurrent-client stress test
|
||||
make ci-test same checks as GitHub Actions
|
||||
|
||||
DEBUG
|
||||
ASAN_OPTIONS=detect_leaks=1 ./tnt
|
||||
|
|
@ -20,25 +25,44 @@ DEBUG
|
|||
COMMANDS (COMMAND mode, prefix with :)
|
||||
list, users, who show online users
|
||||
nick <name> change nickname
|
||||
msg <user> <text> whisper to user
|
||||
msg <user> <message> send private message
|
||||
w <user> <text> alias for msg
|
||||
inbox show private messages
|
||||
last [N] last N messages from log (default 10, max 50)
|
||||
search <keyword> search full history (case-insensitive, 15 results)
|
||||
mute-joins toggle join/leave notifications
|
||||
help show all commands
|
||||
help concise manual
|
||||
lang [en|zh] show or switch UI language
|
||||
clear clear output
|
||||
q / quit / exit disconnect
|
||||
|
||||
INSERT MODE
|
||||
/me <action> action message
|
||||
@username mention (bell + highlight)
|
||||
paste multi-line paste stays in the input buffer
|
||||
limit 1023 bytes/message; over-limit input rings bell
|
||||
normal opens/follows latest; k/PgUp older, j/PgDn newer
|
||||
|
||||
STRUCTURE
|
||||
src/main.c entry, signals
|
||||
src/ssh_server.c SSH, threads, commands
|
||||
src/chat_room.c broadcast
|
||||
src/cli_text.c startup CLI text
|
||||
src/command_catalog.c command metadata, usage, argument shape
|
||||
src/ssh_server.c SSH listener and server setup
|
||||
src/bootstrap.c SSH auth/session bootstrap
|
||||
src/chat_room.c broadcast and room state
|
||||
src/commands.c COMMAND-mode command dispatch
|
||||
src/exec_catalog.c SSH exec command matching, usage, argument shape
|
||||
src/exec.c SSH exec command dispatch
|
||||
src/message.c persistence, search
|
||||
src/tui.c rendering, help
|
||||
src/history_view.c message viewport / scroll state
|
||||
src/help_text.c full-screen key reference text
|
||||
src/manual.c concise manual panel rendering
|
||||
src/manual_text.c concise manual content
|
||||
src/i18n.c UI language and locale selection
|
||||
src/i18n_text.c shared UI text catalog
|
||||
src/ratelimit.c connection limits and rate limiting
|
||||
src/tui.c rendering
|
||||
src/tui_status.c status/input line rendering
|
||||
src/utf8.c unicode
|
||||
|
||||
LIMITS
|
||||
|
|
|
|||
12
include/cli_text.h
Normal file
12
include/cli_text.h
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#ifndef CLI_TEXT_H
|
||||
#define CLI_TEXT_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
|
||||
const char *program_name, ui_lang_t lang);
|
||||
const char *cli_text_invalid_port_format(ui_lang_t lang);
|
||||
const char *cli_text_unknown_option_format(ui_lang_t lang);
|
||||
const char *cli_text_short_usage_format(ui_lang_t lang);
|
||||
|
||||
#endif /* CLI_TEXT_H */
|
||||
39
include/command_catalog.h
Normal file
39
include/command_catalog.h
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
#ifndef COMMAND_CATALOG_H
|
||||
#define COMMAND_CATALOG_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
typedef enum {
|
||||
TNT_COMMAND_USERS,
|
||||
TNT_COMMAND_HELP,
|
||||
TNT_COMMAND_LANG,
|
||||
TNT_COMMAND_MSG,
|
||||
TNT_COMMAND_INBOX,
|
||||
TNT_COMMAND_NICK,
|
||||
TNT_COMMAND_LAST,
|
||||
TNT_COMMAND_SEARCH,
|
||||
TNT_COMMAND_MUTE_JOINS,
|
||||
TNT_COMMAND_QUIT,
|
||||
TNT_COMMAND_CLEAR,
|
||||
TNT_COMMAND_COUNT
|
||||
} tnt_command_id_t;
|
||||
|
||||
typedef struct {
|
||||
tnt_command_id_t id;
|
||||
const char *canonical;
|
||||
const char *names[4];
|
||||
} tnt_command_spec_t;
|
||||
|
||||
const tnt_command_spec_t *command_catalog_get(tnt_command_id_t id);
|
||||
bool command_catalog_match(const char *line, tnt_command_id_t *id,
|
||||
const char **args);
|
||||
bool command_catalog_args_valid(tnt_command_id_t id, const char *args);
|
||||
const char *command_catalog_suggest(const char *name);
|
||||
void command_catalog_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang);
|
||||
void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang);
|
||||
void command_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
|
||||
tnt_command_id_t id, ui_lang_t lang);
|
||||
|
||||
#endif /* COMMAND_CATALOG_H */
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
#include <pthread.h>
|
||||
|
||||
/* Project Metadata */
|
||||
#define TNT_VERSION "1.0.0"
|
||||
#define TNT_VERSION "1.0.1"
|
||||
|
||||
/* Configuration constants */
|
||||
#define DEFAULT_PORT 2222
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
#define MAX_USERNAME_LEN 64
|
||||
#define MAX_MESSAGE_LEN 1024
|
||||
#define MAX_EXEC_COMMAND_LEN 1024
|
||||
#define MAX_COMMAND_OUTPUT_LEN 8192
|
||||
#define MAX_CLIENTS 64
|
||||
#define LOG_FILE "messages.log"
|
||||
#define MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */
|
||||
|
|
@ -39,15 +40,15 @@
|
|||
typedef enum {
|
||||
MODE_INSERT,
|
||||
MODE_NORMAL,
|
||||
MODE_COMMAND,
|
||||
MODE_HELP
|
||||
MODE_COMMAND
|
||||
} client_mode_t;
|
||||
|
||||
/* Help language */
|
||||
/* UI language */
|
||||
typedef enum {
|
||||
LANG_EN,
|
||||
LANG_ZH
|
||||
} help_lang_t;
|
||||
UI_LANG_EN,
|
||||
UI_LANG_ZH,
|
||||
UI_LANG_COUNT
|
||||
} ui_lang_t;
|
||||
|
||||
/* Runtime helpers */
|
||||
const char* tnt_state_dir(void);
|
||||
|
|
|
|||
24
include/exec_catalog.h
Normal file
24
include/exec_catalog.h
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#ifndef EXEC_CATALOG_H
|
||||
#define EXEC_CATALOG_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
typedef enum {
|
||||
TNT_EXEC_COMMAND_HELP,
|
||||
TNT_EXEC_COMMAND_HEALTH,
|
||||
TNT_EXEC_COMMAND_USERS,
|
||||
TNT_EXEC_COMMAND_STATS,
|
||||
TNT_EXEC_COMMAND_TAIL,
|
||||
TNT_EXEC_COMMAND_POST,
|
||||
TNT_EXEC_COMMAND_EXIT
|
||||
} tnt_exec_command_id_t;
|
||||
|
||||
bool exec_catalog_match(const char *line, tnt_exec_command_id_t *id,
|
||||
const char **args);
|
||||
bool exec_catalog_args_valid(tnt_exec_command_id_t id, const char *args);
|
||||
void exec_catalog_append_help(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang);
|
||||
void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
|
||||
tnt_exec_command_id_t id, ui_lang_t lang);
|
||||
|
||||
#endif /* EXEC_CATALOG_H */
|
||||
9
include/help_text.h
Normal file
9
include/help_text.h
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#ifndef HELP_TEXT_H
|
||||
#define HELP_TEXT_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang);
|
||||
|
||||
#endif /* HELP_TEXT_H */
|
||||
16
include/history_view.h
Normal file
16
include/history_view.h
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#ifndef HISTORY_VIEW_H
|
||||
#define HISTORY_VIEW_H
|
||||
|
||||
#include "message.h"
|
||||
|
||||
int history_view_height(int terminal_height);
|
||||
int history_view_max_scroll(int message_count, int view_height);
|
||||
void history_view_scroll_to_latest(int *scroll_pos, bool *follow_tail,
|
||||
int message_count, int view_height);
|
||||
void history_view_scroll_to_oldest(int *scroll_pos, bool *follow_tail);
|
||||
void history_view_scroll_by(int *scroll_pos, bool *follow_tail,
|
||||
int message_count, int view_height, int delta);
|
||||
int history_view_latest_start_for_height(const message_t *messages, int count,
|
||||
int height);
|
||||
|
||||
#endif /* HISTORY_VIEW_H */
|
||||
85
include/i18n.h
Normal file
85
include/i18n.h
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
#ifndef I18N_H
|
||||
#define I18N_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
typedef struct {
|
||||
const char *text[UI_LANG_COUNT];
|
||||
} i18n_string_t;
|
||||
|
||||
#define I18N_STRING(en_text, zh_text) \
|
||||
{{ [UI_LANG_EN] = (en_text), [UI_LANG_ZH] = (zh_text) }}
|
||||
|
||||
typedef enum {
|
||||
I18N_USERNAME_PROMPT,
|
||||
I18N_INVALID_USERNAME,
|
||||
I18N_ROOM_FULL,
|
||||
I18N_WELCOME_SUBTITLE,
|
||||
I18N_WELCOME_TAGLINE,
|
||||
I18N_WELCOME_FALLBACK_FORMAT,
|
||||
I18N_INSERT_HINT_WIDE,
|
||||
I18N_INSERT_HINT_NARROW,
|
||||
I18N_NORMAL_LATEST,
|
||||
I18N_NORMAL_NEW_MESSAGES,
|
||||
I18N_HELP_TITLE,
|
||||
I18N_HELP_STATUS_FORMAT,
|
||||
I18N_COMMAND_OUTPUT_TITLE,
|
||||
I18N_COMMAND_OUTPUT_STATUS_FORMAT,
|
||||
I18N_MOTD_TITLE,
|
||||
I18N_MOTD_CONTINUE_HINT,
|
||||
I18N_TITLE_ONLINE_FORMAT,
|
||||
I18N_TITLE_MUTED,
|
||||
I18N_TITLE_HELP_HINT,
|
||||
I18N_IDLE_TIMEOUT_FORMAT,
|
||||
I18N_SYSTEM_USERNAME,
|
||||
I18N_SYSTEM_JOIN_FORMAT,
|
||||
I18N_SYSTEM_LEAVE_FORMAT,
|
||||
I18N_SYSTEM_NICK_FORMAT,
|
||||
I18N_USERS_TITLE,
|
||||
I18N_MSG_SENT_FORMAT,
|
||||
I18N_MSG_USER_NOT_FOUND_FORMAT,
|
||||
I18N_INBOX_TITLE,
|
||||
I18N_INBOX_EMPTY,
|
||||
I18N_NICK_INVALID,
|
||||
I18N_NICK_TAKEN_FORMAT,
|
||||
I18N_NICK_UNCHANGED,
|
||||
I18N_NICK_CHANGED_FORMAT,
|
||||
I18N_LAST_HEADER_FORMAT,
|
||||
I18N_SEARCH_HEADER_FORMAT,
|
||||
I18N_MUTE_JOINS_FORMAT,
|
||||
I18N_MUTE_JOINS_MUTED,
|
||||
I18N_MUTE_JOINS_UNMUTED,
|
||||
I18N_CLEAR_DONE,
|
||||
I18N_LANG_CURRENT_FORMAT,
|
||||
I18N_LANG_SET_FORMAT,
|
||||
I18N_LANG_UNSUPPORTED_FORMAT,
|
||||
I18N_UNKNOWN_COMMAND_FORMAT,
|
||||
I18N_DID_YOU_MEAN_FORMAT,
|
||||
I18N_UNKNOWN_GUIDANCE,
|
||||
I18N_EXEC_POST_EMPTY,
|
||||
I18N_EXEC_POST_INVALID_UTF8,
|
||||
I18N_EXEC_UNKNOWN_COMMAND_FORMAT,
|
||||
I18N_TEXT_COUNT
|
||||
} i18n_text_id_t;
|
||||
|
||||
bool i18n_try_parse_ui_lang(const char *value, ui_lang_t *lang);
|
||||
ui_lang_t i18n_parse_ui_lang(const char *value, ui_lang_t fallback);
|
||||
ui_lang_t i18n_default_ui_lang(void);
|
||||
ui_lang_t i18n_next_ui_lang(ui_lang_t lang);
|
||||
const char *i18n_ui_lang_code(ui_lang_t lang);
|
||||
const char *i18n_text(ui_lang_t lang, i18n_text_id_t id);
|
||||
|
||||
static inline const char *i18n_string(i18n_string_t value, ui_lang_t lang) {
|
||||
if ((int)lang < 0 || lang >= UI_LANG_COUNT) {
|
||||
lang = UI_LANG_EN;
|
||||
}
|
||||
if (value.text[lang]) {
|
||||
return value.text[lang];
|
||||
}
|
||||
if (value.text[UI_LANG_EN]) {
|
||||
return value.text[UI_LANG_EN];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
#endif /* I18N_H */
|
||||
9
include/manual.h
Normal file
9
include/manual.h
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#ifndef MANUAL_H
|
||||
#define MANUAL_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
void manual_append_interactive_panel(char *buffer, size_t buf_size,
|
||||
size_t *pos, ui_lang_t lang);
|
||||
|
||||
#endif /* MANUAL_H */
|
||||
9
include/manual_text.h
Normal file
9
include/manual_text.h
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#ifndef MANUAL_TEXT_H
|
||||
#define MANUAL_TEXT_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
void manual_text_append_interactive(char *buffer, size_t buf_size,
|
||||
size_t *pos, ui_lang_t lang);
|
||||
|
||||
#endif /* MANUAL_TEXT_H */
|
||||
|
|
@ -7,6 +7,16 @@
|
|||
#include <libssh/libssh.h>
|
||||
#include <libssh/server.h>
|
||||
|
||||
/* One stored whisper. Kept per-recipient, not broadcast to the room
|
||||
* and not persisted to messages.log. Inbox is bounded; oldest slides
|
||||
* out FIFO. */
|
||||
#define WHISPER_INBOX_SIZE 16
|
||||
typedef struct {
|
||||
time_t timestamp;
|
||||
char from[MAX_USERNAME_LEN];
|
||||
char content[MAX_MESSAGE_LEN];
|
||||
} whisper_t;
|
||||
|
||||
/* Client connection structure */
|
||||
typedef struct client {
|
||||
ssh_session session; /* SSH session */
|
||||
|
|
@ -16,21 +26,34 @@ typedef struct client {
|
|||
_Atomic int width;
|
||||
_Atomic int height;
|
||||
client_mode_t mode;
|
||||
help_lang_t help_lang;
|
||||
ui_lang_t ui_lang;
|
||||
int scroll_pos;
|
||||
bool follow_tail; /* NORMAL stays pinned to latest until user scrolls up */
|
||||
int help_scroll_pos;
|
||||
bool show_help;
|
||||
char command_input[256];
|
||||
char command_history[16][256];
|
||||
int command_history_count;
|
||||
int command_history_pos;
|
||||
char command_output[2048];
|
||||
/* INSERT mode chat-message history. Last 16 messages this client
|
||||
* sent, oldest first. Up/Down in INSERT mode walks through it. */
|
||||
char insert_history[16][MAX_MESSAGE_LEN];
|
||||
int insert_history_count;
|
||||
int insert_history_pos;
|
||||
char command_output[MAX_COMMAND_OUTPUT_LEN];
|
||||
int command_output_scroll;
|
||||
bool show_motd; /* command_output holds MOTD text */
|
||||
char exec_command[MAX_EXEC_COMMAND_LEN];
|
||||
char ssh_login[MAX_USERNAME_LEN];
|
||||
time_t connect_time;
|
||||
time_t last_active;
|
||||
atomic_bool redraw_pending;
|
||||
_Atomic int unread_mentions; /* @-mentions received since last reset */
|
||||
_Atomic int unread_whispers; /* whispers received since last :inbox view */
|
||||
/* Per-client whisper inbox. Pushes serialise on io_lock; readers are
|
||||
* the client's own thread inside :inbox handling. */
|
||||
whisper_t whisper_inbox[WHISPER_INBOX_SIZE];
|
||||
int whisper_inbox_count;
|
||||
bool mute_joins;
|
||||
pthread_t thread;
|
||||
atomic_bool connected;
|
||||
|
|
|
|||
17
include/system_message.h
Normal file
17
include/system_message.h
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
#ifndef SYSTEM_MESSAGE_H
|
||||
#define SYSTEM_MESSAGE_H
|
||||
|
||||
#include "common.h"
|
||||
#include "message.h"
|
||||
|
||||
void system_message_make_join(message_t *msg, const char *username,
|
||||
ui_lang_t lang);
|
||||
void system_message_make_leave(message_t *msg, const char *username,
|
||||
ui_lang_t lang);
|
||||
void system_message_make_nick(message_t *msg, const char *old_name,
|
||||
const char *new_name, ui_lang_t lang);
|
||||
|
||||
bool system_message_is_system(const message_t *msg);
|
||||
bool system_message_is_join_leave(const message_t *msg);
|
||||
|
||||
#endif /* SYSTEM_MESSAGE_H */
|
||||
|
|
@ -32,7 +32,4 @@ void tui_clear_screen(struct client *client);
|
|||
* itself afterwards. */
|
||||
void tui_render_welcome(struct client *client);
|
||||
|
||||
/* Get help text based on language */
|
||||
const char* tui_get_help_text(help_lang_t lang);
|
||||
|
||||
#endif /* TUI_H */
|
||||
|
|
|
|||
12
include/tui_status.h
Normal file
12
include/tui_status.h
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#ifndef TUI_STATUS_H
|
||||
#define TUI_STATUS_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
struct client;
|
||||
|
||||
void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
|
||||
const struct client *client, int msg_count,
|
||||
int start, int end);
|
||||
|
||||
#endif /* TUI_STATUS_H */
|
||||
|
|
@ -15,9 +15,16 @@ uint32_t utf8_decode(const char *str, int *bytes_read);
|
|||
/* Calculate display width of a UTF-8 string (considering CJK double-width) */
|
||||
int utf8_string_width(const char *str);
|
||||
|
||||
/* Calculate display width while treating ANSI escape sequences as zero-width */
|
||||
int utf8_ansi_string_width(const char *str);
|
||||
|
||||
/* Truncate string to fit within max_width display characters */
|
||||
void utf8_truncate(char *str, int max_width);
|
||||
|
||||
/* Truncate ANSI-styled UTF-8 text without cutting escape sequences */
|
||||
void utf8_ansi_truncate(const char *src, char *dst, size_t dst_size,
|
||||
int max_width);
|
||||
|
||||
/* Count the number of UTF-8 characters in a string */
|
||||
int utf8_strlen(const char *str);
|
||||
|
||||
|
|
|
|||
78
install.sh
78
install.sh
|
|
@ -1,24 +1,51 @@
|
|||
#!/bin/sh
|
||||
# TNT Auto-deploy script
|
||||
# TNT installer
|
||||
# Usage: curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||
|
||||
set -e
|
||||
|
||||
VERSION=${VERSION:-latest}
|
||||
INSTALL_DIR=${INSTALL_DIR:-/usr/local/bin}
|
||||
REPO="m1ngsama/TNT"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
need_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "$1 is required"
|
||||
}
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
need_cmd curl
|
||||
need_cmd awk
|
||||
|
||||
# Detect OS and architecture
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
ARCH=$(uname -m)
|
||||
|
||||
case "$OS" in
|
||||
linux|darwin) ;;
|
||||
*) fail "Unsupported OS: $OS" ;;
|
||||
esac
|
||||
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64|arm64) ARCH="arm64" ;;
|
||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||
*) fail "Unsupported architecture: $ARCH" ;;
|
||||
esac
|
||||
|
||||
BINARY="tnt-${OS}-${ARCH}"
|
||||
REPO="m1ngsama/TNT"
|
||||
|
||||
echo "=== TNT Installer ==="
|
||||
echo "OS: $OS"
|
||||
|
|
@ -29,34 +56,57 @@ echo ""
|
|||
# Get latest version if not specified
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
echo "Fetching latest version..."
|
||||
VERSION=$(curl -sSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" |
|
||||
sed -n 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' |
|
||||
head -n 1)
|
||||
[ -n "$VERSION" ] || fail "Could not determine latest release version"
|
||||
fi
|
||||
|
||||
echo "Installing version: $VERSION"
|
||||
|
||||
# Download
|
||||
URL="https://github.com/$REPO/releases/download/$VERSION/$BINARY"
|
||||
CHECKSUM_URL="https://github.com/$REPO/releases/download/$VERSION/checksums.txt"
|
||||
echo "Downloading from: $URL"
|
||||
|
||||
TMP_FILE=$(mktemp)
|
||||
if ! curl -sSL -o "$TMP_FILE" "$URL"; then
|
||||
echo "ERROR: Failed to download $BINARY"
|
||||
rm -f "$TMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt.XXXXXX")
|
||||
CHECKSUM_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt-checksums.XXXXXX")
|
||||
cleanup() {
|
||||
rm -f "$TMP_FILE" "$CHECKSUM_FILE"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
curl -fsSL -o "$TMP_FILE" "$URL" || fail "Failed to download $BINARY"
|
||||
|
||||
echo "Downloading checksums from: $CHECKSUM_URL"
|
||||
curl -fsSL -o "$CHECKSUM_FILE" "$CHECKSUM_URL" ||
|
||||
fail "Failed to download checksums.txt"
|
||||
|
||||
EXPECTED_SHA=$(awk -v name="$BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
|
||||
[ -n "$EXPECTED_SHA" ] || fail "No checksum entry found for $BINARY"
|
||||
|
||||
ACTUAL_SHA=$(sha256_of "$TMP_FILE") ||
|
||||
fail "sha256sum or shasum is required for checksum verification"
|
||||
|
||||
[ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] ||
|
||||
fail "Checksum mismatch for $BINARY"
|
||||
|
||||
echo "Checksum verified: $ACTUAL_SHA"
|
||||
|
||||
# Install
|
||||
chmod +x "$TMP_FILE"
|
||||
|
||||
if [ -w "$INSTALL_DIR" ]; then
|
||||
mv "$TMP_FILE" "$INSTALL_DIR/tnt"
|
||||
if [ -d "$INSTALL_DIR" ] && [ -w "$INSTALL_DIR" ]; then
|
||||
install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt"
|
||||
else
|
||||
echo "Need sudo for installation to $INSTALL_DIR"
|
||||
sudo mv "$TMP_FILE" "$INSTALL_DIR/tnt"
|
||||
need_cmd sudo
|
||||
sudo mkdir -p "$INSTALL_DIR"
|
||||
sudo install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✓ TNT installed successfully to $INSTALL_DIR/tnt"
|
||||
echo "TNT installed successfully to $INSTALL_DIR/tnt"
|
||||
echo ""
|
||||
echo "Run with:"
|
||||
echo " tnt"
|
||||
|
|
|
|||
37
packaging/README.md
Normal file
37
packaging/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Packaging
|
||||
|
||||
This directory contains package-manager drafts for TNT. They are intentionally
|
||||
kept out of the root install path and should be reviewed before submission to
|
||||
any public registry.
|
||||
|
||||
## Current targets
|
||||
|
||||
- `arch/` - AUR-ready draft for `tnt-chat`.
|
||||
- `homebrew/` - Homebrew tap formula draft and maintainer notes.
|
||||
- `debian/` - Ubuntu PPA / Debian packaging notes and draft metadata.
|
||||
|
||||
## Release checklist
|
||||
|
||||
1. Confirm `TNT_VERSION` in `include/common.h` and the manpage version match.
|
||||
Also update package versions in Arch, Homebrew, and Debian drafts.
|
||||
2. Create a GitHub release tag such as `v1.0.1`.
|
||||
3. Build and upload release tarballs or rely on GitHub source archives.
|
||||
4. Replace placeholder checksums in package drafts.
|
||||
5. Verify package contents in an isolated directory:
|
||||
|
||||
```sh
|
||||
make release-check
|
||||
```
|
||||
|
||||
6. Before submitting package recipes, replace checksum placeholders and run:
|
||||
|
||||
```sh
|
||||
make release-check-strict
|
||||
```
|
||||
|
||||
7. Submit packages manually:
|
||||
- Arch: upload `PKGBUILD` and generated `.SRCINFO` to AUR.
|
||||
- Homebrew: open a PR to the project tap, or later Homebrew core if eligible.
|
||||
- Ubuntu: build Debian source packages and upload to a Launchpad PPA.
|
||||
|
||||
Do not connect these packaging drafts to automatic production deployment.
|
||||
15
packaging/arch/.SRCINFO
Normal file
15
packaging/arch/.SRCINFO
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
pkgbase = tnt-chat
|
||||
pkgdesc = SSH-native terminal chat server with a Vim-style interface
|
||||
pkgver = 1.0.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/m1ngsama/TNT
|
||||
arch = x86_64
|
||||
arch = aarch64
|
||||
license = MIT
|
||||
makedepends = gcc
|
||||
makedepends = make
|
||||
depends = libssh
|
||||
source = tnt-chat-1.0.1.tar.gz::https://github.com/m1ngsama/TNT/archive/refs/tags/v1.0.1.tar.gz
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = tnt-chat
|
||||
25
packaging/arch/PKGBUILD
Normal file
25
packaging/arch/PKGBUILD
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Maintainer: M1ng <REPLACE_WITH_EMAIL>
|
||||
|
||||
pkgname=tnt-chat
|
||||
pkgver=1.0.1
|
||||
pkgrel=1
|
||||
pkgdesc='SSH-native terminal chat server with a Vim-style interface'
|
||||
arch=('x86_64' 'aarch64')
|
||||
url='https://github.com/m1ngsama/TNT'
|
||||
license=('MIT')
|
||||
depends=('libssh')
|
||||
makedepends=('gcc' 'make')
|
||||
source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "TNT-${pkgver}"
|
||||
make
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "TNT-${pkgver}"
|
||||
make DESTDIR="${pkgdir}" PREFIX=/usr install
|
||||
make DESTDIR="${pkgdir}" PREFIX=/usr install-systemd
|
||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
47
packaging/arch/README.md
Normal file
47
packaging/arch/README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Arch / AUR Packaging
|
||||
|
||||
The draft package name is `tnt-chat` because `tnt` is already a likely name
|
||||
collision in Arch/AUR contexts.
|
||||
|
||||
## Local validation
|
||||
|
||||
From this directory:
|
||||
|
||||
```sh
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
Optional package linting:
|
||||
|
||||
```sh
|
||||
namcap PKGBUILD
|
||||
namcap tnt-chat-*.pkg.tar.zst
|
||||
```
|
||||
|
||||
## Updating metadata
|
||||
|
||||
After editing `PKGBUILD`, regenerate `.SRCINFO`:
|
||||
|
||||
```sh
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
```
|
||||
|
||||
Before AUR submission, replace `sha256sums=('SKIP')` with the real release
|
||||
archive checksum, then run the project-level strict check:
|
||||
|
||||
```sh
|
||||
make release-check-strict
|
||||
```
|
||||
|
||||
## Manual AUR submission
|
||||
|
||||
```sh
|
||||
git clone ssh://aur@aur.archlinux.org/tnt-chat.git aur-tnt-chat
|
||||
cp PKGBUILD .SRCINFO aur-tnt-chat/
|
||||
cd aur-tnt-chat
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "Update to 1.0.1"
|
||||
git push
|
||||
```
|
||||
|
||||
Do not wire this to automatic deployment or release automation.
|
||||
49
packaging/debian/README.md
Normal file
49
packaging/debian/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Debian and Ubuntu Packaging
|
||||
|
||||
Ubuntu distribution should start with a Launchpad PPA. Direct inclusion in
|
||||
Debian or Ubuntu archives is a separate, slower process and should wait until
|
||||
the project has a stable release cadence.
|
||||
|
||||
## Draft metadata
|
||||
|
||||
The `debian/` directory in this folder is a packaging draft. To test it against
|
||||
an upstream release tree, copy it to the root of a clean source checkout:
|
||||
|
||||
```sh
|
||||
cp -a packaging/debian/debian ./debian
|
||||
dpkg-buildpackage -us -uc
|
||||
```
|
||||
|
||||
For PPA uploads, build a signed source package instead:
|
||||
|
||||
```sh
|
||||
debuild -S
|
||||
```
|
||||
|
||||
## Recommended path
|
||||
|
||||
1. Keep the upstream project installable with:
|
||||
|
||||
```sh
|
||||
make DESTDIR="$pkgdir" PREFIX=/usr install
|
||||
```
|
||||
|
||||
2. Review Debian packaging metadata from a release tarball:
|
||||
|
||||
- `debian/control`
|
||||
- `debian/rules`
|
||||
- `debian/changelog`
|
||||
- `debian/copyright`
|
||||
- `debian/source/format`
|
||||
|
||||
3. Build locally with `debuild` or `dpkg-buildpackage`.
|
||||
4. Upload the signed source package to a Launchpad PPA.
|
||||
5. Only after repeated stable releases, consider Debian mentors or Ubuntu
|
||||
archive sponsorship.
|
||||
|
||||
## Package shape
|
||||
|
||||
- Binary package name: `tnt-chat`
|
||||
- Installed command: `/usr/bin/tnt`
|
||||
- Runtime dependency: `libssh`
|
||||
- Optional systemd unit: `/usr/lib/systemd/system/tnt.service`
|
||||
5
packaging/debian/debian/changelog
Normal file
5
packaging/debian/debian/changelog
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
tnt-chat (1.0.1-1) unstable; urgency=medium
|
||||
|
||||
* Initial package draft.
|
||||
|
||||
-- M1ng <REPLACE_WITH_EMAIL> Thu, 21 May 2026 00:00:00 +0800
|
||||
22
packaging/debian/debian/control
Normal file
22
packaging/debian/debian/control
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
Source: tnt-chat
|
||||
Section: net
|
||||
Priority: optional
|
||||
Maintainer: M1ng <REPLACE_WITH_EMAIL>
|
||||
Build-Depends:
|
||||
debhelper-compat (= 13),
|
||||
libssh-dev,
|
||||
make,
|
||||
gcc
|
||||
Standards-Version: 4.7.0
|
||||
Homepage: https://github.com/m1ngsama/TNT
|
||||
Rules-Requires-Root: no
|
||||
|
||||
Package: tnt-chat
|
||||
Architecture: any
|
||||
Depends:
|
||||
${misc:Depends},
|
||||
${shlibs:Depends}
|
||||
Description: SSH-native terminal chat server
|
||||
TNT is a minimalist terminal chat server accessed over SSH. It provides a
|
||||
Vim-style terminal interface, anonymous access by default, persistent message
|
||||
history, and a small non-interactive SSH exec surface for scripts.
|
||||
26
packaging/debian/debian/copyright
Normal file
26
packaging/debian/debian/copyright
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: TNT
|
||||
Source: https://github.com/m1ngsama/TNT
|
||||
|
||||
Files: *
|
||||
Copyright: 2026 M1ng
|
||||
License: MIT
|
||||
|
||||
License: MIT
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
11
packaging/debian/debian/rules
Executable file
11
packaging/debian/debian/rules
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/make -f
|
||||
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_build:
|
||||
$(MAKE)
|
||||
|
||||
override_dh_auto_install:
|
||||
$(MAKE) DESTDIR=$(CURDIR)/debian/tnt-chat PREFIX=/usr install
|
||||
$(MAKE) DESTDIR=$(CURDIR)/debian/tnt-chat PREFIX=/usr install-systemd
|
||||
1
packaging/debian/debian/source/format
Normal file
1
packaging/debian/debian/source/format
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.0 (quilt)
|
||||
49
packaging/homebrew/README.md
Normal file
49
packaging/homebrew/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Homebrew Packaging
|
||||
|
||||
The draft formula is `tnt-chat.rb`. The expected install path for users is a
|
||||
project tap first, not Homebrew core:
|
||||
|
||||
```sh
|
||||
brew tap m1ngsama/tnt
|
||||
brew install tnt-chat
|
||||
```
|
||||
|
||||
Homebrew core should wait until TNT has stable releases and broader usage.
|
||||
|
||||
## Local validation
|
||||
|
||||
From a tap repository:
|
||||
|
||||
```sh
|
||||
brew audit --strict --online tnt-chat
|
||||
brew install --build-from-source ./Formula/tnt-chat.rb
|
||||
brew test tnt-chat
|
||||
```
|
||||
|
||||
For local syntax-only validation from this repository:
|
||||
|
||||
```sh
|
||||
ruby -c packaging/homebrew/tnt-chat.rb
|
||||
```
|
||||
|
||||
## Updating the formula
|
||||
|
||||
1. Publish a GitHub release tag such as `v1.0.1`.
|
||||
2. Download or hash the release source archive:
|
||||
|
||||
```sh
|
||||
curl -L -o tnt-chat-1.0.1.tar.gz \
|
||||
https://github.com/m1ngsama/TNT/archive/refs/tags/v1.0.1.tar.gz
|
||||
shasum -a 256 tnt-chat-1.0.1.tar.gz
|
||||
```
|
||||
|
||||
3. Replace `REPLACE_WITH_RELEASE_TARBALL_SHA256` in `tnt-chat.rb`.
|
||||
4. Run:
|
||||
|
||||
```sh
|
||||
make release-check-strict
|
||||
```
|
||||
|
||||
5. Copy the formula into the tap repository and open a normal review PR.
|
||||
|
||||
Do not connect this tap update to production deployment.
|
||||
21
packaging/homebrew/tnt-chat.rb
Normal file
21
packaging/homebrew/tnt-chat.rb
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
class TntChat < Formula
|
||||
desc "SSH-native terminal chat server with a Vim-style interface"
|
||||
homepage "https://github.com/m1ngsama/TNT"
|
||||
url "https://github.com/m1ngsama/TNT/archive/refs/tags/v1.0.1.tar.gz"
|
||||
sha256 "REPLACE_WITH_RELEASE_TARBALL_SHA256"
|
||||
license "MIT"
|
||||
|
||||
depends_on "libssh"
|
||||
|
||||
def install
|
||||
system "make"
|
||||
system "make", "install", "DESTDIR=#{buildpath}/stage", "PREFIX=#{prefix}"
|
||||
|
||||
bin.install "#{buildpath}/stage#{prefix}/bin/tnt"
|
||||
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tnt.1"
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/tnt --version")
|
||||
end
|
||||
end
|
||||
150
scripts/release_check.sh
Executable file
150
scripts/release_check.sh
Executable file
|
|
@ -0,0 +1,150 @@
|
|||
#!/bin/sh
|
||||
# Local release preflight. This never tags, pushes, publishes, or deploys.
|
||||
|
||||
set -eu
|
||||
|
||||
STRICT=0
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/release_check.sh [--strict]
|
||||
|
||||
Default checks:
|
||||
- version metadata alignment
|
||||
- clean build
|
||||
- unit tests
|
||||
- staged install layout with PREFIX=/usr and DESTDIR
|
||||
- installer shell syntax
|
||||
- Debian packaging metadata
|
||||
- Arch/Homebrew packaging syntax
|
||||
|
||||
Environment:
|
||||
RUN_INTEGRATION=1 also run full make test
|
||||
PORT=12720 base port for integration tests
|
||||
|
||||
Strict checks additionally require real package checksums and a local vX.Y.Z tag.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--strict)
|
||||
STRICT=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$ROOT"
|
||||
|
||||
fail() {
|
||||
echo "release-check: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
step() {
|
||||
printf '\n==> %s\n' "$*"
|
||||
}
|
||||
|
||||
version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
|
||||
[ -n "$version" ] || fail "could not read TNT_VERSION from include/common.h"
|
||||
|
||||
step "checking version metadata for $version"
|
||||
grep -q "\"TNT $version\"" tnt.1 ||
|
||||
fail "tnt.1 does not mention TNT $version"
|
||||
grep -q "^pkgver=$version$" packaging/arch/PKGBUILD ||
|
||||
fail "packaging/arch/PKGBUILD pkgver does not match $version"
|
||||
grep -q "pkgver = $version" packaging/arch/.SRCINFO ||
|
||||
fail "packaging/arch/.SRCINFO pkgver does not match $version"
|
||||
grep -q "^pkgname=tnt-chat$" packaging/arch/PKGBUILD ||
|
||||
fail "packaging/arch/PKGBUILD pkgname is not tnt-chat"
|
||||
grep -q "^pkgname = tnt-chat$" packaging/arch/.SRCINFO ||
|
||||
fail "packaging/arch/.SRCINFO pkgname is not tnt-chat"
|
||||
grep -q "v${version}.tar.gz" packaging/homebrew/tnt-chat.rb ||
|
||||
fail "packaging/homebrew/tnt-chat.rb URL does not match v$version"
|
||||
grep -q "^class TntChat < Formula$" packaging/homebrew/tnt-chat.rb ||
|
||||
fail "packaging/homebrew/tnt-chat.rb formula class is not TntChat"
|
||||
grep -q 'depends_on "libssh"' packaging/homebrew/tnt-chat.rb ||
|
||||
fail "packaging/homebrew/tnt-chat.rb must depend on libssh"
|
||||
grep -q "^tnt-chat (${version}-1)" packaging/debian/debian/changelog ||
|
||||
fail "packaging/debian/debian/changelog version does not match $version"
|
||||
grep -q "^Source: tnt-chat$" packaging/debian/debian/control ||
|
||||
fail "packaging/debian/debian/control Source is not tnt-chat"
|
||||
|
||||
step "building"
|
||||
make clean
|
||||
make
|
||||
|
||||
actual_version=$(./tnt --version)
|
||||
[ "$actual_version" = "tnt $version" ] ||
|
||||
fail "binary version mismatch: expected 'tnt $version', got '$actual_version'"
|
||||
|
||||
step "running unit tests"
|
||||
make -C tests/unit clean
|
||||
make -C tests/unit run
|
||||
|
||||
if [ "${RUN_INTEGRATION:-0}" = "1" ]; then
|
||||
step "running full integration tests"
|
||||
make test PORT="${PORT:-12720}"
|
||||
fi
|
||||
|
||||
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/tnt-release-check.XXXXXX")
|
||||
cleanup() {
|
||||
rm -rf "$tmpdir"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
step "checking staged install layout"
|
||||
make DESTDIR="$tmpdir" PREFIX=/usr install
|
||||
make DESTDIR="$tmpdir" PREFIX=/usr install-systemd
|
||||
|
||||
[ -x "$tmpdir/usr/bin/tnt" ] || fail "missing executable: /usr/bin/tnt"
|
||||
[ -f "$tmpdir/usr/share/man/man1/tnt.1" ] || fail "missing manpage: /usr/share/man/man1/tnt.1"
|
||||
[ -f "$tmpdir/usr/lib/systemd/system/tnt.service" ] ||
|
||||
fail "missing systemd unit: /usr/lib/systemd/system/tnt.service"
|
||||
|
||||
step "checking installer syntax"
|
||||
sh -n install.sh
|
||||
|
||||
step "checking Debian packaging metadata"
|
||||
[ -x packaging/debian/debian/rules ] ||
|
||||
fail "packaging/debian/debian/rules must be executable"
|
||||
grep -q "^3.0 (quilt)$" packaging/debian/debian/source/format ||
|
||||
fail "unsupported Debian source format"
|
||||
|
||||
step "checking packaging syntax"
|
||||
if command -v bash >/dev/null 2>&1; then
|
||||
bash -n packaging/arch/PKGBUILD
|
||||
else
|
||||
echo "bash not found; skipping PKGBUILD syntax check"
|
||||
fi
|
||||
|
||||
if command -v ruby >/dev/null 2>&1; then
|
||||
ruby -c packaging/homebrew/tnt-chat.rb
|
||||
else
|
||||
echo "ruby not found; skipping Homebrew formula syntax check"
|
||||
fi
|
||||
|
||||
if [ "$STRICT" -eq 1 ]; then
|
||||
step "checking strict release gates"
|
||||
! grep -q "sha256sums=('SKIP')" packaging/arch/PKGBUILD ||
|
||||
fail "replace PKGBUILD sha256sums before strict release"
|
||||
! grep -q "sha256sums = SKIP" packaging/arch/.SRCINFO ||
|
||||
fail "replace .SRCINFO sha256sums before strict release"
|
||||
! grep -q "REPLACE_WITH_RELEASE_TARBALL_SHA256" packaging/homebrew/tnt-chat.rb ||
|
||||
fail "replace Homebrew sha256 before strict release"
|
||||
git rev-parse -q --verify "refs/tags/v$version" >/dev/null ||
|
||||
fail "missing local tag v$version"
|
||||
fi
|
||||
|
||||
step "release preflight passed"
|
||||
66
src/cli_text.c
Normal file
66
src/cli_text.c
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#include "cli_text.h"
|
||||
|
||||
#include "i18n.h"
|
||||
|
||||
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
|
||||
const char *program_name, ui_lang_t lang) {
|
||||
static const i18n_string_t help_format = I18N_STRING(
|
||||
"tnt %s - anonymous SSH chat server\n\n"
|
||||
"Usage: %s [options]\n\n"
|
||||
"Options:\n"
|
||||
" -p, --port PORT Listen on PORT (default: %d)\n"
|
||||
" -d, --state-dir DIR Store host key and logs in DIR\n"
|
||||
" -V, --version Show version\n"
|
||||
" -h, --help Show this help\n"
|
||||
"\n"
|
||||
"Environment:\n"
|
||||
" PORT Default listening port\n"
|
||||
" TNT_STATE_DIR State directory\n"
|
||||
" TNT_ACCESS_TOKEN Require this password for SSH auth\n"
|
||||
" TNT_LANG UI language: en or zh (default: locale)\n"
|
||||
" TNT_MAX_CONNECTIONS Global connection limit (default: 64)\n"
|
||||
" TNT_RATE_LIMIT Set to 0 to disable rate limiting\n"
|
||||
" TNT_IDLE_TIMEOUT Idle disconnect timeout in seconds (default: 1800)\n",
|
||||
"tnt %s - 匿名 SSH 聊天服务器\n\n"
|
||||
"用法: %s [options]\n\n"
|
||||
"选项:\n"
|
||||
" -p, --port PORT 监听 PORT (默认: %d)\n"
|
||||
" -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n"
|
||||
" -V, --version 显示版本\n"
|
||||
" -h, --help 显示此帮助\n"
|
||||
"\n"
|
||||
"环境变量:\n"
|
||||
" PORT 默认监听端口\n"
|
||||
" TNT_STATE_DIR 状态目录\n"
|
||||
" TNT_ACCESS_TOKEN 要求 SSH 认证使用此密码\n"
|
||||
" TNT_LANG UI 语言: en 或 zh (默认跟随 locale)\n"
|
||||
" TNT_MAX_CONNECTIONS 全局连接数限制 (默认: 64)\n"
|
||||
" TNT_RATE_LIMIT 设为 0 可禁用速率限制\n"
|
||||
" TNT_IDLE_TIMEOUT 空闲断开时间,单位秒 (默认: 1800)\n"
|
||||
);
|
||||
const char *program = (program_name && program_name[0] != '\0')
|
||||
? program_name
|
||||
: "tnt";
|
||||
|
||||
buffer_appendf(buffer, buf_size, pos, i18n_string(help_format, lang),
|
||||
TNT_VERSION, program, DEFAULT_PORT);
|
||||
}
|
||||
|
||||
const char *cli_text_invalid_port_format(ui_lang_t lang) {
|
||||
static const i18n_string_t text =
|
||||
I18N_STRING("Invalid port: %s\n", "端口无效: %s\n");
|
||||
return i18n_string(text, lang);
|
||||
}
|
||||
|
||||
const char *cli_text_unknown_option_format(ui_lang_t lang) {
|
||||
static const i18n_string_t text =
|
||||
I18N_STRING("Unknown option: %s\n", "未知选项: %s\n");
|
||||
return i18n_string(text, lang);
|
||||
}
|
||||
|
||||
const char *cli_text_short_usage_format(ui_lang_t lang) {
|
||||
static const i18n_string_t text =
|
||||
I18N_STRING("Usage: %s [-p PORT] [-d DIR] [-h]\n",
|
||||
"用法: %s [-p PORT] [-d DIR] [-h]\n");
|
||||
return i18n_string(text, lang);
|
||||
}
|
||||
11
src/client.c
11
src/client.c
|
|
@ -33,6 +33,10 @@ int client_send(client_t *client, const char *data, size_t len) {
|
|||
total += (size_t)sent;
|
||||
}
|
||||
|
||||
if (client->exec_command[0] != '\0') {
|
||||
ssh_blocking_flush(client->session, 1000);
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&client->io_lock);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -58,7 +62,9 @@ void client_release(client_t *client) {
|
|||
ssh_remove_channel_callbacks(client->channel, client->channel_cb);
|
||||
}
|
||||
if (client->channel) {
|
||||
if (ssh_channel_is_open(client->channel)) {
|
||||
ssh_channel_close(client->channel);
|
||||
}
|
||||
ssh_channel_free(client->channel);
|
||||
}
|
||||
if (client->session) {
|
||||
|
|
@ -120,9 +126,14 @@ static void client_channel_eof(ssh_session session, ssh_channel channel,
|
|||
|
||||
client_t *client = (client_t *)userdata;
|
||||
if (client) {
|
||||
/* Exec clients commonly half-close stdin immediately after sending
|
||||
* the command. Keep stdout usable so the exec handler can return
|
||||
* output and an exit status. */
|
||||
if (client->exec_command[0] == '\0') {
|
||||
client->connected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void client_channel_close(ssh_session session, ssh_channel channel,
|
||||
void *userdata) {
|
||||
|
|
|
|||
311
src/command_catalog.c
Normal file
311
src/command_catalog.c
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
#include "command_catalog.h"
|
||||
|
||||
#include "i18n.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
typedef struct {
|
||||
tnt_command_spec_t spec;
|
||||
i18n_string_t full_usage;
|
||||
i18n_string_t summary;
|
||||
i18n_string_t manual_usage;
|
||||
i18n_string_t error_usage;
|
||||
int manual_group;
|
||||
bool no_args;
|
||||
bool requires_args;
|
||||
} command_catalog_entry_t;
|
||||
|
||||
static const command_catalog_entry_t entries[] = {
|
||||
{
|
||||
{TNT_COMMAND_USERS, "users", {"users", "list", "who", NULL}},
|
||||
I18N_STRING(":users, :list, :who", ":users, :list, :who"),
|
||||
I18N_STRING("Show online users", "显示在线用户"),
|
||||
I18N_STRING(":users", ":users"),
|
||||
I18N_STRING("Usage: users\n", "用法: users\n"),
|
||||
1, true, false
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_MSG, "msg", {"msg", "w", NULL}},
|
||||
I18N_STRING(":msg <user> <message>, :w <user> <message>",
|
||||
":msg <user> <message>, :w <user> <message>"),
|
||||
I18N_STRING("Send private message", "发送私信"),
|
||||
I18N_STRING(":msg <user> <message>", ":msg <user> <message>"),
|
||||
I18N_STRING("Usage: msg <user> <message>\n"
|
||||
" w <user> <message>\n",
|
||||
"用法: msg <user> <message>\n"
|
||||
" w <user> <message>\n"),
|
||||
2, false, true
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}},
|
||||
I18N_STRING(":inbox", ":inbox"),
|
||||
I18N_STRING("Show private messages", "查看私信"),
|
||||
I18N_STRING(":inbox", ":inbox"),
|
||||
I18N_STRING("Usage: inbox\n", "用法: inbox\n"),
|
||||
2, true, false
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}},
|
||||
I18N_STRING(":nick <name>, :name <name>",
|
||||
":nick <name>, :name <name>"),
|
||||
I18N_STRING("Change nickname", "更改昵称"),
|
||||
I18N_STRING(":nick <name>", ":nick <name>"),
|
||||
I18N_STRING("Usage: nick <name>\n", "用法: nick <name>\n"),
|
||||
2, false, true
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_LAST, "last", {"last", NULL}},
|
||||
I18N_STRING(":last [N]", ":last [N]"),
|
||||
I18N_STRING("Show last N messages (max 50)",
|
||||
"显示最后 N 条消息(最多50)"),
|
||||
I18N_STRING(":last [N]", ":last [N]"),
|
||||
I18N_STRING("Usage: last [N] (N: 1-50, default 10)\n",
|
||||
"用法: last [N] (N: 1-50,默认 10)\n"),
|
||||
1, false, false
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_SEARCH, "search", {"search", NULL}},
|
||||
I18N_STRING(":search <keyword>", ":search <keyword>"),
|
||||
I18N_STRING("Search message history", "搜索消息历史"),
|
||||
I18N_STRING(":search <keyword>", ":search <keyword>"),
|
||||
I18N_STRING("Usage: search <keyword>\n", "用法: search <keyword>\n"),
|
||||
1, false, true
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_MUTE_JOINS, "mute-joins", {"mute-joins", "mute", NULL}},
|
||||
I18N_STRING(":mute-joins, :mute", ":mute-joins, :mute"),
|
||||
I18N_STRING("Toggle join/leave notices", "切换加入/离开提示"),
|
||||
I18N_STRING(":mute-joins", ":mute-joins"),
|
||||
I18N_STRING("Usage: mute-joins\n", "用法: mute-joins\n"),
|
||||
3, true, false
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_HELP, "help", {"help", NULL}},
|
||||
I18N_STRING(":help", ":help"),
|
||||
I18N_STRING("Show concise manual", "显示简明手册"),
|
||||
I18N_STRING(NULL, NULL),
|
||||
I18N_STRING("Usage: help\n", "用法: help\n"),
|
||||
0, true, false
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_LANG, "lang", {"lang", "language", NULL}},
|
||||
I18N_STRING(":lang <en|zh>", ":lang <en|zh>"),
|
||||
I18N_STRING("Switch UI language", "切换界面语言"),
|
||||
I18N_STRING(NULL, NULL),
|
||||
I18N_STRING("Usage: lang <en|zh>\n", "用法: lang <en|zh>\n"),
|
||||
0, false, false
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_CLEAR, "clear", {"clear", "cls", NULL}},
|
||||
I18N_STRING(":clear, :cls", ":clear, :cls"),
|
||||
I18N_STRING("Clear command output", "清空命令输出"),
|
||||
I18N_STRING(":clear", ":clear"),
|
||||
I18N_STRING("Usage: clear\n", "用法: clear\n"),
|
||||
3, true, false
|
||||
},
|
||||
{
|
||||
{TNT_COMMAND_QUIT, "q", {"q", "quit", "exit", NULL}},
|
||||
I18N_STRING(":q, :quit, :exit", ":q, :quit, :exit"),
|
||||
I18N_STRING("Disconnect", "断开连接"),
|
||||
I18N_STRING(":q", ":q"),
|
||||
I18N_STRING("Usage: q\n", "用法: q\n"),
|
||||
3, true, false
|
||||
}
|
||||
};
|
||||
|
||||
static const command_catalog_entry_t *entry_for_id(tnt_command_id_t id) {
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
if (entries[i].spec.id == id) {
|
||||
return &entries[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static const char *skip_spaces(const char *value) {
|
||||
while (value && *value == ' ') {
|
||||
value++;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static bool name_matches(const char *line, const char *name,
|
||||
const char **args) {
|
||||
size_t len;
|
||||
|
||||
if (!line || !name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
len = strlen(name);
|
||||
if (strncmp(line, name, len) != 0) {
|
||||
return false;
|
||||
}
|
||||
if (line[len] != '\0' && line[len] != ' ') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (args) {
|
||||
*args = skip_spaces(line + len);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static int min3(int a, int b, int c) {
|
||||
int m = a < b ? a : b;
|
||||
return m < c ? m : c;
|
||||
}
|
||||
|
||||
static int edit_distance(const char *a, const char *b) {
|
||||
size_t la = strlen(a);
|
||||
size_t lb = strlen(b);
|
||||
int prev[32];
|
||||
int curr[32];
|
||||
|
||||
if (la >= 32 || lb >= 32) {
|
||||
return 99;
|
||||
}
|
||||
|
||||
for (size_t j = 0; j <= lb; j++) {
|
||||
prev[j] = (int)j;
|
||||
}
|
||||
|
||||
for (size_t i = 1; i <= la; i++) {
|
||||
curr[0] = (int)i;
|
||||
for (size_t j = 1; j <= lb; j++) {
|
||||
int cost = a[i - 1] == b[j - 1] ? 0 : 1;
|
||||
curr[j] = min3(prev[j] + 1, curr[j - 1] + 1,
|
||||
prev[j - 1] + cost);
|
||||
}
|
||||
for (size_t j = 0; j <= lb; j++) {
|
||||
prev[j] = curr[j];
|
||||
}
|
||||
}
|
||||
|
||||
return prev[lb];
|
||||
}
|
||||
|
||||
const tnt_command_spec_t *command_catalog_get(tnt_command_id_t id) {
|
||||
const command_catalog_entry_t *entry = entry_for_id(id);
|
||||
return entry ? &entry->spec : NULL;
|
||||
}
|
||||
|
||||
bool command_catalog_match(const char *line, tnt_command_id_t *id,
|
||||
const char **args) {
|
||||
line = skip_spaces(line);
|
||||
if (!line || line[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
const tnt_command_spec_t *spec = &entries[i].spec;
|
||||
for (size_t n = 0; n < sizeof(spec->names) / sizeof(spec->names[0]); n++) {
|
||||
const char *candidate_args = NULL;
|
||||
if (!spec->names[n]) {
|
||||
break;
|
||||
}
|
||||
if (!name_matches(line, spec->names[n], &candidate_args)) {
|
||||
continue;
|
||||
}
|
||||
if (id) {
|
||||
*id = spec->id;
|
||||
}
|
||||
if (args) {
|
||||
*args = candidate_args ? candidate_args : "";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool command_catalog_args_valid(tnt_command_id_t id, const char *args) {
|
||||
const command_catalog_entry_t *entry = entry_for_id(id);
|
||||
args = skip_spaces(args);
|
||||
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
if (entry->no_args) {
|
||||
return !args || args[0] == '\0';
|
||||
}
|
||||
if (entry->requires_args) {
|
||||
return args && args[0] != '\0';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const char *command_catalog_suggest(const char *name) {
|
||||
const char *best = NULL;
|
||||
int best_distance = 99;
|
||||
|
||||
if (!name || !*name) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
const tnt_command_spec_t *spec = &entries[i].spec;
|
||||
for (size_t n = 0; n < sizeof(spec->names) / sizeof(spec->names[0]); n++) {
|
||||
int distance;
|
||||
if (!spec->names[n]) {
|
||||
break;
|
||||
}
|
||||
distance = edit_distance(name, spec->names[n]);
|
||||
if (distance < best_distance) {
|
||||
best_distance = distance;
|
||||
best = spec->canonical;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best_distance <= 2 ? best : NULL;
|
||||
}
|
||||
|
||||
void command_catalog_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang) {
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
const char *usage = i18n_string(entries[i].full_usage, lang);
|
||||
const char *summary = i18n_string(entries[i].summary, lang);
|
||||
buffer_appendf(buffer, buf_size, pos, " %-40s - %s\n",
|
||||
usage, summary);
|
||||
}
|
||||
}
|
||||
|
||||
void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang) {
|
||||
for (int group = 1; group <= 3; group++) {
|
||||
bool first = true;
|
||||
|
||||
buffer_appendf(buffer, buf_size, pos, " ");
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
const char *usage;
|
||||
if (entries[i].manual_group != group) {
|
||||
continue;
|
||||
}
|
||||
usage = i18n_string(entries[i].manual_usage, lang);
|
||||
if (!usage || usage[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
if (!first) {
|
||||
buffer_appendf(buffer, buf_size, pos, ", ");
|
||||
}
|
||||
buffer_appendf(buffer, buf_size, pos, "%s", usage);
|
||||
first = false;
|
||||
}
|
||||
buffer_appendf(buffer, buf_size, pos, "\n");
|
||||
}
|
||||
}
|
||||
|
||||
void command_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
|
||||
tnt_command_id_t id, ui_lang_t lang) {
|
||||
const command_catalog_entry_t *entry = entry_for_id(id);
|
||||
const char *usage;
|
||||
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
usage = i18n_string(entry->error_usage, lang);
|
||||
buffer_appendf(buffer, buf_size, pos, "%s", usage);
|
||||
}
|
||||
341
src/commands.c
341
src/commands.c
|
|
@ -1,8 +1,18 @@
|
|||
#ifndef _DEFAULT_SOURCE
|
||||
#define _DEFAULT_SOURCE /* for strcasestr() on glibc */
|
||||
#endif
|
||||
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
|
||||
#define _DARWIN_C_SOURCE /* for strcasestr() on macOS */
|
||||
#endif
|
||||
#include "commands.h"
|
||||
#include "chat_room.h"
|
||||
#include "client.h"
|
||||
#include "command_catalog.h"
|
||||
#include "common.h"
|
||||
#include "i18n.h"
|
||||
#include "manual.h"
|
||||
#include "message.h"
|
||||
#include "system_message.h"
|
||||
#include "tui.h"
|
||||
#include "utf8.h"
|
||||
#include <stdio.h>
|
||||
|
|
@ -10,12 +20,44 @@
|
|||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
/* Append `text` to the output buffer with every case-insensitive match of
|
||||
* `needle` wrapped in a reverse-yellow ANSI chip. Preserves the original
|
||||
* casing of the matched substring. needle == NULL or empty appends raw. */
|
||||
static void append_highlighted(char *output, size_t buf_size, size_t *pos,
|
||||
const char *text, const char *needle) {
|
||||
if (!needle || !*needle) {
|
||||
buffer_appendf(output, buf_size, pos, "%s", text);
|
||||
return;
|
||||
}
|
||||
size_t nlen = strlen(needle);
|
||||
const char *p = text;
|
||||
while (*p) {
|
||||
const char *hit = strcasestr(p, needle);
|
||||
if (!hit) {
|
||||
buffer_appendf(output, buf_size, pos, "%s", p);
|
||||
return;
|
||||
}
|
||||
if (hit > p) {
|
||||
buffer_append_bytes(output, buf_size, pos, p, (size_t)(hit - p));
|
||||
}
|
||||
buffer_append_bytes(output, buf_size, pos, "\033[7;33m", 7);
|
||||
buffer_append_bytes(output, buf_size, pos, hit, nlen);
|
||||
buffer_append_bytes(output, buf_size, pos, "\033[0m", 4);
|
||||
p = hit + nlen;
|
||||
}
|
||||
}
|
||||
|
||||
static void append_command_usage(char *output, size_t buf_size, size_t *pos,
|
||||
tnt_command_id_t id, ui_lang_t lang) {
|
||||
command_catalog_append_usage(output, buf_size, pos, id, lang);
|
||||
}
|
||||
|
||||
void commands_dispatch(client_t *client) {
|
||||
char cmd_buf[256];
|
||||
strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1);
|
||||
cmd_buf[sizeof(cmd_buf) - 1] = '\0';
|
||||
char *cmd = cmd_buf;
|
||||
char output[2048] = {0};
|
||||
char output[MAX_COMMAND_OUTPUT_LEN] = {0};
|
||||
size_t pos = 0;
|
||||
|
||||
/* Trim whitespace */
|
||||
|
|
@ -43,22 +85,49 @@ void commands_dispatch(client_t *client) {
|
|||
client->command_history_pos = client->command_history_count;
|
||||
}
|
||||
|
||||
if (strcmp(cmd, "list") == 0 || strcmp(cmd, "users") == 0 ||
|
||||
strcmp(cmd, "who") == 0) {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"========================================\n"
|
||||
" Online Users / 在线用户\n"
|
||||
"========================================\n");
|
||||
if (cmd[0] == '\0') {
|
||||
/* Empty command */
|
||||
client->mode = MODE_NORMAL;
|
||||
client->command_input[0] = '\0';
|
||||
tui_render_screen(client);
|
||||
return;
|
||||
}
|
||||
|
||||
pthread_rwlock_rdlock(&g_room->lock);
|
||||
tnt_command_id_t command_id;
|
||||
const char *arg = "";
|
||||
if (!command_catalog_match(cmd, &command_id, &arg)) {
|
||||
const char *suggestion = command_catalog_suggest(cmd);
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"Total / 总数: %d\n"
|
||||
"----------------------------------------\n",
|
||||
g_room->client_count);
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_UNKNOWN_COMMAND_FORMAT),
|
||||
cmd);
|
||||
if (suggestion) {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_DID_YOU_MEAN_FORMAT),
|
||||
suggestion);
|
||||
}
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->ui_lang, I18N_UNKNOWN_GUIDANCE));
|
||||
goto cmd_done;
|
||||
}
|
||||
|
||||
if (!command_catalog_args_valid(command_id, arg)) {
|
||||
append_command_usage(output, sizeof(output), &pos, command_id,
|
||||
client->ui_lang);
|
||||
goto cmd_done;
|
||||
}
|
||||
|
||||
if (command_id == TNT_COMMAND_USERS) {
|
||||
pthread_rwlock_rdlock(&g_room->lock);
|
||||
int total = g_room->client_count;
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n",
|
||||
i18n_text(client->ui_lang, I18N_USERS_TITLE), total);
|
||||
|
||||
time_t now = time(NULL);
|
||||
for (int i = 0; i < g_room->client_count; i++) {
|
||||
char marker = (g_room->clients[i] == client) ? '*' : ' ';
|
||||
for (int i = 0; i < total; i++) {
|
||||
bool is_self = (g_room->clients[i] == client);
|
||||
int dur = (int)(now - g_room->clients[i]->connect_time);
|
||||
char dur_str[32];
|
||||
if (dur < 60) {
|
||||
|
|
@ -66,42 +135,44 @@ void commands_dispatch(client_t *client) {
|
|||
} else if (dur < 3600) {
|
||||
snprintf(dur_str, sizeof(dur_str), "%dm", dur / 60);
|
||||
} else {
|
||||
snprintf(dur_str, sizeof(dur_str), "%dh%dm", dur / 3600, (dur % 3600) / 60);
|
||||
snprintf(dur_str, sizeof(dur_str), "%dh%dm",
|
||||
dur / 3600, (dur % 3600) / 60);
|
||||
}
|
||||
/* 1-column gutter: ▎ for you, blank for others */
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"%c %d. %s (%s)\n", marker, i + 1,
|
||||
"%s \033[37m%s\033[0m \033[2;37m· %s\033[0m\n",
|
||||
is_self ? "\033[36m▎\033[0m" : " ",
|
||||
g_room->clients[i]->username, dur_str);
|
||||
}
|
||||
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"========================================\n"
|
||||
"* = you / 你\n");
|
||||
} else if (command_id == TNT_COMMAND_HELP) {
|
||||
manual_append_interactive_panel(output, sizeof(output), &pos,
|
||||
client->ui_lang);
|
||||
|
||||
} else if (strcmp(cmd, "help") == 0 || strcmp(cmd, "commands") == 0) {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"========================================\n"
|
||||
" Available Commands / 可用命令\n"
|
||||
"========================================\n"
|
||||
"list, users, who - Show online users\n"
|
||||
"nick/name <name> - Change nickname\n"
|
||||
"msg/w <user> <text> - Whisper to user\n"
|
||||
"last [N] - Show last N messages\n"
|
||||
"search <keyword> - Search message history\n"
|
||||
"mute-joins - Toggle join/leave notices\n"
|
||||
"help, commands - Show this help\n"
|
||||
"clear, cls - Clear command output\n"
|
||||
"q, quit, exit - Disconnect\n"
|
||||
"Up/Down arrows - Command history\n"
|
||||
"========================================\n"
|
||||
"In INSERT mode:\n"
|
||||
" /me <action> - Send action message\n"
|
||||
" @username - Mention (bell notify)\n"
|
||||
"========================================\n");
|
||||
} else if (command_id == TNT_COMMAND_LANG) {
|
||||
ui_lang_t next_lang;
|
||||
|
||||
} else if (strncmp(cmd, "msg ", 4) == 0 || strncmp(cmd, "w ", 2) == 0) {
|
||||
char *rest = (cmd[0] == 'w') ? cmd + 2 : cmd + 4;
|
||||
if (!arg || arg[0] == '\0') {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_LANG_CURRENT_FORMAT),
|
||||
i18n_ui_lang_code(client->ui_lang));
|
||||
} else if (i18n_try_parse_ui_lang(arg, &next_lang)) {
|
||||
client->ui_lang = next_lang;
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_LANG_SET_FORMAT),
|
||||
i18n_ui_lang_code(client->ui_lang));
|
||||
} else {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_LANG_UNSUPPORTED_FORMAT),
|
||||
arg);
|
||||
}
|
||||
|
||||
} else if (command_id == TNT_COMMAND_MSG) {
|
||||
const char *rest = arg;
|
||||
while (*rest == ' ') rest++;
|
||||
char target_name[MAX_USERNAME_LEN] = {0};
|
||||
int ti = 0;
|
||||
|
|
@ -111,9 +182,8 @@ void commands_dispatch(client_t *client) {
|
|||
while (*rest == ' ') rest++;
|
||||
|
||||
if (target_name[0] == '\0' || rest[0] == '\0') {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"Usage: msg <username> <message>\n"
|
||||
" w <username> <message>\n");
|
||||
append_command_usage(output, sizeof(output), &pos,
|
||||
TNT_COMMAND_MSG, client->ui_lang);
|
||||
} else {
|
||||
bool found = false;
|
||||
client_t *target = NULL;
|
||||
|
|
@ -129,34 +199,90 @@ void commands_dispatch(client_t *client) {
|
|||
pthread_rwlock_unlock(&g_room->lock);
|
||||
|
||||
if (target) {
|
||||
char whisper[MAX_MESSAGE_LEN];
|
||||
snprintf(whisper, sizeof(whisper),
|
||||
"\r\n\033[35m[whisper from %s]: %s\033[0m\r\n",
|
||||
client->username, rest);
|
||||
client_send(target, whisper, strlen(whisper));
|
||||
/* Push into recipient's inbox. io_lock serialises so two
|
||||
* senders to the same recipient don't tear the ring. */
|
||||
pthread_mutex_lock(&target->io_lock);
|
||||
int slot;
|
||||
if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) {
|
||||
slot = target->whisper_inbox_count++;
|
||||
} else {
|
||||
/* FIFO evict the oldest */
|
||||
memmove(&target->whisper_inbox[0],
|
||||
&target->whisper_inbox[1],
|
||||
(WHISPER_INBOX_SIZE - 1) * sizeof(whisper_t));
|
||||
slot = WHISPER_INBOX_SIZE - 1;
|
||||
}
|
||||
target->whisper_inbox[slot].timestamp = time(NULL);
|
||||
snprintf(target->whisper_inbox[slot].from,
|
||||
sizeof(target->whisper_inbox[slot].from),
|
||||
"%s", client->username);
|
||||
snprintf(target->whisper_inbox[slot].content,
|
||||
sizeof(target->whisper_inbox[slot].content),
|
||||
"%s", rest);
|
||||
pthread_mutex_unlock(&target->io_lock);
|
||||
|
||||
target->unread_whispers++;
|
||||
target->redraw_pending = true;
|
||||
/* Audible nudge — the title bar ✉ counter (UX-11 style)
|
||||
* carries the persistent signal. */
|
||||
client_send(target, "\a", 1);
|
||||
client_release(target);
|
||||
}
|
||||
|
||||
if (found) {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"Whisper sent to %s\n", target_name);
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_MSG_SENT_FORMAT),
|
||||
target_name);
|
||||
} else {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"User '%s' not found\n", target_name);
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_MSG_USER_NOT_FOUND_FORMAT),
|
||||
target_name);
|
||||
}
|
||||
}
|
||||
|
||||
} else if (strncmp(cmd, "nick ", 5) == 0 || strncmp(cmd, "name ", 5) == 0) {
|
||||
char *new_name = cmd + 5;
|
||||
} else if (command_id == TNT_COMMAND_INBOX) {
|
||||
/* Snapshot the inbox under io_lock so a concurrent sender doesn't
|
||||
* tear what we're rendering. Counter reset happens after copy. */
|
||||
whisper_t snapshot[WHISPER_INBOX_SIZE];
|
||||
int snap_count;
|
||||
pthread_mutex_lock(&client->io_lock);
|
||||
snap_count = client->whisper_inbox_count;
|
||||
memcpy(snapshot, client->whisper_inbox,
|
||||
snap_count * sizeof(whisper_t));
|
||||
pthread_mutex_unlock(&client->io_lock);
|
||||
client->unread_whispers = 0;
|
||||
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n",
|
||||
i18n_text(client->ui_lang, I18N_INBOX_TITLE),
|
||||
snap_count);
|
||||
if (snap_count == 0) {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
" \033[2;37m%s\033[0m\n",
|
||||
i18n_text(client->ui_lang, I18N_INBOX_EMPTY));
|
||||
}
|
||||
for (int i = 0; i < snap_count; i++) {
|
||||
char ts[20];
|
||||
struct tm tmi;
|
||||
localtime_r(&snapshot[i].timestamp, &tmi);
|
||||
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
" \033[90m%s\033[0m \033[35m%s\033[0m: %s\n",
|
||||
ts, snapshot[i].from, snapshot[i].content);
|
||||
}
|
||||
|
||||
} else if (command_id == TNT_COMMAND_NICK) {
|
||||
const char *new_name = arg;
|
||||
while (*new_name == ' ') new_name++;
|
||||
|
||||
if (new_name[0] == '\0') {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"Usage: nick <new_username>\n");
|
||||
append_command_usage(output, sizeof(output), &pos,
|
||||
TNT_COMMAND_NICK, client->ui_lang);
|
||||
} else if (!is_valid_username(new_name)) {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"Invalid username\n");
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->ui_lang, I18N_NICK_INVALID));
|
||||
} else {
|
||||
char validated_name[MAX_USERNAME_LEN];
|
||||
snprintf(validated_name, sizeof(validated_name), "%s", new_name);
|
||||
|
|
@ -164,33 +290,60 @@ void commands_dispatch(client_t *client) {
|
|||
utf8_truncate(validated_name, 20);
|
||||
}
|
||||
|
||||
/* Reject collisions with active room members. Held under
|
||||
* wrlock so the username swap below races neither read nor
|
||||
* concurrent :nick from another client. */
|
||||
char old_name[MAX_USERNAME_LEN];
|
||||
bool taken = false;
|
||||
pthread_rwlock_wrlock(&g_room->lock);
|
||||
snprintf(old_name, sizeof(old_name), "%s", client->username);
|
||||
if (strcmp(validated_name, old_name) != 0) {
|
||||
for (int i = 0; i < g_room->client_count; i++) {
|
||||
if (g_room->clients[i] == client) continue;
|
||||
if (strcmp(g_room->clients[i]->username,
|
||||
validated_name) == 0) {
|
||||
taken = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!taken) {
|
||||
snprintf(client->username, MAX_USERNAME_LEN, "%s", validated_name);
|
||||
}
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
|
||||
message_t nick_msg = { .timestamp = time(NULL) };
|
||||
snprintf(nick_msg.username, MAX_USERNAME_LEN, "系统");
|
||||
snprintf(nick_msg.content, MAX_MESSAGE_LEN,
|
||||
"%s 更名为 %s", old_name, client->username);
|
||||
if (taken) {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_NICK_TAKEN_FORMAT),
|
||||
validated_name);
|
||||
} else if (strcmp(validated_name, old_name) == 0) {
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_NICK_UNCHANGED));
|
||||
} else {
|
||||
message_t nick_msg;
|
||||
system_message_make_nick(&nick_msg, old_name,
|
||||
client->username, client->ui_lang);
|
||||
room_broadcast(g_room, &nick_msg);
|
||||
message_save(&nick_msg);
|
||||
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"Nickname changed: %s -> %s\n", old_name, client->username);
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_NICK_CHANGED_FORMAT),
|
||||
old_name, client->username);
|
||||
}
|
||||
}
|
||||
|
||||
} else if (strncmp(cmd, "last", 4) == 0 && (cmd[4] == ' ' || cmd[4] == '\0')) {
|
||||
char *arg = cmd + 4;
|
||||
} else if (command_id == TNT_COMMAND_LAST) {
|
||||
while (*arg == ' ') arg++;
|
||||
int n = 10;
|
||||
if (*arg != '\0') {
|
||||
char *endp;
|
||||
long val = strtol(arg, &endp, 10);
|
||||
if (*endp != '\0' || val < 1 || val > 50) {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"Usage: last [N] (N: 1-50, default 10)\n");
|
||||
append_command_usage(output, sizeof(output), &pos,
|
||||
TNT_COMMAND_LAST, client->ui_lang);
|
||||
goto cmd_done;
|
||||
}
|
||||
n = (int)val;
|
||||
|
|
@ -199,7 +352,8 @@ void commands_dispatch(client_t *client) {
|
|||
message_t *last_msgs = NULL;
|
||||
int last_count = message_load(&last_msgs, n);
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"--- Last %d message(s) ---\n", last_count);
|
||||
i18n_text(client->ui_lang, I18N_LAST_HEADER_FORMAT),
|
||||
last_count);
|
||||
for (int i = 0; i < last_count; i++) {
|
||||
char ts[20];
|
||||
struct tm tmi;
|
||||
|
|
@ -210,60 +364,57 @@ void commands_dispatch(client_t *client) {
|
|||
}
|
||||
free(last_msgs);
|
||||
|
||||
} else if (strncmp(cmd, "search ", 7) == 0) {
|
||||
char *query = cmd + 7;
|
||||
} else if (command_id == TNT_COMMAND_SEARCH) {
|
||||
const char *query = arg;
|
||||
while (*query == ' ') query++;
|
||||
if (*query == '\0') {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"Usage: search <keyword>\n");
|
||||
append_command_usage(output, sizeof(output), &pos,
|
||||
TNT_COMMAND_SEARCH, client->ui_lang);
|
||||
} else {
|
||||
message_t *found = NULL;
|
||||
int found_count = message_search(query, &found, 15);
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"--- Search: \"%s\" (%d match(es)) ---\n", query, found_count);
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_SEARCH_HEADER_FORMAT),
|
||||
query, found_count);
|
||||
for (int i = 0; i < found_count; i++) {
|
||||
char ts[20];
|
||||
struct tm tmi;
|
||||
localtime_r(&found[i].timestamp, &tmi);
|
||||
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"[%s] %s: %s\n", ts, found[i].username, found[i].content);
|
||||
"[%s] ", ts);
|
||||
append_highlighted(output, sizeof(output), &pos,
|
||||
found[i].username, query);
|
||||
buffer_appendf(output, sizeof(output), &pos, ": ");
|
||||
append_highlighted(output, sizeof(output), &pos,
|
||||
found[i].content, query);
|
||||
buffer_appendf(output, sizeof(output), &pos, "\n");
|
||||
}
|
||||
free(found);
|
||||
}
|
||||
|
||||
} else if (strcmp(cmd, "mute-joins") == 0 || strcmp(cmd, "mute") == 0) {
|
||||
} else if (command_id == TNT_COMMAND_MUTE_JOINS) {
|
||||
client->mute_joins = !client->mute_joins;
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"Join/leave notifications: %s\n",
|
||||
client->mute_joins ? "muted" : "unmuted");
|
||||
i18n_text(client->ui_lang, I18N_MUTE_JOINS_FORMAT),
|
||||
i18n_text(client->ui_lang,
|
||||
client->mute_joins ?
|
||||
I18N_MUTE_JOINS_MUTED :
|
||||
I18N_MUTE_JOINS_UNMUTED));
|
||||
|
||||
} else if (strcmp(cmd, "q") == 0 || strcmp(cmd, "quit") == 0 ||
|
||||
strcmp(cmd, "exit") == 0) {
|
||||
} else if (command_id == TNT_COMMAND_QUIT) {
|
||||
client->connected = false;
|
||||
return;
|
||||
|
||||
} else if (strcmp(cmd, "clear") == 0 || strcmp(cmd, "cls") == 0) {
|
||||
buffer_appendf(output, sizeof(output), &pos, "Command output cleared\n");
|
||||
|
||||
} else if (cmd[0] == '\0') {
|
||||
/* Empty command */
|
||||
client->mode = MODE_NORMAL;
|
||||
client->command_input[0] = '\0';
|
||||
tui_render_screen(client);
|
||||
return;
|
||||
|
||||
} else {
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"Unknown command: %s\n"
|
||||
"Type 'help' for available commands\n", cmd);
|
||||
} else if (command_id == TNT_COMMAND_CLEAR) {
|
||||
buffer_appendf(output, sizeof(output), &pos, "%s",
|
||||
i18n_text(client->ui_lang, I18N_CLEAR_DONE));
|
||||
}
|
||||
|
||||
cmd_done:
|
||||
buffer_appendf(output, sizeof(output), &pos,
|
||||
"\nPress any key to continue...");
|
||||
|
||||
snprintf(client->command_output, sizeof(client->command_output), "%s", output);
|
||||
client->command_output_scroll = 0;
|
||||
client->command_input[0] = '\0';
|
||||
tui_render_command_output(client);
|
||||
}
|
||||
|
|
|
|||
119
src/exec.c
119
src/exec.c
|
|
@ -2,6 +2,8 @@
|
|||
#include "chat_room.h"
|
||||
#include "client.h"
|
||||
#include "common.h"
|
||||
#include "exec_catalog.h"
|
||||
#include "i18n.h"
|
||||
#include "input.h"
|
||||
#include "message.h"
|
||||
#include "ratelimit.h"
|
||||
|
|
@ -115,20 +117,24 @@ static void resolve_exec_username(const client_t *client, char *buffer,
|
|||
}
|
||||
|
||||
static int exec_command_help(client_t *client) {
|
||||
static const char help_text[] =
|
||||
"TNT exec interface\n"
|
||||
"Commands:\n"
|
||||
" help Show this help\n"
|
||||
" health Print service health\n"
|
||||
" users [--json] List online users\n"
|
||||
" stats [--json] Print room statistics\n"
|
||||
" tail [N] Print recent messages\n"
|
||||
" tail -n N Print recent messages\n"
|
||||
" post MESSAGE Post a message non-interactively\n"
|
||||
" post \"/me act\" Post an action message\n"
|
||||
" exit Exit successfully\n";
|
||||
char help_text[1024];
|
||||
size_t pos = 0;
|
||||
|
||||
return client_send(client, help_text, sizeof(help_text) - 1) == 0 ? 0 : 1;
|
||||
help_text[0] = '\0';
|
||||
exec_catalog_append_help(help_text, sizeof(help_text), &pos,
|
||||
client->ui_lang);
|
||||
return client_send(client, help_text, pos) == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
static int exec_command_usage(client_t *client, tnt_exec_command_id_t id) {
|
||||
char usage[128];
|
||||
size_t pos = 0;
|
||||
|
||||
usage[0] = '\0';
|
||||
exec_catalog_append_usage(usage, sizeof(usage), &pos, id,
|
||||
client->ui_lang);
|
||||
client_printf(client, "%s", usage);
|
||||
return 64;
|
||||
}
|
||||
|
||||
static int exec_command_health(client_t *client) {
|
||||
|
|
@ -294,8 +300,7 @@ static int exec_command_tail(client_t *client, const char *args) {
|
|||
int rc;
|
||||
|
||||
if (parse_tail_count(args, &requested) < 0) {
|
||||
client_printf(client, "tail: usage: tail [N] | tail -n N\n");
|
||||
return 64;
|
||||
return exec_command_usage(client, TNT_EXEC_COMMAND_TAIL);
|
||||
}
|
||||
|
||||
pthread_rwlock_rdlock(&g_room->lock);
|
||||
|
|
@ -347,8 +352,7 @@ static int exec_command_post(client_t *client, const char *args) {
|
|||
};
|
||||
|
||||
if (!args || args[0] == '\0') {
|
||||
client_printf(client, "post: usage: post MESSAGE\n");
|
||||
return 64;
|
||||
return exec_command_usage(client, TNT_EXEC_COMMAND_POST);
|
||||
}
|
||||
|
||||
strncpy(content, args, sizeof(content) - 1);
|
||||
|
|
@ -356,12 +360,15 @@ static int exec_command_post(client_t *client, const char *args) {
|
|||
trim_ascii_whitespace(content);
|
||||
|
||||
if (content[0] == '\0') {
|
||||
client_printf(client, "post: message cannot be empty\n");
|
||||
client_printf(client, "%s",
|
||||
i18n_text(client->ui_lang, I18N_EXEC_POST_EMPTY));
|
||||
return 64;
|
||||
}
|
||||
|
||||
if (!utf8_is_valid_string(content)) {
|
||||
client_printf(client, "post: invalid UTF-8 input\n");
|
||||
client_printf(client, "%s",
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_EXEC_POST_INVALID_UTF8));
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -382,72 +389,64 @@ static int exec_command_post(client_t *client, const char *args) {
|
|||
}
|
||||
|
||||
room_broadcast(g_room, &msg);
|
||||
notify_mentions(msg.content, client);
|
||||
if (message_save(&msg) < 0) {
|
||||
client_printf(client, "post: failed to persist message\n");
|
||||
if (client_send(client, "posted\n", 7) != 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return client_send(client, "posted\n", 7) == 0 ? 0 : 1;
|
||||
notify_mentions(msg.content, client);
|
||||
if (message_save(&msg) < 0) {
|
||||
fprintf(stderr, "post: failed to persist message\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int exec_dispatch(client_t *client) {
|
||||
char command_copy[MAX_EXEC_COMMAND_LEN];
|
||||
char *cmd;
|
||||
char *args;
|
||||
tnt_exec_command_id_t command_id;
|
||||
const char *args = NULL;
|
||||
|
||||
strncpy(command_copy, client->exec_command, sizeof(command_copy) - 1);
|
||||
command_copy[sizeof(command_copy) - 1] = '\0';
|
||||
trim_ascii_whitespace(command_copy);
|
||||
|
||||
cmd = command_copy;
|
||||
if (*cmd == '\0') {
|
||||
if (command_copy[0] == '\0') {
|
||||
return exec_command_help(client);
|
||||
}
|
||||
|
||||
args = cmd;
|
||||
while (*args && !isspace((unsigned char)*args)) {
|
||||
args++;
|
||||
}
|
||||
if (*args) {
|
||||
*args++ = '\0';
|
||||
while (*args && isspace((unsigned char)*args)) {
|
||||
args++;
|
||||
}
|
||||
} else {
|
||||
args = NULL;
|
||||
if (exec_catalog_match(command_copy, &command_id, &args)) {
|
||||
if (!exec_catalog_args_valid(command_id, args)) {
|
||||
return exec_command_usage(client, command_id);
|
||||
}
|
||||
|
||||
if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0) {
|
||||
switch (command_id) {
|
||||
case TNT_EXEC_COMMAND_HELP:
|
||||
return exec_command_help(client);
|
||||
}
|
||||
if (strcmp(cmd, "health") == 0) {
|
||||
case TNT_EXEC_COMMAND_HEALTH:
|
||||
return exec_command_health(client);
|
||||
}
|
||||
if (strcmp(cmd, "users") == 0) {
|
||||
if (args && strcmp(args, "--json") != 0) {
|
||||
client_printf(client, "users: usage: users [--json]\n");
|
||||
return 64;
|
||||
}
|
||||
case TNT_EXEC_COMMAND_USERS:
|
||||
return exec_command_users(client, args != NULL);
|
||||
}
|
||||
if (strcmp(cmd, "stats") == 0) {
|
||||
if (args && strcmp(args, "--json") != 0) {
|
||||
client_printf(client, "stats: usage: stats [--json]\n");
|
||||
return 64;
|
||||
}
|
||||
case TNT_EXEC_COMMAND_STATS:
|
||||
return exec_command_stats(client, args != NULL);
|
||||
}
|
||||
if (strcmp(cmd, "tail") == 0) {
|
||||
case TNT_EXEC_COMMAND_TAIL:
|
||||
return exec_command_tail(client, args);
|
||||
}
|
||||
if (strcmp(cmd, "post") == 0) {
|
||||
case TNT_EXEC_COMMAND_POST:
|
||||
return exec_command_post(client, args);
|
||||
}
|
||||
if (strcmp(cmd, "exit") == 0) {
|
||||
case TNT_EXEC_COMMAND_EXIT:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
client_printf(client, "Unknown command: %s\n", cmd);
|
||||
for (char *p = command_copy; *p; p++) {
|
||||
if (isspace((unsigned char)*p)) {
|
||||
*p = '\0';
|
||||
break;
|
||||
}
|
||||
}
|
||||
client_printf(client,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
||||
command_copy);
|
||||
return 64;
|
||||
}
|
||||
|
|
|
|||
161
src/exec_catalog.c
Normal file
161
src/exec_catalog.c
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
#include "exec_catalog.h"
|
||||
|
||||
#include "i18n.h"
|
||||
|
||||
typedef struct {
|
||||
tnt_exec_command_id_t id;
|
||||
const char *name;
|
||||
const char *alias;
|
||||
const char *usage;
|
||||
const char *usage_syntax;
|
||||
i18n_string_t summary;
|
||||
bool no_args;
|
||||
bool optional_json;
|
||||
bool requires_args;
|
||||
} exec_catalog_entry_t;
|
||||
|
||||
static const exec_catalog_entry_t entries[] = {
|
||||
{TNT_EXEC_COMMAND_HELP, "help", "--help",
|
||||
"help", "help", I18N_STRING("Show this help", "显示此帮助"),
|
||||
true, false, false},
|
||||
{TNT_EXEC_COMMAND_HEALTH, "health", NULL,
|
||||
"health", "health",
|
||||
I18N_STRING("Print service health", "输出服务健康状态"),
|
||||
true, false, false},
|
||||
{TNT_EXEC_COMMAND_USERS, "users", NULL,
|
||||
"users [--json]", "users [--json]",
|
||||
I18N_STRING("List online users", "列出在线用户"),
|
||||
false, true, false},
|
||||
{TNT_EXEC_COMMAND_STATS, "stats", NULL,
|
||||
"stats [--json]", "stats [--json]",
|
||||
I18N_STRING("Print room statistics", "输出房间统计"),
|
||||
false, true, false},
|
||||
{TNT_EXEC_COMMAND_TAIL, "tail", NULL,
|
||||
"tail [N]", "tail [N] | tail -n N",
|
||||
I18N_STRING("Print recent messages", "输出最近消息"),
|
||||
false, false, false},
|
||||
{TNT_EXEC_COMMAND_TAIL, "tail", NULL,
|
||||
"tail -n N", "tail [N] | tail -n N",
|
||||
I18N_STRING("Print recent messages", "输出最近消息"),
|
||||
false, false, false},
|
||||
{TNT_EXEC_COMMAND_POST, "post", NULL,
|
||||
"post MESSAGE", "post MESSAGE",
|
||||
I18N_STRING("Post a message non-interactively", "非交互发送消息"),
|
||||
false, false, true},
|
||||
{TNT_EXEC_COMMAND_POST, "post", NULL,
|
||||
"post \"/me act\"", "post MESSAGE",
|
||||
I18N_STRING("Post an action message", "发送动作消息"),
|
||||
false, false, true},
|
||||
{TNT_EXEC_COMMAND_EXIT, "exit", NULL,
|
||||
"exit", "exit", I18N_STRING("Exit successfully", "成功退出"),
|
||||
true, false, false}
|
||||
};
|
||||
|
||||
static const exec_catalog_entry_t *entry_for_id(tnt_exec_command_id_t id) {
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
if (entries[i].id == id) {
|
||||
return &entries[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static const char *skip_spaces(const char *value) {
|
||||
while (value && *value && (*value == ' ' || *value == '\t')) {
|
||||
value++;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static bool name_matches(const char *line, const char *name,
|
||||
const char **args) {
|
||||
size_t len;
|
||||
|
||||
if (!line || !name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
len = strlen(name);
|
||||
if (strncmp(line, name, len) != 0) {
|
||||
return false;
|
||||
}
|
||||
if (line[len] != '\0' && line[len] != ' ' && line[len] != '\t') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (args) {
|
||||
const char *candidate_args = skip_spaces(line + len);
|
||||
*args = candidate_args && candidate_args[0] != '\0'
|
||||
? candidate_args
|
||||
: NULL;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool exec_catalog_match(const char *line, tnt_exec_command_id_t *id,
|
||||
const char **args) {
|
||||
line = skip_spaces(line);
|
||||
if (!line || line[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
if (!name_matches(line, entries[i].name, args) &&
|
||||
!name_matches(line, entries[i].alias, args)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
*id = entries[i].id;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool exec_catalog_args_valid(tnt_exec_command_id_t id, const char *args) {
|
||||
const exec_catalog_entry_t *entry = entry_for_id(id);
|
||||
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
if (entry->no_args) {
|
||||
return !args || args[0] == '\0';
|
||||
}
|
||||
if (entry->optional_json) {
|
||||
return !args || strcmp(args, "--json") == 0;
|
||||
}
|
||||
if (entry->requires_args) {
|
||||
return args && args[0] != '\0';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void exec_catalog_append_help(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang) {
|
||||
static const i18n_string_t header =
|
||||
I18N_STRING("TNT exec interface\nCommands:\n",
|
||||
"TNT exec 接口\n命令:\n");
|
||||
|
||||
buffer_appendf(buffer, buf_size, pos, "%s", i18n_string(header, lang));
|
||||
|
||||
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
|
||||
const char *summary = i18n_string(entries[i].summary, lang);
|
||||
buffer_appendf(buffer, buf_size, pos, " %-15s %s\n",
|
||||
entries[i].usage, summary);
|
||||
}
|
||||
}
|
||||
|
||||
void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
|
||||
tnt_exec_command_id_t id, ui_lang_t lang) {
|
||||
const exec_catalog_entry_t *entry = entry_for_id(id);
|
||||
static const i18n_string_t usage_format =
|
||||
I18N_STRING("%s: usage: %s\n", "%s: 用法: %s\n");
|
||||
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
buffer_appendf(buffer, buf_size, pos, i18n_string(usage_format, lang),
|
||||
entry->name, entry->usage_syntax);
|
||||
}
|
||||
116
src/help_text.c
Normal file
116
src/help_text.c
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
#include "help_text.h"
|
||||
|
||||
#include "command_catalog.h"
|
||||
#include "i18n.h"
|
||||
|
||||
void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||
ui_lang_t lang) {
|
||||
static const i18n_string_t before_commands = I18N_STRING(
|
||||
"TNT KEY REFERENCE\n"
|
||||
"\n"
|
||||
"OPERATING MODES:\n"
|
||||
" INSERT - Type and send messages (default)\n"
|
||||
" NORMAL - Browse message history\n"
|
||||
" COMMAND - Execute commands\n"
|
||||
"\n"
|
||||
"INSERT MODE KEYS:\n"
|
||||
" ESC - Enter NORMAL mode\n"
|
||||
" Enter - Send message\n"
|
||||
" Backspace - Delete character\n"
|
||||
" Ctrl+W - Delete last word\n"
|
||||
" Ctrl+U - Delete line\n"
|
||||
" Ctrl+C - Enter NORMAL mode\n"
|
||||
"\n"
|
||||
"NORMAL MODE KEYS:\n"
|
||||
" Opens at latest messages\n"
|
||||
" Follows latest until you scroll up\n"
|
||||
" i - Return to INSERT mode\n"
|
||||
" : - Enter COMMAND mode\n"
|
||||
" j/k - Scroll down/up one line\n"
|
||||
" Ctrl+D/U - Scroll half page down/up\n"
|
||||
" Ctrl+F/B - Scroll full page down/up\n"
|
||||
" PgDn/PgUp - Scroll full page down/up\n"
|
||||
" End/Home - Jump to bottom/top\n"
|
||||
" g/G - Jump to top/bottom\n"
|
||||
" ? - Show full key reference\n"
|
||||
" Ctrl+C - Exit chat\n"
|
||||
"\n"
|
||||
"AVAILABLE COMMANDS:\n",
|
||||
"TNT 按键参考\n"
|
||||
"\n"
|
||||
"操作模式:\n"
|
||||
" INSERT - 输入和发送消息(默认)\n"
|
||||
" NORMAL - 浏览消息历史\n"
|
||||
" COMMAND - 执行命令\n"
|
||||
"\n"
|
||||
"INSERT 模式按键:\n"
|
||||
" ESC - 进入 NORMAL 模式\n"
|
||||
" Enter - 发送消息\n"
|
||||
" Backspace - 删除字符\n"
|
||||
" Ctrl+W - 删除上个单词\n"
|
||||
" Ctrl+U - 删除整行\n"
|
||||
" Ctrl+C - 进入 NORMAL 模式\n"
|
||||
"\n"
|
||||
"NORMAL 模式按键:\n"
|
||||
" 默认停在最新消息\n"
|
||||
" 未向上翻阅时自动跟随最新消息\n"
|
||||
" i - 返回 INSERT 模式\n"
|
||||
" : - 进入 COMMAND 模式\n"
|
||||
" j/k - 向下/上滚动一行\n"
|
||||
" Ctrl+D/U - 向下/上滚动半页\n"
|
||||
" Ctrl+F/B - 向下/上滚动整页\n"
|
||||
" PgDn/PgUp - 向下/上滚动整页\n"
|
||||
" End/Home - 跳到底部/顶部\n"
|
||||
" g/G - 跳到顶部/底部\n"
|
||||
" ? - 显示完整按键参考\n"
|
||||
" Ctrl+C - 退出聊天\n"
|
||||
"\n"
|
||||
"可用命令:\n"
|
||||
);
|
||||
static const i18n_string_t after_commands = I18N_STRING(
|
||||
"\n"
|
||||
"COMMAND OUTPUT KEYS:\n"
|
||||
" q, ESC - Close output\n"
|
||||
" j/k - Scroll down/up\n"
|
||||
" Ctrl+D/U - Scroll half page down/up\n"
|
||||
" Ctrl+F/B - Scroll full page down/up\n"
|
||||
" g/G - Jump to top/bottom\n"
|
||||
"\n"
|
||||
"SPECIAL MESSAGES:\n"
|
||||
" /me <action> - Send action (e.g. /me waves)\n"
|
||||
" @username - Mention user (bell + highlight)\n"
|
||||
"\n"
|
||||
"HELP SCREEN KEYS:\n"
|
||||
" q, ESC - Close help\n"
|
||||
" j/k - Scroll down/up\n"
|
||||
" Ctrl+D/U - Scroll half page down/up\n"
|
||||
" Ctrl+F/B - Scroll full page down/up\n"
|
||||
" g/G - Jump to top/bottom\n"
|
||||
" l - Cycle UI language\n",
|
||||
"\n"
|
||||
"命令输出按键:\n"
|
||||
" q, ESC - 关闭输出\n"
|
||||
" j/k - 向下/上滚动\n"
|
||||
" Ctrl+D/U - 向下/上滚动半页\n"
|
||||
" Ctrl+F/B - 向下/上滚动整页\n"
|
||||
" g/G - 跳到顶部/底部\n"
|
||||
"\n"
|
||||
"特殊消息:\n"
|
||||
" /me <action> - 发送动作 (如 /me waves)\n"
|
||||
" @username - 提及用户 (响铃+高亮)\n"
|
||||
"\n"
|
||||
"帮助界面按键:\n"
|
||||
" q, ESC - 关闭帮助\n"
|
||||
" j/k - 向下/上滚动\n"
|
||||
" Ctrl+D/U - 向下/上滚动半页\n"
|
||||
" Ctrl+F/B - 向下/上滚动整页\n"
|
||||
" g/G - 跳到顶部/底部\n"
|
||||
" l - 切换界面语言\n"
|
||||
);
|
||||
|
||||
buffer_appendf(buffer, buf_size, pos, "%s",
|
||||
i18n_string(before_commands, lang));
|
||||
command_catalog_append_full(buffer, buf_size, pos, lang);
|
||||
buffer_appendf(buffer, buf_size, pos, "%s",
|
||||
i18n_string(after_commands, lang));
|
||||
}
|
||||
84
src/history_view.c
Normal file
84
src/history_view.c
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#include "history_view.h"
|
||||
|
||||
static void message_date_key(const message_t *msg, char out[11]) {
|
||||
struct tm tmi;
|
||||
localtime_r(&msg->timestamp, &tmi);
|
||||
strftime(out, 11, "%Y-%m-%d", &tmi);
|
||||
}
|
||||
|
||||
static int rendered_rows_for_slice(const message_t *messages, int start,
|
||||
int end) {
|
||||
int rows = 0;
|
||||
char last_date[11] = "";
|
||||
|
||||
for (int i = start; i < end; i++) {
|
||||
char this_date[11];
|
||||
message_date_key(&messages[i], this_date);
|
||||
if (strcmp(this_date, last_date) != 0) {
|
||||
rows++;
|
||||
memcpy(last_date, this_date, sizeof(last_date));
|
||||
}
|
||||
rows++;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
int history_view_height(int terminal_height) {
|
||||
int height = terminal_height - 3;
|
||||
return height < 1 ? 1 : height;
|
||||
}
|
||||
|
||||
int history_view_max_scroll(int message_count, int view_height) {
|
||||
int max_scroll = message_count - view_height;
|
||||
return max_scroll < 0 ? 0 : max_scroll;
|
||||
}
|
||||
|
||||
void history_view_scroll_to_latest(int *scroll_pos, bool *follow_tail,
|
||||
int message_count, int view_height) {
|
||||
if (!scroll_pos || !follow_tail) return;
|
||||
*scroll_pos = history_view_max_scroll(message_count, view_height);
|
||||
*follow_tail = true;
|
||||
}
|
||||
|
||||
void history_view_scroll_to_oldest(int *scroll_pos, bool *follow_tail) {
|
||||
if (!scroll_pos || !follow_tail) return;
|
||||
*scroll_pos = 0;
|
||||
*follow_tail = false;
|
||||
}
|
||||
|
||||
void history_view_scroll_by(int *scroll_pos, bool *follow_tail,
|
||||
int message_count, int view_height, int delta) {
|
||||
if (!scroll_pos || !follow_tail) return;
|
||||
|
||||
int max_scroll = history_view_max_scroll(message_count, view_height);
|
||||
if (*follow_tail && delta < 0) {
|
||||
*scroll_pos = max_scroll;
|
||||
}
|
||||
|
||||
*scroll_pos += delta;
|
||||
if (*scroll_pos < 0) {
|
||||
*scroll_pos = 0;
|
||||
} else if (*scroll_pos > max_scroll) {
|
||||
*scroll_pos = max_scroll;
|
||||
}
|
||||
*follow_tail = *scroll_pos >= max_scroll;
|
||||
}
|
||||
|
||||
int history_view_latest_start_for_height(const message_t *messages, int count,
|
||||
int height) {
|
||||
int start = count;
|
||||
|
||||
for (int candidate = count - 1; candidate >= 0; candidate--) {
|
||||
int rows = rendered_rows_for_slice(messages, candidate, count);
|
||||
if (rows > height) {
|
||||
break;
|
||||
}
|
||||
start = candidate;
|
||||
}
|
||||
|
||||
if (start == count && count > 0) {
|
||||
start = count - 1;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
116
src/i18n.c
Normal file
116
src/i18n.c
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
#include "i18n.h"
|
||||
|
||||
#include <ctype.h>
|
||||
|
||||
typedef struct {
|
||||
ui_lang_t lang;
|
||||
const char *code;
|
||||
const char *prefixes[4];
|
||||
} ui_lang_definition_t;
|
||||
|
||||
static const ui_lang_definition_t ui_lang_defs[] = {
|
||||
{UI_LANG_EN, "en", {"en", "c", "posix", NULL}},
|
||||
{UI_LANG_ZH, "zh", {"zh", NULL}}
|
||||
};
|
||||
typedef char ui_lang_defs_must_cover_enum[
|
||||
sizeof(ui_lang_defs) / sizeof(ui_lang_defs[0]) == UI_LANG_COUNT ? 1 : -1];
|
||||
|
||||
static const char *skip_space(const char *value) {
|
||||
while (value && *value &&
|
||||
isspace((unsigned char)*value)) {
|
||||
value++;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static bool is_lang_boundary(const char *value) {
|
||||
if (*value == '\0' || *value == '_' || *value == '-' || *value == '.') {
|
||||
return true;
|
||||
}
|
||||
if (!isspace((unsigned char)*value)) {
|
||||
return false;
|
||||
}
|
||||
return *skip_space(value) == '\0';
|
||||
}
|
||||
|
||||
static bool starts_with_lang(const char *value, const char *prefix) {
|
||||
if (!value || !prefix) return false;
|
||||
|
||||
value = skip_space(value);
|
||||
while (*prefix) {
|
||||
if (tolower((unsigned char)*value) !=
|
||||
tolower((unsigned char)*prefix)) {
|
||||
return false;
|
||||
}
|
||||
value++;
|
||||
prefix++;
|
||||
}
|
||||
|
||||
return is_lang_boundary(value);
|
||||
}
|
||||
|
||||
bool i18n_try_parse_ui_lang(const char *value, ui_lang_t *lang) {
|
||||
if (!value || value[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < sizeof(ui_lang_defs) / sizeof(ui_lang_defs[0]); i++) {
|
||||
for (size_t j = 0; ui_lang_defs[i].prefixes[j]; j++) {
|
||||
if (starts_with_lang(value, ui_lang_defs[i].prefixes[j])) {
|
||||
if (lang) {
|
||||
*lang = ui_lang_defs[i].lang;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
ui_lang_t i18n_parse_ui_lang(const char *value, ui_lang_t fallback) {
|
||||
ui_lang_t lang;
|
||||
if (i18n_try_parse_ui_lang(value, &lang)) {
|
||||
return lang;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
ui_lang_t i18n_default_ui_lang(void) {
|
||||
const char *explicit_lang = getenv("TNT_LANG");
|
||||
if (explicit_lang && explicit_lang[0] != '\0') {
|
||||
return i18n_parse_ui_lang(explicit_lang, UI_LANG_EN);
|
||||
}
|
||||
|
||||
const char *locale = getenv("LC_ALL");
|
||||
if (!locale || locale[0] == '\0') {
|
||||
locale = getenv("LC_MESSAGES");
|
||||
}
|
||||
if (!locale || locale[0] == '\0') {
|
||||
locale = getenv("LANG");
|
||||
}
|
||||
|
||||
return i18n_parse_ui_lang(locale, UI_LANG_EN);
|
||||
}
|
||||
|
||||
ui_lang_t i18n_next_ui_lang(ui_lang_t lang) {
|
||||
size_t count = sizeof(ui_lang_defs) / sizeof(ui_lang_defs[0]);
|
||||
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (ui_lang_defs[i].lang == lang) {
|
||||
return ui_lang_defs[(i + 1) % count].lang;
|
||||
}
|
||||
}
|
||||
|
||||
return ui_lang_defs[0].lang;
|
||||
}
|
||||
|
||||
const char *i18n_ui_lang_code(ui_lang_t lang) {
|
||||
for (size_t i = 0; i < sizeof(ui_lang_defs) / sizeof(ui_lang_defs[0]); i++) {
|
||||
if (ui_lang_defs[i].lang == lang) {
|
||||
return ui_lang_defs[i].code;
|
||||
}
|
||||
}
|
||||
|
||||
return ui_lang_defs[0].code;
|
||||
}
|
||||
209
src/i18n_text.c
Normal file
209
src/i18n_text.c
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
#include "i18n.h"
|
||||
|
||||
static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
|
||||
[I18N_USERNAME_PROMPT] = I18N_STRING(
|
||||
" Enter display name (blank for anonymous): ",
|
||||
" 请输入用户名 (留空 anonymous): "
|
||||
),
|
||||
[I18N_INVALID_USERNAME] = I18N_STRING(
|
||||
"Invalid username. Using 'anonymous' instead.\r\n",
|
||||
"用户名无效,已改用 anonymous。\r\n"
|
||||
),
|
||||
[I18N_ROOM_FULL] = I18N_STRING(
|
||||
"Room is full\r\n",
|
||||
"房间已满\r\n"
|
||||
),
|
||||
[I18N_WELCOME_SUBTITLE] = I18N_STRING(
|
||||
"anonymous chat · SSH",
|
||||
"匿名聊天室 · SSH"
|
||||
),
|
||||
[I18N_WELCOME_TAGLINE] = I18N_STRING(
|
||||
"keyboard-first terminal chat",
|
||||
"键盘友好的终端交流"
|
||||
),
|
||||
[I18N_WELCOME_FALLBACK_FORMAT] = I18N_STRING(
|
||||
"TNT %s - anonymous chat over SSH\r\n\r\n",
|
||||
"TNT %s - SSH 匿名聊天室\r\n\r\n"
|
||||
),
|
||||
[I18N_INSERT_HINT_WIDE] = I18N_STRING(
|
||||
"Enter send · Esc browse · :help",
|
||||
"Enter 发送 · Esc 浏览 · :help"
|
||||
),
|
||||
[I18N_INSERT_HINT_NARROW] = I18N_STRING(
|
||||
"Enter · Esc · :help",
|
||||
"Enter · Esc · :help"
|
||||
),
|
||||
[I18N_NORMAL_LATEST] = I18N_STRING(
|
||||
"G latest",
|
||||
"G 最新"
|
||||
),
|
||||
[I18N_NORMAL_NEW_MESSAGES] = I18N_STRING(
|
||||
"new",
|
||||
"新消息"
|
||||
),
|
||||
[I18N_HELP_TITLE] = I18N_STRING(
|
||||
" KEYS ",
|
||||
" 按键 "
|
||||
),
|
||||
[I18N_HELP_STATUS_FORMAT] = I18N_STRING(
|
||||
"-- KEY REFERENCE -- (%d/%d) j/k:scroll g/G:top/bottom l:lang q:close",
|
||||
"-- 按键参考 -- (%d/%d) j/k:滚动 g/G:首尾 l:语言 q:关闭"
|
||||
),
|
||||
[I18N_COMMAND_OUTPUT_TITLE] = I18N_STRING(
|
||||
" COMMAND OUTPUT ",
|
||||
" 命令输出 "
|
||||
),
|
||||
[I18N_COMMAND_OUTPUT_STATUS_FORMAT] = I18N_STRING(
|
||||
"-- COMMAND OUTPUT -- (%d/%d) j/k:scroll Ctrl-D/U:half g/G:top/bottom q:close",
|
||||
"-- 命令输出 -- (%d/%d) j/k:滚动 Ctrl-D/U:半页 g/G:首尾 q:关闭"
|
||||
),
|
||||
[I18N_MOTD_TITLE] = I18N_STRING(
|
||||
" NOTICE ",
|
||||
" 公告 "
|
||||
),
|
||||
[I18N_MOTD_CONTINUE_HINT] = I18N_STRING(
|
||||
" Press any key ",
|
||||
" 按任意键继续 "
|
||||
),
|
||||
[I18N_TITLE_ONLINE_FORMAT] = I18N_STRING(
|
||||
"online %d",
|
||||
"在线 %d"
|
||||
),
|
||||
[I18N_TITLE_MUTED] = I18N_STRING(
|
||||
"muted",
|
||||
"静音"
|
||||
),
|
||||
[I18N_TITLE_HELP_HINT] = I18N_STRING(
|
||||
"? keys",
|
||||
"? 按键"
|
||||
),
|
||||
[I18N_IDLE_TIMEOUT_FORMAT] = I18N_STRING(
|
||||
"\r\n\033[33mDisconnected: idle timeout (%d min)\033[0m\r\n",
|
||||
"\r\n\033[33m已断开: 空闲超时 (%d 分钟)\033[0m\r\n"
|
||||
),
|
||||
[I18N_SYSTEM_USERNAME] = I18N_STRING(
|
||||
"system",
|
||||
"系统"
|
||||
),
|
||||
[I18N_SYSTEM_JOIN_FORMAT] = I18N_STRING(
|
||||
"%s joined the room",
|
||||
"%s 加入了聊天室"
|
||||
),
|
||||
[I18N_SYSTEM_LEAVE_FORMAT] = I18N_STRING(
|
||||
"%s left the room",
|
||||
"%s 离开了聊天室"
|
||||
),
|
||||
[I18N_SYSTEM_NICK_FORMAT] = I18N_STRING(
|
||||
"%s renamed to %s",
|
||||
"%s 更名为 %s"
|
||||
),
|
||||
[I18N_USERS_TITLE] = I18N_STRING(
|
||||
"Online users",
|
||||
"在线用户"
|
||||
),
|
||||
[I18N_MSG_SENT_FORMAT] = I18N_STRING(
|
||||
"Private message sent to %s\n",
|
||||
"私信已发送给 %s\n"
|
||||
),
|
||||
[I18N_MSG_USER_NOT_FOUND_FORMAT] = I18N_STRING(
|
||||
"User '%s' not found\n",
|
||||
"未找到用户 '%s'\n"
|
||||
),
|
||||
[I18N_INBOX_TITLE] = I18N_STRING(
|
||||
"Private messages",
|
||||
"私信"
|
||||
),
|
||||
[I18N_INBOX_EMPTY] = I18N_STRING(
|
||||
"(empty)",
|
||||
"(空)"
|
||||
),
|
||||
[I18N_NICK_INVALID] = I18N_STRING(
|
||||
"Invalid username\n",
|
||||
"用户名无效\n"
|
||||
),
|
||||
[I18N_NICK_TAKEN_FORMAT] = I18N_STRING(
|
||||
"Nickname '%s' is already taken\n",
|
||||
"昵称 '%s' 已被使用\n"
|
||||
),
|
||||
[I18N_NICK_UNCHANGED] = I18N_STRING(
|
||||
"Nickname unchanged\n",
|
||||
"昵称未变化\n"
|
||||
),
|
||||
[I18N_NICK_CHANGED_FORMAT] = I18N_STRING(
|
||||
"Nickname changed: %s -> %s\n",
|
||||
"昵称已修改: %s -> %s\n"
|
||||
),
|
||||
[I18N_LAST_HEADER_FORMAT] = I18N_STRING(
|
||||
"--- Last %d message(s) ---\n",
|
||||
"--- 最近 %d 条消息 ---\n"
|
||||
),
|
||||
[I18N_SEARCH_HEADER_FORMAT] = I18N_STRING(
|
||||
"--- Search: \"%s\" (%d match(es)) ---\n",
|
||||
"--- 搜索: \"%s\" (%d 条匹配) ---\n"
|
||||
),
|
||||
[I18N_MUTE_JOINS_FORMAT] = I18N_STRING(
|
||||
"Join/leave notifications: %s\n",
|
||||
"加入/离开提示: %s\n"
|
||||
),
|
||||
[I18N_MUTE_JOINS_MUTED] = I18N_STRING(
|
||||
"muted",
|
||||
"已静音"
|
||||
),
|
||||
[I18N_MUTE_JOINS_UNMUTED] = I18N_STRING(
|
||||
"unmuted",
|
||||
"已开启"
|
||||
),
|
||||
[I18N_CLEAR_DONE] = I18N_STRING(
|
||||
"Command output cleared\n",
|
||||
"命令输出已清空\n"
|
||||
),
|
||||
[I18N_LANG_CURRENT_FORMAT] = I18N_STRING(
|
||||
"Current language: %s\n"
|
||||
"Usage: lang <en|zh>\n",
|
||||
"当前语言: %s\n"
|
||||
"用法: lang <en|zh>\n"
|
||||
),
|
||||
[I18N_LANG_SET_FORMAT] = I18N_STRING(
|
||||
"Language set to: %s\n",
|
||||
"语言已切换为: %s\n"
|
||||
),
|
||||
[I18N_LANG_UNSUPPORTED_FORMAT] = I18N_STRING(
|
||||
"Unsupported language: %s\n"
|
||||
"Usage: lang <en|zh>\n",
|
||||
"不支持的语言: %s\n"
|
||||
"用法: lang <en|zh>\n"
|
||||
),
|
||||
[I18N_UNKNOWN_COMMAND_FORMAT] = I18N_STRING(
|
||||
"Unknown command: %s\n",
|
||||
"未知命令: %s\n"
|
||||
),
|
||||
[I18N_DID_YOU_MEAN_FORMAT] = I18N_STRING(
|
||||
"Did you mean :%s?\n",
|
||||
"你是想输入 :%s 吗?\n"
|
||||
),
|
||||
[I18N_UNKNOWN_GUIDANCE] = I18N_STRING(
|
||||
"Type :help for help\n",
|
||||
"输入 :help 查看帮助\n"
|
||||
),
|
||||
[I18N_EXEC_POST_EMPTY] = I18N_STRING(
|
||||
"post: message cannot be empty\n",
|
||||
"post: 消息不能为空\n"
|
||||
),
|
||||
[I18N_EXEC_POST_INVALID_UTF8] = I18N_STRING(
|
||||
"post: invalid UTF-8 input\n",
|
||||
"post: 输入不是有效 UTF-8\n"
|
||||
),
|
||||
[I18N_EXEC_UNKNOWN_COMMAND_FORMAT] = I18N_STRING(
|
||||
"Unknown command: %s\n",
|
||||
"未知命令: %s\n"
|
||||
)
|
||||
};
|
||||
|
||||
const char *i18n_text(ui_lang_t lang, i18n_text_id_t id) {
|
||||
if (id < 0 || id >= I18N_TEXT_COUNT) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const i18n_string_t *entry = &text_catalog[id];
|
||||
return i18n_string(*entry, lang);
|
||||
}
|
||||
445
src/input.c
445
src/input.c
|
|
@ -4,22 +4,28 @@
|
|||
#include "commands.h"
|
||||
#include "common.h"
|
||||
#include "exec.h"
|
||||
#include "history_view.h"
|
||||
#include "i18n.h"
|
||||
#include "message.h"
|
||||
#include "ratelimit.h"
|
||||
#include "system_message.h"
|
||||
#include "tui.h"
|
||||
#include "utf8.h"
|
||||
#include <libssh/callbacks.h>
|
||||
#include <libssh/libssh.h>
|
||||
#include <libssh/server.h>
|
||||
#include <strings.h> /* strncasecmp */
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
static int g_idle_timeout = DEFAULT_IDLE_TIMEOUT;
|
||||
static ui_lang_t g_default_ui_lang = UI_LANG_EN;
|
||||
|
||||
void input_init(void) {
|
||||
g_idle_timeout = env_int("TNT_IDLE_TIMEOUT", DEFAULT_IDLE_TIMEOUT, 0, 86400);
|
||||
g_default_ui_lang = i18n_default_ui_lang();
|
||||
}
|
||||
|
||||
static int read_username(client_t *client) {
|
||||
|
|
@ -28,7 +34,8 @@ static int read_username(client_t *client) {
|
|||
char buf[4];
|
||||
|
||||
tui_render_welcome(client);
|
||||
client_printf(client, " 请输入用户名 (留空 anonymous): ");
|
||||
client_printf(client, "%s", i18n_text(client->ui_lang,
|
||||
I18N_USERNAME_PROMPT));
|
||||
|
||||
while (1) {
|
||||
int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */
|
||||
|
|
@ -110,7 +117,8 @@ static int read_username(client_t *client) {
|
|||
|
||||
/* Validate username for security */
|
||||
if (!is_valid_username(client->username)) {
|
||||
client_printf(client, "Invalid username. Using 'anonymous' instead.\r\n");
|
||||
client_printf(client, "%s", i18n_text(client->ui_lang,
|
||||
I18N_INVALID_USERNAME));
|
||||
strcpy(client->username, "anonymous");
|
||||
} else {
|
||||
/* Truncate to 20 characters */
|
||||
|
|
@ -143,20 +151,93 @@ void notify_mentions(const char *content, const client_t *sender) {
|
|||
|
||||
for (int i = 0; i < target_count; i++) {
|
||||
client_send(targets[i], "\a", 1);
|
||||
targets[i]->unread_mentions++;
|
||||
targets[i]->redraw_pending = true;
|
||||
client_release(targets[i]);
|
||||
}
|
||||
}
|
||||
|
||||
static int read_channel_exact(client_t *client, char *buf, size_t len,
|
||||
int timeout_ms) {
|
||||
size_t got = 0;
|
||||
|
||||
while (got < len) {
|
||||
int n = ssh_channel_read_timeout(client->channel, buf + got,
|
||||
len - got, 0, timeout_ms);
|
||||
if (n == SSH_AGAIN || n <= 0) {
|
||||
break;
|
||||
}
|
||||
got += (size_t)n;
|
||||
}
|
||||
|
||||
return (int)got;
|
||||
}
|
||||
|
||||
static bool append_paste_byte(char *input, unsigned char b) {
|
||||
if (b == '\r' || b == '\n' || b == '\t') {
|
||||
b = ' ';
|
||||
}
|
||||
if (b < 32) {
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t cur = strlen(input);
|
||||
if (cur < MAX_MESSAGE_LEN - 1) {
|
||||
input[cur] = (char)b;
|
||||
input[cur + 1] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static void normal_scroll_to_latest(client_t *client) {
|
||||
if (!client) return;
|
||||
history_view_scroll_to_latest(&client->scroll_pos, &client->follow_tail,
|
||||
room_get_message_count(g_room),
|
||||
history_view_height(client->height));
|
||||
}
|
||||
|
||||
static void normal_scroll_by(client_t *client, int delta) {
|
||||
if (!client) return;
|
||||
history_view_scroll_by(&client->scroll_pos, &client->follow_tail,
|
||||
room_get_message_count(g_room),
|
||||
history_view_height(client->height), delta);
|
||||
}
|
||||
|
||||
static void dismiss_command_output(client_t *client) {
|
||||
bool was_motd;
|
||||
|
||||
if (!client) return;
|
||||
|
||||
was_motd = client->show_motd;
|
||||
client->command_output[0] = '\0';
|
||||
client->command_output_scroll = 0;
|
||||
client->show_motd = false;
|
||||
client->mode = MODE_NORMAL;
|
||||
if (was_motd) {
|
||||
normal_scroll_to_latest(client);
|
||||
}
|
||||
tui_render_screen(client);
|
||||
}
|
||||
|
||||
/* Handle a single key press. Returns true if the key was fully consumed
|
||||
* (no further character buffering needed). */
|
||||
static bool handle_key(client_t *client, unsigned char key, char *input) {
|
||||
/* Handle Ctrl+C (Exit or switch to NORMAL) */
|
||||
if (key == 3) {
|
||||
if (client->mode != MODE_NORMAL) {
|
||||
client_mode_t previous_mode = client->mode;
|
||||
if (client->command_output[0] != '\0') {
|
||||
dismiss_command_output(client);
|
||||
return true;
|
||||
}
|
||||
if (previous_mode != MODE_NORMAL) {
|
||||
client->mode = MODE_NORMAL;
|
||||
client->command_input[0] = '\0';
|
||||
client->show_help = false;
|
||||
if (previous_mode == MODE_INSERT) {
|
||||
normal_scroll_to_latest(client);
|
||||
}
|
||||
tui_render_screen(client);
|
||||
} else {
|
||||
/* In NORMAL mode, Ctrl+C exits */
|
||||
|
|
@ -167,15 +248,17 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
|||
|
||||
/* Handle help screen */
|
||||
if (client->show_help) {
|
||||
/* Page size: roughly the visible help body region. */
|
||||
int page = client->height - 2;
|
||||
if (page < 1) page = 1;
|
||||
int half = page / 2;
|
||||
if (half < 1) half = 1;
|
||||
|
||||
if (key == 'q' || key == 27) {
|
||||
client->show_help = false;
|
||||
tui_render_screen(client);
|
||||
} else if (key == 'e' || key == 'E') {
|
||||
client->help_lang = LANG_EN;
|
||||
client->help_scroll_pos = 0;
|
||||
tui_render_help(client);
|
||||
} else if (key == 'z' || key == 'Z') {
|
||||
client->help_lang = LANG_ZH;
|
||||
} else if (key == 'l' || key == 'L') {
|
||||
client->ui_lang = i18n_next_ui_lang(client->ui_lang);
|
||||
client->help_scroll_pos = 0;
|
||||
tui_render_help(client);
|
||||
} else if (key == 'j') {
|
||||
|
|
@ -184,6 +267,20 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
|||
} else if (key == 'k' && client->help_scroll_pos > 0) {
|
||||
client->help_scroll_pos--;
|
||||
tui_render_help(client);
|
||||
} else if (key == 4) { /* Ctrl+D: half page down */
|
||||
client->help_scroll_pos += half;
|
||||
tui_render_help(client);
|
||||
} else if (key == 21) { /* Ctrl+U: half page up */
|
||||
client->help_scroll_pos -= half;
|
||||
if (client->help_scroll_pos < 0) client->help_scroll_pos = 0;
|
||||
tui_render_help(client);
|
||||
} else if (key == 6) { /* Ctrl+F: full page down */
|
||||
client->help_scroll_pos += page;
|
||||
tui_render_help(client);
|
||||
} else if (key == 2) { /* Ctrl+B: full page up */
|
||||
client->help_scroll_pos -= page;
|
||||
if (client->help_scroll_pos < 0) client->help_scroll_pos = 0;
|
||||
tui_render_help(client);
|
||||
} else if (key == 'g') {
|
||||
client->help_scroll_pos = 0;
|
||||
tui_render_help(client);
|
||||
|
|
@ -194,25 +291,171 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
|||
return true; /* Key consumed */
|
||||
}
|
||||
|
||||
/* Handle command output / MOTD display: any key dismisses */
|
||||
/* Handle command output / MOTD display. MOTD remains a simple notice;
|
||||
* command output behaves like a small pager so long results can be read. */
|
||||
if (client->command_output[0] != '\0') {
|
||||
client->command_output[0] = '\0';
|
||||
client->show_motd = false;
|
||||
client->mode = MODE_NORMAL;
|
||||
tui_render_screen(client);
|
||||
int page = client->height - 2;
|
||||
int half;
|
||||
|
||||
if (client->show_motd) {
|
||||
dismiss_command_output(client);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (page < 1) page = 1;
|
||||
half = page / 2;
|
||||
if (half < 1) half = 1;
|
||||
|
||||
if (key == 'q' || key == 27) {
|
||||
dismiss_command_output(client);
|
||||
} else if (key == 'j') {
|
||||
client->command_output_scroll++;
|
||||
tui_render_command_output(client);
|
||||
} else if (key == 'k') {
|
||||
client->command_output_scroll--;
|
||||
if (client->command_output_scroll < 0) {
|
||||
client->command_output_scroll = 0;
|
||||
}
|
||||
tui_render_command_output(client);
|
||||
} else if (key == 4) { /* Ctrl+D: half page down */
|
||||
client->command_output_scroll += half;
|
||||
tui_render_command_output(client);
|
||||
} else if (key == 21) { /* Ctrl+U: half page up */
|
||||
client->command_output_scroll -= half;
|
||||
if (client->command_output_scroll < 0) {
|
||||
client->command_output_scroll = 0;
|
||||
}
|
||||
tui_render_command_output(client);
|
||||
} else if (key == 6) { /* Ctrl+F: full page down */
|
||||
client->command_output_scroll += page;
|
||||
tui_render_command_output(client);
|
||||
} else if (key == 2) { /* Ctrl+B: full page up */
|
||||
client->command_output_scroll -= page;
|
||||
if (client->command_output_scroll < 0) {
|
||||
client->command_output_scroll = 0;
|
||||
}
|
||||
tui_render_command_output(client);
|
||||
} else if (key == 'g') {
|
||||
client->command_output_scroll = 0;
|
||||
tui_render_command_output(client);
|
||||
} else if (key == 'G') {
|
||||
client->command_output_scroll = 999;
|
||||
tui_render_command_output(client);
|
||||
}
|
||||
return true; /* Key consumed */
|
||||
}
|
||||
|
||||
/* Mode-specific handling */
|
||||
switch (client->mode) {
|
||||
case MODE_INSERT:
|
||||
if (key == 27) { /* ESC */
|
||||
if (key == 27) { /* ESC — may also be the start of an arrow seq */
|
||||
char seq[2];
|
||||
int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50);
|
||||
if (n == 1 && seq[0] == '[') {
|
||||
n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50);
|
||||
if (n == 1) {
|
||||
if (seq[1] == 'A') { /* Up — walk back through sent history */
|
||||
if (client->insert_history_count > 0 &&
|
||||
client->insert_history_pos > 0) {
|
||||
client->insert_history_pos--;
|
||||
strncpy(input,
|
||||
client->insert_history[client->insert_history_pos],
|
||||
MAX_MESSAGE_LEN - 1);
|
||||
input[MAX_MESSAGE_LEN - 1] = '\0';
|
||||
tui_render_input(client, input);
|
||||
}
|
||||
return true;
|
||||
} else if (seq[1] == 'B') { /* Down — walk forward */
|
||||
if (client->insert_history_pos <
|
||||
client->insert_history_count - 1) {
|
||||
client->insert_history_pos++;
|
||||
strncpy(input,
|
||||
client->insert_history[client->insert_history_pos],
|
||||
MAX_MESSAGE_LEN - 1);
|
||||
input[MAX_MESSAGE_LEN - 1] = '\0';
|
||||
} else {
|
||||
client->insert_history_pos =
|
||||
client->insert_history_count;
|
||||
input[0] = '\0';
|
||||
}
|
||||
tui_render_input(client, input);
|
||||
return true;
|
||||
} else if (seq[1] == '2') {
|
||||
/* Could be bracketed-paste start "ESC[200~".
|
||||
* Read the next 3 bytes and confirm. */
|
||||
char rest[3];
|
||||
int m = read_channel_exact(client, rest,
|
||||
sizeof(rest), 500);
|
||||
if (m == 3 && rest[0] == '0' && rest[1] == '0'
|
||||
&& rest[2] == '~') {
|
||||
/* Drain bytes into `input` until we see
|
||||
* the end marker ESC[201~. Newlines become
|
||||
* spaces so a multi-line paste stays a
|
||||
* single message instead of N sends. */
|
||||
bool overflow = false;
|
||||
while (1) {
|
||||
char b;
|
||||
int k = ssh_channel_read_timeout(
|
||||
client->channel, &b, 1, 0, 5000);
|
||||
if (k != 1) break;
|
||||
if (b == '\033') {
|
||||
char tail[5];
|
||||
int t = read_channel_exact(
|
||||
client, tail, sizeof(tail), 500);
|
||||
if (t == 5 && tail[0] == '['
|
||||
&& tail[1] == '2'
|
||||
&& tail[2] == '0'
|
||||
&& tail[3] == '1'
|
||||
&& tail[4] == '~') {
|
||||
break; /* end of paste */
|
||||
}
|
||||
/* Stray ESC inside paste: drop the ESC
|
||||
* but keep printable bytes that
|
||||
* followed it. */
|
||||
for (int i = 0; i < t; i++) {
|
||||
if (!append_paste_byte(
|
||||
input,
|
||||
(unsigned char)tail[i])) {
|
||||
overflow = true;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!append_paste_byte(input,
|
||||
(unsigned char)b)) {
|
||||
overflow = true;
|
||||
}
|
||||
}
|
||||
tui_render_input(client, input);
|
||||
if (overflow) {
|
||||
client_send(client, "\a", 1);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Plain ESC — fall through to NORMAL mode */
|
||||
client->mode = MODE_NORMAL;
|
||||
client->scroll_pos = 0;
|
||||
normal_scroll_to_latest(client);
|
||||
tui_render_screen(client);
|
||||
return true; /* Key consumed */
|
||||
return true;
|
||||
} else if (key == '\r' || key == '\n') { /* Enter */
|
||||
if (input[0] != '\0') {
|
||||
/* Record into the per-client INSERT history ring */
|
||||
int max_hist = (int)(sizeof(client->insert_history) /
|
||||
sizeof(client->insert_history[0]));
|
||||
if (client->insert_history_count >= max_hist) {
|
||||
memmove(&client->insert_history[0],
|
||||
&client->insert_history[1],
|
||||
(max_hist - 1) * sizeof(client->insert_history[0]));
|
||||
client->insert_history_count = max_hist - 1;
|
||||
}
|
||||
snprintf(client->insert_history[client->insert_history_count],
|
||||
sizeof(client->insert_history[0]), "%s", input);
|
||||
client->insert_history_count++;
|
||||
client->insert_history_pos = client->insert_history_count;
|
||||
|
||||
message_t msg = {
|
||||
.timestamp = time(NULL),
|
||||
};
|
||||
|
|
@ -253,18 +496,62 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
|||
tui_render_input(client, input);
|
||||
}
|
||||
return true;
|
||||
} else if (key == 9) { /* Tab: complete @mention */
|
||||
/* Walk back from end to find the start of the trailing
|
||||
* "@…" token (an '@' not preceded by an alphanumeric).
|
||||
* If found, scan g_room for the first case-insensitive
|
||||
* username prefix-match (cycling past self) and replace
|
||||
* the token. */
|
||||
size_t in_len = strlen(input);
|
||||
ssize_t at_idx = -1;
|
||||
for (ssize_t i = (ssize_t)in_len - 1; i >= 0; i--) {
|
||||
unsigned char c = (unsigned char)input[i];
|
||||
if (c == '@') {
|
||||
if (i == 0 || input[i - 1] == ' ') at_idx = i;
|
||||
break;
|
||||
}
|
||||
if (c == ' ') break;
|
||||
}
|
||||
if (at_idx >= 0) {
|
||||
const char *prefix = input + at_idx + 1;
|
||||
size_t plen = strlen(prefix);
|
||||
char match[MAX_USERNAME_LEN] = "";
|
||||
pthread_rwlock_rdlock(&g_room->lock);
|
||||
for (int i = 0; i < g_room->client_count; i++) {
|
||||
const char *uname = g_room->clients[i]->username;
|
||||
if (plen == 0
|
||||
? strcmp(uname, client->username) != 0
|
||||
: strncasecmp(uname, prefix, plen) == 0) {
|
||||
snprintf(match, sizeof(match), "%s", uname);
|
||||
break;
|
||||
}
|
||||
}
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
if (match[0] != '\0') {
|
||||
/* Replace "@<prefix>" with "@<match> " (trailing
|
||||
* space so the next word starts cleanly). */
|
||||
size_t avail = MAX_MESSAGE_LEN - 1
|
||||
- (size_t)at_idx - 1;
|
||||
size_t mlen = strlen(match);
|
||||
if (mlen + 1 <= avail) {
|
||||
input[at_idx + 1] = '\0';
|
||||
strncat(input, match, avail);
|
||||
strncat(input, " ", 1);
|
||||
tui_render_input(client, input);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
|
||||
case MODE_NORMAL: {
|
||||
int nm_msg_count = room_get_message_count(g_room);
|
||||
int nm_msg_height = client->height - 3;
|
||||
if (nm_msg_height < 1) nm_msg_height = 1;
|
||||
int nm_max_scroll = nm_msg_count - nm_msg_height;
|
||||
if (nm_max_scroll < 0) nm_max_scroll = 0;
|
||||
int nm_msg_height = history_view_height(client->height);
|
||||
|
||||
if (key == 'i') {
|
||||
client->mode = MODE_INSERT;
|
||||
client->follow_tail = true;
|
||||
client->unread_mentions = 0;
|
||||
tui_render_screen(client);
|
||||
return true;
|
||||
} else if (key == ':') {
|
||||
|
|
@ -273,47 +560,79 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
|||
tui_render_screen(client);
|
||||
return true;
|
||||
} else if (key == 'j') {
|
||||
if (client->scroll_pos < nm_max_scroll) {
|
||||
client->scroll_pos++;
|
||||
normal_scroll_by(client, 1);
|
||||
tui_render_screen(client);
|
||||
}
|
||||
return true;
|
||||
} else if (key == 'k' && client->scroll_pos > 0) {
|
||||
client->scroll_pos--;
|
||||
normal_scroll_by(client, -1);
|
||||
tui_render_screen(client);
|
||||
return true;
|
||||
} else if (key == 4) { /* Ctrl+D: half page down */
|
||||
int half = nm_msg_height / 2;
|
||||
if (half < 1) half = 1;
|
||||
client->scroll_pos += half;
|
||||
if (client->scroll_pos > nm_max_scroll) client->scroll_pos = nm_max_scroll;
|
||||
normal_scroll_by(client, half);
|
||||
tui_render_screen(client);
|
||||
return true;
|
||||
} else if (key == 21) { /* Ctrl+U: half page up */
|
||||
int half = nm_msg_height / 2;
|
||||
if (half < 1) half = 1;
|
||||
client->scroll_pos -= half;
|
||||
if (client->scroll_pos < 0) client->scroll_pos = 0;
|
||||
normal_scroll_by(client, -half);
|
||||
tui_render_screen(client);
|
||||
return true;
|
||||
} else if (key == 6) { /* Ctrl+F: full page down */
|
||||
client->scroll_pos += nm_msg_height;
|
||||
if (client->scroll_pos > nm_max_scroll) client->scroll_pos = nm_max_scroll;
|
||||
normal_scroll_by(client, nm_msg_height);
|
||||
tui_render_screen(client);
|
||||
return true;
|
||||
} else if (key == 2) { /* Ctrl+B: full page up */
|
||||
client->scroll_pos -= nm_msg_height;
|
||||
if (client->scroll_pos < 0) client->scroll_pos = 0;
|
||||
normal_scroll_by(client, -nm_msg_height);
|
||||
tui_render_screen(client);
|
||||
return true;
|
||||
} else if (key == 'g') {
|
||||
client->scroll_pos = 0;
|
||||
history_view_scroll_to_oldest(&client->scroll_pos,
|
||||
&client->follow_tail);
|
||||
tui_render_screen(client);
|
||||
return true;
|
||||
} else if (key == 'G') {
|
||||
client->scroll_pos = nm_max_scroll;
|
||||
normal_scroll_to_latest(client);
|
||||
client->unread_mentions = 0;
|
||||
tui_render_screen(client);
|
||||
return true;
|
||||
} else if (key == 27) {
|
||||
char seq[4];
|
||||
int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50);
|
||||
if (n == 1 && seq[0] == '[') {
|
||||
n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50);
|
||||
if (n == 1) {
|
||||
if (seq[1] == 'A') { /* Up arrow */
|
||||
normal_scroll_by(client, -1);
|
||||
} else if (seq[1] == 'B') { /* Down arrow */
|
||||
normal_scroll_by(client, 1);
|
||||
} else if (seq[1] == 'H') { /* Home */
|
||||
history_view_scroll_to_oldest(&client->scroll_pos,
|
||||
&client->follow_tail);
|
||||
} else if (seq[1] == 'F') { /* End */
|
||||
normal_scroll_to_latest(client);
|
||||
} else if (seq[1] >= '1' && seq[1] <= '6') {
|
||||
n = ssh_channel_read_timeout(client->channel,
|
||||
&seq[2], 1, 0, 50);
|
||||
if (n == 1 && seq[2] == '~') {
|
||||
if (seq[1] == '5') { /* PageUp */
|
||||
normal_scroll_by(client, -nm_msg_height);
|
||||
} else if (seq[1] == '6') { /* PageDown */
|
||||
normal_scroll_by(client, nm_msg_height);
|
||||
} else if (seq[1] == '1') { /* Home */
|
||||
history_view_scroll_to_oldest(
|
||||
&client->scroll_pos,
|
||||
&client->follow_tail);
|
||||
} else if (seq[1] == '4') { /* End */
|
||||
normal_scroll_to_latest(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
tui_render_screen(client);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} else if (key == '?') {
|
||||
client->show_help = true;
|
||||
client->help_scroll_pos = 0;
|
||||
|
|
@ -396,15 +715,18 @@ void input_run_session(client_t *client) {
|
|||
char input[MAX_MESSAGE_LEN] = {0};
|
||||
char buf[4];
|
||||
bool joined_room = false;
|
||||
bool bracketed_paste_enabled = false;
|
||||
uint64_t seen_update_seq;
|
||||
time_t last_keepalive = time(NULL);
|
||||
|
||||
/* Terminal size already set from PTY request */
|
||||
client->mode = MODE_INSERT;
|
||||
client->help_lang = LANG_ZH;
|
||||
client->follow_tail = true;
|
||||
client->ui_lang = g_default_ui_lang;
|
||||
client->connected = true;
|
||||
client->command_history_count = 0;
|
||||
client->command_history_pos = 0;
|
||||
client->command_output_scroll = 0;
|
||||
client->connect_time = time(NULL);
|
||||
client->last_active = time(NULL);
|
||||
|
||||
|
|
@ -412,6 +734,9 @@ void input_run_session(client_t *client) {
|
|||
if (client->exec_command[0] != '\0') {
|
||||
int exit_status = exec_dispatch(client);
|
||||
ssh_channel_request_send_exit_status(client->channel, exit_status);
|
||||
ssh_channel_send_eof(client->channel);
|
||||
ssh_blocking_flush(client->session, 1000);
|
||||
ssh_channel_close(client->channel);
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
|
|
@ -422,18 +747,21 @@ void input_run_session(client_t *client) {
|
|||
|
||||
/* Add to room */
|
||||
if (room_add_client(g_room, client) < 0) {
|
||||
client_printf(client, "Room is full\n");
|
||||
client_printf(client, "%s", i18n_text(client->ui_lang,
|
||||
I18N_ROOM_FULL));
|
||||
goto cleanup;
|
||||
}
|
||||
joined_room = true;
|
||||
|
||||
/* Enable xterm bracketed-paste mode only for interactive chat, so
|
||||
* multi-line pastes arrive framed by ESC[200~...ESC[201~ instead of
|
||||
* as a stream of Enters. Terminals that don't recognise it ignore it. */
|
||||
client_send(client, "\033[?2004h", 8);
|
||||
bracketed_paste_enabled = true;
|
||||
|
||||
/* Broadcast join message */
|
||||
message_t join_msg = {
|
||||
.timestamp = time(NULL),
|
||||
};
|
||||
strncpy(join_msg.username, "系统", MAX_USERNAME_LEN - 1);
|
||||
join_msg.username[MAX_USERNAME_LEN - 1] = '\0';
|
||||
snprintf(join_msg.content, MAX_MESSAGE_LEN, "%s 加入了聊天室", client->username);
|
||||
message_t join_msg;
|
||||
system_message_make_join(&join_msg, client->username, client->ui_lang);
|
||||
room_broadcast(g_room, &join_msg);
|
||||
message_save(&join_msg);
|
||||
|
||||
|
|
@ -451,6 +779,7 @@ void input_run_session(client_t *client) {
|
|||
snprintf(client->command_output,
|
||||
sizeof(client->command_output),
|
||||
"%s", motd_buf);
|
||||
client->command_output_scroll = 0;
|
||||
client->show_motd = true;
|
||||
tui_render_motd(client);
|
||||
seen_update_seq = room_get_update_seq(g_room);
|
||||
|
|
@ -499,6 +828,10 @@ main_loop:
|
|||
} else if (client->command_output[0] != '\0') {
|
||||
tui_render_command_output(client);
|
||||
} else {
|
||||
if (room_updated && client->mode == MODE_NORMAL &&
|
||||
client->follow_tail) {
|
||||
normal_scroll_to_latest(client);
|
||||
}
|
||||
tui_render_screen(client);
|
||||
if (client->mode == MODE_INSERT && input[0] != '\0') {
|
||||
tui_render_input(client, input);
|
||||
|
|
@ -513,7 +846,9 @@ main_loop:
|
|||
|
||||
if (g_idle_timeout > 0 && joined_room &&
|
||||
time(NULL) - client->last_active >= g_idle_timeout) {
|
||||
client_printf(client, "\r\n\033[33mDisconnected: idle timeout (%d min)\033[0m\r\n",
|
||||
client_printf(client,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_IDLE_TIMEOUT_FORMAT),
|
||||
g_idle_timeout / 60);
|
||||
break;
|
||||
}
|
||||
|
|
@ -546,6 +881,8 @@ main_loop:
|
|||
input[len] = b;
|
||||
input[len + 1] = '\0';
|
||||
tui_render_input(client, input);
|
||||
} else {
|
||||
client_send(client, "\a", 1);
|
||||
}
|
||||
} else if (b >= 128) { /* UTF-8 multi-byte */
|
||||
int char_len = utf8_byte_length(b);
|
||||
|
|
@ -567,10 +904,12 @@ main_loop:
|
|||
continue;
|
||||
}
|
||||
int len = strlen(input);
|
||||
if (len + char_len < MAX_MESSAGE_LEN - 1) {
|
||||
if (len + char_len <= MAX_MESSAGE_LEN - 1) {
|
||||
memcpy(input + len, buf, char_len);
|
||||
input[len + char_len] = '\0';
|
||||
tui_render_input(client, input);
|
||||
} else {
|
||||
client_send(client, "\a", 1);
|
||||
}
|
||||
}
|
||||
} else if (client->mode == MODE_COMMAND && !client->show_help &&
|
||||
|
|
@ -604,14 +943,16 @@ main_loop:
|
|||
}
|
||||
|
||||
cleanup:
|
||||
if (bracketed_paste_enabled && client->channel &&
|
||||
ssh_channel_is_open(client->channel)) {
|
||||
client_send(client, "\033[?2004l", 8);
|
||||
}
|
||||
|
||||
/* Broadcast leave message */
|
||||
if (joined_room) {
|
||||
message_t leave_msg = {
|
||||
.timestamp = time(NULL),
|
||||
};
|
||||
strncpy(leave_msg.username, "系统", MAX_USERNAME_LEN - 1);
|
||||
leave_msg.username[MAX_USERNAME_LEN - 1] = '\0';
|
||||
snprintf(leave_msg.content, MAX_MESSAGE_LEN, "%s 离开了聊天室", client->username);
|
||||
message_t leave_msg;
|
||||
system_message_make_leave(&leave_msg, client->username,
|
||||
client->ui_lang);
|
||||
|
||||
client->connected = false;
|
||||
room_remove_client(g_room, client);
|
||||
|
|
|
|||
33
src/main.c
33
src/main.c
|
|
@ -1,7 +1,9 @@
|
|||
#include "common.h"
|
||||
#include "ssh_server.h"
|
||||
#include "chat_room.h"
|
||||
#include "cli_text.h"
|
||||
#include "common.h"
|
||||
#include "i18n.h"
|
||||
#include "message.h"
|
||||
#include "ssh_server.h"
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
|
||||
|
|
@ -18,6 +20,7 @@ static void signal_handler(int sig) {
|
|||
|
||||
int main(int argc, char **argv) {
|
||||
int port = DEFAULT_PORT;
|
||||
ui_lang_t lang = i18n_default_ui_lang();
|
||||
|
||||
/* Environment provides defaults; command-line flags override it. */
|
||||
const char *port_env = getenv("PORT");
|
||||
|
|
@ -36,7 +39,8 @@ int main(int argc, char **argv) {
|
|||
char *end;
|
||||
long val = strtol(argv[i + 1], &end, 10);
|
||||
if (*end != '\0' || val <= 0 || val > 65535) {
|
||||
fprintf(stderr, "Invalid port: %s\n", argv[i + 1]);
|
||||
fprintf(stderr, cli_text_invalid_port_format(lang),
|
||||
argv[i + 1]);
|
||||
return 1;
|
||||
}
|
||||
port = (int)val;
|
||||
|
|
@ -52,24 +56,15 @@ int main(int argc, char **argv) {
|
|||
printf("tnt %s\n", TNT_VERSION);
|
||||
return 0;
|
||||
} else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
|
||||
printf("tnt %s - anonymous SSH chat server\n\n", TNT_VERSION);
|
||||
printf("Usage: %s [options]\n\n", argv[0]);
|
||||
printf("Options:\n");
|
||||
printf(" -p, --port PORT Listen on PORT (default: %d)\n", DEFAULT_PORT);
|
||||
printf(" -d, --state-dir DIR Store host key and logs in DIR\n");
|
||||
printf(" -V, --version Show version\n");
|
||||
printf(" -h, --help Show this help\n");
|
||||
printf("\nEnvironment:\n");
|
||||
printf(" PORT Default listening port\n");
|
||||
printf(" TNT_STATE_DIR State directory\n");
|
||||
printf(" TNT_ACCESS_TOKEN Require this password for SSH auth\n");
|
||||
printf(" TNT_MAX_CONNECTIONS Global connection limit (default: 64)\n");
|
||||
printf(" TNT_RATE_LIMIT Set to 0 to disable rate limiting\n");
|
||||
printf(" TNT_IDLE_TIMEOUT Idle disconnect timeout in seconds (default: 1800)\n");
|
||||
char output[2048] = {0};
|
||||
size_t pos = 0;
|
||||
|
||||
cli_text_append_help(output, sizeof(output), &pos, argv[0], lang);
|
||||
fputs(output, stdout);
|
||||
return 0;
|
||||
} else {
|
||||
fprintf(stderr, "Unknown option: %s\n", argv[i]);
|
||||
fprintf(stderr, "Usage: %s [-p PORT] [-d DIR] [-h]\n", argv[0]);
|
||||
fprintf(stderr, cli_text_unknown_option_format(lang), argv[i]);
|
||||
fprintf(stderr, cli_text_short_usage_format(lang), argv[0]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
src/manual.c
Normal file
9
src/manual.c
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#include "manual.h"
|
||||
#include "manual_text.h"
|
||||
|
||||
void manual_append_interactive_panel(char *buffer, size_t buf_size,
|
||||
size_t *pos, ui_lang_t lang) {
|
||||
if (!buffer || !pos) return;
|
||||
|
||||
manual_text_append_interactive(buffer, buf_size, pos, lang);
|
||||
}
|
||||
50
src/manual_text.c
Normal file
50
src/manual_text.c
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#include "manual_text.h"
|
||||
|
||||
#include "command_catalog.h"
|
||||
#include "i18n.h"
|
||||
|
||||
void manual_text_append_interactive(char *buffer, size_t buf_size,
|
||||
size_t *pos, ui_lang_t lang) {
|
||||
static const i18n_string_t intro = I18N_STRING(
|
||||
"\033[1;36mTNT(1) help\033[0m\n"
|
||||
"\n"
|
||||
"\033[1;37mName\033[0m\n"
|
||||
" TNT - SSH terminal chat room\n"
|
||||
"\n"
|
||||
"\033[1;37mUse\033[0m\n"
|
||||
" Type a message and press Enter; Esc browses; G latest; i types\n"
|
||||
" : runs commands; ? opens the full key reference\n"
|
||||
"\n"
|
||||
"\033[1;37mCommands\033[0m\n",
|
||||
"\033[1;36mTNT(1) 帮助\033[0m\n"
|
||||
"\n"
|
||||
"\033[1;37m名称\033[0m\n"
|
||||
" TNT - SSH 终端聊天室\n"
|
||||
"\n"
|
||||
"\033[1;37m使用\033[0m\n"
|
||||
" 输入消息并 Enter 发送;Esc 浏览历史;G 最新;i 输入\n"
|
||||
" : 运行命令;? 打开完整按键参考\n"
|
||||
"\n"
|
||||
"\033[1;37m命令\033[0m\n"
|
||||
);
|
||||
static const i18n_string_t outro = I18N_STRING(
|
||||
"\n"
|
||||
"\033[1;37mLanguage\033[0m\n"
|
||||
" :lang show current language\n"
|
||||
" :lang en|zh switch language\n"
|
||||
"\n"
|
||||
"\033[1;37mSee also\033[0m\n"
|
||||
" ? full key reference\n",
|
||||
"\n"
|
||||
"\033[1;37m语言\033[0m\n"
|
||||
" :lang 显示当前语言\n"
|
||||
" :lang en|zh 切换语言\n"
|
||||
"\n"
|
||||
"\033[1;37m参见\033[0m\n"
|
||||
" ? 完整按键参考\n"
|
||||
);
|
||||
|
||||
buffer_appendf(buffer, buf_size, pos, "%s", i18n_string(intro, lang));
|
||||
command_catalog_append_manual(buffer, buf_size, pos, lang);
|
||||
buffer_appendf(buffer, buf_size, pos, "%s", i18n_string(outro, lang));
|
||||
}
|
||||
|
|
@ -134,7 +134,7 @@ bool ratelimit_check_ip(const char *ip) {
|
|||
}
|
||||
|
||||
entry->recent_connection_count++;
|
||||
if (entry->recent_connection_count >= g_max_conn_rate_per_ip) {
|
||||
if (entry->recent_connection_count > g_max_conn_rate_per_ip) {
|
||||
entry->is_blocked = true;
|
||||
entry->block_until = now + BLOCK_DURATION;
|
||||
pthread_mutex_unlock(&g_rate_limit_lock);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
#include "tui.h"
|
||||
#include "utf8.h"
|
||||
#include <libssh/libssh.h>
|
||||
#include <libssh/libssh_version.h>
|
||||
#include <libssh/server.h>
|
||||
#include <libssh/callbacks.h>
|
||||
#include <sys/socket.h>
|
||||
|
|
@ -33,6 +34,29 @@ time_t ssh_server_start_time(void) {
|
|||
/* Configuration from environment variables. Rate-limiting moved to ratelimit.{c,h},
|
||||
* the access token to bootstrap.{c,h}, and the idle timeout to input.{c,h}. */
|
||||
|
||||
static int generate_rsa_host_key(ssh_key *key) {
|
||||
#if defined(LIBSSH_VERSION_INT) && LIBSSH_VERSION_INT >= SSH_VERSION_INT(0, 12, 0)
|
||||
ssh_pki_ctx pki_ctx = ssh_pki_ctx_new();
|
||||
int rsa_bits = 4096;
|
||||
int rc;
|
||||
|
||||
if (!pki_ctx) {
|
||||
return -1;
|
||||
}
|
||||
if (ssh_pki_ctx_options_set(pki_ctx, SSH_PKI_OPTION_RSA_KEY_SIZE,
|
||||
&rsa_bits) < 0) {
|
||||
ssh_pki_ctx_free(pki_ctx);
|
||||
return -1;
|
||||
}
|
||||
|
||||
rc = ssh_pki_generate_key(SSH_KEYTYPE_RSA, pki_ctx, key);
|
||||
ssh_pki_ctx_free(pki_ctx);
|
||||
return rc;
|
||||
#else
|
||||
return ssh_pki_generate(SSH_KEYTYPE_RSA, 4096, key);
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Generate or load SSH host key */
|
||||
static int setup_host_key(ssh_bind sshbind) {
|
||||
struct stat st;
|
||||
|
|
@ -73,7 +97,7 @@ static int setup_host_key(ssh_bind sshbind) {
|
|||
/* Generate new key */
|
||||
printf("Generating new RSA 4096-bit host key...\n");
|
||||
ssh_key key;
|
||||
if (ssh_pki_generate(SSH_KEYTYPE_RSA, 4096, &key) < 0) {
|
||||
if (generate_rsa_host_key(&key) < 0) {
|
||||
fprintf(stderr, "Failed to generate RSA key\n");
|
||||
return -1;
|
||||
}
|
||||
|
|
|
|||
72
src/system_message.c
Normal file
72
src/system_message.c
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#include "system_message.h"
|
||||
#include "i18n.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
static void system_message_init(message_t *msg, ui_lang_t lang) {
|
||||
if (!msg) {
|
||||
return;
|
||||
}
|
||||
|
||||
memset(msg, 0, sizeof(*msg));
|
||||
msg->timestamp = time(NULL);
|
||||
snprintf(msg->username, sizeof(msg->username), "%s",
|
||||
i18n_text(lang, I18N_SYSTEM_USERNAME));
|
||||
}
|
||||
|
||||
void system_message_make_join(message_t *msg, const char *username,
|
||||
ui_lang_t lang) {
|
||||
system_message_init(msg, lang);
|
||||
if (!msg) {
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(msg->content, sizeof(msg->content),
|
||||
i18n_text(lang, I18N_SYSTEM_JOIN_FORMAT),
|
||||
username ? username : "");
|
||||
}
|
||||
|
||||
void system_message_make_leave(message_t *msg, const char *username,
|
||||
ui_lang_t lang) {
|
||||
system_message_init(msg, lang);
|
||||
if (!msg) {
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(msg->content, sizeof(msg->content),
|
||||
i18n_text(lang, I18N_SYSTEM_LEAVE_FORMAT),
|
||||
username ? username : "");
|
||||
}
|
||||
|
||||
void system_message_make_nick(message_t *msg, const char *old_name,
|
||||
const char *new_name, ui_lang_t lang) {
|
||||
system_message_init(msg, lang);
|
||||
if (!msg) {
|
||||
return;
|
||||
}
|
||||
|
||||
snprintf(msg->content, sizeof(msg->content),
|
||||
i18n_text(lang, I18N_SYSTEM_NICK_FORMAT),
|
||||
old_name ? old_name : "", new_name ? new_name : "");
|
||||
}
|
||||
|
||||
bool system_message_is_system(const message_t *msg) {
|
||||
if (!msg) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strcmp(msg->username, "系统") == 0 ||
|
||||
strcmp(msg->username, "system") == 0;
|
||||
}
|
||||
|
||||
bool system_message_is_join_leave(const message_t *msg) {
|
||||
if (!system_message_is_system(msg)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strstr(msg->content, "加入了聊天室") != NULL ||
|
||||
strstr(msg->content, "离开了聊天室") != NULL ||
|
||||
strstr(msg->content, "joined the room") != NULL ||
|
||||
strstr(msg->content, "left the room") != NULL;
|
||||
}
|
||||
459
src/tui.c
459
src/tui.c
|
|
@ -2,15 +2,14 @@
|
|||
#include "client.h"
|
||||
#include "ssh_server.h"
|
||||
#include "chat_room.h"
|
||||
#include "help_text.h"
|
||||
#include "history_view.h"
|
||||
#include "i18n.h"
|
||||
#include "system_message.h"
|
||||
#include "tui_status.h"
|
||||
#include "utf8.h"
|
||||
#include <unistd.h>
|
||||
|
||||
static bool is_join_leave_msg(const message_t *msg) {
|
||||
if (strcmp(msg->username, "系统") != 0) return false;
|
||||
return strstr(msg->content, "加入了聊天室") != NULL ||
|
||||
strstr(msg->content, "离开了聊天室") != NULL;
|
||||
}
|
||||
|
||||
static const char *username_color(const char *name) {
|
||||
static const char *colors[] = {
|
||||
"\033[31m", "\033[32m", "\033[33m",
|
||||
|
|
@ -30,9 +29,28 @@ static void format_message_colored(const message_t *msg, char *buffer,
|
|||
char time_str[32];
|
||||
strftime(time_str, sizeof(time_str), "%H:%M", &tm_info);
|
||||
|
||||
/* Is this message from the local user? Used to draw a 1-column gutter
|
||||
* marker so they can scan their own contributions when scrolling. */
|
||||
bool is_self = false;
|
||||
if (my_username && my_username[0] != '\0' &&
|
||||
!system_message_is_system(msg)) {
|
||||
if (strcmp(msg->username, "*") == 0) {
|
||||
/* /me message: content starts with the actor's username */
|
||||
size_t un_len = strlen(my_username);
|
||||
if (strncmp(msg->content, my_username, un_len) == 0 &&
|
||||
(msg->content[un_len] == ' ' || msg->content[un_len] == '\0')) {
|
||||
is_self = true;
|
||||
}
|
||||
} else if (strcmp(msg->username, my_username) == 0) {
|
||||
is_self = true;
|
||||
}
|
||||
}
|
||||
/* Always 1 column wide so all messages align vertically. */
|
||||
const char *gutter = is_self ? "\033[36m▎\033[0m" : " ";
|
||||
|
||||
bool mentioned = false;
|
||||
if (my_username && my_username[0] != '\0' &&
|
||||
strcmp(msg->username, "系统") != 0) {
|
||||
!system_message_is_system(msg)) {
|
||||
char mention[MAX_USERNAME_LEN + 2];
|
||||
snprintf(mention, sizeof(mention), "@%s", my_username);
|
||||
if (strstr(msg->content, mention) != NULL) {
|
||||
|
|
@ -42,23 +60,23 @@ static void format_message_colored(const message_t *msg, char *buffer,
|
|||
const char *hl_start = mentioned ? "\033[1;33m" : "";
|
||||
const char *hl_end = mentioned ? "\033[0m" : "";
|
||||
|
||||
if (strcmp(msg->username, "系统") == 0) {
|
||||
if (system_message_is_system(msg)) {
|
||||
snprintf(buffer, buf_size,
|
||||
"\033[90m--> %s\033[0m", msg->content);
|
||||
"%s\033[90m--> %s\033[0m", gutter, msg->content);
|
||||
} else if (strcmp(msg->username, "*") == 0) {
|
||||
snprintf(buffer, buf_size,
|
||||
"\033[90m%s\033[0m \033[3;36m* %s\033[0m",
|
||||
time_str, msg->content);
|
||||
"%s\033[90m%s\033[0m \033[3;36m* %s\033[0m",
|
||||
gutter, time_str, msg->content);
|
||||
} else {
|
||||
snprintf(buffer, buf_size,
|
||||
"\033[90m%s\033[0m %s%s\033[0m: %s%s%s",
|
||||
time_str, username_color(msg->username),
|
||||
"%s\033[90m%s\033[0m %s%s\033[0m: %s%s%s",
|
||||
gutter, time_str, username_color(msg->username),
|
||||
msg->username, hl_start, msg->content, hl_end);
|
||||
}
|
||||
|
||||
/* Plain-text version for width calculation */
|
||||
/* Plain-text version for width calculation — gutter is 1 column. */
|
||||
char plain[MAX_MESSAGE_LEN + 128];
|
||||
if (strcmp(msg->username, "系统") == 0) {
|
||||
if (system_message_is_system(msg)) {
|
||||
snprintf(plain, sizeof(plain), " --> %s", msg->content);
|
||||
} else if (strcmp(msg->username, "*") == 0) {
|
||||
snprintf(plain, sizeof(plain), " %s * %s", time_str, msg->content);
|
||||
|
|
@ -68,10 +86,11 @@ static void format_message_colored(const message_t *msg, char *buffer,
|
|||
}
|
||||
|
||||
if (utf8_string_width(plain) > width) {
|
||||
/* Rebuild with truncated content */
|
||||
/* Rebuild with truncated content — prefix_plain also includes the
|
||||
* 1-column gutter so the budget math comes out right. */
|
||||
int prefix_width;
|
||||
char prefix_plain[256];
|
||||
if (strcmp(msg->username, "系统") == 0) {
|
||||
if (system_message_is_system(msg)) {
|
||||
snprintf(prefix_plain, sizeof(prefix_plain), " --> ");
|
||||
} else if (strcmp(msg->username, "*") == 0) {
|
||||
snprintf(prefix_plain, sizeof(prefix_plain), " %s * ", time_str);
|
||||
|
|
@ -84,7 +103,7 @@ static void format_message_colored(const message_t *msg, char *buffer,
|
|||
if (content_width < 4) content_width = 4;
|
||||
|
||||
char truncated_content[MAX_MESSAGE_LEN];
|
||||
if (strcmp(msg->username, "系统") == 0) {
|
||||
if (system_message_is_system(msg)) {
|
||||
strncpy(truncated_content, msg->content, sizeof(truncated_content) - 1);
|
||||
truncated_content[sizeof(truncated_content) - 1] = '\0';
|
||||
} else if (strcmp(msg->username, "*") == 0) {
|
||||
|
|
@ -95,17 +114,17 @@ static void format_message_colored(const message_t *msg, char *buffer,
|
|||
}
|
||||
utf8_truncate(truncated_content, content_width);
|
||||
|
||||
if (strcmp(msg->username, "系统") == 0) {
|
||||
if (system_message_is_system(msg)) {
|
||||
snprintf(buffer, buf_size,
|
||||
"\033[90m--> %s\033[0m", truncated_content);
|
||||
"%s\033[90m--> %s\033[0m", gutter, truncated_content);
|
||||
} else if (strcmp(msg->username, "*") == 0) {
|
||||
snprintf(buffer, buf_size,
|
||||
"\033[90m%s\033[0m \033[3;36m%s\033[0m",
|
||||
time_str, truncated_content);
|
||||
"%s\033[90m%s\033[0m \033[3;36m%s\033[0m",
|
||||
gutter, time_str, truncated_content);
|
||||
} else {
|
||||
snprintf(buffer, buf_size,
|
||||
"\033[90m%s\033[0m %s%s\033[0m: %s%s%s",
|
||||
time_str, username_color(msg->username),
|
||||
"%s\033[90m%s\033[0m %s%s\033[0m: %s%s%s",
|
||||
gutter, time_str, username_color(msg->username),
|
||||
msg->username, hl_start, truncated_content, hl_end);
|
||||
}
|
||||
}
|
||||
|
|
@ -135,8 +154,8 @@ void tui_render_welcome(client_t *client) {
|
|||
|
||||
/* Lines, in display order. Width is computed in display columns. */
|
||||
const char *line1 = "TNT · " TNT_VERSION;
|
||||
const char *line2 = "匿名聊天室 · SSH";
|
||||
const char *line3 = "Anonymous chat over SSH";
|
||||
const char *line2 = i18n_text(client->ui_lang, I18N_WELCOME_SUBTITLE);
|
||||
const char *line3 = i18n_text(client->ui_lang, I18N_WELCOME_TAGLINE);
|
||||
|
||||
int inner_w = utf8_string_width(line1);
|
||||
int w2 = utf8_string_width(line2);
|
||||
|
|
@ -147,11 +166,13 @@ void tui_render_welcome(client_t *client) {
|
|||
|
||||
/* Fall back to plain prompt if the terminal is too narrow for the frame. */
|
||||
if (inner_w + 2 > rw) {
|
||||
char fallback_text[96];
|
||||
char fallback[128];
|
||||
int n = snprintf(fallback, sizeof(fallback),
|
||||
ANSI_CLEAR ANSI_HOME
|
||||
"TNT %s — anonymous chat over SSH\r\n\r\n",
|
||||
snprintf(fallback_text, sizeof(fallback_text),
|
||||
i18n_text(client->ui_lang, I18N_WELCOME_FALLBACK_FORMAT),
|
||||
TNT_VERSION);
|
||||
int n = snprintf(fallback, sizeof(fallback), ANSI_CLEAR ANSI_HOME "%s",
|
||||
fallback_text);
|
||||
if (n > 0) client_send(client, fallback, (size_t)n);
|
||||
return;
|
||||
}
|
||||
|
|
@ -235,22 +256,25 @@ void tui_render_screen(client_t *client) {
|
|||
int msg_count = g_room->message_count;
|
||||
pthread_rwlock_unlock(&g_room->lock);
|
||||
|
||||
/* Calculate which messages to show */
|
||||
int msg_height = render_height - 3;
|
||||
if (msg_height < 1) msg_height = 1;
|
||||
/* Calculate which messages to show. The initial slice is capped by
|
||||
* message count; the lock-held copy below tightens "latest" slices so
|
||||
* date dividers cannot push the newest messages off-screen. */
|
||||
int msg_height = history_view_height(render_height);
|
||||
|
||||
int start = 0;
|
||||
int latest_scroll_start = history_view_max_scroll(msg_count, msg_height);
|
||||
bool anchor_latest = client->mode != MODE_NORMAL ||
|
||||
client->follow_tail ||
|
||||
client->scroll_pos >= latest_scroll_start;
|
||||
if (client->mode == MODE_NORMAL) {
|
||||
start = client->scroll_pos;
|
||||
if (start > msg_count - msg_height) {
|
||||
start = msg_count - msg_height;
|
||||
if (start > latest_scroll_start) {
|
||||
start = latest_scroll_start;
|
||||
}
|
||||
if (start < 0) start = 0;
|
||||
} else {
|
||||
/* INSERT mode: show latest */
|
||||
if (msg_count > msg_height) {
|
||||
start = msg_count - msg_height;
|
||||
}
|
||||
start = latest_scroll_start;
|
||||
}
|
||||
|
||||
int end = start + msg_height;
|
||||
|
|
@ -258,10 +282,11 @@ void tui_render_screen(client_t *client) {
|
|||
|
||||
/* Allocate snapshot outside the lock to avoid blocking writers */
|
||||
message_t *msg_snapshot = NULL;
|
||||
int snapshot_capacity = msg_height;
|
||||
int snapshot_count = end - start;
|
||||
|
||||
if (snapshot_count > 0) {
|
||||
msg_snapshot = calloc(snapshot_count, sizeof(message_t));
|
||||
if (snapshot_count > 0 && snapshot_capacity > 0) {
|
||||
msg_snapshot = calloc(snapshot_capacity, sizeof(message_t));
|
||||
}
|
||||
|
||||
/* Second pass under lock: copy messages */
|
||||
|
|
@ -269,12 +294,22 @@ void tui_render_screen(client_t *client) {
|
|||
pthread_rwlock_rdlock(&g_room->lock);
|
||||
/* Re-clamp in case msg_count changed */
|
||||
int actual_count = g_room->message_count;
|
||||
int actual_end = (end <= actual_count) ? end : actual_count;
|
||||
int actual_start = (start < actual_end) ? start : actual_end;
|
||||
int actual_start = start;
|
||||
int actual_end = end;
|
||||
if (anchor_latest) {
|
||||
actual_end = actual_count;
|
||||
actual_start = history_view_latest_start_for_height(
|
||||
g_room->messages, actual_count, msg_height);
|
||||
} else {
|
||||
actual_end = (actual_end <= actual_count) ? actual_end : actual_count;
|
||||
actual_start = (actual_start < actual_end) ? actual_start : actual_end;
|
||||
}
|
||||
int actual_snapshot = actual_end - actual_start;
|
||||
if (actual_snapshot > 0 && actual_snapshot <= snapshot_count) {
|
||||
if (actual_snapshot > 0 && actual_snapshot <= snapshot_capacity) {
|
||||
memcpy(msg_snapshot, &g_room->messages[actual_start],
|
||||
actual_snapshot * sizeof(message_t));
|
||||
start = actual_start;
|
||||
end = actual_end;
|
||||
snapshot_count = actual_snapshot;
|
||||
} else {
|
||||
snapshot_count = 0;
|
||||
|
|
@ -288,7 +323,7 @@ void tui_render_screen(client_t *client) {
|
|||
if (client->mute_joins && msg_snapshot) {
|
||||
int filtered = 0;
|
||||
for (int i = 0; i < snapshot_count; i++) {
|
||||
if (!is_join_leave_msg(&msg_snapshot[i])) {
|
||||
if (!system_message_is_join_leave(&msg_snapshot[i])) {
|
||||
msg_snapshot[filtered++] = msg_snapshot[i];
|
||||
}
|
||||
}
|
||||
|
|
@ -300,15 +335,16 @@ void tui_render_screen(client_t *client) {
|
|||
|
||||
/* Title bar — segmented chips on a single line, no full-line reverse.
|
||||
*
|
||||
* Segments (left to right):
|
||||
* Segments (left to right), each followed by a dim middle-dot:
|
||||
* • bold username
|
||||
* • online count
|
||||
* • mode name (colour matches the mode itself: cyan/yellow/magenta)
|
||||
* • mute marker, only when active
|
||||
* • right-aligned hint
|
||||
*
|
||||
* `· ` separators are dim grey so the eye groups segments without
|
||||
* mistaking them for content. */
|
||||
* When the terminal is narrow, drop the optional segments in
|
||||
* reverse priority: hint → mute → mode chip → online count, until
|
||||
* what's left fits. The bold username is always shown. */
|
||||
struct title_chip { const char *value; const char *value_color; };
|
||||
struct title_chip chips[3];
|
||||
int chip_count = 0;
|
||||
|
|
@ -318,7 +354,9 @@ void tui_render_screen(client_t *client) {
|
|||
chip_count++;
|
||||
|
||||
char online_buf[32];
|
||||
snprintf(online_buf, sizeof(online_buf), "在线 %d", online);
|
||||
snprintf(online_buf, sizeof(online_buf),
|
||||
i18n_text(client->ui_lang, I18N_TITLE_ONLINE_FORMAT),
|
||||
online);
|
||||
chips[chip_count].value = online_buf;
|
||||
chips[chip_count].value_color = "\033[37m";
|
||||
chip_count++;
|
||||
|
|
@ -335,11 +373,67 @@ void tui_render_screen(client_t *client) {
|
|||
chips[chip_count].value_color = mode_color;
|
||||
chip_count++;
|
||||
|
||||
const char *hint = i18n_text(client->ui_lang, I18N_TITLE_HELP_HINT);
|
||||
int hint_width = utf8_string_width(hint);
|
||||
const char *mute_label = i18n_text(client->ui_lang, I18N_TITLE_MUTED);
|
||||
int mute_width = client->mute_joins ? utf8_string_width(mute_label) + 2 : 0;
|
||||
|
||||
/* Unread @-mentions chip — high-priority, gets a bright yellow star.
|
||||
* Sits between mode and hint when present, and survives degradation
|
||||
* longer than the hint / mute / mode chips. */
|
||||
int unread_count = client->unread_mentions;
|
||||
char unread_buf[32] = "";
|
||||
int unread_width = 0;
|
||||
if (unread_count > 0) {
|
||||
snprintf(unread_buf, sizeof(unread_buf), "★ %d", unread_count);
|
||||
unread_width = utf8_string_width(unread_buf) + 2; /* leading " · " minus initial space accounted later */
|
||||
}
|
||||
|
||||
/* Unread whispers chip — bright magenta envelope. Same priority as
|
||||
* the mentions chip; both signal "you missed something". */
|
||||
int whisper_count = client->unread_whispers;
|
||||
char whisper_buf[32] = "";
|
||||
int whisper_width = 0;
|
||||
if (whisper_count > 0) {
|
||||
snprintf(whisper_buf, sizeof(whisper_buf), "✉ %d", whisper_count);
|
||||
whisper_width = utf8_string_width(whisper_buf) + 2;
|
||||
}
|
||||
|
||||
/* Decide what fits. Reserve at least 1 col of gap between left and
|
||||
* right halves so they never visually touch. */
|
||||
int show_hint = 1;
|
||||
int show_mute = client->mute_joins ? 1 : 0;
|
||||
int show_unread = unread_count > 0 ? 1 : 0;
|
||||
int show_whisper = whisper_count > 0 ? 1 : 0;
|
||||
int show_chips = chip_count;
|
||||
|
||||
while (show_chips > 1) {
|
||||
int left_w = 1 /*leading space*/;
|
||||
for (int i = 0; i < show_chips; i++) {
|
||||
if (i > 0) left_w += 3; /* " · " */
|
||||
left_w += utf8_string_width(chips[i].value);
|
||||
}
|
||||
if (show_mute) left_w += mute_width;
|
||||
if (show_unread) left_w += unread_width + 1;
|
||||
if (show_whisper) left_w += whisper_width + 1;
|
||||
int right_w = (show_hint ? hint_width + 1 /*trailing space*/ : 0);
|
||||
int needed = left_w + 1 /*min gap*/ + right_w;
|
||||
if (needed <= render_width) break;
|
||||
|
||||
/* Drop priority: hint → mute → mode → online → whispers → mentions. */
|
||||
if (show_hint) { show_hint = 0; continue; }
|
||||
if (show_mute) { show_mute = 0; continue; }
|
||||
if (show_chips > 1) { show_chips--; continue; }
|
||||
if (show_whisper) { show_whisper = 0; continue; }
|
||||
if (show_unread) { show_unread = 0; continue; }
|
||||
break;
|
||||
}
|
||||
|
||||
/* Compose left half. */
|
||||
char left[256];
|
||||
size_t lpos = 0;
|
||||
int left_width = 0;
|
||||
for (int i = 0; i < chip_count; i++) {
|
||||
for (int i = 0; i < show_chips; i++) {
|
||||
if (i > 0) {
|
||||
buffer_appendf(left, sizeof(left), &lpos, "\033[2;37m · \033[0m");
|
||||
left_width += 3;
|
||||
|
|
@ -348,21 +442,35 @@ void tui_render_screen(client_t *client) {
|
|||
chips[i].value_color, chips[i].value);
|
||||
left_width += utf8_string_width(chips[i].value);
|
||||
}
|
||||
if (client->mute_joins) {
|
||||
buffer_appendf(left, sizeof(left), &lpos, " \033[2;37m静音\033[0m");
|
||||
left_width += 4;
|
||||
if (show_mute) {
|
||||
buffer_appendf(left, sizeof(left), &lpos,
|
||||
" \033[2;37m%s\033[0m", mute_label);
|
||||
left_width += mute_width;
|
||||
}
|
||||
if (show_unread) {
|
||||
buffer_appendf(left, sizeof(left), &lpos,
|
||||
" \033[1;33m%s\033[0m", unread_buf);
|
||||
left_width += unread_width + 1;
|
||||
}
|
||||
if (show_whisper) {
|
||||
buffer_appendf(left, sizeof(left), &lpos,
|
||||
" \033[1;35m%s\033[0m", whisper_buf);
|
||||
left_width += whisper_width + 1;
|
||||
}
|
||||
|
||||
const char *hint = "? 帮助";
|
||||
int hint_width = utf8_string_width(hint);
|
||||
int gap = render_width - left_width - hint_width - 2;
|
||||
int gap = render_width - left_width - (show_hint ? hint_width + 2 : 1);
|
||||
if (gap < 1) gap = 1;
|
||||
|
||||
buffer_appendf(buffer, buf_size, &pos, " %s", left);
|
||||
for (int i = 0; i < gap; i++) {
|
||||
buffer_append_bytes(buffer, buf_size, &pos, " ", 1);
|
||||
}
|
||||
buffer_appendf(buffer, buf_size, &pos, "\033[2;37m%s\033[0m \033[K\r\n", hint);
|
||||
if (show_hint) {
|
||||
buffer_appendf(buffer, buf_size, &pos,
|
||||
"\033[2;37m%s\033[0m \033[K\r\n", hint);
|
||||
} else {
|
||||
buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n");
|
||||
}
|
||||
|
||||
/* Render messages from snapshot. Insert a dim "── YYYY-MM-DD ──" divider
|
||||
* before the first message of each new day so the eye can land on dates
|
||||
|
|
@ -418,36 +526,17 @@ void tui_render_screen(client_t *client) {
|
|||
buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n");
|
||||
|
||||
/* Status/Input line */
|
||||
if (client->mode == MODE_INSERT) {
|
||||
buffer_appendf(buffer, buf_size, &pos, "\033[2;37m›\033[0m \033[K");
|
||||
} else if (client->mode == MODE_NORMAL) {
|
||||
int total = msg_count;
|
||||
int scroll_pos = client->scroll_pos + 1;
|
||||
if (total == 0) scroll_pos = 0;
|
||||
int unseen = msg_count - end;
|
||||
/* mode reverse-video chip + dim position + optional unseen marker */
|
||||
if (unseen > 0) {
|
||||
buffer_appendf(buffer, buf_size, &pos,
|
||||
"\033[7;33m NORMAL \033[0m"
|
||||
" \033[2;37m%d / %d\033[0m"
|
||||
" \033[33m▼ %d new\033[0m\033[K",
|
||||
scroll_pos, total, unseen);
|
||||
} else {
|
||||
buffer_appendf(buffer, buf_size, &pos,
|
||||
"\033[7;33m NORMAL \033[0m"
|
||||
" \033[2;37m%d / %d\033[0m\033[K",
|
||||
scroll_pos, total);
|
||||
}
|
||||
} else if (client->mode == MODE_COMMAND) {
|
||||
buffer_appendf(buffer, buf_size, &pos,
|
||||
"\033[35m:\033[0m%s\033[K", client->command_input);
|
||||
}
|
||||
tui_status_append(buffer, buf_size, &pos, client, msg_count, start, end);
|
||||
|
||||
client_send(client, buffer, pos);
|
||||
free(buffer);
|
||||
}
|
||||
|
||||
/* Render the input line */
|
||||
/* Render the input line.
|
||||
*
|
||||
* Format: "› <input>" with optional right-aligned length indicator
|
||||
* once the buffer is past 80% full. The indicator turns bold-yellow
|
||||
* past 95% so users can see further keystrokes will be dropped. */
|
||||
void tui_render_input(client_t *client, const char *input) {
|
||||
if (!client || !client->connected) return;
|
||||
|
||||
|
|
@ -458,7 +547,25 @@ void tui_render_input(client_t *client, const char *input) {
|
|||
|
||||
char buffer[2048];
|
||||
int input_width = utf8_string_width(input);
|
||||
int avail = rw - 3;
|
||||
size_t input_bytes = strlen(input);
|
||||
|
||||
/* Decide whether to show the length gauge and how loud. */
|
||||
int gauge_width = 0;
|
||||
char gauge[64] = "";
|
||||
if (input_bytes > (MAX_MESSAGE_LEN * 8) / 10) { /* > 80 % */
|
||||
size_t remaining = (input_bytes < MAX_MESSAGE_LEN)
|
||||
? (MAX_MESSAGE_LEN - 1 - input_bytes) : 0;
|
||||
const char *color =
|
||||
(input_bytes > (MAX_MESSAGE_LEN * 95) / 100) ? "\033[1;33m"
|
||||
: "\033[2;37m";
|
||||
snprintf(gauge, sizeof(gauge), "%s… %zu B\033[0m", color, remaining);
|
||||
/* Plain-text width: " … 1234 B" → 4 + len(digits) + 2 */
|
||||
char digits[12];
|
||||
snprintf(digits, sizeof(digits), "%zu", remaining);
|
||||
gauge_width = 4 + (int)strlen(digits) + 2; /* "… ", digits, " B" + leading space */
|
||||
}
|
||||
|
||||
int avail = rw - 3 - (gauge_width > 0 ? gauge_width + 1 : 0);
|
||||
if (avail < 1) avail = 1;
|
||||
|
||||
/* Truncate from start if too long */
|
||||
|
|
@ -481,10 +588,20 @@ void tui_render_input(client_t *client, const char *input) {
|
|||
strncpy(display, p, sizeof(display) - 1);
|
||||
}
|
||||
|
||||
/* Move to input line and clear it, then write input */
|
||||
/* Compose: cursor to input row, clear line, "› " prompt, input.
|
||||
* If a gauge is active, append it right-aligned. */
|
||||
if (gauge_width > 0) {
|
||||
int displayed_width = utf8_string_width(display);
|
||||
int padding = rw - 2 - displayed_width - gauge_width;
|
||||
if (padding < 1) padding = 1;
|
||||
snprintf(buffer, sizeof(buffer),
|
||||
"\033[%d;1H" ANSI_CLEAR_LINE "\033[2;37m›\033[0m %s%*s%s",
|
||||
rh, display, padding, "", gauge);
|
||||
} else {
|
||||
snprintf(buffer, sizeof(buffer),
|
||||
"\033[%d;1H" ANSI_CLEAR_LINE "\033[2;37m›\033[0m %s",
|
||||
rh, display);
|
||||
}
|
||||
|
||||
client_send(client, buffer, strlen(buffer));
|
||||
}
|
||||
|
|
@ -498,7 +615,7 @@ void tui_render_command_output(client_t *client) {
|
|||
if (rw < 10) rw = 10;
|
||||
if (rh < 4) rh = 4;
|
||||
|
||||
char buffer[4096];
|
||||
char buffer[MAX_COMMAND_OUTPUT_LEN + 1024];
|
||||
size_t pos = 0;
|
||||
buffer[0] = '\0';
|
||||
|
||||
|
|
@ -506,40 +623,63 @@ void tui_render_command_output(client_t *client) {
|
|||
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
|
||||
|
||||
/* Title */
|
||||
const char *title = " COMMAND OUTPUT ";
|
||||
int title_width = strlen(title);
|
||||
const char *title = i18n_text(client->ui_lang,
|
||||
I18N_COMMAND_OUTPUT_TITLE);
|
||||
char title_display[64];
|
||||
utf8_ansi_truncate(title, title_display, sizeof(title_display), rw);
|
||||
int title_width = utf8_ansi_string_width(title_display);
|
||||
int padding = rw - title_width;
|
||||
if (padding < 0) padding = 0;
|
||||
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_REVERSE "%s", title);
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_REVERSE "%s",
|
||||
title_display);
|
||||
for (int i = 0; i < padding; i++) {
|
||||
buffer_append_bytes(buffer, sizeof(buffer), &pos, " ", 1);
|
||||
}
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_RESET "\r\n");
|
||||
|
||||
/* Command output - use a copy to avoid strtok corruption */
|
||||
char output_copy[2048];
|
||||
char output_copy[MAX_COMMAND_OUTPUT_LEN];
|
||||
strncpy(output_copy, client->command_output, sizeof(output_copy) - 1);
|
||||
output_copy[sizeof(output_copy) - 1] = '\0';
|
||||
|
||||
char *line = strtok(output_copy, "\n");
|
||||
char *lines[256];
|
||||
int line_count = 0;
|
||||
int max_lines = rh - 2;
|
||||
|
||||
while (line && line_count < max_lines) {
|
||||
char truncated[1024];
|
||||
strncpy(truncated, line, sizeof(truncated) - 1);
|
||||
truncated[sizeof(truncated) - 1] = '\0';
|
||||
|
||||
if (utf8_string_width(truncated) > rw) {
|
||||
utf8_truncate(truncated, rw);
|
||||
char *line = strtok(output_copy, "\n");
|
||||
while (line && line_count < (int)(sizeof(lines) / sizeof(lines[0]))) {
|
||||
lines[line_count++] = line;
|
||||
line = strtok(NULL, "\n");
|
||||
}
|
||||
|
||||
int content_height = rh - 2;
|
||||
if (content_height < 1) content_height = 1;
|
||||
int max_scroll = line_count - content_height;
|
||||
if (max_scroll < 0) max_scroll = 0;
|
||||
if (client->command_output_scroll < 0) client->command_output_scroll = 0;
|
||||
if (client->command_output_scroll > max_scroll) {
|
||||
client->command_output_scroll = max_scroll;
|
||||
}
|
||||
|
||||
int start = client->command_output_scroll;
|
||||
int end = start + content_height;
|
||||
if (end > line_count) end = line_count;
|
||||
|
||||
for (int i = start; i < end; i++) {
|
||||
char truncated[1024];
|
||||
utf8_ansi_truncate(lines[i], truncated, sizeof(truncated), rw);
|
||||
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos, "%s\r\n", truncated);
|
||||
line = strtok(NULL, "\n");
|
||||
line_count++;
|
||||
}
|
||||
|
||||
for (int i = end - start; i < content_height; i++) {
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos, "\033[K\r\n");
|
||||
}
|
||||
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_COMMAND_OUTPUT_STATUS_FORMAT),
|
||||
start + 1, max_scroll + 1);
|
||||
|
||||
client_send(client, buffer, pos);
|
||||
}
|
||||
|
||||
|
|
@ -565,8 +705,8 @@ void tui_render_motd(client_t *client) {
|
|||
size_t pos = 0;
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
|
||||
|
||||
/* Top border: ╭─ 公告 / MOTD ──...──╮ */
|
||||
const char *title = " 公告 / MOTD ";
|
||||
/* Top border with a localized title chip. */
|
||||
const char *title = i18n_text(client->ui_lang, I18N_MOTD_TITLE);
|
||||
int title_w = utf8_string_width(title);
|
||||
int top_dash_fill = rw - 2 - title_w - 1; /* 2 corners, 1 leading ─ */
|
||||
if (top_dash_fill < 0) top_dash_fill = 0;
|
||||
|
|
@ -617,8 +757,9 @@ void tui_render_motd(client_t *client) {
|
|||
/* Bottom breathing-room line */
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos, "\r\n");
|
||||
|
||||
/* Bottom border: ╰─ 按任意键继续 ─...─╯ */
|
||||
const char *footer = " 按任意键继续 / press any key ";
|
||||
/* Bottom border with a localized continue hint. */
|
||||
const char *footer = i18n_text(client->ui_lang,
|
||||
I18N_MOTD_CONTINUE_HINT);
|
||||
int footer_w = utf8_string_width(footer);
|
||||
int bot_dash_fill = rw - 2 - footer_w - 1;
|
||||
if (bot_dash_fill < 0) bot_dash_fill = 0;
|
||||
|
|
@ -632,104 +773,6 @@ void tui_render_motd(client_t *client) {
|
|||
|
||||
client_send(client, buffer, pos);
|
||||
}
|
||||
const char* tui_get_help_text(help_lang_t lang) {
|
||||
if (lang == LANG_EN) {
|
||||
return "TERMINAL CHAT ROOM - HELP\n"
|
||||
"\n"
|
||||
"OPERATING MODES:\n"
|
||||
" INSERT - Type and send messages (default)\n"
|
||||
" NORMAL - Browse message history\n"
|
||||
" COMMAND - Execute commands\n"
|
||||
"\n"
|
||||
"INSERT MODE KEYS:\n"
|
||||
" ESC - Enter NORMAL mode\n"
|
||||
" Enter - Send message\n"
|
||||
" Backspace - Delete character\n"
|
||||
" Ctrl+W - Delete last word\n"
|
||||
" Ctrl+U - Delete line\n"
|
||||
" Ctrl+C - Enter NORMAL mode\n"
|
||||
"\n"
|
||||
"NORMAL MODE KEYS:\n"
|
||||
" i - Return to INSERT mode\n"
|
||||
" : - Enter COMMAND mode\n"
|
||||
" j/k - Scroll down/up one line\n"
|
||||
" Ctrl+D/U - Scroll half page down/up\n"
|
||||
" Ctrl+F/B - Scroll full page down/up\n"
|
||||
" g/G - Jump to top/bottom\n"
|
||||
" ? - Show this help\n"
|
||||
" Ctrl+C - Exit chat\n"
|
||||
"\n"
|
||||
"AVAILABLE COMMANDS:\n"
|
||||
" :list, :users - Show online users\n"
|
||||
" :nick <name> - Change nickname\n"
|
||||
" :msg <user> <text> - Whisper to user\n"
|
||||
" :w <user> <text> - Short alias for :msg\n"
|
||||
" :last [N] - Show last N messages (max 50)\n"
|
||||
" :search <keyword> - Search message history\n"
|
||||
" :mute-joins - Toggle join/leave notices\n"
|
||||
" :help - Show available commands\n"
|
||||
" :clear - Clear command output\n"
|
||||
" :q, :quit, :exit - Disconnect\n"
|
||||
"\n"
|
||||
"SPECIAL MESSAGES:\n"
|
||||
" /me <action> - Send action (e.g. /me waves)\n"
|
||||
" @username - Mention user (bell + highlight)\n"
|
||||
"\n"
|
||||
"HELP SCREEN KEYS:\n"
|
||||
" q, ESC - Close help\n"
|
||||
" j/k - Scroll down/up\n"
|
||||
" g/G - Jump to top/bottom\n"
|
||||
" e/z - Switch English/Chinese\n";
|
||||
} else {
|
||||
return "终端聊天室 - 帮助\n"
|
||||
"\n"
|
||||
"操作模式:\n"
|
||||
" INSERT - 输入和发送消息(默认)\n"
|
||||
" NORMAL - 浏览消息历史\n"
|
||||
" COMMAND - 执行命令\n"
|
||||
"\n"
|
||||
"INSERT 模式按键:\n"
|
||||
" ESC - 进入 NORMAL 模式\n"
|
||||
" Enter - 发送消息\n"
|
||||
" Backspace - 删除字符\n"
|
||||
" Ctrl+W - 删除上个单词\n"
|
||||
" Ctrl+U - 删除整行\n"
|
||||
" Ctrl+C - 进入 NORMAL 模式\n"
|
||||
"\n"
|
||||
"NORMAL 模式按键:\n"
|
||||
" i - 返回 INSERT 模式\n"
|
||||
" : - 进入 COMMAND 模式\n"
|
||||
" j/k - 向下/上滚动一行\n"
|
||||
" Ctrl+D/U - 向下/上滚动半页\n"
|
||||
" Ctrl+F/B - 向下/上滚动整页\n"
|
||||
" g/G - 跳到顶部/底部\n"
|
||||
" ? - 显示此帮助\n"
|
||||
" Ctrl+C - 退出聊天\n"
|
||||
"\n"
|
||||
"可用命令:\n"
|
||||
" :list, :users - 显示在线用户\n"
|
||||
" :nick <名字> - 更改昵称\n"
|
||||
" :msg <用户> <文本> - 私聊\n"
|
||||
" :w <用户> <文本> - :msg 的简写\n"
|
||||
" :last [N] - 显示最后 N 条消息(最多50)\n"
|
||||
" :search <关键词> - 搜索消息历史\n"
|
||||
" :mute-joins - 切换加入/离开提示\n"
|
||||
" :help - 显示可用命令\n"
|
||||
" :clear - 清空命令输出\n"
|
||||
" :q, :quit, :exit - 断开连接\n"
|
||||
"\n"
|
||||
"特殊消息:\n"
|
||||
" /me <动作> - 发送动作 (如 /me 挥手)\n"
|
||||
" @用户名 - 提及用户 (响铃+高亮)\n"
|
||||
"\n"
|
||||
"帮助界面按键:\n"
|
||||
" q, ESC - 关闭帮助\n"
|
||||
" j/k - 向下/上滚动\n"
|
||||
" g/G - 跳到顶部/底部\n"
|
||||
" e/z - 切换英文/中文\n";
|
||||
}
|
||||
}
|
||||
|
||||
/* Render the help screen */
|
||||
void tui_render_help(client_t *client) {
|
||||
if (!client || !client->connected) return;
|
||||
|
|
@ -747,8 +790,8 @@ void tui_render_help(client_t *client) {
|
|||
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
|
||||
|
||||
/* Title */
|
||||
const char *title = " HELP ";
|
||||
int title_width = strlen(title);
|
||||
const char *title = i18n_text(client->ui_lang, I18N_HELP_TITLE);
|
||||
int title_width = utf8_string_width(title);
|
||||
int padding = rw - title_width;
|
||||
if (padding < 0) padding = 0;
|
||||
|
||||
|
|
@ -758,11 +801,11 @@ void tui_render_help(client_t *client) {
|
|||
}
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_RESET "\r\n");
|
||||
|
||||
/* Help content */
|
||||
const char *help_text = tui_get_help_text(client->help_lang);
|
||||
char help_copy[8192];
|
||||
strncpy(help_copy, help_text, sizeof(help_copy) - 1);
|
||||
help_copy[sizeof(help_copy) - 1] = '\0';
|
||||
size_t help_pos = 0;
|
||||
help_copy[0] = '\0';
|
||||
help_text_append_full(help_copy, sizeof(help_copy), &help_pos,
|
||||
client->ui_lang);
|
||||
|
||||
/* Split into lines and display with scrolling */
|
||||
char *lines[100];
|
||||
|
|
@ -793,7 +836,7 @@ void tui_render_help(client_t *client) {
|
|||
|
||||
/* Status line */
|
||||
buffer_appendf(buffer, sizeof(buffer), &pos,
|
||||
"-- HELP -- (%d/%d) j/k:scroll g/G:top/bottom e/z:lang q:close",
|
||||
i18n_text(client->ui_lang, I18N_HELP_STATUS_FORMAT),
|
||||
start + 1, max_scroll + 1);
|
||||
|
||||
client_send(client, buffer, pos);
|
||||
|
|
|
|||
54
src/tui_status.c
Normal file
54
src/tui_status.c
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
#include "tui_status.h"
|
||||
#include "i18n.h"
|
||||
#include "ssh_server.h"
|
||||
|
||||
void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
|
||||
const struct client *client, int msg_count,
|
||||
int start, int end) {
|
||||
if (!buffer || !pos || !client) return;
|
||||
|
||||
if (client->mode == MODE_INSERT) {
|
||||
if (client->width >= 58) {
|
||||
buffer_appendf(buffer, buf_size, pos,
|
||||
"\033[2;37m›\033[0m "
|
||||
"\033[2;37m%s\033[0m"
|
||||
"\033[K",
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_INSERT_HINT_WIDE));
|
||||
} else if (client->width >= 36) {
|
||||
buffer_appendf(buffer, buf_size, pos,
|
||||
"\033[2;37m›\033[0m "
|
||||
"\033[2;37m%s\033[0m\033[K",
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_INSERT_HINT_NARROW));
|
||||
} else {
|
||||
buffer_appendf(buffer, buf_size, pos, "\033[2;37m›\033[0m \033[K");
|
||||
}
|
||||
} else if (client->mode == MODE_NORMAL) {
|
||||
int total = msg_count;
|
||||
int range_start = total == 0 ? 0 : start + 1;
|
||||
int range_end = total == 0 ? 0 : end;
|
||||
int unseen = msg_count - end;
|
||||
|
||||
if (unseen > 0) {
|
||||
buffer_appendf(buffer, buf_size, pos,
|
||||
"\033[7;33m NORMAL \033[0m"
|
||||
" \033[2;37m%d-%d / %d\033[0m"
|
||||
" \033[33m▼ %d %s · %s\033[0m\033[K",
|
||||
range_start, range_end, total, unseen,
|
||||
i18n_text(client->ui_lang,
|
||||
I18N_NORMAL_NEW_MESSAGES),
|
||||
i18n_text(client->ui_lang, I18N_NORMAL_LATEST));
|
||||
} else {
|
||||
buffer_appendf(buffer, buf_size, pos,
|
||||
"\033[7;33m NORMAL \033[0m"
|
||||
" \033[2;37m%d-%d / %d\033[0m"
|
||||
" \033[2;37m%s\033[0m\033[K",
|
||||
range_start, range_end, total,
|
||||
i18n_text(client->ui_lang, I18N_NORMAL_LATEST));
|
||||
}
|
||||
} else if (client->mode == MODE_COMMAND) {
|
||||
buffer_appendf(buffer, buf_size, pos,
|
||||
"\033[35m:\033[0m%s\033[K", client->command_input);
|
||||
}
|
||||
}
|
||||
109
src/utf8.c
109
src/utf8.c
|
|
@ -96,6 +96,49 @@ int utf8_string_width(const char *str) {
|
|||
return width;
|
||||
}
|
||||
|
||||
static const char *skip_ansi_sequence(const char *p) {
|
||||
if (!p || *p != '\033') {
|
||||
return p;
|
||||
}
|
||||
|
||||
if (p[1] == '[') {
|
||||
const char *q = p + 2;
|
||||
while (*q) {
|
||||
unsigned char c = (unsigned char)*q;
|
||||
if (c >= 0x40 && c <= 0x7E) {
|
||||
return q + 1;
|
||||
}
|
||||
q++;
|
||||
}
|
||||
return p + 1;
|
||||
}
|
||||
|
||||
return p[1] ? p + 2 : p + 1;
|
||||
}
|
||||
|
||||
int utf8_ansi_string_width(const char *str) {
|
||||
int width = 0;
|
||||
int bytes_read;
|
||||
const char *p = str;
|
||||
|
||||
if (!str) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
while (*p != '\0') {
|
||||
if (*p == '\033') {
|
||||
p = skip_ansi_sequence(p);
|
||||
continue;
|
||||
}
|
||||
|
||||
uint32_t codepoint = utf8_decode(p, &bytes_read);
|
||||
width += utf8_char_width(codepoint);
|
||||
p += bytes_read;
|
||||
}
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
/* Count the number of UTF-8 characters in a string */
|
||||
int utf8_strlen(const char *str) {
|
||||
int count = 0;
|
||||
|
|
@ -134,6 +177,72 @@ void utf8_truncate(char *str, int max_width) {
|
|||
*last_valid = '\0';
|
||||
}
|
||||
|
||||
void utf8_ansi_truncate(const char *src, char *dst, size_t dst_size,
|
||||
int max_width) {
|
||||
int width = 0;
|
||||
int bytes_read;
|
||||
size_t pos = 0;
|
||||
bool copied_ansi = false;
|
||||
bool last_ansi_was_reset = false;
|
||||
bool truncated = false;
|
||||
const char *p = src;
|
||||
|
||||
if (!dst || dst_size == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dst[0] = '\0';
|
||||
if (!src || max_width <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (*p != '\0') {
|
||||
if (*p == '\033') {
|
||||
const char *end = skip_ansi_sequence(p);
|
||||
size_t len = (size_t)(end - p);
|
||||
|
||||
if (pos + len >= dst_size) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
memcpy(dst + pos, p, len);
|
||||
pos += len;
|
||||
copied_ansi = true;
|
||||
last_ansi_was_reset =
|
||||
len == strlen(ANSI_RESET) && memcmp(p, ANSI_RESET, len) == 0;
|
||||
p = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
uint32_t codepoint = utf8_decode(p, &bytes_read);
|
||||
int char_width = utf8_char_width(codepoint);
|
||||
|
||||
if (width + char_width > max_width) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
if (pos + (size_t)bytes_read >= dst_size) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
memcpy(dst + pos, p, (size_t)bytes_read);
|
||||
pos += (size_t)bytes_read;
|
||||
width += char_width;
|
||||
p += bytes_read;
|
||||
}
|
||||
|
||||
if (truncated && copied_ansi && !last_ansi_was_reset) {
|
||||
size_t reset_len = strlen(ANSI_RESET);
|
||||
if (pos + reset_len < dst_size) {
|
||||
memcpy(dst + pos, ANSI_RESET, reset_len);
|
||||
pos += reset_len;
|
||||
}
|
||||
}
|
||||
|
||||
dst[pos] = '\0';
|
||||
}
|
||||
|
||||
/* Remove last UTF-8 character from string */
|
||||
void utf8_remove_last_char(char *str) {
|
||||
int len = strlen(str);
|
||||
|
|
|
|||
|
|
@ -1,90 +1,112 @@
|
|||
#!/bin/bash
|
||||
# Test anonymous SSH access
|
||||
# Anonymous SSH access regression tests for TNT
|
||||
|
||||
BIN="../tnt"
|
||||
PORT=${PORT:-2222}
|
||||
PASS=0
|
||||
FAIL=0
|
||||
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-anonymous-test.XXXXXX")
|
||||
SERVER_PID=""
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$SERVER_PID" ]; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$STATE_DIR"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "Error: Binary $BIN not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting TNT server on port $PORT..."
|
||||
$BIN -p $PORT > /dev/null 2>&1 &
|
||||
SSH_BASE="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PubkeyAuthentication=no -p $PORT"
|
||||
|
||||
wait_for_health() {
|
||||
local out
|
||||
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||
if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
out=$(ssh -n $SSH_BASE localhost health 2>/dev/null || true)
|
||||
[ "$out" = "ok" ] && return 0
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
run_password_test() {
|
||||
local name="$1"
|
||||
local user="$2"
|
||||
local password="$3"
|
||||
local display_name="$4"
|
||||
local script="$STATE_DIR/$name.expect"
|
||||
local log="$STATE_DIR/$name.log"
|
||||
|
||||
cat >"$script" <<EOF
|
||||
set timeout 10
|
||||
spawn ssh $SSH_BASE -o PreferredAuthentications=password $user@localhost
|
||||
expect {
|
||||
-re "(?i)password:" {
|
||||
send -- "$password\r"
|
||||
exp_continue
|
||||
}
|
||||
"请输入用户名" {
|
||||
send -- "$display_name\r"
|
||||
send -- "\003"
|
||||
expect eof
|
||||
exit 0
|
||||
}
|
||||
timeout { exit 1 }
|
||||
eof { exit 1 }
|
||||
}
|
||||
EOF
|
||||
|
||||
if expect "$script" >"$log" 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
sed -n '1,120p' "$log"
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "=== TNT Anonymous Access Tests ==="
|
||||
|
||||
TNT_LANG=zh TNT_RATE_LIMIT=0 "$BIN" -p "$PORT" -d "$STATE_DIR" \
|
||||
>"$STATE_DIR/server.log" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
sleep 2
|
||||
|
||||
cleanup() {
|
||||
kill $SERVER_PID 2>/dev/null
|
||||
wait 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Detect timeout command
|
||||
TIMEOUT_CMD="timeout"
|
||||
if command -v gtimeout >/dev/null 2>&1; then
|
||||
TIMEOUT_CMD="gtimeout"
|
||||
fi
|
||||
|
||||
echo "Testing anonymous SSH access to TNT server..."
|
||||
echo ""
|
||||
|
||||
# Test 1: Connection with any username and password
|
||||
echo "Test 1: Connection with any username (should succeed)"
|
||||
$TIMEOUT_CMD 10 expect -c "
|
||||
spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT testuser@localhost
|
||||
expect {
|
||||
\"password:\" {
|
||||
send \"anypassword\r\"
|
||||
expect {
|
||||
\"请输入用户名\" {
|
||||
send \"TestUser\r\"
|
||||
send \"\003\"
|
||||
exit 0
|
||||
}
|
||||
timeout { exit 1 }
|
||||
}
|
||||
}
|
||||
timeout { exit 1 }
|
||||
}
|
||||
" 2>&1 | grep -q "请输入用户名"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ Test 1 PASSED: Can connect with any password"
|
||||
if wait_for_health; then
|
||||
echo "✓ server started"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ Test 1 FAILED: Cannot connect with any password"
|
||||
echo "✗ server failed to start"
|
||||
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Test 2: Connection should work with empty password
|
||||
echo "Test 2: Simple connection (standard SSH command)"
|
||||
$TIMEOUT_CMD 10 expect -c "
|
||||
spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT anonymous@localhost
|
||||
expect {
|
||||
\"password:\" {
|
||||
send \"\r\"
|
||||
expect {
|
||||
\"请输入用户名\" {
|
||||
send \"\r\"
|
||||
send \"\003\"
|
||||
exit 0
|
||||
}
|
||||
timeout { exit 1 }
|
||||
}
|
||||
}
|
||||
timeout { exit 1 }
|
||||
}
|
||||
" 2>&1 | grep -q "请输入用户名"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ Test 2 PASSED: Can connect with empty password"
|
||||
if run_password_test "any-password" "testuser" "anypassword" "TestUser"; then
|
||||
echo "✓ accepts any password when no access token is configured"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ Test 2 FAILED: Cannot connect with empty password"
|
||||
exit 1
|
||||
echo "✗ any-password login failed"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
if run_password_test "empty-password" "anonymous" "" ""; then
|
||||
echo "✓ accepts empty password when no access token is configured"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ empty-password login failed"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Anonymous access test completed."
|
||||
exit 0
|
||||
echo "PASSED: $PASS"
|
||||
echo "FAILED: $FAIL"
|
||||
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
|
||||
exit "$FAIL"
|
||||
|
|
|
|||
|
|
@ -5,64 +5,108 @@
|
|||
PORT=${PORT:-2222}
|
||||
PASS=0
|
||||
FAIL=0
|
||||
BIN="../tnt"
|
||||
SERVER_PID=""
|
||||
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-basic-test.XXXXXX")
|
||||
SSH_HEALTH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
|
||||
|
||||
cleanup() {
|
||||
kill $SERVER_PID 2>/dev/null
|
||||
rm -f test.log
|
||||
if [ -n "$SERVER_PID" ]; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$STATE_DIR"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
# Detect timeout command
|
||||
TIMEOUT_CMD="timeout"
|
||||
if command -v gtimeout >/dev/null 2>&1; then
|
||||
TIMEOUT_CMD="gtimeout"
|
||||
wait_for_health() {
|
||||
out=""
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||
if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
out=$(ssh $SSH_HEALTH_OPTS localhost health 2>/dev/null || true)
|
||||
[ "$out" = "ok" ] && return 0
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "=== TNT Basic Tests ==="
|
||||
|
||||
# Path to binary
|
||||
BIN="../tnt"
|
||||
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "Error: Binary $BIN not found. Run make first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Start server
|
||||
$BIN -p $PORT >test.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
sleep 5
|
||||
if ! command -v expect >/dev/null 2>&1; then
|
||||
echo "expect not installed; skipping basic interactive tests"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Test 1: Server started
|
||||
if kill -0 $SERVER_PID 2>/dev/null; then
|
||||
# Start server
|
||||
"$BIN" -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Test 1: Server started and accepts exec health checks
|
||||
if wait_for_health; then
|
||||
echo "✓ Server started"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ Server failed to start"
|
||||
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||
FAIL=$((FAIL + 1))
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 2: SSH connection
|
||||
if $TIMEOUT_CMD 5 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
-o BatchMode=yes -p $PORT localhost exit 2>/dev/null; then
|
||||
CONNECT_SCRIPT="$STATE_DIR/connect.expect"
|
||||
cat >"$CONNECT_SCRIPT" <<EOF
|
||||
set timeout 10
|
||||
spawn ssh -e none -p $PORT -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR anonymous@127.0.0.1
|
||||
sleep 1
|
||||
send -- "basic\r"
|
||||
expect "›"
|
||||
send -- "\003"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
expect eof
|
||||
EOF
|
||||
|
||||
if expect "$CONNECT_SCRIPT" >"$STATE_DIR/connect.log" 2>&1; then
|
||||
echo "✓ SSH connection works"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ SSH connection failed"
|
||||
sed -n '1,120p' "$STATE_DIR/connect.log"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# Test 3: Message logging
|
||||
(echo "testuser"; echo "test message"; sleep 1) | $TIMEOUT_CMD 5 ssh -o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null -p $PORT localhost >/dev/null 2>&1 &
|
||||
sleep 3
|
||||
if [ -f messages.log ]; then
|
||||
MESSAGE_SCRIPT="$STATE_DIR/message.expect"
|
||||
cat >"$MESSAGE_SCRIPT" <<EOF
|
||||
set timeout 10
|
||||
spawn ssh -e none -p $PORT -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR anonymous@127.0.0.1
|
||||
sleep 1
|
||||
send -- "testuser\r"
|
||||
expect "›"
|
||||
send -- "test message\r"
|
||||
sleep 1
|
||||
send -- "\003"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
expect eof
|
||||
EOF
|
||||
|
||||
if expect "$MESSAGE_SCRIPT" >"$STATE_DIR/message.log.out" 2>&1 &&
|
||||
grep -q 'testuser|test message' "$STATE_DIR/messages.log"; then
|
||||
echo "✓ Message logging works"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ Message logging failed"
|
||||
sed -n '1,120p' "$STATE_DIR/message.log.out"
|
||||
cat "$STATE_DIR/messages.log" 2>/dev/null || true
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -28,14 +28,16 @@ if [ ! -f "$BIN" ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
|
||||
SSH_COMMON_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes"
|
||||
SSH_OPTS="$SSH_COMMON_OPTS -p $PORT"
|
||||
|
||||
wait_for_health() {
|
||||
wait_for_health_on_port() {
|
||||
health_port=$1
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||
if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
OUT=$(ssh $SSH_OPTS localhost health 2>/dev/null || true)
|
||||
OUT=$(ssh $SSH_COMMON_OPTS -p "$health_port" localhost health 2>/dev/null || true)
|
||||
[ "$OUT" = "ok" ] && return 0
|
||||
sleep 1
|
||||
done
|
||||
|
|
@ -44,11 +46,11 @@ wait_for_health() {
|
|||
|
||||
echo "=== TNT Connection Limit Tests ==="
|
||||
|
||||
TNT_RATE_LIMIT=0 TNT_MAX_CONN_PER_IP=1 "$BIN" -p "$PORT" -d "$STATE_DIR" \
|
||||
TNT_LANG=zh TNT_RATE_LIMIT=0 TNT_MAX_CONN_PER_IP=1 "$BIN" -p "$PORT" -d "$STATE_DIR" \
|
||||
>"$STATE_DIR/concurrent.log" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
if wait_for_health; then
|
||||
if wait_for_health_on_port "$PORT"; then
|
||||
echo "✓ server started for concurrent limit test"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
|
|
@ -97,14 +99,16 @@ wait "$SERVER_PID" 2>/dev/null || true
|
|||
SERVER_PID=""
|
||||
|
||||
RATE_PORT=$((PORT + 1))
|
||||
SSH_RATE_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $RATE_PORT"
|
||||
SSH_RATE_OPTS="$SSH_COMMON_OPTS -p $RATE_PORT"
|
||||
|
||||
TNT_MAX_CONN_PER_IP=10 TNT_MAX_CONN_RATE_PER_IP=2 "$BIN" -p "$RATE_PORT" -d "$STATE_DIR" \
|
||||
# The health readiness probe is a real SSH connection and counts toward the
|
||||
# per-IP rate window. Use a burst of 3 so readiness consumes one slot, then the
|
||||
# test can still assert two successful client connections before the block.
|
||||
TNT_LANG=zh TNT_MAX_CONN_PER_IP=10 TNT_MAX_CONN_RATE_PER_IP=3 "$BIN" -p "$RATE_PORT" -d "$STATE_DIR" \
|
||||
>"$STATE_DIR/rate.log" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
sleep 2
|
||||
if kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
if wait_for_health_on_port "$RATE_PORT"; then
|
||||
echo "✓ server started for rate limit test"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
|
|
|
|||
|
|
@ -25,11 +25,11 @@ if [ ! -f "$BIN" ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
|
||||
SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
|
||||
|
||||
echo "=== TNT Exec Mode Tests ==="
|
||||
|
||||
TNT_RATE_LIMIT=0 $BIN -p "$PORT" -d "$STATE_DIR" >"${STATE_DIR}/server.log" 2>&1 &
|
||||
TNT_LANG=zh TNT_RATE_LIMIT=0 $BIN -p "$PORT" -d "$STATE_DIR" >"${STATE_DIR}/server.log" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
HEALTH_OUTPUT=""
|
||||
|
|
@ -51,6 +51,17 @@ else
|
|||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
HEALTH_USAGE=$(ssh $SSH_OPTS localhost health extra 2>/dev/null || true)
|
||||
printf '%s\n' "$HEALTH_USAGE" | grep -q '^health: 用法: health$'
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ no-arg exec usage follows TNT_LANG"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ no-arg exec usage output unexpected"
|
||||
printf '%s\n' "$HEALTH_USAGE"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
STATS_OUTPUT=$(ssh $SSH_OPTS localhost stats 2>/dev/null || true)
|
||||
printf '%s\n' "$STATS_OUTPUT" | grep -q '^status ok$' &&
|
||||
printf '%s\n' "$STATS_OUTPUT" | grep -q '^online_users 0$'
|
||||
|
|
@ -75,6 +86,51 @@ else
|
|||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
HELP_OUTPUT=$(ssh $SSH_OPTS localhost help 2>/dev/null || true)
|
||||
printf '%s\n' "$HELP_OUTPUT" | grep -q '^TNT exec 接口$' &&
|
||||
printf '%s\n' "$HELP_OUTPUT" | grep -q '^命令:$'
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ help follows TNT_LANG"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ help output unexpected"
|
||||
printf '%s\n' "$HELP_OUTPUT"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
UNKNOWN_OUTPUT=$(ssh $SSH_OPTS localhost nope 2>/dev/null || true)
|
||||
printf '%s\n' "$UNKNOWN_OUTPUT" | grep -q '^未知命令: nope$'
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ unknown command follows TNT_LANG"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ unknown command output unexpected"
|
||||
printf '%s\n' "$UNKNOWN_OUTPUT"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
POST_USAGE=$(ssh $SSH_OPTS localhost post 2>/dev/null || true)
|
||||
printf '%s\n' "$POST_USAGE" | grep -q '^post: 用法: post MESSAGE$'
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ post usage follows TNT_LANG"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ post usage output unexpected"
|
||||
printf '%s\n' "$POST_USAGE"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
USERS_USAGE=$(ssh $SSH_OPTS localhost users --xml 2>/dev/null || true)
|
||||
printf '%s\n' "$USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$'
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ users usage follows TNT_LANG"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ users usage output unexpected"
|
||||
printf '%s\n' "$USERS_USAGE"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
POST_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "hello from exec" 2>/dev/null || true)
|
||||
if [ "$POST_OUTPUT" = "posted" ]; then
|
||||
echo "✓ post publishes a message"
|
||||
|
|
@ -112,7 +168,7 @@ EOF
|
|||
expect "$EXPECT_SCRIPT" >"${STATE_DIR}/expect.log" 2>&1 &
|
||||
INTERACTIVE_PID=$!
|
||||
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
for _ in 1 2 3 4 5; do
|
||||
[ -f "$WATCHER_READY" ] && break
|
||||
sleep 1
|
||||
done
|
||||
|
|
@ -138,7 +194,7 @@ else
|
|||
fi
|
||||
|
||||
USERS_JSON=""
|
||||
for _ in 1 2 3 4 5; do
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
USERS_JSON=$(ssh $SSH_OPTS localhost users --json 2>/dev/null || true)
|
||||
printf '%s\n' "$USERS_JSON" | grep -q '"watcher"' && break
|
||||
sleep 1
|
||||
|
|
|
|||
465
tests/test_interactive_input.sh
Executable file
465
tests/test_interactive_input.sh
Executable file
|
|
@ -0,0 +1,465 @@
|
|||
#!/bin/sh
|
||||
# Interactive input regression tests for TNT.
|
||||
|
||||
PORT=${PORT:-12347}
|
||||
PASS=0
|
||||
FAIL=0
|
||||
BIN="../tnt"
|
||||
SERVER_PID=""
|
||||
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-input-test.XXXXXX")
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$SERVER_PID" ]; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$STATE_DIR"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
if ! command -v expect >/dev/null 2>&1; then
|
||||
echo "expect not installed; skipping interactive input tests"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "Error: Binary $BIN not found. Run make first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SSH_OPTS="-e none -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT"
|
||||
|
||||
echo "=== TNT Interactive Input Tests ==="
|
||||
|
||||
TNT_LANG=zh TNT_RATE_LIMIT=0 "$BIN" -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
SERVER_READY=0
|
||||
for _ in 1 2 3 4 5; do
|
||||
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
echo "x Server failed to start"
|
||||
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||
exit 1
|
||||
fi
|
||||
if grep -q "TNT chat server listening" "$STATE_DIR/server.log"; then
|
||||
SERVER_READY=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$SERVER_READY" -eq 1 ]; then
|
||||
echo "✓ server started"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "x Server did not become ready"
|
||||
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXPECT_SCRIPT="$STATE_DIR/bracketed-paste.expect"
|
||||
cat >"$EXPECT_SCRIPT" <<EOF
|
||||
set timeout 10
|
||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||
sleep 1
|
||||
send -- "tester\r"
|
||||
expect ":help"
|
||||
send -- "\033\[200~"
|
||||
send -- "line1\nline2\nline3"
|
||||
send -- "\033\[201~"
|
||||
sleep 1
|
||||
send -- "\r"
|
||||
sleep 1
|
||||
send -- "\003"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
expect eof
|
||||
EOF
|
||||
|
||||
if expect "$EXPECT_SCRIPT" >"$STATE_DIR/expect.log" 2>&1; then
|
||||
if grep -q 'tester|line1 line2 line3' "$STATE_DIR/messages.log" &&
|
||||
! grep -q 'tester|line1$' "$STATE_DIR/messages.log"; then
|
||||
echo "✓ bracketed paste becomes one message"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "x bracketed paste message log unexpected"
|
||||
cat "$STATE_DIR/messages.log" 2>/dev/null || true
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
else
|
||||
echo "x bracketed paste client failed"
|
||||
sed -n '1,120p' "$STATE_DIR/expect.log"
|
||||
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
LONG_SCRIPT="$STATE_DIR/long-paste.expect"
|
||||
cat >"$LONG_SCRIPT" <<EOF
|
||||
set timeout 10
|
||||
set payload [string repeat a 1100]
|
||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||
sleep 1
|
||||
send -- "longer\r"
|
||||
expect "›"
|
||||
send -- "\033\[200~"
|
||||
send -- \$payload
|
||||
send -- "\033\[201~"
|
||||
sleep 1
|
||||
send -- "\r"
|
||||
sleep 1
|
||||
send -- "\003"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
expect eof
|
||||
EOF
|
||||
|
||||
if expect "$LONG_SCRIPT" >"$STATE_DIR/long-paste.log" 2>&1; then
|
||||
long_line=$(grep 'longer|' "$STATE_DIR/messages.log" | tail -1)
|
||||
content=${long_line#*|}
|
||||
content=${content#*|}
|
||||
content_len=$(printf '%s' "$content" | wc -c | tr -d ' ')
|
||||
if [ "$content_len" -eq 1023 ]; then
|
||||
echo "✓ overlong paste is capped at message limit"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "x overlong paste length unexpected: $content_len"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
else
|
||||
echo "x overlong paste client failed"
|
||||
sed -n '1,120p' "$STATE_DIR/long-paste.log"
|
||||
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
HELP_SCRIPT="$STATE_DIR/help.expect"
|
||||
cat >"$HELP_SCRIPT" <<EOF
|
||||
set timeout 10
|
||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||
sleep 1
|
||||
send -- "helper\r"
|
||||
expect ":help"
|
||||
send -- "\033"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "help\r"
|
||||
expect "TNT\\(1\\) 帮助"
|
||||
expect "q:关闭"
|
||||
send -- "q"
|
||||
expect "NORMAL"
|
||||
send -- "?"
|
||||
expect "TNT 按键参考"
|
||||
expect "l:语言"
|
||||
send -- "l"
|
||||
expect "TNT KEY REFERENCE"
|
||||
expect "l:lang"
|
||||
send -- "q"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "lang en\r"
|
||||
expect "Language set to: en"
|
||||
expect "q:close"
|
||||
send -- "q"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
expect eof
|
||||
EOF
|
||||
|
||||
if expect "$HELP_SCRIPT" >"$STATE_DIR/help.log" 2>&1; then
|
||||
echo "✓ :help renders concise manual"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "x :help command failed"
|
||||
sed -n '1,160p' "$STATE_DIR/help.log"
|
||||
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
UNKNOWN_SCRIPT="$STATE_DIR/unknown-command.expect"
|
||||
cat >"$UNKNOWN_SCRIPT" <<EOF
|
||||
set timeout 10
|
||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||
sleep 1
|
||||
send -- "mistype\r"
|
||||
expect ":help"
|
||||
send -- "\033"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "hlep\r"
|
||||
expect "你是想输入 :help 吗?"
|
||||
expect "q:关闭"
|
||||
send -- "q"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
expect eof
|
||||
EOF
|
||||
|
||||
if expect "$UNKNOWN_SCRIPT" >"$STATE_DIR/unknown-command.log" 2>&1; then
|
||||
echo "✓ mistyped command suggests nearest command"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "x mistyped command suggestion failed"
|
||||
sed -n '1,160p' "$STATE_DIR/unknown-command.log"
|
||||
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
LOCALIZED_COMMANDS_SCRIPT="$STATE_DIR/localized-commands.expect"
|
||||
cat >"$LOCALIZED_COMMANDS_SCRIPT" <<EOF
|
||||
set timeout 10
|
||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||
sleep 1
|
||||
send -- "localized\r"
|
||||
expect ":help"
|
||||
send -- "\033"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "mute-joins\r"
|
||||
expect "命令输出"
|
||||
expect "加入/离开提示"
|
||||
expect "已静音"
|
||||
expect "q:关闭"
|
||||
send -- "q"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "lang en\r"
|
||||
expect "Language set to: en"
|
||||
expect "q:close"
|
||||
send -- "q"
|
||||
expect "NORMAL"
|
||||
expect "online"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "users\r"
|
||||
expect "COMMAND OUTPUT"
|
||||
expect "Online users"
|
||||
expect "q:close"
|
||||
send -- "q"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
expect eof
|
||||
EOF
|
||||
|
||||
if expect "$LOCALIZED_COMMANDS_SCRIPT" >"$STATE_DIR/localized-commands.log" 2>&1; then
|
||||
echo "✓ common command output follows session language"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "x localized command output failed"
|
||||
sed -n '1,200p' "$STATE_DIR/localized-commands.log"
|
||||
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
COMMAND_USAGE_SCRIPT="$STATE_DIR/command-usage.expect"
|
||||
cat >"$COMMAND_USAGE_SCRIPT" <<EOF
|
||||
set timeout 10
|
||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||
sleep 1
|
||||
send -- "usageuser\r"
|
||||
expect ":help"
|
||||
send -- "\033"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "search\r"
|
||||
expect "用法: search <keyword>"
|
||||
expect "q:关闭"
|
||||
send -- "q"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "msg\r"
|
||||
expect "用法: msg <user> <message>"
|
||||
expect "q:关闭"
|
||||
send -- "q"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "nick\r"
|
||||
expect "用法: nick <name>"
|
||||
expect "q:关闭"
|
||||
send -- "q"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "lang en\r"
|
||||
expect "Language set to: en"
|
||||
expect "q:close"
|
||||
send -- "q"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "inbox\r"
|
||||
expect "Private messages"
|
||||
expect "(empty)"
|
||||
expect "q:close"
|
||||
send -- "q"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "last 999\r"
|
||||
expect "Usage: last \\[N\\]"
|
||||
expect "q:close"
|
||||
send -- "q"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "users extra\r"
|
||||
expect "Usage: users"
|
||||
expect "q:close"
|
||||
send -- "q"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "help now\r"
|
||||
expect "Usage: help"
|
||||
expect "q:close"
|
||||
send -- "q"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
expect eof
|
||||
EOF
|
||||
|
||||
if expect "$COMMAND_USAGE_SCRIPT" >"$STATE_DIR/command-usage.log" 2>&1; then
|
||||
echo "✓ command usage errors follow session language"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "x localized command usage failed"
|
||||
sed -n '1,220p' "$STATE_DIR/command-usage.log"
|
||||
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
scroll_ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
scroll_i=1
|
||||
while [ "$scroll_i" -le 30 ]; do
|
||||
printf '%s|fixture|scroll fixture %02d\n' "$scroll_ts" "$scroll_i" >>"$STATE_DIR/messages.log"
|
||||
scroll_i=$((scroll_i + 1))
|
||||
done
|
||||
|
||||
COMMAND_OUTPUT_SCROLL_SCRIPT="$STATE_DIR/command-output-scroll.expect"
|
||||
cat >"$COMMAND_OUTPUT_SCROLL_SCRIPT" <<EOF
|
||||
set timeout 10
|
||||
stty rows 8 columns 80
|
||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||
sleep 1
|
||||
send -- "pageruser\r"
|
||||
expect ":help"
|
||||
send -- "\033"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "last 50\r"
|
||||
expect "j/k:滚动"
|
||||
expect -re {\(1/[2-9][0-9]*\)}
|
||||
send -- "j"
|
||||
expect -re {\(2/[2-9][0-9]*\)}
|
||||
send -- "q"
|
||||
expect "NORMAL"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
expect eof
|
||||
EOF
|
||||
|
||||
if expect "$COMMAND_OUTPUT_SCROLL_SCRIPT" >"$STATE_DIR/command-output-scroll.log" 2>&1; then
|
||||
echo "✓ command output can scroll before closing"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "x command output scrolling failed"
|
||||
sed -n '1,220p' "$STATE_DIR/command-output-scroll.log"
|
||||
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
SYSTEM_MESSAGES_SCRIPT="$STATE_DIR/system-messages.expect"
|
||||
cat >"$SYSTEM_MESSAGES_SCRIPT" <<EOF
|
||||
set timeout 10
|
||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||
sleep 1
|
||||
send -- "systemuser\r"
|
||||
expect ":help"
|
||||
send -- "\033"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "lang en\r"
|
||||
expect "Language set to: en"
|
||||
expect "q:close"
|
||||
send -- "q"
|
||||
expect "NORMAL"
|
||||
send -- ":"
|
||||
expect ":"
|
||||
send -- "nick systemuser2\r"
|
||||
expect "Nickname changed: systemuser -> systemuser2"
|
||||
expect "q:close"
|
||||
send -- "q"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
expect eof
|
||||
EOF
|
||||
|
||||
if expect "$SYSTEM_MESSAGES_SCRIPT" >"$STATE_DIR/system-messages.log" 2>&1 &&
|
||||
grep -q 'system|systemuser renamed to systemuser2' "$STATE_DIR/messages.log" &&
|
||||
grep -q 'system|systemuser2 left the room' "$STATE_DIR/messages.log"; then
|
||||
echo "✓ system messages follow session language"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "x localized system messages failed"
|
||||
sed -n '1,220p' "$STATE_DIR/system-messages.log" 2>/dev/null || true
|
||||
cat "$STATE_DIR/messages.log" 2>/dev/null || true
|
||||
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
printf '维护窗口\n' >"$STATE_DIR/motd.txt"
|
||||
MOTD_SCRIPT="$STATE_DIR/motd.expect"
|
||||
cat >"$MOTD_SCRIPT" <<EOF
|
||||
set timeout 10
|
||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||
sleep 1
|
||||
send -- "motduser\r"
|
||||
expect "公告"
|
||||
expect "维护窗口"
|
||||
expect "按任意键继续"
|
||||
send -- "x"
|
||||
expect "NORMAL"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
sleep 0.2
|
||||
send -- "\003"
|
||||
expect eof
|
||||
EOF
|
||||
|
||||
if expect "$MOTD_SCRIPT" >"$STATE_DIR/motd.log" 2>&1; then
|
||||
echo "✓ MOTD chrome follows session language"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "x localized MOTD chrome failed"
|
||||
sed -n '1,200p' "$STATE_DIR/motd.log"
|
||||
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "PASSED: $PASS"
|
||||
echo "FAILED: $FAIL"
|
||||
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
|
||||
exit "$FAIL"
|
||||
|
|
@ -11,6 +11,9 @@ NC='\033[0m'
|
|||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
STATE_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/tnt-security-test.XXXXXX")
|
||||
SERVER_PIDS=""
|
||||
NEXT_PORT=${PORT:-13600}
|
||||
|
||||
print_test() {
|
||||
echo -e "\n${YELLOW}[TEST]${NC} $1"
|
||||
|
|
@ -27,8 +30,11 @@ fail() {
|
|||
}
|
||||
|
||||
cleanup() {
|
||||
pkill -f "^\.\./tnt" 2>/dev/null || true
|
||||
sleep 1
|
||||
for pid in $SERVER_PIDS; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
done
|
||||
rm -rf "$STATE_ROOT"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
|
@ -43,23 +49,47 @@ if [ ! -f "$BIN" ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# Detect timeout command
|
||||
TIMEOUT_CMD="timeout"
|
||||
if command -v gtimeout >/dev/null 2>&1; then
|
||||
TIMEOUT_CMD="gtimeout"
|
||||
run_server_probe() {
|
||||
local name="$1"
|
||||
local port="$NEXT_PORT"
|
||||
local pid
|
||||
local state_dir
|
||||
local log_file
|
||||
shift
|
||||
|
||||
NEXT_PORT=$((NEXT_PORT + 1))
|
||||
state_dir="$STATE_ROOT/$name"
|
||||
log_file="$state_dir/server.log"
|
||||
mkdir -p "$state_dir"
|
||||
|
||||
"$@" "$BIN" -p "$port" -d "$state_dir" >"$log_file" 2>&1 &
|
||||
pid=$!
|
||||
SERVER_PIDS="$SERVER_PIDS $pid"
|
||||
|
||||
for _ in 1 2 3 4 5 6 7 8; do
|
||||
if grep -q "TNT chat server listening" "$log_file"; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
kill "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
sed -n '1,120p' "$log_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Test 1: 4096-bit RSA Key Generation
|
||||
print_test "1. RSA 4096-bit Key Generation"
|
||||
rm -f host_key
|
||||
$BIN &
|
||||
PID=$!
|
||||
sleep 8 # Wait for key generation
|
||||
kill $PID 2>/dev/null || true
|
||||
sleep 1
|
||||
KEY_DIR="$STATE_ROOT/host-key"
|
||||
|
||||
if [ -f host_key ]; then
|
||||
KEY_SIZE=$(ssh-keygen -l -f host_key 2>/dev/null | awk '{print $1}')
|
||||
if run_server_probe host-key env && [ -f "$KEY_DIR/host_key" ]; then
|
||||
KEY_SIZE=$(ssh-keygen -l -f "$KEY_DIR/host_key" 2>/dev/null | awk '{print $1}')
|
||||
if [ "$KEY_SIZE" = "4096" ]; then
|
||||
pass "RSA key upgraded to 4096 bits (was 2048)"
|
||||
else
|
||||
|
|
@ -68,9 +98,9 @@ if [ -f host_key ]; then
|
|||
|
||||
# Check permissions
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
PERMS=$(stat -f "%OLp" host_key)
|
||||
PERMS=$(stat -f "%OLp" "$KEY_DIR/host_key")
|
||||
else
|
||||
PERMS=$(stat -c "%a" host_key)
|
||||
PERMS=$(stat -c "%a" "$KEY_DIR/host_key")
|
||||
fi
|
||||
|
||||
if [ "$PERMS" = "600" ]; then
|
||||
|
|
@ -86,33 +116,34 @@ fi
|
|||
print_test "2. Environment Variable Configuration"
|
||||
|
||||
# Test bind address
|
||||
TNT_BIND_ADDR=127.0.0.1 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
|
||||
run_server_probe bind-addr env TNT_BIND_ADDR=127.0.0.1 && \
|
||||
pass "TNT_BIND_ADDR configuration works" || fail "TNT_BIND_ADDR not working"
|
||||
|
||||
# Test with access token set (just verify it starts)
|
||||
TNT_ACCESS_TOKEN="test123" $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
|
||||
run_server_probe access-token env TNT_ACCESS_TOKEN="test123" && \
|
||||
pass "TNT_ACCESS_TOKEN configuration accepted" || fail "TNT_ACCESS_TOKEN not working"
|
||||
|
||||
# Test max connections configuration
|
||||
TNT_MAX_CONNECTIONS=10 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
|
||||
run_server_probe max-connections env TNT_MAX_CONNECTIONS=10 && \
|
||||
pass "TNT_MAX_CONNECTIONS configuration accepted" || fail "TNT_MAX_CONNECTIONS not working"
|
||||
|
||||
# Test per-IP connection rate configuration
|
||||
TNT_MAX_CONN_RATE_PER_IP=20 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
|
||||
run_server_probe conn-rate env TNT_MAX_CONN_RATE_PER_IP=20 && \
|
||||
pass "TNT_MAX_CONN_RATE_PER_IP configuration accepted" || fail "TNT_MAX_CONN_RATE_PER_IP not working"
|
||||
|
||||
# Test rate limit toggle
|
||||
TNT_RATE_LIMIT=0 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
|
||||
run_server_probe rate-toggle env TNT_RATE_LIMIT=0 && \
|
||||
pass "TNT_RATE_LIMIT configuration accepted" || fail "TNT_RATE_LIMIT not working"
|
||||
|
||||
sleep 1
|
||||
|
||||
# Test 3: Input Validation in Message Log
|
||||
print_test "3. Message Log Sanitization"
|
||||
rm -f messages.log
|
||||
MESSAGE_DIR="$STATE_ROOT/message-log"
|
||||
mkdir -p "$MESSAGE_DIR"
|
||||
|
||||
# Create a test message log with potentially dangerous content
|
||||
cat > messages.log <<EOF
|
||||
cat > "$MESSAGE_DIR/messages.log" <<EOF
|
||||
2026-01-22T10:00:00Z|testuser|normal message
|
||||
2026-01-22T10:01:00Z|user|with|pipes|attempt to break format
|
||||
2026-01-22T10:02:00Z|user
|
||||
|
|
@ -121,15 +152,9 @@ newline
|
|||
2026-01-22T10:03:00Z|validuser|valid content
|
||||
EOF
|
||||
|
||||
# Start server and let it load messages
|
||||
$BIN &
|
||||
PID=$!
|
||||
sleep 3
|
||||
kill $PID 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Check if server handled malformed log entries safely
|
||||
if grep -q "validuser" messages.log; then
|
||||
# Start server and let it load messages, then verify it kept valid entries.
|
||||
if run_server_probe message-log env >/dev/null &&
|
||||
grep -q "validuser" "$MESSAGE_DIR/messages.log"; then
|
||||
pass "Server loads messages from log file"
|
||||
else
|
||||
fail "Server message loading issue"
|
||||
|
|
@ -215,21 +240,16 @@ fi
|
|||
|
||||
# Test 7: Resource Management (Dynamic Allocation)
|
||||
print_test "7. Resource Management (Large Log Files)"
|
||||
rm -f messages.log
|
||||
LARGE_DIR="$STATE_ROOT/large-log"
|
||||
mkdir -p "$LARGE_DIR"
|
||||
# Create a large message log (2000 entries, more than old fixed 1000 limit)
|
||||
for i in $(seq 1 2000); do
|
||||
echo "2026-01-22T$(printf "%02d" $((i/100))):$(printf "%02d" $((i%60))):00Z|user$i|message $i" >> messages.log
|
||||
echo "2026-01-22T$(printf "%02d" $((i/100))):$(printf "%02d" $((i%60))):00Z|user$i|message $i" >> "$LARGE_DIR/messages.log"
|
||||
done
|
||||
|
||||
$BIN &
|
||||
PID=$!
|
||||
sleep 4
|
||||
kill $PID 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Check if server started successfully with large log
|
||||
if [ -f messages.log ]; then
|
||||
LINE_COUNT=$(wc -l < messages.log)
|
||||
if run_server_probe large-log env >/dev/null && [ -f "$LARGE_DIR/messages.log" ]; then
|
||||
LINE_COUNT=$(wc -l < "$LARGE_DIR/messages.log")
|
||||
if [ "$LINE_COUNT" -ge 2000 ]; then
|
||||
pass "Server handles large message log (${LINE_COUNT} messages)"
|
||||
else
|
||||
|
|
|
|||
|
|
@ -1,56 +1,142 @@
|
|||
#!/bin/sh
|
||||
# Stress test for TNT server
|
||||
# Usage: ./test_stress.sh [num_clients]
|
||||
# Lightweight concurrent-client stress test for TNT.
|
||||
# Usage: ./test_stress.sh [num_clients] [duration_seconds]
|
||||
|
||||
PORT=${PORT:-2222}
|
||||
CLIENTS=${1:-10}
|
||||
DURATION=${2:-30}
|
||||
BIN="../tnt"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-stress-test.XXXXXX")
|
||||
SERVER_PID=""
|
||||
CLIENT_PIDS=""
|
||||
|
||||
cleanup() {
|
||||
for pid in $CLIENT_PIDS; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
done
|
||||
if [ -n "$SERVER_PID" ]; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$STATE_DIR"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "Error: Binary $BIN not found."
|
||||
echo "Error: Binary $BIN not found. Run make first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Detect timeout command
|
||||
TIMEOUT_CMD="timeout"
|
||||
if command -v gtimeout >/dev/null 2>&1; then
|
||||
TIMEOUT_CMD="gtimeout"
|
||||
case "$CLIENTS" in
|
||||
''|*[!0-9]*)
|
||||
echo "Error: num_clients must be a positive integer"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$DURATION" in
|
||||
''|*[!0-9]*)
|
||||
echo "Error: duration_seconds must be a positive integer"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$CLIENTS" -lt 1 ] || [ "$DURATION" -lt 1 ]; then
|
||||
echo "Error: num_clients and duration_seconds must be positive"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "Starting TNT server on port $PORT..."
|
||||
TNT_RATE_LIMIT=0 TNT_MAX_CONN_PER_IP=$CLIENTS $BIN -p $PORT &
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
|
||||
|
||||
wait_for_health() {
|
||||
out=""
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||
if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
out=$(ssh -n $SSH_OPTS localhost health 2>/dev/null || true)
|
||||
[ "$out" = "ok" ] && return 0
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "=== TNT Stress Test ==="
|
||||
echo "clients=$CLIENTS duration=${DURATION}s port=$PORT"
|
||||
|
||||
MAX_CONN_PER_IP=$((CLIENTS + 5))
|
||||
TNT_LANG=zh TNT_RATE_LIMIT=0 TNT_MAX_CONN_PER_IP=$MAX_CONN_PER_IP \
|
||||
"$BIN" -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
sleep 2
|
||||
|
||||
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo "Server failed to start"
|
||||
if wait_for_health; then
|
||||
echo "✓ server started"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ server failed to start"
|
||||
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Spawning $CLIENTS clients for ${DURATION}s..."
|
||||
for i in $(seq 1 "$CLIENTS"); do
|
||||
script="$STATE_DIR/client-$i.expect"
|
||||
log="$STATE_DIR/client-$i.log"
|
||||
ready="$STATE_DIR/client-$i.ready"
|
||||
|
||||
for i in $(seq 1 $CLIENTS); do
|
||||
(
|
||||
sleep $((i % 5))
|
||||
echo "test user $i" | $TIMEOUT_CMD $DURATION ssh -o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null -p $PORT localhost \
|
||||
>/dev/null 2>&1
|
||||
) &
|
||||
cat >"$script" <<EOF
|
||||
set timeout [expr {$DURATION + 15}]
|
||||
spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT stress$i@localhost
|
||||
expect "请输入用户名"
|
||||
send -- "stress$i\r"
|
||||
exec touch "$ready"
|
||||
sleep $DURATION
|
||||
send -- "\003"
|
||||
expect eof
|
||||
EOF
|
||||
|
||||
expect "$script" >"$log" 2>&1 &
|
||||
CLIENT_PIDS="$CLIENT_PIDS $!"
|
||||
done
|
||||
|
||||
echo "Running stress test..."
|
||||
sleep $DURATION
|
||||
ready_count=0
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||
ready_count=$(find "$STATE_DIR" -name 'client-*.ready' -type f | wc -l | tr -d ' ')
|
||||
[ "$ready_count" = "$CLIENTS" ] && break
|
||||
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Cleaning up..."
|
||||
kill $SERVER_PID 2>/dev/null
|
||||
wait
|
||||
|
||||
echo "Stress test complete"
|
||||
if kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo "WARNING: tnt process still running"
|
||||
if [ "$ready_count" = "$CLIENTS" ]; then
|
||||
echo "✓ all clients reached chat"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "Server shutdown confirmed."
|
||||
echo "✗ only $ready_count/$CLIENTS clients reached chat"
|
||||
sed -n '1,160p' "$STATE_DIR/server.log"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
exit 0
|
||||
for pid in $CLIENT_PIDS; do
|
||||
wait "$pid" 2>/dev/null || FAIL=$((FAIL + 1))
|
||||
done
|
||||
CLIENT_PIDS=""
|
||||
|
||||
if kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
echo "✓ server survived concurrent clients"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "✗ server exited during stress test"
|
||||
sed -n '1,160p' "$STATE_DIR/server.log"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "PASSED: $PASS"
|
||||
echo "FAILED: $FAIL"
|
||||
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
|
||||
exit "$FAIL"
|
||||
|
|
|
|||
|
|
@ -13,9 +13,19 @@ endif
|
|||
UTF8_SRC = ../../src/utf8.c
|
||||
MESSAGE_SRC = ../../src/message.c
|
||||
COMMON_SRC = ../../src/common.c
|
||||
COMMAND_CATALOG_SRC = ../../src/command_catalog.c
|
||||
CLI_TEXT_SRC = ../../src/cli_text.c
|
||||
CHAT_ROOM_SRC = ../../src/chat_room.c
|
||||
HISTORY_VIEW_SRC = ../../src/history_view.c
|
||||
I18N_SRC = ../../src/i18n.c
|
||||
I18N_TEXT_SRC = ../../src/i18n_text.c
|
||||
EXEC_CATALOG_SRC = ../../src/exec_catalog.c
|
||||
SYSTEM_MESSAGE_SRC = ../../src/system_message.c
|
||||
HELP_TEXT_SRC = ../../src/help_text.c
|
||||
MANUAL_TEXT_SRC = ../../src/manual_text.c
|
||||
RATELIMIT_SRC = ../../src/ratelimit.c
|
||||
|
||||
TESTS = test_utf8 test_message test_chat_room
|
||||
TESTS = test_utf8 test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_ratelimit
|
||||
|
||||
.PHONY: all clean run
|
||||
|
||||
|
|
@ -30,6 +40,33 @@ test_message: test_message.c $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC)
|
|||
test_chat_room: test_chat_room.c $(CHAT_ROOM_SRC) $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
test_history_view: test_history_view.c $(HISTORY_VIEW_SRC)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
test_i18n: test_i18n.c $(I18N_SRC) $(I18N_TEXT_SRC)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
test_system_message: test_system_message.c $(SYSTEM_MESSAGE_SRC) $(I18N_SRC) $(I18N_TEXT_SRC)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
test_command_catalog: test_command_catalog.c $(COMMAND_CATALOG_SRC) $(COMMON_SRC)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
test_exec_catalog: test_exec_catalog.c $(EXEC_CATALOG_SRC) $(COMMON_SRC)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
test_help_text: test_help_text.c $(HELP_TEXT_SRC) $(COMMAND_CATALOG_SRC) $(COMMON_SRC)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
test_manual_text: test_manual_text.c $(MANUAL_TEXT_SRC) $(COMMAND_CATALOG_SRC) $(COMMON_SRC)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
test_cli_text: test_cli_text.c $(CLI_TEXT_SRC) $(COMMON_SRC)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
test_ratelimit: test_ratelimit.c $(RATELIMIT_SRC) $(COMMON_SRC)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||
|
||||
run: all
|
||||
@echo "=== Running UTF-8 Tests ==="
|
||||
./test_utf8
|
||||
|
|
@ -39,6 +76,33 @@ run: all
|
|||
@echo ""
|
||||
@echo "=== Running Chat Room Tests ==="
|
||||
./test_chat_room
|
||||
@echo ""
|
||||
@echo "=== Running History View Tests ==="
|
||||
./test_history_view
|
||||
@echo ""
|
||||
@echo "=== Running i18n Tests ==="
|
||||
./test_i18n
|
||||
@echo ""
|
||||
@echo "=== Running System Message Tests ==="
|
||||
./test_system_message
|
||||
@echo ""
|
||||
@echo "=== Running Command Catalog Tests ==="
|
||||
./test_command_catalog
|
||||
@echo ""
|
||||
@echo "=== Running Exec Catalog Tests ==="
|
||||
./test_exec_catalog
|
||||
@echo ""
|
||||
@echo "=== Running Help Text Tests ==="
|
||||
./test_help_text
|
||||
@echo ""
|
||||
@echo "=== Running Manual Text Tests ==="
|
||||
./test_manual_text
|
||||
@echo ""
|
||||
@echo "=== Running CLI Text Tests ==="
|
||||
./test_cli_text
|
||||
@echo ""
|
||||
@echo "=== Running Rate Limit Tests ==="
|
||||
./test_ratelimit
|
||||
|
||||
clean:
|
||||
rm -f $(TESTS) *.o test_messages.log
|
||||
|
|
|
|||
66
tests/unit/test_cli_text.c
Normal file
66
tests/unit/test_cli_text.c
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/* Unit tests for command-line help and error text */
|
||||
|
||||
#include "../../include/cli_text.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#define TEST(name) static void test_##name()
|
||||
#define RUN_TEST(name) do { \
|
||||
printf("Running %s... ", #name); \
|
||||
test_##name(); \
|
||||
printf("✓\n"); \
|
||||
tests_passed++; \
|
||||
} while(0)
|
||||
|
||||
static int tests_passed = 0;
|
||||
|
||||
TEST(help_matches_language) {
|
||||
char output[2048] = {0};
|
||||
size_t pos = 0;
|
||||
|
||||
cli_text_append_help(output, sizeof(output), &pos, "tnt", UI_LANG_EN);
|
||||
assert(strstr(output, "anonymous SSH chat server") != NULL);
|
||||
assert(strstr(output, "Usage: tnt [options]") != NULL);
|
||||
assert(strstr(output, "TNT_LANG") != NULL);
|
||||
|
||||
memset(output, 0, sizeof(output));
|
||||
pos = 0;
|
||||
cli_text_append_help(output, sizeof(output), &pos, "", (ui_lang_t)99);
|
||||
assert(strstr(output, "Usage: tnt [options]") != NULL);
|
||||
|
||||
memset(output, 0, sizeof(output));
|
||||
pos = 0;
|
||||
cli_text_append_help(output, sizeof(output), &pos, "tnt", UI_LANG_ZH);
|
||||
assert(strstr(output, "匿名 SSH 聊天服务器") != NULL);
|
||||
assert(strstr(output, "用法: tnt [options]") != NULL);
|
||||
assert(strstr(output, "[选项]") == NULL);
|
||||
assert(strstr(output, "TNT_LANG") != NULL);
|
||||
}
|
||||
|
||||
TEST(error_formats_match_language) {
|
||||
assert(strcmp(cli_text_invalid_port_format(UI_LANG_EN),
|
||||
"Invalid port: %s\n") == 0);
|
||||
assert(strcmp(cli_text_invalid_port_format(UI_LANG_ZH),
|
||||
"端口无效: %s\n") == 0);
|
||||
assert(strcmp(cli_text_unknown_option_format(UI_LANG_EN),
|
||||
"Unknown option: %s\n") == 0);
|
||||
assert(strcmp(cli_text_unknown_option_format(UI_LANG_ZH),
|
||||
"未知选项: %s\n") == 0);
|
||||
assert(strcmp(cli_text_short_usage_format(UI_LANG_EN),
|
||||
"Usage: %s [-p PORT] [-d DIR] [-h]\n") == 0);
|
||||
assert(strcmp(cli_text_short_usage_format(UI_LANG_ZH),
|
||||
"用法: %s [-p PORT] [-d DIR] [-h]\n") == 0);
|
||||
assert(strcmp(cli_text_invalid_port_format((ui_lang_t)99),
|
||||
"Invalid port: %s\n") == 0);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("Running CLI text unit tests...\n\n");
|
||||
|
||||
RUN_TEST(help_matches_language);
|
||||
RUN_TEST(error_formats_match_language);
|
||||
|
||||
printf("\n✓ All %d tests passed!\n", tests_passed);
|
||||
return 0;
|
||||
}
|
||||
142
tests/unit/test_command_catalog.c
Normal file
142
tests/unit/test_command_catalog.c
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/* Unit tests for command catalog names, aliases, and generated help text */
|
||||
|
||||
#include "../../include/command_catalog.h"
|
||||
#include "text_assert.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#define TEST(name) static void test_##name()
|
||||
#define RUN_TEST(name) do { \
|
||||
printf("Running %s... ", #name); \
|
||||
test_##name(); \
|
||||
printf("✓\n"); \
|
||||
tests_passed++; \
|
||||
} while(0)
|
||||
|
||||
static int tests_passed = 0;
|
||||
|
||||
TEST(matches_canonical_names_and_aliases) {
|
||||
tnt_command_id_t id;
|
||||
const char *args;
|
||||
|
||||
assert(command_catalog_match("users", &id, &args));
|
||||
assert(id == TNT_COMMAND_USERS);
|
||||
assert(strcmp(args, "") == 0);
|
||||
|
||||
assert(command_catalog_match("list", &id, &args));
|
||||
assert(id == TNT_COMMAND_USERS);
|
||||
|
||||
assert(command_catalog_match("msg alice hello", &id, &args));
|
||||
assert(id == TNT_COMMAND_MSG);
|
||||
assert(strcmp(args, "alice hello") == 0);
|
||||
|
||||
assert(command_catalog_match("w alice hello", &id, &args));
|
||||
assert(id == TNT_COMMAND_MSG);
|
||||
assert(strcmp(args, "alice hello") == 0);
|
||||
|
||||
assert(command_catalog_match("language zh", &id, &args));
|
||||
assert(id == TNT_COMMAND_LANG);
|
||||
assert(strcmp(args, "zh") == 0);
|
||||
}
|
||||
|
||||
TEST(matches_known_commands_before_argument_validation) {
|
||||
tnt_command_id_t id;
|
||||
const char *args;
|
||||
|
||||
assert(command_catalog_match("users extra", &id, &args));
|
||||
assert(id == TNT_COMMAND_USERS);
|
||||
assert(strcmp(args, "extra") == 0);
|
||||
|
||||
assert(command_catalog_match("help now", &id, &args));
|
||||
assert(id == TNT_COMMAND_HELP);
|
||||
assert(strcmp(args, "now") == 0);
|
||||
|
||||
assert(command_catalog_match("q now", &id, &args));
|
||||
assert(id == TNT_COMMAND_QUIT);
|
||||
assert(strcmp(args, "now") == 0);
|
||||
}
|
||||
|
||||
TEST(validates_argument_shapes) {
|
||||
assert(command_catalog_args_valid(TNT_COMMAND_USERS, NULL));
|
||||
assert(!command_catalog_args_valid(TNT_COMMAND_USERS, "extra"));
|
||||
assert(command_catalog_args_valid(TNT_COMMAND_HELP, NULL));
|
||||
assert(!command_catalog_args_valid(TNT_COMMAND_HELP, "now"));
|
||||
|
||||
assert(!command_catalog_args_valid(TNT_COMMAND_MSG, NULL));
|
||||
assert(command_catalog_args_valid(TNT_COMMAND_MSG, "alice hello"));
|
||||
assert(!command_catalog_args_valid(TNT_COMMAND_SEARCH, ""));
|
||||
assert(command_catalog_args_valid(TNT_COMMAND_SEARCH, "needle"));
|
||||
|
||||
assert(command_catalog_args_valid(TNT_COMMAND_LAST, NULL));
|
||||
assert(command_catalog_args_valid(TNT_COMMAND_LAST, "999"));
|
||||
assert(command_catalog_args_valid(TNT_COMMAND_LANG, "fr"));
|
||||
}
|
||||
|
||||
TEST(suggests_from_catalog_aliases) {
|
||||
assert(strcmp(command_catalog_suggest("hlep"), "help") == 0);
|
||||
assert(strcmp(command_catalog_suggest("usres"), "users") == 0);
|
||||
assert(strcmp(command_catalog_suggest("laguage"), "lang") == 0);
|
||||
assert(command_catalog_suggest("not-even-close") == NULL);
|
||||
}
|
||||
|
||||
TEST(generates_localized_help_sections) {
|
||||
char en[4096] = {0};
|
||||
char zh[4096] = {0};
|
||||
size_t en_pos = 0;
|
||||
size_t zh_pos = 0;
|
||||
|
||||
command_catalog_append_full(en, sizeof(en), &en_pos, UI_LANG_EN);
|
||||
command_catalog_append_full(zh, sizeof(zh), &zh_pos, UI_LANG_ZH);
|
||||
|
||||
assert(strstr(en, ":users, :list, :who") != NULL);
|
||||
assert(strstr(en, "Show online users") != NULL);
|
||||
assert(strstr(en, ":msg <user> <message>") != NULL);
|
||||
assert(strstr(en, "Show private messages") != NULL);
|
||||
assert(strstr(en, ":support") == NULL);
|
||||
|
||||
assert(strstr(zh, ":users, :list, :who") != NULL);
|
||||
assert(strstr(zh, "显示在线用户") != NULL);
|
||||
assert(strstr(zh, "查看私信") != NULL);
|
||||
assert(strstr(zh, ":msg <user> <message>") != NULL);
|
||||
assert(strstr(zh, "<用户>") == NULL);
|
||||
assert(strstr(zh, "<消息>") == NULL);
|
||||
assert(strstr(zh, ":support") == NULL);
|
||||
assert_ascii_angle_placeholders(zh);
|
||||
}
|
||||
|
||||
TEST(generates_localized_usage) {
|
||||
char en[256] = {0};
|
||||
char zh[256] = {0};
|
||||
size_t en_pos = 0;
|
||||
size_t zh_pos = 0;
|
||||
|
||||
command_catalog_append_usage(en, sizeof(en), &en_pos,
|
||||
TNT_COMMAND_LAST, UI_LANG_EN);
|
||||
command_catalog_append_usage(zh, sizeof(zh), &zh_pos,
|
||||
TNT_COMMAND_MSG, UI_LANG_ZH);
|
||||
|
||||
assert(strcmp(en, "Usage: last [N] (N: 1-50, default 10)\n") == 0);
|
||||
assert(strcmp(zh, "用法: msg <user> <message>\n"
|
||||
" w <user> <message>\n") == 0);
|
||||
|
||||
en[0] = '\0';
|
||||
en_pos = 0;
|
||||
command_catalog_append_usage(en, sizeof(en), &en_pos,
|
||||
TNT_COMMAND_USERS, (ui_lang_t)99);
|
||||
assert(strcmp(en, "Usage: users\n") == 0);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("Running command catalog unit tests...\n\n");
|
||||
|
||||
RUN_TEST(matches_canonical_names_and_aliases);
|
||||
RUN_TEST(matches_known_commands_before_argument_validation);
|
||||
RUN_TEST(validates_argument_shapes);
|
||||
RUN_TEST(suggests_from_catalog_aliases);
|
||||
RUN_TEST(generates_localized_help_sections);
|
||||
RUN_TEST(generates_localized_usage);
|
||||
|
||||
printf("\n✓ All %d tests passed!\n", tests_passed);
|
||||
return 0;
|
||||
}
|
||||
128
tests/unit/test_exec_catalog.c
Normal file
128
tests/unit/test_exec_catalog.c
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/* Unit tests for SSH exec command help catalog */
|
||||
|
||||
#include "../../include/exec_catalog.h"
|
||||
#include "text_assert.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#define TEST(name) static void test_##name()
|
||||
#define RUN_TEST(name) do { \
|
||||
printf("Running %s... ", #name); \
|
||||
test_##name(); \
|
||||
printf("✓\n"); \
|
||||
tests_passed++; \
|
||||
} while(0)
|
||||
|
||||
static int tests_passed = 0;
|
||||
|
||||
TEST(generates_localized_exec_help) {
|
||||
char en[2048] = {0};
|
||||
char zh[2048] = {0};
|
||||
size_t en_pos = 0;
|
||||
size_t zh_pos = 0;
|
||||
|
||||
exec_catalog_append_help(en, sizeof(en), &en_pos, UI_LANG_EN);
|
||||
exec_catalog_append_help(zh, sizeof(zh), &zh_pos, UI_LANG_ZH);
|
||||
|
||||
assert(strstr(en, "TNT exec interface") != NULL);
|
||||
assert(strstr(en, "Commands:") != NULL);
|
||||
assert(strstr(en, "users [--json]") != NULL);
|
||||
assert(strstr(en, "post MESSAGE") != NULL);
|
||||
assert(strstr(en, "support") == NULL);
|
||||
|
||||
assert(strstr(zh, "TNT exec 接口") != NULL);
|
||||
assert(strstr(zh, "命令:") != NULL);
|
||||
assert(strstr(zh, "users [--json]") != NULL);
|
||||
assert(strstr(zh, "post MESSAGE") != NULL);
|
||||
assert(strstr(zh, "support") == NULL);
|
||||
assert_ascii_angle_placeholders(zh);
|
||||
|
||||
en[0] = '\0';
|
||||
en_pos = 0;
|
||||
exec_catalog_append_help(en, sizeof(en), &en_pos, (ui_lang_t)99);
|
||||
assert(strstr(en, "TNT exec interface") != NULL);
|
||||
assert(strstr(en, "Show this help") != NULL);
|
||||
}
|
||||
|
||||
TEST(matches_exec_commands_and_args) {
|
||||
tnt_exec_command_id_t id;
|
||||
const char *args;
|
||||
|
||||
assert(exec_catalog_match("help", &id, &args));
|
||||
assert(id == TNT_EXEC_COMMAND_HELP);
|
||||
assert(args == NULL);
|
||||
|
||||
assert(exec_catalog_match("--help", &id, &args));
|
||||
assert(id == TNT_EXEC_COMMAND_HELP);
|
||||
assert(args == NULL);
|
||||
|
||||
assert(exec_catalog_match("users --json", &id, &args));
|
||||
assert(id == TNT_EXEC_COMMAND_USERS);
|
||||
assert(strcmp(args, "--json") == 0);
|
||||
|
||||
assert(exec_catalog_match("tail -n 20", &id, &args));
|
||||
assert(id == TNT_EXEC_COMMAND_TAIL);
|
||||
assert(strcmp(args, "-n 20") == 0);
|
||||
|
||||
assert(exec_catalog_match("post hello world", &id, &args));
|
||||
assert(id == TNT_EXEC_COMMAND_POST);
|
||||
assert(strcmp(args, "hello world") == 0);
|
||||
|
||||
assert(exec_catalog_match("exit", &id, &args));
|
||||
assert(id == TNT_EXEC_COMMAND_EXIT);
|
||||
assert(args == NULL);
|
||||
|
||||
assert(!exec_catalog_match("usersx", &id, &args));
|
||||
assert(!exec_catalog_match("nope", &id, &args));
|
||||
}
|
||||
|
||||
TEST(validates_argument_shapes) {
|
||||
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_HELP, NULL));
|
||||
assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_HELP, "now"));
|
||||
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_HEALTH, NULL));
|
||||
assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_HEALTH, "now"));
|
||||
|
||||
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_USERS, NULL));
|
||||
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_USERS, "--json"));
|
||||
assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_USERS, "--xml"));
|
||||
|
||||
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_TAIL, NULL));
|
||||
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_TAIL, "-n 20"));
|
||||
|
||||
assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, NULL));
|
||||
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, "hello"));
|
||||
}
|
||||
|
||||
TEST(generates_localized_usage) {
|
||||
char en[256] = {0};
|
||||
char zh[256] = {0};
|
||||
size_t en_pos = 0;
|
||||
size_t zh_pos = 0;
|
||||
|
||||
exec_catalog_append_usage(en, sizeof(en), &en_pos,
|
||||
TNT_EXEC_COMMAND_TAIL, UI_LANG_EN);
|
||||
exec_catalog_append_usage(zh, sizeof(zh), &zh_pos,
|
||||
TNT_EXEC_COMMAND_POST, UI_LANG_ZH);
|
||||
|
||||
assert(strcmp(en, "tail: usage: tail [N] | tail -n N\n") == 0);
|
||||
assert(strcmp(zh, "post: 用法: post MESSAGE\n") == 0);
|
||||
|
||||
memset(en, 0, sizeof(en));
|
||||
en_pos = 0;
|
||||
exec_catalog_append_usage(en, sizeof(en), &en_pos,
|
||||
TNT_EXEC_COMMAND_TAIL, (ui_lang_t)99);
|
||||
assert(strcmp(en, "tail: usage: tail [N] | tail -n N\n") == 0);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("Running exec catalog unit tests...\n\n");
|
||||
|
||||
RUN_TEST(generates_localized_exec_help);
|
||||
RUN_TEST(matches_exec_commands_and_args);
|
||||
RUN_TEST(validates_argument_shapes);
|
||||
RUN_TEST(generates_localized_usage);
|
||||
|
||||
printf("\n✓ All %d tests passed!\n", tests_passed);
|
||||
return 0;
|
||||
}
|
||||
74
tests/unit/test_help_text.c
Normal file
74
tests/unit/test_help_text.c
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/* Unit tests for help text ownership and language selection */
|
||||
|
||||
#include "../../include/help_text.h"
|
||||
#include "text_assert.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#define TEST(name) static void test_##name()
|
||||
#define RUN_TEST(name) do { \
|
||||
printf("Running %s... ", #name); \
|
||||
test_##name(); \
|
||||
printf("✓\n"); \
|
||||
tests_passed++; \
|
||||
} while(0)
|
||||
|
||||
static int tests_passed = 0;
|
||||
|
||||
TEST(full_help_matches_language) {
|
||||
char en[8192] = {0};
|
||||
char zh[8192] = {0};
|
||||
size_t en_pos = 0;
|
||||
size_t zh_pos = 0;
|
||||
|
||||
help_text_append_full(en, sizeof(en), &en_pos, UI_LANG_EN);
|
||||
help_text_append_full(zh, sizeof(zh), &zh_pos, UI_LANG_ZH);
|
||||
|
||||
assert(strstr(en, "TNT KEY REFERENCE") != NULL);
|
||||
assert(strstr(en, "AVAILABLE COMMANDS") != NULL);
|
||||
assert(strstr(en, "COMMAND OUTPUT KEYS") != NULL);
|
||||
assert(strstr(en, ":inbox") != NULL);
|
||||
assert(strstr(en, ":support") == NULL);
|
||||
assert(strstr(en, ":commands") == NULL);
|
||||
assert(strstr(en, "Cycle UI language") != NULL);
|
||||
assert(strstr(en, "Switch English/Chinese") == NULL);
|
||||
|
||||
assert(strstr(zh, "TNT 按键参考") != NULL);
|
||||
assert(strstr(zh, "可用命令") != NULL);
|
||||
assert(strstr(zh, "命令输出按键") != NULL);
|
||||
assert(strstr(zh, ":inbox") != NULL);
|
||||
assert(strstr(zh, "/me <action>") != NULL);
|
||||
assert(strstr(zh, "@username") != NULL);
|
||||
assert(strstr(zh, "<动作>") == NULL);
|
||||
assert(strstr(zh, "@用户名") == NULL);
|
||||
assert(strstr(zh, ":support") == NULL);
|
||||
assert(strstr(zh, ":commands") == NULL);
|
||||
assert(strstr(zh, "切换界面语言") != NULL);
|
||||
assert(strstr(zh, "切换英文/中文") == NULL);
|
||||
assert_ascii_angle_placeholders(zh);
|
||||
}
|
||||
|
||||
TEST(full_help_falls_back_to_english) {
|
||||
char text[8192] = {0};
|
||||
size_t pos = 0;
|
||||
|
||||
help_text_append_full(text, sizeof(text), &pos, (ui_lang_t)99);
|
||||
|
||||
assert(strstr(text, "TNT KEY REFERENCE") != NULL);
|
||||
assert(strstr(text, "AVAILABLE COMMANDS") != NULL);
|
||||
assert(strstr(text, "COMMAND OUTPUT KEYS") != NULL);
|
||||
assert(strstr(text, "Cycle UI language") != NULL);
|
||||
assert(strstr(text, "TNT 按键参考") == NULL);
|
||||
assert(strstr(text, "切换界面语言") == NULL);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("Running help text unit tests...\n\n");
|
||||
|
||||
RUN_TEST(full_help_matches_language);
|
||||
RUN_TEST(full_help_falls_back_to_english);
|
||||
|
||||
printf("\n✓ All %d tests passed!\n", tests_passed);
|
||||
return 0;
|
||||
}
|
||||
110
tests/unit/test_history_view.c
Normal file
110
tests/unit/test_history_view.c
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/* Unit tests for history_view viewport and scroll rules */
|
||||
|
||||
#include "../../include/history_view.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#define TEST(name) static void test_##name()
|
||||
#define RUN_TEST(name) do { \
|
||||
printf("Running %s... ", #name); \
|
||||
test_##name(); \
|
||||
printf("✓\n"); \
|
||||
tests_passed++; \
|
||||
} while(0)
|
||||
|
||||
static int tests_passed = 0;
|
||||
|
||||
static message_t make_msg(time_t timestamp, const char *content) {
|
||||
message_t msg = { .timestamp = timestamp };
|
||||
snprintf(msg.username, sizeof(msg.username), "user");
|
||||
snprintf(msg.content, sizeof(msg.content), "%s", content);
|
||||
return msg;
|
||||
}
|
||||
|
||||
TEST(height_clamps_to_message_area) {
|
||||
assert(history_view_height(24) == 21);
|
||||
assert(history_view_height(4) == 1);
|
||||
assert(history_view_height(1) == 1);
|
||||
assert(history_view_height(0) == 1);
|
||||
}
|
||||
|
||||
TEST(max_scroll_clamps_to_zero) {
|
||||
assert(history_view_max_scroll(0, 20) == 0);
|
||||
assert(history_view_max_scroll(10, 20) == 0);
|
||||
assert(history_view_max_scroll(20, 20) == 0);
|
||||
assert(history_view_max_scroll(25, 20) == 5);
|
||||
}
|
||||
|
||||
TEST(scroll_to_latest_enables_follow_tail) {
|
||||
int scroll = 0;
|
||||
bool follow = false;
|
||||
|
||||
history_view_scroll_to_latest(&scroll, &follow, 30, 10);
|
||||
assert(scroll == 20);
|
||||
assert(follow == true);
|
||||
}
|
||||
|
||||
TEST(scroll_to_oldest_disables_follow_tail) {
|
||||
int scroll = 12;
|
||||
bool follow = true;
|
||||
|
||||
history_view_scroll_to_oldest(&scroll, &follow);
|
||||
assert(scroll == 0);
|
||||
assert(follow == false);
|
||||
}
|
||||
|
||||
TEST(scroll_by_clamps_and_toggles_follow) {
|
||||
int scroll = 20;
|
||||
bool follow = true;
|
||||
|
||||
history_view_scroll_by(&scroll, &follow, 30, 10, -3);
|
||||
assert(scroll == 17);
|
||||
assert(follow == false);
|
||||
|
||||
history_view_scroll_by(&scroll, &follow, 30, 10, 100);
|
||||
assert(scroll == 20);
|
||||
assert(follow == true);
|
||||
|
||||
history_view_scroll_by(&scroll, &follow, 30, 10, -100);
|
||||
assert(scroll == 0);
|
||||
assert(follow == false);
|
||||
}
|
||||
|
||||
TEST(latest_start_counts_date_dividers) {
|
||||
message_t messages[6];
|
||||
messages[0] = make_msg(1704067200, "day1-1"); /* 2024-01-01 */
|
||||
messages[1] = make_msg(1704067260, "day1-2");
|
||||
messages[2] = make_msg(1704153600, "day2-1"); /* 2024-01-02 */
|
||||
messages[3] = make_msg(1704153660, "day2-2");
|
||||
messages[4] = make_msg(1704240000, "day3-1"); /* 2024-01-03 */
|
||||
messages[5] = make_msg(1704240060, "day3-2");
|
||||
|
||||
assert(history_view_latest_start_for_height(messages, 6, 3) == 4);
|
||||
assert(history_view_latest_start_for_height(messages, 6, 4) == 4);
|
||||
assert(history_view_latest_start_for_height(messages, 6, 5) == 3);
|
||||
assert(history_view_latest_start_for_height(messages, 6, 6) == 2);
|
||||
}
|
||||
|
||||
TEST(latest_start_handles_empty_and_tiny_view) {
|
||||
message_t messages[1];
|
||||
messages[0] = make_msg(1704067200, "only");
|
||||
|
||||
assert(history_view_latest_start_for_height(messages, 0, 3) == 0);
|
||||
assert(history_view_latest_start_for_height(messages, 1, 1) == 0);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("=== History View Unit Tests ===\n");
|
||||
|
||||
RUN_TEST(height_clamps_to_message_area);
|
||||
RUN_TEST(max_scroll_clamps_to_zero);
|
||||
RUN_TEST(scroll_to_latest_enables_follow_tail);
|
||||
RUN_TEST(scroll_to_oldest_disables_follow_tail);
|
||||
RUN_TEST(scroll_by_clamps_and_toggles_follow);
|
||||
RUN_TEST(latest_start_counts_date_dividers);
|
||||
RUN_TEST(latest_start_handles_empty_and_tiny_view);
|
||||
|
||||
printf("\nAll %d tests passed!\n", tests_passed);
|
||||
return 0;
|
||||
}
|
||||
189
tests/unit/test_i18n.c
Normal file
189
tests/unit/test_i18n.c
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/* Unit tests for i18n language selection and text lookup */
|
||||
|
||||
#include "../../include/i18n.h"
|
||||
#include "text_assert.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define TEST(name) static void test_##name()
|
||||
#define RUN_TEST(name) do { \
|
||||
printf("Running %s... ", #name); \
|
||||
test_##name(); \
|
||||
printf("✓\n"); \
|
||||
tests_passed++; \
|
||||
} while(0)
|
||||
|
||||
static int tests_passed = 0;
|
||||
|
||||
TEST(parse_explicit_languages) {
|
||||
ui_lang_t lang;
|
||||
|
||||
assert(i18n_parse_ui_lang("zh", UI_LANG_EN) == UI_LANG_ZH);
|
||||
assert(i18n_parse_ui_lang("zh_CN.UTF-8", UI_LANG_EN) == UI_LANG_ZH);
|
||||
assert(i18n_parse_ui_lang("en", UI_LANG_ZH) == UI_LANG_EN);
|
||||
assert(i18n_parse_ui_lang("en_US.UTF-8", UI_LANG_ZH) == UI_LANG_EN);
|
||||
assert(i18n_parse_ui_lang("C", UI_LANG_ZH) == UI_LANG_EN);
|
||||
assert(i18n_parse_ui_lang("POSIX", UI_LANG_ZH) == UI_LANG_EN);
|
||||
|
||||
assert(i18n_try_parse_ui_lang("zh", &lang) == true);
|
||||
assert(lang == UI_LANG_ZH);
|
||||
assert(i18n_try_parse_ui_lang("en", &lang) == true);
|
||||
assert(lang == UI_LANG_EN);
|
||||
assert(i18n_try_parse_ui_lang("cn", &lang) == false);
|
||||
assert(i18n_try_parse_ui_lang("english", &lang) == false);
|
||||
assert(i18n_try_parse_ui_lang("chinese", &lang) == false);
|
||||
assert(i18n_try_parse_ui_lang("中文", &lang) == false);
|
||||
assert(i18n_try_parse_ui_lang("英文", &lang) == false);
|
||||
assert(i18n_try_parse_ui_lang("fr", &lang) == false);
|
||||
}
|
||||
|
||||
TEST(parse_unknown_uses_fallback) {
|
||||
assert(i18n_parse_ui_lang(NULL, UI_LANG_ZH) == UI_LANG_ZH);
|
||||
assert(i18n_parse_ui_lang("", UI_LANG_EN) == UI_LANG_EN);
|
||||
assert(i18n_parse_ui_lang("fr_FR.UTF-8", UI_LANG_ZH) == UI_LANG_ZH);
|
||||
}
|
||||
|
||||
TEST(parse_ignores_surrounding_whitespace) {
|
||||
ui_lang_t lang;
|
||||
|
||||
assert(i18n_try_parse_ui_lang(" zh ", &lang) == true);
|
||||
assert(lang == UI_LANG_ZH);
|
||||
assert(i18n_parse_ui_lang("\ten_US.UTF-8\n", UI_LANG_ZH) == UI_LANG_EN);
|
||||
assert(i18n_try_parse_ui_lang(" english ", &lang) == false);
|
||||
assert(i18n_try_parse_ui_lang("zh CN", &lang) == false);
|
||||
|
||||
setenv("TNT_LANG", " zh ", 1);
|
||||
setenv("LC_ALL", "en_US.UTF-8", 1);
|
||||
assert(i18n_default_ui_lang() == UI_LANG_ZH);
|
||||
}
|
||||
|
||||
TEST(default_prefers_tnt_lang) {
|
||||
setenv("TNT_LANG", "zh_CN.UTF-8", 1);
|
||||
setenv("LC_ALL", "en_US.UTF-8", 1);
|
||||
assert(i18n_default_ui_lang() == UI_LANG_ZH);
|
||||
|
||||
setenv("TNT_LANG", "en", 1);
|
||||
setenv("LC_ALL", "zh_CN.UTF-8", 1);
|
||||
assert(i18n_default_ui_lang() == UI_LANG_EN);
|
||||
}
|
||||
|
||||
TEST(default_uses_locale_when_no_tnt_lang) {
|
||||
unsetenv("TNT_LANG");
|
||||
setenv("LC_ALL", "zh_CN.UTF-8", 1);
|
||||
assert(i18n_default_ui_lang() == UI_LANG_ZH);
|
||||
|
||||
setenv("LC_ALL", "C", 1);
|
||||
assert(i18n_default_ui_lang() == UI_LANG_EN);
|
||||
}
|
||||
|
||||
TEST(text_lookup_matches_language) {
|
||||
i18n_string_t sample = I18N_STRING("fallback", "替代");
|
||||
|
||||
assert(strcmp(i18n_string(sample, UI_LANG_EN), "fallback") == 0);
|
||||
assert(strcmp(i18n_string(sample, UI_LANG_ZH), "替代") == 0);
|
||||
assert(strcmp(i18n_string(sample, (ui_lang_t)99), "fallback") == 0);
|
||||
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_USERNAME_PROMPT),
|
||||
"display name") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_USERNAME_PROMPT),
|
||||
"用户名") != NULL);
|
||||
assert(strstr(i18n_text((ui_lang_t)99, I18N_USERNAME_PROMPT),
|
||||
"display name") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_WELCOME_SUBTITLE),
|
||||
"anonymous chat") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_WELCOME_SUBTITLE),
|
||||
"匿名聊天室") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_HELP_STATUS_FORMAT),
|
||||
"KEY REFERENCE") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_HELP_STATUS_FORMAT),
|
||||
"l:lang") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_HELP_STATUS_FORMAT),
|
||||
"按键参考") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_HELP_STATUS_FORMAT),
|
||||
"l:语言") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_COMMAND_OUTPUT_TITLE),
|
||||
"COMMAND") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_COMMAND_OUTPUT_TITLE),
|
||||
"命令输出") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_COMMAND_OUTPUT_STATUS_FORMAT),
|
||||
"q:close") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_COMMAND_OUTPUT_STATUS_FORMAT),
|
||||
"q:关闭") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_MOTD_CONTINUE_HINT),
|
||||
"Press any key") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MOTD_CONTINUE_HINT),
|
||||
"按任意键") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_TITLE_ONLINE_FORMAT),
|
||||
"online") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_TITLE_ONLINE_FORMAT),
|
||||
"在线") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_IDLE_TIMEOUT_FORMAT),
|
||||
"idle timeout") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_IDLE_TIMEOUT_FORMAT),
|
||||
"空闲超时") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_MSG_SENT_FORMAT),
|
||||
"Private message sent") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MSG_SENT_FORMAT),
|
||||
"私信已发送") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_INBOX_TITLE),
|
||||
"Private messages") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_TITLE),
|
||||
"私信") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_SEARCH_HEADER_FORMAT),
|
||||
"Search") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_HEADER_FORMAT),
|
||||
"搜索") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_LANG_CURRENT_FORMAT),
|
||||
"lang <en|zh>") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_LANG_CURRENT_FORMAT),
|
||||
"lang <en|zh>") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_UNKNOWN_COMMAND_FORMAT),
|
||||
"Unknown command") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_UNKNOWN_COMMAND_FORMAT),
|
||||
"未知命令") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_POST_EMPTY),
|
||||
"message cannot be empty") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_POST_EMPTY),
|
||||
"消息不能为空") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
||||
"Unknown command") != NULL);
|
||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
||||
"未知命令") != NULL);
|
||||
assert(strcmp(i18n_ui_lang_code(UI_LANG_EN), "en") == 0);
|
||||
assert(strcmp(i18n_ui_lang_code(UI_LANG_ZH), "zh") == 0);
|
||||
assert(strcmp(i18n_ui_lang_code((ui_lang_t)99), "en") == 0);
|
||||
assert(i18n_next_ui_lang(UI_LANG_EN) == UI_LANG_ZH);
|
||||
assert(i18n_next_ui_lang(UI_LANG_ZH) == UI_LANG_EN);
|
||||
assert(i18n_next_ui_lang((ui_lang_t)99) == UI_LANG_EN);
|
||||
}
|
||||
|
||||
TEST(text_catalog_is_complete) {
|
||||
for (int id = 0; id < I18N_TEXT_COUNT; id++) {
|
||||
assert(i18n_text(UI_LANG_EN, (i18n_text_id_t)id)[0] != '\0');
|
||||
assert(i18n_text(UI_LANG_ZH, (i18n_text_id_t)id)[0] != '\0');
|
||||
assert_ascii_angle_placeholders(
|
||||
i18n_text(UI_LANG_ZH, (i18n_text_id_t)id));
|
||||
}
|
||||
|
||||
assert(strcmp(i18n_text(UI_LANG_EN,
|
||||
(i18n_text_id_t)I18N_TEXT_COUNT), "") == 0);
|
||||
assert(strcmp(i18n_text(UI_LANG_ZH,
|
||||
(i18n_text_id_t)I18N_TEXT_COUNT), "") == 0);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("Running i18n unit tests...\n\n");
|
||||
|
||||
RUN_TEST(parse_explicit_languages);
|
||||
RUN_TEST(parse_unknown_uses_fallback);
|
||||
RUN_TEST(parse_ignores_surrounding_whitespace);
|
||||
RUN_TEST(default_prefers_tnt_lang);
|
||||
RUN_TEST(default_uses_locale_when_no_tnt_lang);
|
||||
RUN_TEST(text_lookup_matches_language);
|
||||
RUN_TEST(text_catalog_is_complete);
|
||||
|
||||
printf("\n✓ All %d tests passed!\n", tests_passed);
|
||||
return 0;
|
||||
}
|
||||
77
tests/unit/test_manual_text.c
Normal file
77
tests/unit/test_manual_text.c
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/* Unit tests for concise manual text language selection */
|
||||
|
||||
#include "../../include/manual_text.h"
|
||||
#include "text_assert.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#define TEST(name) static void test_##name()
|
||||
#define RUN_TEST(name) do { \
|
||||
printf("Running %s... ", #name); \
|
||||
test_##name(); \
|
||||
printf("✓\n"); \
|
||||
tests_passed++; \
|
||||
} while(0)
|
||||
|
||||
static int tests_passed = 0;
|
||||
|
||||
static int count_lines(const char *text) {
|
||||
int lines = 0;
|
||||
|
||||
while (text && *text) {
|
||||
if (*text == '\n') {
|
||||
lines++;
|
||||
}
|
||||
text++;
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
TEST(interactive_manual_matches_language) {
|
||||
char en[4096] = {0};
|
||||
char zh[4096] = {0};
|
||||
size_t en_pos = 0;
|
||||
size_t zh_pos = 0;
|
||||
|
||||
manual_text_append_interactive(en, sizeof(en), &en_pos, UI_LANG_EN);
|
||||
manual_text_append_interactive(zh, sizeof(zh), &zh_pos, UI_LANG_ZH);
|
||||
|
||||
assert(strstr(en, "TNT(1) help") != NULL);
|
||||
assert(strstr(en, "Use") != NULL);
|
||||
assert(strstr(en, "Commands") != NULL);
|
||||
assert(strstr(en, ":lang en|zh") != NULL);
|
||||
assert(strstr(en, ":mute-joins") != NULL);
|
||||
assert(strstr(en, ":mute-joins, :clear, :q") != NULL);
|
||||
assert(strstr(en, ":support") == NULL);
|
||||
assert(strstr(en, ":commands") == NULL);
|
||||
assert(count_lines(en) <= 20);
|
||||
|
||||
memset(en, 0, sizeof(en));
|
||||
en_pos = 0;
|
||||
manual_text_append_interactive(en, sizeof(en), &en_pos, (ui_lang_t)99);
|
||||
assert(strstr(en, "TNT(1) help") != NULL);
|
||||
|
||||
assert(strstr(zh, "TNT(1) 帮助") != NULL);
|
||||
assert(strstr(zh, "使用") != NULL);
|
||||
assert(strstr(zh, "命令") != NULL);
|
||||
assert(strstr(zh, ":lang en|zh") != NULL);
|
||||
assert(strstr(zh, ":mute-joins") != NULL);
|
||||
assert(strstr(zh, ":msg <user> <message>") != NULL);
|
||||
assert(strstr(zh, "<用户>") == NULL);
|
||||
assert(strstr(zh, ":mute-joins, :clear, :q") != NULL);
|
||||
assert(strstr(zh, ":support") == NULL);
|
||||
assert(strstr(zh, ":commands") == NULL);
|
||||
assert(count_lines(zh) <= 20);
|
||||
assert_ascii_angle_placeholders(zh);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("Running manual text unit tests...\n\n");
|
||||
|
||||
RUN_TEST(interactive_manual_matches_language);
|
||||
|
||||
printf("\n✓ All %d tests passed!\n", tests_passed);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -22,18 +22,6 @@ static void cleanup_test_log(void) {
|
|||
unlink(test_log);
|
||||
}
|
||||
|
||||
/* Helper: Create test log with N messages */
|
||||
static void create_test_log(int count) {
|
||||
FILE *fp = fopen(test_log, "w");
|
||||
assert(fp != NULL);
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
fprintf(fp, "2026-02-08T10:00:%02d+08:00|user%d|Test message %d\n",
|
||||
i, i, i);
|
||||
}
|
||||
fclose(fp);
|
||||
}
|
||||
|
||||
/* Test message initialization */
|
||||
TEST(message_init) {
|
||||
message_init();
|
||||
|
|
@ -48,7 +36,6 @@ TEST(message_load_empty) {
|
|||
FILE *fp = fopen(test_log, "w");
|
||||
fclose(fp);
|
||||
|
||||
message_t *messages = NULL;
|
||||
/* Can't easily override LOG_FILE constant, so this is a documentation test */
|
||||
|
||||
cleanup_test_log();
|
||||
|
|
|
|||
71
tests/unit/test_ratelimit.c
Normal file
71
tests/unit/test_ratelimit.c
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/* Unit tests for connection and rate-limit accounting */
|
||||
|
||||
#include "../../include/ratelimit.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#define TEST(name) static void test_##name()
|
||||
#define RUN_TEST(name) do { \
|
||||
printf("Running %s... ", #name); \
|
||||
test_##name(); \
|
||||
printf("✓\n"); \
|
||||
tests_passed++; \
|
||||
} while(0)
|
||||
|
||||
static int tests_passed = 0;
|
||||
|
||||
TEST(per_ip_concurrent_limit_blocks_second_active_connection) {
|
||||
const char *ip = "203.0.113.10";
|
||||
|
||||
setenv("TNT_RATE_LIMIT", "0", 1);
|
||||
setenv("TNT_MAX_CONN_PER_IP", "1", 1);
|
||||
ratelimit_init();
|
||||
|
||||
assert(ratelimit_check_ip(ip) == true);
|
||||
assert(ratelimit_check_ip(ip) == false);
|
||||
|
||||
ratelimit_release_ip(ip);
|
||||
assert(ratelimit_check_ip(ip) == true);
|
||||
ratelimit_release_ip(ip);
|
||||
}
|
||||
|
||||
TEST(rate_limit_allows_configured_burst_then_blocks) {
|
||||
const char *ip = "203.0.113.20";
|
||||
|
||||
setenv("TNT_RATE_LIMIT", "1", 1);
|
||||
setenv("TNT_MAX_CONN_PER_IP", "10", 1);
|
||||
setenv("TNT_MAX_CONN_RATE_PER_IP", "2", 1);
|
||||
ratelimit_init();
|
||||
|
||||
assert(ratelimit_check_ip(ip) == true);
|
||||
ratelimit_release_ip(ip);
|
||||
assert(ratelimit_check_ip(ip) == true);
|
||||
ratelimit_release_ip(ip);
|
||||
assert(ratelimit_check_ip(ip) == false);
|
||||
}
|
||||
|
||||
TEST(global_limit_tracks_active_total) {
|
||||
setenv("TNT_MAX_CONNECTIONS", "1", 1);
|
||||
ratelimit_init();
|
||||
|
||||
assert(ratelimit_check_and_increment_total() == true);
|
||||
assert(ratelimit_get_active_total() == 1);
|
||||
assert(ratelimit_check_and_increment_total() == false);
|
||||
|
||||
ratelimit_decrement_total();
|
||||
assert(ratelimit_get_active_total() == 0);
|
||||
assert(ratelimit_check_and_increment_total() == true);
|
||||
ratelimit_decrement_total();
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("Running rate-limit unit tests...\n\n");
|
||||
|
||||
RUN_TEST(per_ip_concurrent_limit_blocks_second_active_connection);
|
||||
RUN_TEST(rate_limit_allows_configured_burst_then_blocks);
|
||||
RUN_TEST(global_limit_tracks_active_total);
|
||||
|
||||
printf("\n✓ All %d tests passed!\n", tests_passed);
|
||||
return 0;
|
||||
}
|
||||
77
tests/unit/test_system_message.c
Normal file
77
tests/unit/test_system_message.c
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/* Unit tests for localized system event messages */
|
||||
|
||||
#include "../../include/system_message.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#define TEST(name) static void test_##name()
|
||||
#define RUN_TEST(name) do { \
|
||||
printf("Running %s... ", #name); \
|
||||
test_##name(); \
|
||||
printf("✓\n"); \
|
||||
tests_passed++; \
|
||||
} while(0)
|
||||
|
||||
static int tests_passed = 0;
|
||||
|
||||
TEST(join_leave_follow_language) {
|
||||
message_t msg;
|
||||
|
||||
system_message_make_join(&msg, "alice", UI_LANG_ZH);
|
||||
assert(strcmp(msg.username, "系统") == 0);
|
||||
assert(strstr(msg.content, "alice") != NULL);
|
||||
assert(strstr(msg.content, "加入了聊天室") != NULL);
|
||||
assert(system_message_is_system(&msg));
|
||||
assert(system_message_is_join_leave(&msg));
|
||||
|
||||
system_message_make_leave(&msg, "bob", UI_LANG_EN);
|
||||
assert(strcmp(msg.username, "system") == 0);
|
||||
assert(strstr(msg.content, "bob") != NULL);
|
||||
assert(strstr(msg.content, "left the room") != NULL);
|
||||
assert(system_message_is_system(&msg));
|
||||
assert(system_message_is_join_leave(&msg));
|
||||
}
|
||||
|
||||
TEST(nick_messages_are_system_events_not_join_leave) {
|
||||
message_t msg;
|
||||
|
||||
system_message_make_nick(&msg, "old", "new", UI_LANG_EN);
|
||||
assert(strcmp(msg.username, "system") == 0);
|
||||
assert(strstr(msg.content, "old") != NULL);
|
||||
assert(strstr(msg.content, "new") != NULL);
|
||||
assert(strstr(msg.content, "renamed") != NULL);
|
||||
assert(system_message_is_system(&msg));
|
||||
assert(!system_message_is_join_leave(&msg));
|
||||
|
||||
system_message_make_nick(&msg, "旧", "新", UI_LANG_ZH);
|
||||
assert(strcmp(msg.username, "系统") == 0);
|
||||
assert(strstr(msg.content, "更名为") != NULL);
|
||||
assert(system_message_is_system(&msg));
|
||||
assert(!system_message_is_join_leave(&msg));
|
||||
}
|
||||
|
||||
TEST(legacy_system_names_are_recognized) {
|
||||
message_t msg = {0};
|
||||
|
||||
snprintf(msg.username, sizeof(msg.username), "系统");
|
||||
snprintf(msg.content, sizeof(msg.content), "alice 离开了聊天室");
|
||||
assert(system_message_is_system(&msg));
|
||||
assert(system_message_is_join_leave(&msg));
|
||||
|
||||
snprintf(msg.username, sizeof(msg.username), "system");
|
||||
snprintf(msg.content, sizeof(msg.content), "alice joined the room");
|
||||
assert(system_message_is_system(&msg));
|
||||
assert(system_message_is_join_leave(&msg));
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("Running system message unit tests...\n\n");
|
||||
|
||||
RUN_TEST(join_leave_follow_language);
|
||||
RUN_TEST(nick_messages_are_system_events_not_join_leave);
|
||||
RUN_TEST(legacy_system_names_are_recognized);
|
||||
|
||||
printf("\n✓ All %d tests passed!\n", tests_passed);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -114,6 +114,22 @@ TEST(utf8_string_width_cjk_only) {
|
|||
assert(utf8_string_width("中文字符") == 8);
|
||||
}
|
||||
|
||||
TEST(utf8_ansi_string_width_ignores_escape_sequences) {
|
||||
assert(utf8_ansi_string_width("\033[1;36mHello\033[0m") == 5);
|
||||
assert(utf8_ansi_string_width("\033[31m支持\033[0m") == 4);
|
||||
assert(utf8_ansi_string_width("A\033[7;33m中\033[0mB") == 4);
|
||||
}
|
||||
|
||||
TEST(utf8_ansi_truncate_preserves_escape_sequences) {
|
||||
char out[64];
|
||||
|
||||
utf8_ansi_truncate("\033[31mHello世界\033[0m", out, sizeof(out), 7);
|
||||
assert(strcmp(out, "\033[31mHello世\033[0m") == 0);
|
||||
|
||||
utf8_ansi_truncate("A\033[7;33m中文\033[0mB", out, sizeof(out), 5);
|
||||
assert(strcmp(out, "A\033[7;33m中文\033[0m") == 0);
|
||||
}
|
||||
|
||||
/* Test backspace handling */
|
||||
TEST(utf8_remove_last_char) {
|
||||
char buffer[256];
|
||||
|
|
@ -228,6 +244,8 @@ int main(void) {
|
|||
RUN_TEST(utf8_string_width_ascii);
|
||||
RUN_TEST(utf8_string_width_mixed);
|
||||
RUN_TEST(utf8_string_width_cjk_only);
|
||||
RUN_TEST(utf8_ansi_string_width_ignores_escape_sequences);
|
||||
RUN_TEST(utf8_ansi_truncate_preserves_escape_sequences);
|
||||
RUN_TEST(utf8_remove_last_char);
|
||||
RUN_TEST(utf8_remove_last_char_multibyte);
|
||||
RUN_TEST(utf8_remove_last_word);
|
||||
|
|
|
|||
24
tests/unit/text_assert.h
Normal file
24
tests/unit/text_assert.h
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#ifndef TEST_TEXT_ASSERT_H
|
||||
#define TEST_TEXT_ASSERT_H
|
||||
|
||||
#include <assert.h>
|
||||
|
||||
static void assert_ascii_angle_placeholders(const char *text) {
|
||||
int in_placeholder = 0;
|
||||
|
||||
while (text && *text) {
|
||||
unsigned char ch = (unsigned char)*text;
|
||||
|
||||
if (ch == '<') {
|
||||
in_placeholder = 1;
|
||||
} else if (ch == '>') {
|
||||
in_placeholder = 0;
|
||||
} else if (in_placeholder) {
|
||||
assert(ch < 128);
|
||||
}
|
||||
|
||||
text++;
|
||||
}
|
||||
}
|
||||
|
||||
#endif /* TEST_TEXT_ASSERT_H */
|
||||
35
tnt.1
35
tnt.1
|
|
@ -1,5 +1,5 @@
|
|||
.\" tnt(1) - Terminal Network Talk
|
||||
.TH TNT 1 "April 2026" "TNT 1.0.0" "User Commands"
|
||||
.TH TNT 1 "2026-05-24" "TNT 1.0.1" "User Commands"
|
||||
.SH NAME
|
||||
tnt \- anonymous SSH chat server with Vim\-style TUI
|
||||
.SH SYNOPSIS
|
||||
|
|
@ -15,7 +15,8 @@ tnt \- anonymous SSH chat server with Vim\-style TUI
|
|||
is a multi\-user anonymous chat server accessed over SSH.
|
||||
It provides a Vim\-style terminal user interface with INSERT, NORMAL, and
|
||||
COMMAND modes.
|
||||
Users connect with any standard SSH client; no account or registration is needed.
|
||||
Users connect with any standard SSH client; no account or registration is
|
||||
needed.
|
||||
.PP
|
||||
Messages are persisted to a log file and restored on server restart.
|
||||
The server supports CJK and emoji input, rate limiting, access tokens, and
|
||||
|
|
@ -44,7 +45,6 @@ Print version and exit.
|
|||
.BR \-h ", " \-\-help
|
||||
Print a short usage summary and exit.
|
||||
.SH CONNECTING
|
||||
.PP
|
||||
.nf
|
||||
ssh any\-username@hostname \-p 2222
|
||||
.fi
|
||||
|
|
@ -70,7 +70,7 @@ to return to INSERT,
|
|||
.B :
|
||||
to enter COMMAND mode,
|
||||
.B ?
|
||||
to open the help screen.
|
||||
to open the full key reference.
|
||||
.TP
|
||||
.B COMMAND
|
||||
Execute commands prefixed with
|
||||
|
|
@ -84,33 +84,45 @@ ESC Switch to NORMAL
|
|||
Ctrl+W Delete last word
|
||||
Ctrl+U Clear input line
|
||||
Ctrl+C Switch to NORMAL
|
||||
Paste Keep multi-line paste in the input buffer
|
||||
/me \fIaction\fR Send action message (e.g. /me waves)
|
||||
@\fIusername\fR Mention user (bell notification + highlight)
|
||||
.TE
|
||||
.PP
|
||||
The input line shows remaining bytes near the message limit. Extra input
|
||||
past the limit is ignored with a terminal bell.
|
||||
.SS NORMAL mode
|
||||
.TS
|
||||
l l.
|
||||
j/k Scroll down/up one line
|
||||
Ctrl+D/Ctrl+U Scroll half page down/up
|
||||
Ctrl+F/Ctrl+B Scroll full page down/up
|
||||
PageDown/PageUp Scroll full page down/up
|
||||
End/Home Jump to bottom/top
|
||||
g/G Jump to top/bottom
|
||||
i Switch to INSERT
|
||||
: Enter COMMAND mode
|
||||
? Open help screen
|
||||
? Open full key reference
|
||||
Ctrl+C Disconnect
|
||||
.TE
|
||||
.PP
|
||||
NORMAL mode opens on the latest visible messages and stays pinned there
|
||||
until you scroll up. Use k, Ctrl+U, Ctrl+B, or PageUp to move toward
|
||||
older history; use G or End to return to the latest messages.
|
||||
.SS COMMAND mode
|
||||
.TS
|
||||
l l.
|
||||
:list Show online users
|
||||
:nick \fIname\fR Change nickname
|
||||
:name \fIname\fR Alias for :nick
|
||||
:msg \fIuser text\fR Send private whisper
|
||||
:msg \fIuser message\fR Send private message
|
||||
:w \fIuser text\fR Short alias for :msg
|
||||
:inbox Show private messages
|
||||
:last [\fIN\fR] Show last N messages from history (1\-50, default 10)
|
||||
:search \fIkeyword\fR Case\-insensitive search across full message history
|
||||
:mute\-joins Toggle join/leave system notifications on/off
|
||||
:help Show available commands
|
||||
:lang \fIen|zh\fR Switch UI language for this session
|
||||
:help Show concise manual
|
||||
:clear Clear command output
|
||||
:q, :quit, :exit Disconnect
|
||||
Up/Down Browse command history
|
||||
|
|
@ -144,6 +156,14 @@ Directory for host key and message log (default: current directory).
|
|||
If set, clients must supply this string as their SSH password.
|
||||
Compared in constant time.
|
||||
.TP
|
||||
.B TNT_LANG
|
||||
Default interactive UI language.
|
||||
Accepts
|
||||
.B en
|
||||
or
|
||||
.BR zh .
|
||||
When unset, TNT detects the process locale and falls back to English.
|
||||
.TP
|
||||
.B TNT_MAX_CONNECTIONS
|
||||
Global connection limit (default: 64, max: 1024).
|
||||
.TP
|
||||
|
|
@ -225,6 +245,7 @@ m1ngsama <contact@m1ng.space>
|
|||
.SH BUGS
|
||||
Report bugs at
|
||||
.UR https://github.com/m1ngsama/TNT/issues
|
||||
the project issue tracker
|
||||
.UE .
|
||||
.SH SEE ALSO
|
||||
.BR ssh (1),
|
||||
|
|
|
|||
Loading…
Reference in a new issue