fix(session): give the PTY a UTF-8 locale so tmux stops mangling CJK
A launchd/Finder-launched server has no LANG/LC_* at all, so tmux — which
decides per client whether the terminal is UTF-8 capable purely from those
variables — fell back to its non-UTF-8 mode and rewrote the byte stream
server-side, before it ever reached xterm:
- every wide character became `_` (中文测试 → ________)
- `✓` / `⏺` / emoji became `_`
- `═║╔╗` was downgraded to DEC Special Graphics (ESC ( 0), so rounded
corners vanished and boxes rendered as loose horizontal lines
The `-l` login shell cannot fix this: tmux is the PTY's root process and has
already made its decision by the time the shell sources ~/.zprofile.
withUtf8Locale() fills a locale in on the spawn env, and only when needed —
an existing UTF-8 LC_ALL / LC_CTYPE / LANG (zh_CN.UTF-8, ja_JP.UTF-8, …) is
left untouched; only a missing or non-UTF-8 charset is replaced.
Existing tmux sessions do not need recreating: re-attaching with a UTF-8
client restores correct output (already-scrolled `_` stays mangled).
This commit is contained in:
64
src/session/locale.ts
Normal file
64
src/session/locale.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* src/session/locale.ts — guarantee the spawned PTY has a UTF-8 locale.
|
||||
*
|
||||
* The bug this fixes: when the server is launched from Finder / launchd (the Mac
|
||||
* desktop app, or the launchd-managed tunnel agent) the process environment has
|
||||
* NO `LANG` / `LC_*` at all — unlike a shell started from Terminal.app. tmux
|
||||
* decides per client whether the terminal is UTF-8 capable purely from those
|
||||
* variables, so with none set it falls back to its non-UTF-8 mode and:
|
||||
*
|
||||
* - rewrites every wide character to `_` → `中文测试` renders as `________`
|
||||
* - rewrites other non-Latin-1 glyphs to `_` (`✓`, `⏺`, emoji …)
|
||||
* - downgrades double-line box drawing (`═║╔╗`) to DEC Special Graphics
|
||||
* (`ESC ( 0` + `qxlk`), i.e. single lines
|
||||
*
|
||||
* That mangling happens on the server side, *before* the bytes reach xterm.js,
|
||||
* so no amount of frontend font work can recover it. The `-l` login shell can't
|
||||
* fix it either: tmux is the PTY's root process and has already made its
|
||||
* decision by the time the shell sources `~/.zprofile`.
|
||||
*
|
||||
* Policy: leave the environment alone whenever the effective charset is already
|
||||
* UTF-8 — a user who picked `zh_CN.UTF-8` or `ja_JP.UTF-8` keeps it. Only a
|
||||
* missing or non-UTF-8 charset is replaced. Precedence follows POSIX:
|
||||
* `LC_ALL` > `LC_CTYPE` > `LANG`.
|
||||
*/
|
||||
|
||||
/** Fallback locale when the environment specifies no UTF-8 charset. */
|
||||
export const DEFAULT_UTF8_LOCALE = 'en_US.UTF-8';
|
||||
|
||||
/** Matches the charset suffix of a locale: `.UTF-8`, `.utf8`, or a bare `UTF-8`. */
|
||||
const UTF8_PATTERN = /utf-?8$/i;
|
||||
|
||||
/** Environment mapping as node-pty / process.env exposes it. */
|
||||
type Env = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
function isUtf8(value: string | undefined): boolean {
|
||||
return value !== undefined && value !== '' && UTF8_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a copy of `env` that is guaranteed to select a UTF-8 charset.
|
||||
*
|
||||
* Existing values are never rewritten — if the effective locale is already
|
||||
* UTF-8 the env comes back unchanged (aside from being a fresh object).
|
||||
*/
|
||||
export function withUtf8Locale(env: Env): Record<string, string | undefined> {
|
||||
// LC_ALL outranks everything; LC_CTYPE outranks LANG for character handling.
|
||||
// If whichever one is in effect is already UTF-8, there is nothing to do.
|
||||
const effective =
|
||||
env.LC_ALL !== undefined && env.LC_ALL !== ''
|
||||
? env.LC_ALL
|
||||
: env.LC_CTYPE !== undefined && env.LC_CTYPE !== ''
|
||||
? env.LC_CTYPE
|
||||
: env.LANG;
|
||||
|
||||
if (isUtf8(effective)) return { ...env };
|
||||
|
||||
// Setting LANG (the lowest-precedence variable) is the least invasive fix, but
|
||||
// it only takes effect if nothing above it is overriding — so clear a
|
||||
// non-UTF-8 LC_ALL / LC_CTYPE that would otherwise win.
|
||||
const next: Record<string, string | undefined> = { ...env, LANG: DEFAULT_UTF8_LOCALE };
|
||||
if (env.LC_ALL !== undefined && env.LC_ALL !== '') delete next.LC_ALL;
|
||||
if (env.LC_CTYPE !== undefined && env.LC_CTYPE !== '') delete next.LC_CTYPE;
|
||||
return next;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { WS_OPEN } from '../types.js';
|
||||
import { serialize } from '../protocol.js';
|
||||
import { createRingBuffer } from './ring-buffer.js';
|
||||
import { tmuxName, killSession } from './tmux.js';
|
||||
import { withUtf8Locale } from './locale.js';
|
||||
|
||||
/** Send a server message to `ws` only if it is OPEN (M5). Never throws on a
|
||||
* closed socket; forwarding to a dead ws is simply a no-op. */
|
||||
@@ -105,8 +106,11 @@ export function createSession(
|
||||
cols: dims.cols,
|
||||
rows: dims.rows,
|
||||
cwd: cwd ?? cfg.homeDir,
|
||||
// A Finder/launchd-launched server has NO LANG/LC_* — which puts tmux in its
|
||||
// non-UTF-8 mode and turns every CJK/wide character into `_` before the bytes
|
||||
// ever reach xterm. withUtf8Locale() fills one in (and only then).
|
||||
env: {
|
||||
...process.env,
|
||||
...withUtf8Locale(process.env),
|
||||
WEBTERM_SESSION: id,
|
||||
WEBTERM_HOOK_URL: `http://127.0.0.1:${cfg.port}/hook`,
|
||||
// B2: statusLine scripts POST telemetry here; WEBTERM_NTFY_* vars forwarded
|
||||
|
||||
Reference in New Issue
Block a user