From 7a72cf434098c13cab154dce2670069f89c787a1 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Fri, 16 Oct 2026 00:07:09 +0800 Subject: [PATCH] feat(i18n): start each session in the language its SSH client sends OpenSSH sends LANG and LC_* by default, so a session now follows LC_ALL, LC_MESSAGES or LANG when it names English or Chinese, and the server's language otherwise. The suites unset those variables so a developer's locale no longer changes what they see. --- include/ssh_server.h | 2 + src/bootstrap.c | 28 +++++++++++++ src/input.c | 2 +- tests/run_suites.sh | 6 ++- tests/test_client_lang.sh | 83 +++++++++++++++++++++++++++++++++++++++ tnt-chat.7 | 6 +++ tnt.8 | 4 ++ 7 files changed, 129 insertions(+), 2 deletions(-) create mode 100755 tests/test_client_lang.sh diff --git a/include/ssh_server.h b/include/ssh_server.h index c24d891..c79ae0e 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -65,6 +65,8 @@ typedef struct client { char ssh_login[MAX_USERNAME_LEN]; char session_id[32]; /* Opaque id an identity module sees */ char key_fingerprint[TNT_IDENTITY_FINGERPRINT_SIZE]; /* Signed SSH key */ + bool has_client_lang; /* The client sent LANG or LC_* */ + ui_lang_t client_lang; time_t connect_time; time_t last_active; atomic_bool redraw_pending; diff --git a/src/bootstrap.c b/src/bootstrap.c index 1f379cf..e4040cb 100644 --- a/src/bootstrap.c +++ b/src/bootstrap.c @@ -1,6 +1,7 @@ #include "bootstrap.h" #include "client.h" #include "common.h" +#include "i18n.h" #include "identity.h" #include "input.h" #include "ratelimit.h" @@ -27,6 +28,8 @@ typedef struct { char client_ip[INET6_ADDRSTRLEN]; char requested_user[MAX_USERNAME_LEN]; char key_fingerprint[TNT_IDENTITY_FINGERPRINT_SIZE]; + int lang_rank; /* 0 none, 1 LANG, 2 LC_MESSAGES, 3 LC_ALL */ + ui_lang_t lang; int pty_width; int pty_height; char exec_command[MAX_EXEC_COMMAND_LEN]; @@ -422,6 +425,28 @@ static int channel_pty_window_change(ssh_session session, ssh_channel channel, return SSH_OK; } +/* OpenSSH clients send LANG and LC_* by default (SendEnv), which lets each + * session start in its own language. Nothing is exported to the process. */ +static int channel_env_request(ssh_session session, ssh_channel channel, + const char *name, const char *value, + void *userdata) { + (void)session; + (void)channel; + session_context_t *ctx = (session_context_t *)userdata; + int rank = !name ? 0 + : strcmp(name, "LC_ALL") == 0 ? 3 + : strcmp(name, "LC_MESSAGES") == 0 ? 2 + : strcmp(name, "LANG") == 0 ? 1 + : 0; + ui_lang_t lang; + + if (rank > ctx->lang_rank && i18n_try_parse_ui_lang(value, &lang)) { + ctx->lang_rank = rank; + ctx->lang = lang; + } + return SSH_OK; +} + /* Shell request callback */ static int channel_shell_request(ssh_session session, ssh_channel channel, void *userdata) { @@ -478,6 +503,7 @@ static void setup_session_channel_callbacks(ssh_channel channel, ctx->channel_cb->channel_shell_request_function = channel_shell_request; ctx->channel_cb->channel_pty_window_change_function = channel_pty_window_change; ctx->channel_cb->channel_exec_request_function = channel_exec_request; + ctx->channel_cb->channel_env_request_function = channel_env_request; ssh_set_channel_callbacks(channel, ctx->channel_cb); } @@ -681,6 +707,8 @@ void *bootstrap_run(void *arg) { } snprintf(client->key_fingerprint, sizeof(client->key_fingerprint), "%s", ctx->key_fingerprint); + client->has_client_lang = ctx->lang_rank > 0; + client->client_lang = ctx->lang; if (ctx->exec_command[0] != '\0') { strncpy(client->exec_command, ctx->exec_command, sizeof(client->exec_command) - 1); diff --git a/src/input.c b/src/input.c index 751f142..544a316 100644 --- a/src/input.c +++ b/src/input.c @@ -1642,7 +1642,7 @@ void input_run_session(client_t *client) { * leaves `ssh vim@host` free to mean "give me the vim keys". */ client->keymap = tnt_keymap_from_login(client->ssh_login, g_default_keymap); client->follow_tail = true; - client->ui_lang = g_default_ui_lang; + client->ui_lang = client->has_client_lang ? client->client_lang : g_default_ui_lang; client->connected = true; client->command_history_count = 0; client->command_history_pos = 0; diff --git a/tests/run_suites.sh b/tests/run_suites.sh index bb5e148..f0a17fa 100755 --- a/tests/run_suites.sh +++ b/tests/run_suites.sh @@ -2,6 +2,9 @@ set -u BASE_PORT=${PORT:-2222} +# ssh forwards these (SendEnv), and a session follows them over the server's +# TNT_LANG, so a developer's locale would change what the suites see. +unset LANG LC_ALL LC_MESSAGES JOBS=${INTEGRATION_JOBS:-4} # Slowest first, so the last job slot never waits on a long suite. @@ -23,7 +26,8 @@ vim_keymap 12 wide_text_view 9 module_privileged 16 identity 17 -module_private 18" +module_private 18 +client_lang 19" WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-suites.XXXXXX") trap 'rm -rf "$WORK_DIR"' EXIT diff --git a/tests/test_client_lang.sh b/tests/test_client_lang.sh new file mode 100755 index 0000000..24a8e84 --- /dev/null +++ b/tests/test_client_lang.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# Each session starts in the language its SSH client sends with LANG or LC_*. + +BIN="../tnt" +PORT=${PORT:-2222} +PASS=0 +FAIL=0 +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-client-lang-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 + +SSH_BASE="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PubkeyAuthentication=no -o ConnectionAttempts=3 -o ConnectTimeout=15 -o SendEnv=-* -p $PORT" + +wait_for_health() { + for _ in $(seq 1 15); do + [ "$(ssh -n $SSH_BASE localhost health 2>/dev/null)" = "ok" ] && return 0 + sleep 1 + done + return 1 +} + +# Prints which prompt language the session showed: zh, en or none. +prompt_language() { + local name="$1" + shift + local script="$STATE_DIR/$name.expect" + cat >"$script" <&1 | sed -n 's/^LANGUAGE=//p' | tr -d '\r' +} + +check() { + local description="$1" want="$2" got="$3" + if [ "$got" = "$want" ]; then + echo "✓ $description" + PASS=$((PASS + 1)) + else + echo "✗ $description (want $want, got $got)" + FAIL=$((FAIL + 1)) + fi +} + +echo "=== TNT Client Language Tests ===" + +TNT_LANG=en TNT_RATE_LIMIT=0 "$BIN" -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 & +SERVER_PID=$! +if ! wait_for_health; then + echo "✗ server did not start" + cat "$STATE_DIR/server.log" + exit 1 +fi + +check "LANG=zh_CN.UTF-8 starts in Chinese" zh "$(prompt_language zh -o SetEnv=LANG=zh_CN.UTF-8)" +check "LC_ALL wins over LANG" zh "$(prompt_language lcall '-o "SetEnv=LANG=en_US.UTF-8 LC_ALL=zh_CN.UTF-8"')" +check "LANG=en_US.UTF-8 keeps English" en "$(prompt_language en -o SetEnv=LANG=en_US.UTF-8)" +check "an unknown locale falls back to the server default" en "$(prompt_language other -o SetEnv=LANG=fr_FR.UTF-8)" +check "no locale falls back to the server default" en "$(prompt_language none)" + +echo "" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" +[ "$FAIL" -eq 0 ] && echo "All tests passed" +exit "$FAIL" diff --git a/tnt-chat.7 b/tnt-chat.7 index 5eb53f6..b57f41d 100644 --- a/tnt-chat.7 +++ b/tnt-chat.7 @@ -519,6 +519,12 @@ The default is .TQ .BI ":language " [en|zh] Show or set the session interface language. +A session starts in the language the SSH client sends in +.BR LC_ALL , +.B LC_MESSAGES +or +.BR LANG , +when it is English or Chinese, and in the server's language otherwise. .TP .B :help Open the concise interactive manual. diff --git a/tnt.8 b/tnt.8 index 7ba25f7..fccd713 100644 --- a/tnt.8 +++ b/tnt.8 @@ -239,6 +239,10 @@ An invalid explicit value selects English. Select the language when .B TNT_LANG is unset, in the precedence shown. +A session whose SSH client sends one of these variables, as OpenSSH does by +default with +.BR SendEnv , +starts in that language instead; otherwise it uses the server's. .TP .B TNT_MAX_CONNECTIONS Global connection limit.