i18n: select interactive language from locale

This commit is contained in:
m1ngsama 2026-05-23 18:06:39 +08:00
parent 39f7f1c7c4
commit 0c27976763
19 changed files with 332 additions and 45 deletions

1
.gitignore vendored
View file

@ -12,3 +12,4 @@ tests/unit/test_utf8
tests/unit/test_message
tests/unit/test_chat_room
tests/unit/test_history_view
tests/unit/test_i18n

View file

@ -127,6 +127,9 @@ TNT_STATE_DIR=/var/lib/tnt tnt
# Show the public SSH endpoint in startup logs
TNT_PUBLIC_HOST=chat.m1ng.space tnt
# Choose interactive UI language (en or zh; defaults from locale)
TNT_LANG=zh tnt
```
**Rate limiting:**

View file

@ -2,6 +2,11 @@
## 2026-05-21 - Message browsing polish
### 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 language, and `:support`.
### Changed
- 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

21
include/i18n.h Normal file
View file

@ -0,0 +1,21 @@
#ifndef I18N_H
#define I18N_H
#include "common.h"
typedef enum {
I18N_USERNAME_PROMPT,
I18N_INVALID_USERNAME,
I18N_ROOM_FULL,
I18N_INSERT_HINT_WIDE,
I18N_INSERT_HINT_NARROW,
I18N_NORMAL_LATEST,
I18N_NORMAL_NEW_MESSAGES
} i18n_text_id_t;
help_lang_t i18n_parse_lang(const char *value, help_lang_t fallback);
help_lang_t i18n_default_lang(void);
const char *i18n_lang_code(help_lang_t lang);
const char *i18n_text(help_lang_t lang, i18n_text_id_t id);
#endif /* I18N_H */

View file

@ -4,7 +4,8 @@
#include "common.h"
void support_append_interactive_panel(char *buffer, size_t buf_size,
size_t *pos);
void support_append_exec_panel(char *buffer, size_t buf_size, size_t *pos);
size_t *pos, help_lang_t lang);
void support_append_exec_panel(char *buffer, size_t buf_size, size_t *pos,
help_lang_t lang);
#endif /* SUPPORT_H */

View file

@ -188,7 +188,8 @@ void commands_dispatch(client_t *client) {
"========================================\n");
} else if (strcmp(cmd, "support") == 0 || strcmp(cmd, "guide") == 0) {
support_append_interactive_panel(output, sizeof(output), &pos);
support_append_interactive_panel(output, sizeof(output), &pos,
client->help_lang);
} else if (strncmp(cmd, "msg ", 4) == 0 || strncmp(cmd, "w ", 2) == 0) {
char *rest = (cmd[0] == 'w') ? cmd + 2 : cmd + 4;

View file

@ -137,7 +137,7 @@ static int exec_command_support(client_t *client) {
char output[2048] = {0};
size_t pos = 0;
support_append_exec_panel(output, sizeof(output), &pos);
support_append_exec_panel(output, sizeof(output), &pos, client->help_lang);
return client_send(client, output, pos) == 0 ? 0 : 1;
}

99
src/i18n.c Normal file
View file

@ -0,0 +1,99 @@
#include "i18n.h"
#include <ctype.h>
static bool starts_with_lang(const char *value, const char *prefix) {
if (!value || !prefix) return false;
while (*prefix) {
if (tolower((unsigned char)*value) !=
tolower((unsigned char)*prefix)) {
return false;
}
value++;
prefix++;
}
return *value == '\0' || *value == '_' || *value == '-' || *value == '.';
}
help_lang_t i18n_parse_lang(const char *value, help_lang_t fallback) {
if (!value || value[0] == '\0') {
return fallback;
}
if (starts_with_lang(value, "zh") ||
starts_with_lang(value, "cn") ||
starts_with_lang(value, "chinese")) {
return LANG_ZH;
}
if (starts_with_lang(value, "en") ||
starts_with_lang(value, "c") ||
starts_with_lang(value, "posix")) {
return LANG_EN;
}
return fallback;
}
help_lang_t i18n_default_lang(void) {
const char *explicit_lang = getenv("TNT_LANG");
if (explicit_lang && explicit_lang[0] != '\0') {
return i18n_parse_lang(explicit_lang, 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_lang(locale, LANG_EN);
}
const char *i18n_lang_code(help_lang_t lang) {
return lang == LANG_ZH ? "zh" : "en";
}
const char *i18n_text(help_lang_t lang, i18n_text_id_t id) {
if (lang == LANG_ZH) {
switch (id) {
case I18N_USERNAME_PROMPT:
return " 请输入用户名 (留空 anonymous): ";
case I18N_INVALID_USERNAME:
return "用户名无效,已改用 anonymous。\r\n";
case I18N_ROOM_FULL:
return "房间已满\r\n";
case I18N_INSERT_HINT_WIDE:
return "Enter 发送 · Esc 浏览 · :support";
case I18N_INSERT_HINT_NARROW:
return "Enter · Esc · :support";
case I18N_NORMAL_LATEST:
return "G 最新";
case I18N_NORMAL_NEW_MESSAGES:
return "新消息";
}
}
switch (id) {
case I18N_USERNAME_PROMPT:
return " Enter display name (blank for anonymous): ";
case I18N_INVALID_USERNAME:
return "Invalid username. Using 'anonymous' instead.\r\n";
case I18N_ROOM_FULL:
return "Room is full\r\n";
case I18N_INSERT_HINT_WIDE:
return "Enter send · Esc browse · :support";
case I18N_INSERT_HINT_NARROW:
return "Enter · Esc · :support";
case I18N_NORMAL_LATEST:
return "G latest";
case I18N_NORMAL_NEW_MESSAGES:
return "new";
}
return "";
}

View file

@ -5,6 +5,7 @@
#include "common.h"
#include "exec.h"
#include "history_view.h"
#include "i18n.h"
#include "message.h"
#include "ratelimit.h"
#include "tui.h"
@ -19,9 +20,11 @@
#include <time.h>
static int g_idle_timeout = DEFAULT_IDLE_TIMEOUT;
static help_lang_t g_default_lang = LANG_EN;
void input_init(void) {
g_idle_timeout = env_int("TNT_IDLE_TIMEOUT", DEFAULT_IDLE_TIMEOUT, 0, 86400);
g_default_lang = i18n_default_lang();
}
static int read_username(client_t *client) {
@ -30,7 +33,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->help_lang,
I18N_USERNAME_PROMPT));
while (1) {
int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */
@ -112,7 +116,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->help_lang,
I18N_INVALID_USERNAME));
strcpy(client->username, "anonymous");
} else {
/* Truncate to 20 characters */
@ -659,7 +664,7 @@ void input_run_session(client_t *client) {
/* Terminal size already set from PTY request */
client->mode = MODE_INSERT;
client->follow_tail = true;
client->help_lang = LANG_ZH;
client->help_lang = g_default_lang;
client->connected = true;
client->command_history_count = 0;
client->command_history_pos = 0;
@ -683,7 +688,8 @@ 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->help_lang,
I18N_ROOM_FULL));
goto cleanup;
}
joined_room = true;

View file

@ -63,6 +63,7 @@ int main(int argc, char **argv) {
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_LANG UI language: en or zh (default: locale)\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");

View file

@ -1,39 +1,92 @@
#include "support.h"
void support_append_interactive_panel(char *buffer, size_t buf_size,
size_t *pos) {
size_t *pos, help_lang_t lang) {
if (!buffer || !pos) return;
if (lang == LANG_ZH) {
buffer_appendf(buffer, buf_size, pos,
"\033[1;36m支持 · support\033[0m\n"
"\n"
"\033[1;37m第一次进来\033[0m\n"
" INSERT 输入消息Enter 发送ESC 进入 NORMAL\n"
" NORMAL 浏览消息G 回到最新i 继续输入\n"
" COMMAND 按 : 输入命令q/ESC 关闭当前面板\n"
"\n"
"\033[1;37m我想...\033[0m\n"
" 看谁在线 :users\n"
" 看最近历史 :last 20\n"
" 搜索聊天记录 :search <keyword>\n"
" 回到最新消息 G 或 End\n"
" 私聊某个人 :msg <user> <text>\n"
" 查看私聊收件箱 :inbox\n"
" 静音进出提示 :mute-joins\n"
"\n"
"\033[1;37m遇到问题\033[0m\n"
" 看不到新消息: 在 NORMAL 按 G 或 End 回到最新\n"
" 粘贴多行文本: 直接粘贴TNT 会等 Enter 后一次发送\n"
" 输入太长: 状态行接近限制时会提示,超出会响铃\n"
" 命令不记得: 输入 :help 看列表,输入 :support 回到这里\n"
" 连接断开: 可能是空闲超时、连接数限制或网络重连\n"
"\n"
"\033[2;37m更多: ? 打开完整按键帮助,:help 查看命令列表\033[0m\n");
return;
}
buffer_appendf(buffer, buf_size, pos,
"\033[1;36m支持 · support\033[0m\n"
"\033[1;36mSupport\033[0m\n"
"\n"
"\033[1;37m第一次进来\033[0m\n"
" INSERT 输入消息Enter 发送ESC 进入 NORMAL\n"
" NORMAL 浏览消息G 回到最新i 继续输入\n"
" COMMAND 按 : 输入命令q/ESC 关闭当前面板\n"
"\033[1;37mFirst minute\033[0m\n"
" INSERT Type messages, Enter sends, ESC enters NORMAL\n"
" NORMAL Browse history, G jumps latest, i continues typing\n"
" COMMAND Press : for commands, q/ESC closes this panel\n"
"\n"
"\033[1;37m我想...\033[0m\n"
" 看谁在线 :users\n"
" 看最近历史 :last 20\n"
" 搜索聊天记录 :search <keyword>\n"
" 回到最新消息 G 或 End\n"
" 私聊某个人 :msg <user> <text>\n"
" 查看私聊收件箱 :inbox\n"
" 静音进出提示 :mute-joins\n"
"\033[1;37mI want to...\033[0m\n"
" See who is online :users\n"
" See recent history :last 20\n"
" Search history :search <keyword>\n"
" Return to latest G or End\n"
" Whisper someone :msg <user> <text>\n"
" Read whispers :inbox\n"
" Mute join notices :mute-joins\n"
"\n"
"\033[1;37m遇到问题\033[0m\n"
" 看不到新消息: 在 NORMAL 按 G 或 End 回到最新\n"
" 粘贴多行文本: 直接粘贴TNT 会等 Enter 后一次发送\n"
" 输入太长: 状态行接近限制时会提示,超出会响铃\n"
" 命令不记得: 输入 :help 看列表,输入 :support 回到这里\n"
" 连接断开: 可能是空闲超时、连接数限制或网络重连\n"
"\033[1;37mTroubleshooting\033[0m\n"
" Missing new messages: press G or End in NORMAL\n"
" Pasting many lines: paste normally, then Enter sends once\n"
" Message too long: the status line warns near the limit\n"
" Forgot a command: type :help or return here with :support\n"
" Disconnected: check idle timeout, limits, or reconnect\n"
"\n"
"\033[2;37m更多: ? 打开完整按键帮助,:help 查看命令列表\033[0m\n");
"\033[2;37mMore: ? opens full key help, :help lists commands\033[0m\n");
}
void support_append_exec_panel(char *buffer, size_t buf_size, size_t *pos) {
void support_append_exec_panel(char *buffer, size_t buf_size, size_t *pos,
help_lang_t lang) {
if (!buffer || !pos) return;
if (lang == LANG_ZH) {
buffer_appendf(buffer, buf_size, pos,
"TNT 支持\n"
"\n"
"交互使用:\n"
" ssh -p 2222 HOST\n"
" INSERT: 输入消息并按 Enter 发送\n"
" NORMAL: G 回到最新k/PageUp 查看更早消息\n"
" COMMAND: 按 : 后可运行 users, last, search, msg, inbox\n"
"\n"
"非交互检查:\n"
" ssh -p 2222 HOST health\n"
" ssh -p 2222 HOST stats --json\n"
" ssh -p 2222 HOST users --json\n"
" ssh -p 2222 HOST 'tail -n 20'\n"
" ssh -p 2222 USER@HOST post 'message'\n"
"\n"
"排查:\n"
" 连接过早关闭: 检查限流、空闲超时、连接容量、\n"
" 单 IP 限制和防火墙规则。\n");
return;
}
buffer_appendf(buffer, buf_size, pos,
"TNT support\n"
"\n"

View file

@ -1,4 +1,5 @@
#include "tui_status.h"
#include "i18n.h"
#include "ssh_server.h"
void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
@ -10,12 +11,16 @@ void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
if (client->width >= 58) {
buffer_appendf(buffer, buf_size, pos,
"\033[2;37m\033[0m "
"\033[2;37mEnter send · Esc browse · :support\033[0m"
"\033[K");
"\033[2;37m%s\033[0m"
"\033[K",
i18n_text(client->help_lang,
I18N_INSERT_HINT_WIDE));
} else if (client->width >= 36) {
buffer_appendf(buffer, buf_size, pos,
"\033[2;37m\033[0m "
"\033[2;37mEnter · Esc · :support\033[0m\033[K");
"\033[2;37m%s\033[0m\033[K",
i18n_text(client->help_lang,
I18N_INSERT_HINT_NARROW));
} else {
buffer_appendf(buffer, buf_size, pos, "\033[2;37m\033[0m \033[K");
}
@ -29,14 +34,18 @@ void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
buffer_appendf(buffer, buf_size, pos,
"\033[7;33m NORMAL \033[0m"
" \033[2;37m%d-%d / %d\033[0m"
" \033[33m▼ %d new · G latest\033[0m\033[K",
range_start, range_end, total, unseen);
" \033[33m▼ %d %s · %s\033[0m\033[K",
range_start, range_end, total, unseen,
i18n_text(client->help_lang,
I18N_NORMAL_NEW_MESSAGES),
i18n_text(client->help_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;37mG latest\033[0m\033[K",
range_start, range_end, total);
" \033[2;37m%s\033[0m\033[K",
range_start, range_end, total,
i18n_text(client->help_lang, I18N_NORMAL_LATEST));
}
} else if (client->mode == MODE_COMMAND) {
buffer_appendf(buffer, buf_size, pos,

View file

@ -10,7 +10,7 @@ if [ ! -f "$BIN" ]; then
fi
echo "Starting TNT server on port $PORT..."
$BIN -p $PORT > /dev/null 2>&1 &
TNT_LANG=zh $BIN -p $PORT > /dev/null 2>&1 &
SERVER_PID=$!
sleep 2

View file

@ -44,7 +44,7 @@ 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=$!
@ -99,7 +99,7 @@ SERVER_PID=""
RATE_PORT=$((PORT + 1))
SSH_RATE_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $RATE_PORT"
TNT_MAX_CONN_PER_IP=10 TNT_MAX_CONN_RATE_PER_IP=2 "$BIN" -p "$RATE_PORT" -d "$STATE_DIR" \
TNT_LANG=zh TNT_MAX_CONN_PER_IP=10 TNT_MAX_CONN_RATE_PER_IP=2 "$BIN" -p "$RATE_PORT" -d "$STATE_DIR" \
>"$STATE_DIR/rate.log" 2>&1 &
SERVER_PID=$!

View file

@ -29,7 +29,7 @@ SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o Batc
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=""
@ -76,8 +76,8 @@ else
fi
SUPPORT_OUTPUT=$(ssh $SSH_OPTS localhost support 2>/dev/null || true)
printf '%s\n' "$SUPPORT_OUTPUT" | grep -q '^TNT support$' &&
printf '%s\n' "$SUPPORT_OUTPUT" | grep -q '^Troubleshooting:'
printf '%s\n' "$SUPPORT_OUTPUT" | grep -Eq '^TNT (support|支持)$' &&
printf '%s\n' "$SUPPORT_OUTPUT" | grep -Eq '^(Troubleshooting|排查):'
if [ $? -eq 0 ]; then
echo "✓ support returns quick guide"
PASS=$((PASS + 1))

View file

@ -32,7 +32,7 @@ SSH_OPTS="-e none -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o
echo "=== TNT Interactive Input 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=$!
SERVER_READY=0

View file

@ -15,8 +15,9 @@ MESSAGE_SRC = ../../src/message.c
COMMON_SRC = ../../src/common.c
CHAT_ROOM_SRC = ../../src/chat_room.c
HISTORY_VIEW_SRC = ../../src/history_view.c
I18N_SRC = ../../src/i18n.c
TESTS = test_utf8 test_message test_chat_room test_history_view
TESTS = test_utf8 test_message test_chat_room test_history_view test_i18n
.PHONY: all clean run
@ -34,6 +35,9 @@ test_chat_room: test_chat_room.c $(CHAT_ROOM_SRC) $(MESSAGE_SRC) $(UTF8_SRC) $(C
test_history_view: test_history_view.c $(HISTORY_VIEW_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_i18n: test_i18n.c $(I18N_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
run: all
@echo "=== Running UTF-8 Tests ==="
./test_utf8
@ -46,6 +50,9 @@ run: all
@echo ""
@echo "=== Running History View Tests ==="
./test_history_view
@echo ""
@echo "=== Running i18n Tests ==="
./test_i18n
clean:
rm -f $(TESTS) *.o test_messages.log

72
tests/unit/test_i18n.c Normal file
View file

@ -0,0 +1,72 @@
/* Unit tests for i18n language selection and text lookup */
#include "../../include/i18n.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) {
assert(i18n_parse_lang("zh", LANG_EN) == LANG_ZH);
assert(i18n_parse_lang("zh_CN.UTF-8", LANG_EN) == LANG_ZH);
assert(i18n_parse_lang("cn", LANG_EN) == LANG_ZH);
assert(i18n_parse_lang("en", LANG_ZH) == LANG_EN);
assert(i18n_parse_lang("en_US.UTF-8", LANG_ZH) == LANG_EN);
}
TEST(parse_unknown_uses_fallback) {
assert(i18n_parse_lang(NULL, LANG_ZH) == LANG_ZH);
assert(i18n_parse_lang("", LANG_EN) == LANG_EN);
assert(i18n_parse_lang("fr_FR.UTF-8", LANG_ZH) == 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_lang() == LANG_ZH);
setenv("TNT_LANG", "en", 1);
setenv("LC_ALL", "zh_CN.UTF-8", 1);
assert(i18n_default_lang() == LANG_EN);
}
TEST(default_uses_locale_when_no_tnt_lang) {
unsetenv("TNT_LANG");
setenv("LC_ALL", "zh_CN.UTF-8", 1);
assert(i18n_default_lang() == LANG_ZH);
setenv("LC_ALL", "C", 1);
assert(i18n_default_lang() == LANG_EN);
}
TEST(text_lookup_matches_language) {
assert(strstr(i18n_text(LANG_EN, I18N_USERNAME_PROMPT),
"display name") != NULL);
assert(strstr(i18n_text(LANG_ZH, I18N_USERNAME_PROMPT),
"用户名") != NULL);
assert(strcmp(i18n_lang_code(LANG_EN), "en") == 0);
assert(strcmp(i18n_lang_code(LANG_ZH), "zh") == 0);
}
int main(void) {
printf("Running i18n unit tests...\n\n");
RUN_TEST(parse_explicit_languages);
RUN_TEST(parse_unknown_uses_fallback);
RUN_TEST(default_prefers_tnt_lang);
RUN_TEST(default_uses_locale_when_no_tnt_lang);
RUN_TEST(text_lookup_matches_language);
printf("\n✓ All %d tests passed!\n", tests_passed);
return 0;
}

8
tnt.1
View file

@ -156,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