Merge pull request #11759 from esengine/feat/tray-language

fix(studio): pass the system language to the kernel host so the tray follows it
This commit is contained in:
YHH authored and GitHub committed 2026-10-03 02:40:15 -07:00
commit 744fbd4f33
8 files changed
+249 -9

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
package main
import (
"testing"
"reasonix/internal/base/i18n"
"reasonix/internal/contract/config"
)
func TestKernelLanguageWithShellSystemLanguage(t *testing.T) {
before := i18n.CurrentLanguage()
t.Cleanup(func() { i18n.DetectLanguage(before) })
for _, key := range []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"} {
t.Setenv(key, "")
}
t.Setenv("REASONIX_SYSTEM_LANG", "zh-CN")
for _, tt := range []struct{ setting, env, want string }{
{"auto", "", "zh"},
{"en", "", "en"},
{"auto", "en", "en"},
{"zh-TW", "en", "zh-TW"},
} {
t.Setenv("REASONIX_LANG", tt.env)
if got := resolveKernelLanguage(&config.Config{Language: tt.setting}); got != tt.want {
t.Errorf("setting %q, env %q: language = %q, want %q", tt.setting, tt.env, got, tt.want)
}
}
}
@@ -0,0 +1,100 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
"reasonix/internal/base/i18n"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/frontend/serve"
)
func TestAssembledTrayLanguage(t *testing.T) {
before := i18n.CurrentLanguage()
t.Cleanup(func() { i18n.DetectLanguage(before) })
for _, tt := range []struct{ name, system, desktop, env, locale, want string }{
{"system", "zh-Hans-CN", "auto", "", "", "zh"},
{"traditional", "zh-Hant-TW", "auto", "", "", "zh-TW"},
{"desktop", "zh-Hans-CN", "en", "", "", "en"},
{"reasonix", "zh-Hans-CN", "auto", "en-US", "", "en"},
{"desktop-over-env", "en-US", "zh", "en", "", "zh"},
{"c-locale", "zh-Hans-CN", "auto", "", "C", "en"},
{"posix-locale", "zh-Hans-CN", "auto", "", "POSIX.UTF-8", "en"},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
t.Chdir(testenv.TempDir(t))
for _, key := range []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"} {
t.Setenv(key, "")
}
t.Setenv("REASONIX_SYSTEM_LANG", tt.system)
t.Setenv("REASONIX_LANG", tt.env)
t.Setenv("LANG", tt.locale)
cfg := config.Default()
disabled := false
cfg.Desktop.Telemetry, cfg.Desktop.Metrics = &disabled, &disabled
if err := cfg.SetDesktopLanguage(tt.desktop); err != nil {
t.Fatal(err)
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
hub, err := assemble(ctx, io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
if err != nil {
t.Fatalf("assemble: %v", err)
}
defer hub.Shutdown()
b, err := bind(hub.Handler())
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- b.serve(ctx) }()
defer func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(30 * time.Second):
t.Error("host did not drain")
}
}()
check := func(want string) {
t.Helper()
status, body := ask(t, b, http.MethodGet, "/tray/state", nil)
if status != http.StatusOK {
t.Fatalf("tray status = %d", status)
}
var state serve.TrayState
if err := json.Unmarshal([]byte(body), &state); err != nil {
t.Fatal(err)
}
say := i18n.Catalog(want)
labels := serve.TrayLabels{Open: say.TrayOpen, CloseToTray: say.TrayCloseToTray, Quit: say.TrayQuit}
if state.Labels != labels || state.Line != say.TrayIdle {
t.Fatalf("tray = %+v, want labels %+v and line %q", state, labels, say.TrayIdle)
}
}
check(tt.want)
for _, lang := range []string{"en", "zh"} {
status, _ := ask(t, b, http.MethodPost, "/appearance", func(r *http.Request) {
r.Body = io.NopCloser(strings.NewReader(`{"language":"` + lang + `"}`))
})
if status != http.StatusOK {
t.Fatalf("appearance status = %d", status)
}
check(lang)
}
})
}
}
+4 -2
View File
@@ -25,8 +25,10 @@ class HostStartError extends Error {
// the only channel pointing this way. stdin is the lease: the kernel drains
// when this end closes, which is what stops it outliving a parent that exited
// without being asked to.
function start(binary, args, { onStderr, onExit, onAct, onSlow, timeoutMs = HANDSHAKE_TIMEOUT_MS } = {}) {
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"] });
function start(binary, args, { onStderr, onExit, onAct, onSlow, systemLanguage, timeoutMs = HANDSHAKE_TIMEOUT_MS } = {}) {
const env = { ...process.env };
if (systemLanguage) env.REASONIX_SYSTEM_LANG = systemLanguage;
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"], env });
child.stderr.setEncoding("utf8");
if (onStderr) child.stderr.on("data", onStderr);
if (onExit) child.on("exit", onExit);
+1
View File
@@ -94,6 +94,7 @@ async function launchKernel(args) {
const began = Date.now();
logs.shell.line(`host: starting ${hostBinary} (attempt ${attempt})`);
kernel = start(hostBinary, args, {
systemLanguage: app.getPreferredSystemLanguages()[0] ?? app.getLocale(),
timeoutMs: handshakeTimeout(),
onSlow: () => {
logs.shell.line("host: no handshake yet; showing the starting window");
+18 -1
View File
@@ -6,7 +6,7 @@ import os from "node:os";
import fs from "node:fs";
const require = createRequire(import.meta.url);
const { parse, readActs } = require("../src/host.js");
const { parse, readActs, start } = require("../src/host.js");
const { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller } = require("../src/editmenu.js");
const { uiLanguage } = require("../src/uilang.js");
const { externalTarget } = require("../src/links.js");
@@ -17,6 +17,23 @@ const { pick, loadPrefs, savePrefs, registerPrefs } = require("../src/prefs.js")
const TOKEN = "a".repeat(64);
const line = (over) => JSON.stringify({ version: 1, origin: "http://127.0.0.1:8080", token: TOKEN, ...over });
test("the spawned host receives the system language and inherits explicit locale overrides", async () => {
const script = `console.log(${JSON.stringify(line())});
console.log(JSON.stringify({act: JSON.stringify({system: process.env.REASONIX_SYSTEM_LANG,
explicit: process.env.REASONIX_LANG})}));`;
const before = process.env.REASONIX_SYSTEM_LANG;
const explicit = process.env.REASONIX_LANG;
const state = new Promise((resolve) => {
const host = start(process.execPath, ["-e", script], {
systemLanguage: "zh-Hant-TW",
onAct: (act) => resolve(JSON.parse(act)),
});
host.ready.catch(resolve);
});
assert.deepEqual(await state, { system: "zh-Hant-TW", ...(explicit === undefined ? {} : { explicit }) });
assert.equal(process.env.REASONIX_SYSTEM_LANG, before);
});
test("the handshake is accepted only when every field accounts for itself", () => {
assert.deepEqual(parse(line()), { origin: "http://127.0.0.1:8080", token: TOKEN });
+7 -6
View File
@@ -5,11 +5,9 @@ import (
"strings"
)
// DetectLanguage selects a catalogue from override (e.g. cfg.Language) or the
// environment and installs it as M. Returns the resolved tag ("en", "zh") so
// callers can log or expose it.
//
// Priority: override > REASONIX_LANG > LC_ALL > LC_MESSAGES > LANG > "en".
// DetectLanguage installs the catalogue for an explicit setting or locale.
// Explicit settings and locale variables take priority over the shell's system
// language and the native OS probe; unresolved locales fall back to English.
func DetectLanguage(override string) string {
for _, c := range append([]string{override}, envCandidates()...) {
if tag := normalize(c); tag != "" {
@@ -24,7 +22,7 @@ func DetectLanguage(override string) string {
var osLanguage = detectOSLanguage
func envCandidates() []string {
keys := []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"}
keys := []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG", "REASONIX_SYSTEM_LANG"}
out := make([]string, 0, len(keys)+1)
for _, k := range keys {
out = append(out, os.Getenv(k))
@@ -58,6 +56,9 @@ func normalize(s string) string {
if s == "" {
return ""
}
if s == "c" || s == "posix" || strings.HasPrefix(s, "c.") || strings.HasPrefix(s, "posix.") {
return "en"
}
if strings.HasPrefix(s, "zh-tw") || strings.HasPrefix(s, "zh-hant") || strings.Contains(s, "chinese traditional") || strings.Contains(s, "繁體") {
return "zh-TW"
}
+49
View File
@@ -0,0 +1,49 @@
package i18n
import "testing"
func TestNormalizePOSIXLocales(t *testing.T) {
for _, tt := range []struct{ locale, want string }{
{"C", "en"}, {"POSIX", "en"}, {"C.UTF-8", "en"},
{"C.ISO8859-1", "en"}, {"POSIX.UTF-8", "en"}, {"POSIX.ASCII", "en"},
{" c.utf-8 ", "en"}, {"", ""}, {" ", ""},
{"zh-Hans-CN", "zh"}, {"zh_TW.UTF-8", "zh-TW"}, {"en-US", "en"},
{"ca-ES", ""}, {"posix-extra", ""},
} {
t.Run(tt.locale, func(t *testing.T) {
if got := normalize(tt.locale); got != tt.want {
t.Fatalf("normalize(%q) = %q, want %q", tt.locale, got, tt.want)
}
})
}
}
func TestPOSIXLocaleWinsOverSystemLanguage(t *testing.T) {
before, probe := CurrentLanguage(), osLanguage
osLanguage = func() string { return "zh-TW" }
t.Cleanup(func() { setLanguage(before); osLanguage = probe })
for _, key := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
for _, locale := range []string{"C", "POSIX", "C.UTF-8", "C.ASCII", "POSIX.UTF-8"} {
t.Run(key+"/"+locale, func(t *testing.T) {
for _, candidate := range []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"} {
t.Setenv(candidate, "")
}
t.Setenv("REASONIX_SYSTEM_LANG", "zh-Hans-CN")
t.Setenv(key, locale)
if got := DetectLanguage("auto"); got != "en" {
t.Fatalf("language = %q, want en", got)
}
if got := CatalogFor("auto").TrayOpen; got != English.TrayOpen {
t.Fatalf("tray open = %q, want %q", got, English.TrayOpen)
}
t.Setenv("REASONIX_LANG", "zh-TW")
if got := DetectLanguage("auto"); got != "zh-TW" {
t.Fatalf("environment override = %q, want zh-TW", got)
}
if got := DetectLanguage("zh"); got != "zh" {
t.Fatalf("explicit override = %q, want zh", got)
}
})
}
}
}
@@ -0,0 +1,42 @@
package i18n
import "testing"
func TestShellSystemLanguagePriority(t *testing.T) {
probe := osLanguage
osLanguage = func() string { return "en-US" }
t.Cleanup(func() { osLanguage = probe })
before := CurrentLanguage()
t.Cleanup(func() { setLanguage(before) })
for _, tt := range []struct {
name, override, key, value, system, want string
}{
{"auto", "auto", "", "", "zh-CN", "zh"},
{"traditional", "auto", "", "", "zh-Hant-TW", "zh-TW"},
{"setting", "en", "", "", "zh-CN", "en"},
{"reasonix", "auto", "REASONIX_LANG", "en", "zh-CN", "en"},
{"all", "auto", "LC_ALL", "en_US.UTF-8", "zh-CN", "en"},
{"messages", "auto", "LC_MESSAGES", "en_US.UTF-8", "zh-CN", "en"},
{"lang", "auto", "LANG", "en_US.UTF-8", "zh-CN", "en"},
{"unsupported", "auto", "", "", "fr-FR", "en"},
{"absent", "auto", "", "", "", "en"},
} {
t.Run(tt.name, func(t *testing.T) {
for _, key := range []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"} {
t.Setenv(key, "")
}
t.Setenv("REASONIX_SYSTEM_LANG", tt.system)
if tt.key != "" {
t.Setenv(tt.key, tt.value)
}
if got := DetectLanguage(tt.override); got != tt.want {
t.Fatalf("language = %q, want %q", got, tt.want)
}
say := CatalogFor(tt.override)
want := Catalog(tt.want)
if say.TrayOpen != want.TrayOpen || say.TrayCloseToTray != want.TrayCloseToTray || say.TrayQuit != want.TrayQuit || say.TrayIdle != want.TrayIdle {
t.Fatal("tray catalogue does not follow the resolved language")
}
})
}
}