diff --git a/cmd/reasonix-studio-host/language_test.go b/cmd/reasonix-studio-host/language_test.go new file mode 100644 index 0000000000..e6d0d36c5b --- /dev/null +++ b/cmd/reasonix-studio-host/language_test.go @@ -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) + } + } +} diff --git a/cmd/reasonix-studio-host/tray_language_effect_test.go b/cmd/reasonix-studio-host/tray_language_effect_test.go new file mode 100644 index 0000000000..6ac0f3db95 --- /dev/null +++ b/cmd/reasonix-studio-host/tray_language_effect_test.go @@ -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) + } + }) + } +} diff --git a/desktop/electron/src/host.js b/desktop/electron/src/host.js index 08907203db..801ec25d23 100644 --- a/desktop/electron/src/host.js +++ b/desktop/electron/src/host.js @@ -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); diff --git a/desktop/electron/src/main.js b/desktop/electron/src/main.js index 9c83811237..442443b34a 100644 --- a/desktop/electron/src/main.js +++ b/desktop/electron/src/main.js @@ -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"); diff --git a/desktop/electron/test/unit.mjs b/desktop/electron/test/unit.mjs index 098743961b..d88d5b1fa6 100644 --- a/desktop/electron/test/unit.mjs +++ b/desktop/electron/test/unit.mjs @@ -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 }); diff --git a/internal/base/i18n/detect.go b/internal/base/i18n/detect.go index 81defa7382..b5a4c8bdfc 100644 --- a/internal/base/i18n/detect.go +++ b/internal/base/i18n/detect.go @@ -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" } diff --git a/internal/base/i18n/locale_test.go b/internal/base/i18n/locale_test.go new file mode 100644 index 0000000000..41df5eeaa5 --- /dev/null +++ b/internal/base/i18n/locale_test.go @@ -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) + } + }) + } + } +} diff --git a/internal/base/i18n/system_language_test.go b/internal/base/i18n/system_language_test.go new file mode 100644 index 0000000000..9f25ad041d --- /dev/null +++ b/internal/base/i18n/system_language_test.go @@ -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") + } + }) + } +}