diff --git a/README.md b/README.md index b7b4d987..31387b78 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,19 @@ an environment value the menu would not offer (say 12 GiB on a 16 GiB Mac) is still accepted — the menu is deliberately conservative, the launcher is a safety floor. +## Traditional Chinese + +Choose **Switch to Traditional Chinese (繁體中文)** next to **Language** on +the start menu to boot Omarchy in Traditional Chinese (`zh_TW.UTF-8`); +**Use English (Default)** switches back. The change takes effect on the next +launch. + +The desktop, file manager, browser, and system dialogs are translated, and +fcitx5 adds Chewing (Bopomofo) input, reachable with `Ctrl + Space`; the US +keyboard layout remains the default input method. Omarchy's own setup wizard +and menus stay in English either way: upstream Omarchy has no translation +mechanism, and those strings are hardcoded in its shell scripts. + ## Requirements - Apple Silicon Mac (`arm64`) diff --git a/docs/architecture.md b/docs/architecture.md index 1b678d84..b4dba26c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -184,6 +184,16 @@ creates the account on first boot. An Omarchy-supported `pre-refresh-pacman` hook restores them after a channel refresh writes its x86_64 templates to `/etc`; the upstream templates remain unchanged. +- Traditional Chinese is available to opt into from the start menu's Language + row, without changing the default session: the choice becomes the + `tryomarchy.locale=zh_TW.UTF-8` kernel argument, and a guest oneshot unit + consumes it to write `LANG` into `/etc/locale.conf` before either login + entry point starts, which is where the login shell takes it from. `zh_TW.UTF-8` + is generated alongside `en_US.UTF-8`, fcitx5 is seeded with US and Chewing + (Bopomofo) input, a fontconfig rule prefers Traditional Chinese Han glyph + variants for `zh-TW` text, and Chromium is launched with the Wayland IME + flag it needs to receive fcitx5 input at all. Leaving the row untouched + emits no kernel argument, and `LANG`/`KEYMAP` stay `en_US`/`us`. Nothing is overwritten while the app runs. The app bundle and packaged factory disk remain unchanged. Normal user launches use one private writable disk under diff --git a/guest/factory-overlay/etc/fonts/conf.d/30-try-omarchy.conf b/guest/factory-overlay/etc/fonts/conf.d/30-try-omarchy.conf new file mode 100644 index 00000000..534d9cad --- /dev/null +++ b/guest/factory-overlay/etc/fonts/conf.d/30-try-omarchy.conf @@ -0,0 +1,84 @@ + + + + + + + sans-serif + + + zh-tw + + + Noto Sans CJK TC + + + + + sans-serif + + + zh-hant + + + Noto Sans CJK TC + + + + + + serif + + + zh-tw + + + Noto Serif CJK TC + + + + + serif + + + zh-hant + + + Noto Serif CJK TC + + + + + + monospace + + + zh-tw + + + Noto Sans Mono CJK TC + + + + + monospace + + + zh-hant + + + Noto Sans Mono CJK TC + + + diff --git a/guest/factory-overlay/usr/lib/environment.d/90-try-omarchy.conf b/guest/factory-overlay/usr/lib/environment.d/90-try-omarchy.conf index fd3c3ca5..50afee7f 100644 --- a/guest/factory-overlay/usr/lib/environment.d/90-try-omarchy.conf +++ b/guest/factory-overlay/usr/lib/environment.d/90-try-omarchy.conf @@ -4,3 +4,12 @@ ELECTRON_OZONE_PLATFORM_HINT=wayland MOZ_ENABLE_WAYLAND=1 OZONE_PLATFORM=wayland QT_QPA_PLATFORM=wayland + +# fcitx5 input method: XWayland clients only speak the legacy XIM +# protocol, so they still need XMODIFIERS. Do NOT set GTK_IM_MODULE or +# QT_IM_MODULE here: GTK4 and Qt6 talk to fcitx5 over text-input-v3, +# which Hyprland implements natively, and those variables are global +# to every process, so setting them would also force native Wayland +# GTK/Qt apps off text-input-v3 and onto the legacy im-module path, +# making them worse, not better. +XMODIFIERS=@im=fcitx diff --git a/guest/fragments/chromium-flags-wayland-ime.append.conf b/guest/fragments/chromium-flags-wayland-ime.append.conf new file mode 100644 index 00000000..48a0b6fb --- /dev/null +++ b/guest/fragments/chromium-flags-wayland-ime.append.conf @@ -0,0 +1 @@ +--enable-wayland-ime diff --git a/guest/fragments/fcitx5-profile.ini b/guest/fragments/fcitx5-profile.ini new file mode 100644 index 00000000..10694f07 --- /dev/null +++ b/guest/fragments/fcitx5-profile.ini @@ -0,0 +1,19 @@ +[Groups/0] +Name=Default +Default Layout=us +# DefaultIM= below is inert: fcitx5 resolves the group's real default from +# Items order and rewrites this line (to chewing) on first save. What keeps +# English users unaffected is keyboard-us being Items/0, ahead of chewing — +# don't "fix" that ordering to match this line. +DefaultIM=keyboard-us + +[Groups/0/Items/0] +Name=keyboard-us +Layout= + +[Groups/0/Items/1] +Name=chewing +Layout= + +[GroupOrder] +0=Default diff --git a/guest/native-overlay/usr/lib/systemd/system/try-omarchy-locale.service b/guest/native-overlay/usr/lib/systemd/system/try-omarchy-locale.service new file mode 100644 index 00000000..ead13691 --- /dev/null +++ b/guest/native-overlay/usr/lib/systemd/system/try-omarchy-locale.service @@ -0,0 +1,24 @@ +[Unit] +Description=Set /etc/locale.conf from the host-chosen UI locale +# Must finish before anything that starts a login session can read the +# system locale. sddm.service (aliased to display-manager.service once +# enabled) is the graphical entry point: omarchy-provision-owner.service, +# this project's pinned upstream first-boot unit, itself orders +# Before=display-manager.service and hands off to SDDM purely on that +# ordering once it exits -- no explicit `systemctl start`. getty@tty1.service +# is the console entry point; the same upstream unit also orders +# Before=getty@tty1.service (and briefly Conflicts= it) while it owns tty1. +# Both of those already-correct orderings are the proof that ordering +# Before= the same three units guarantees this one has run first, regardless +# of which of the two starts sooner: systemd will not start either before +# every unit ordered Before= it -- including this one -- has finished. +After=local-fs.target +Before=sddm.service display-manager.service getty@tty1.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/bin/try-omarchy-locale + +[Install] +WantedBy=multi-user.target diff --git a/guest/native-overlay/usr/local/bin/try-omarchy-locale b/guest/native-overlay/usr/local/bin/try-omarchy-locale new file mode 100755 index 00000000..9885d822 --- /dev/null +++ b/guest/native-overlay/usr/local/bin/try-omarchy-locale @@ -0,0 +1,55 @@ +#!/bin/sh + +# Write a host-chosen UI locale (tryomarchy.locale= on the kernel +# command line) into /etc/locale.conf. Run by try-omarchy-locale.service, +# early enough that the value is in place before anything starts a login +# session. +# +# This used to be a system generator that wrote /run/environment.d instead, +# which works for XMODIFIERS (nothing else sets it) but not for LANG: +# Omarchy's session starts through uwsm, which reads the systemd user +# manager's environment, merges in the *login shell's* environment -- whose +# LANG comes from /etc/locale.conf via PAM -- and pushes the merge back over +# D-Bus after environment.d has already run. English always wins that race. +# A generator must never write persistent state, so this lives in a real +# (enabled) oneshot unit instead, and writes /etc/locale.conf directly. + +set -eu + +cmdline_path=${TRY_OMARCHY_LOCALE_CMDLINE_PATH:-/proc/cmdline} +locale_conf=${TRY_OMARCHY_LOCALE_CONF_PATH:-/etc/locale.conf} + +# Refuse to write through a symlink (dangling or not). This runs as root on +# every boot, and locale_conf's target would otherwise be unchecked. +[ ! -L "$locale_conf" ] || exit 1 + +command_line="" +IFS= read -r command_line <"$cmdline_path" || exit 1 + +# Default to the image's own default locale. This is deliberate, not merely +# a placeholder: writing it even when no token is present is what makes +# switching back to English in the launcher work on a persistent VM, instead +# of leaving a stale non-English locale from an earlier boot in place. +locale=en_US.UTF-8 +set -f +for argument in $command_line; do + case $argument in + tryomarchy.locale=*) + candidate=${argument#tryomarchy.locale=} + # Allowlist: only locales configure-rootfs.sh actually generates into + # /etc/locale.gen. The kernel command line is attacker-influenced in + # principle and this value is about to be written into a file PAM + # reads on every login, so anything else is ignored (keeping whatever + # is already selected) rather than trusted or shell-interpolated. + case $candidate in + en_US.UTF-8 | zh_TW.UTF-8) locale=$candidate ;; + esac + ;; + esac +done + +# Whole-file rewrite, not a patch: /etc/locale.conf in this image holds only +# LANG (KEYMAP lives in vconsole.conf and is never touched here), so the +# output depends only on the command line and is byte-identical on repeat +# runs with the same command line. +printf 'LANG=%s\n' "$locale" >"$locale_conf" diff --git a/guest/packages.lock.json b/guest/packages.lock.json index 0f1eec7a..796217b5 100644 --- a/guest/packages.lock.json +++ b/guest/packages.lock.json @@ -58,6 +58,7 @@ "duktape": "2.7.0-7", "e2fsprogs": "1.47.4-1", "elfutils": "0.196-1", + "enchant": "2.8.21-1", "exempi": "2.6.6-3", "exiv2": "0.28.9-1", "expat": "2.8.4-1", @@ -65,6 +66,10 @@ "fakeroot": "1:1.37.2-3", "fastfetch": "2.68.1-1", "fcft": "3.3.3-1", + "fcitx5": "5.1.22-1", + "fcitx5-chewing": "5.1.13-1", + "fcitx5-gtk": "5.1.7-1", + "fcitx5-qt": "5.1.15-1", "fd": "10.5.0-3", "ffmpeg": "2:9.0.1-4", "fftw": "3.3.11-1", @@ -212,6 +217,7 @@ "libcdio": "2.4.0-1", "libcdio-paranoia": "10.2+2.0.2-3", "libcgif": "0.5.4-1", + "libchewing": "0.13.1-1", "libcloudproviders": "0.4.1-1", "libcolord": "1.4.8-1", "libcue": "2.3.0-1", @@ -398,8 +404,8 @@ "libzip": "1.11.4-1", "licenses": "20240728-1", "lilv": "0.28.0-1", - "linux-aarch64": "7.2.3-2", - "linux-aarch64-headers": "7.2.3-2", + "linux-aarch64": "7.2.4-1", + "linux-aarch64-headers": "7.2.4-1", "linux-api-headers": "7.2-1", "llhttp": "9.3.1-1", "llvm-libs": "22.1.8-2", @@ -436,6 +442,7 @@ "networkmanager": "1.58.1-1", "nftables": "1:1.1.7-3", "noto-fonts": "1:2026.09.01-1", + "noto-fonts-cjk": "20240730-1", "noto-fonts-emoji": "1:2.051-1", "npth": "1.8-1", "nspr": "4.40-1", @@ -453,7 +460,7 @@ "openssh": "10.5p1-1", "openssl": "3.6.4-1", "opus": "1.6.1-1", - "orc": "0.4.43-1", + "orc": "0.4.44-1", "osinfo-db": "20260812-1", "p11-kit": "0.26.5-1", "pacman": "7.1.0.r9.g54d9411-2", @@ -578,6 +585,7 @@ "wpa_supplicant": "2:2.12-1", "x264": "3:0.165.r3222.b35605a-2", "x265": "4.3-1", + "xcb-imdkit": "1.0.9-1", "xcb-proto": "1.17.0-4", "xcb-util": "0.4.1-2", "xcb-util-cursor": "0.1.6-1", @@ -606,6 +614,7 @@ "xvidcore": "1.3.7-4", "xxhash": "0.8.3-1", "xz": "5.8.3-1", + "yoga": "3.2.1-1", "yyjson": "0.12.0-1", "zeromq": "4.3.5-3", "zimg": "3.0.6-2", @@ -616,6 +625,6 @@ "zram-generator": "1.2.1-1", "zstd": "1.5.7-3" }, - "requestedFileSha256": "addad9e5b4e8c7ce1aadd3a38d45e4abad341725edd1465ca9abd3c75f659ad5", + "requestedFileSha256": "1943b67414bfe03c3889052dfe286e49816437f3dcfacdc947bfa30db862e218", "schemaVersion": 1 } diff --git a/guest/packages.txt b/guest/packages.txt index 018d12a3..5660b9c4 100644 --- a/guest/packages.txt +++ b/guest/packages.txt @@ -13,6 +13,10 @@ e2fsprogs eza fakeroot fastfetch +fcitx5 +fcitx5-chewing +fcitx5-gtk +fcitx5-qt fd fontconfig foot @@ -41,6 +45,7 @@ nautilus neovim networkmanager noto-fonts +noto-fonts-cjk noto-fonts-emoji omarchy-keyring openssh diff --git a/guest/scripts/configure-rootfs.sh b/guest/scripts/configure-rootfs.sh index 3603a7f7..35552f3a 100755 --- a/guest/scripts/configure-rootfs.sh +++ b/guest/scripts/configure-rootfs.sh @@ -141,9 +141,22 @@ cat >"$root/etc/hosts" <"$root/etc/locale.gen" +# zh_TW is generated so Traditional Chinese is available to opt into (see the +# fcitx5 profile seeded into /etc/skel below), but LANG and KEYMAP stay +# en_US/us -- the default session must not change for a user who never +# touches the IME. +printf 'en_US.UTF-8 UTF-8\nzh_TW.UTF-8 UTF-8\n' >"$root/etc/locale.gen" printf 'LANG=en_US.UTF-8\n' >"$root/etc/locale.conf" printf 'KEYMAP=us\n' >"$root/etc/vconsole.conf" +# try-omarchy-locale.service rewrites LANG in /etc/locale.conf from the +# kernel command line on every boot (see the unit and its ExecStart= script +# for why this must be a real unit rather than a system generator). This +# script runs before arch-chroot, with no systemd/D-Bus available to run +# `systemctl enable`, so it links the unit into multi-user.target.wants +# itself -- the same symlink that command would create. +mkdir -p "$root/etc/systemd/system/multi-user.target.wants" +ln -sfn /usr/lib/systemd/system/try-omarchy-locale.service \ + "$root/etc/systemd/system/multi-user.target.wants/try-omarchy-locale.service" # An unprovisioned machine receives a new identity from systemd on first boot. : >"$root/etc/machine-id" ln -sfn /usr/share/zoneinfo/UTC "$root/etc/localtime" @@ -165,6 +178,23 @@ install -d -m 0755 "$root/etc/skel/.config/omarchy/hooks/pre-refresh-pacman.d" install -m 0755 "$refresh_hook" \ "$root/etc/skel/.config/omarchy/hooks/pre-refresh-pacman.d/restore-arm-pacman" +# Seed fcitx5 with the US keyboard as item 0 and Chewing (Bopomofo/zhuyin) as +# item 1. Ctrl+Space is fcitx5's built-in default trigger, so a user who never +# presses it stays on plain US input; pressing it reaches Traditional Chinese. +fcitx5_profile="$guest_dir/fragments/fcitx5-profile.ini" +[[ -f $fcitx5_profile ]] || fail "fcitx5 profile fragment not found: $fcitx5_profile" +install -d -m 0755 "$root/etc/skel/.config/fcitx5" +install -m 0644 "$fcitx5_profile" \ + "$root/etc/skel/.config/fcitx5/profile" + +# Chromium's Ozone/Wayland backend drops input-method text unless launched +# with --enable-wayland-ime, so fcitx5 can't reach it otherwise. Basecamp's +# chromium-flags.conf is already materialized into /etc/skel, so append +# rather than replace it to keep the upstream ozone/extension flags intact. +chromium_flags="$root/etc/skel/.config/chromium-flags.conf" +[[ -f $chromium_flags ]] || fail "expected upstream chromium-flags.conf not found: $chromium_flags" +cat "$guest_dir/fragments/chromium-flags-wayland-ime.append.conf" >>"$chromium_flags" + provision_unit="$root/usr/share/omarchy/install/provisioning/omarchy-provision-owner.service" [[ -f $provision_unit ]] || fail "pinned upstream owner-provisioning service is missing" mkdir -p "$root/etc/systemd/system" "$root/var/lib/omarchy/provisioning" diff --git a/guest/spec.json b/guest/spec.json index b71ac26b..c1d1ff72 100644 --- a/guest/spec.json +++ b/guest/spec.json @@ -493,6 +493,7 @@ "bin/omarchy-provision-user", "bin/omarchy-screensaver", "bin/omarchy-theme-set", + "config/chromium-flags.conf", "config/foot/foot.ini", "config/hypr/hyprland.lua", "config/hypr/monitors.lua", diff --git a/guest/tests/test_locale.py b/guest/tests/test_locale.py new file mode 100644 index 00000000..bbdaffc1 --- /dev/null +++ b/guest/tests/test_locale.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Isolated contracts for the host-locale boot script.""" + +from __future__ import annotations + +from pathlib import Path +import subprocess +import tempfile +import unittest + + +GUEST = Path(__file__).resolve().parents[1] +SCRIPT = GUEST / "native-overlay/usr/local/bin/try-omarchy-locale" + + +class LocaleScriptTests(unittest.TestCase): + def run_script( + self, command_line: str + ) -> tuple[ + subprocess.CompletedProcess[str], Path, tempfile.TemporaryDirectory[str] + ]: + temporary = tempfile.TemporaryDirectory() + root = Path(temporary.name) + cmdline = root / "cmdline" + cmdline.write_text(command_line + "\n", encoding="utf-8") + locale_conf = root / "locale.conf" + result = subprocess.run( + [str(SCRIPT)], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env={ + "PATH": "/usr/bin:/bin", + "TRY_OMARCHY_LOCALE_CMDLINE_PATH": str(cmdline), + "TRY_OMARCHY_LOCALE_CONF_PATH": str(locale_conf), + }, + ) + return result, locale_conf, temporary + + def test_no_token_writes_the_default_english_locale(self) -> None: + result, locale_conf, temporary = self.run_script("root=/dev/vda rw") + with temporary: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + locale_conf.read_text(encoding="utf-8"), "LANG=en_US.UTF-8\n" + ) + + def test_each_allowlisted_locale_writes_the_matching_lang_line(self) -> None: + for locale in ("en_US.UTF-8", "zh_TW.UTF-8"): + result, locale_conf, temporary = self.run_script( + f"root=/dev/vda rw tryomarchy.locale={locale}" + ) + with temporary: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + locale_conf.read_text(encoding="utf-8"), f"LANG={locale}\n" + ) + + def test_locale_not_on_allowlist_falls_back_to_english(self) -> None: + for locale in ("fr_FR.UTF-8", "en_US", "zh_CN.UTF-8", "en_US.UTF-8x"): + result, locale_conf, temporary = self.run_script( + f"root=/dev/vda rw tryomarchy.locale={locale}" + ) + with temporary: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + locale_conf.read_text(encoding="utf-8"), "LANG=en_US.UTF-8\n" + ) + + def test_empty_token_value_falls_back_to_english(self) -> None: + result, locale_conf, temporary = self.run_script( + "root=/dev/vda rw tryomarchy.locale=" + ) + with temporary: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + locale_conf.read_text(encoding="utf-8"), "LANG=en_US.UTF-8\n" + ) + + def test_shell_metacharacter_value_falls_back_and_has_no_side_effect( + self, + ) -> None: + temporary = tempfile.TemporaryDirectory() + with temporary: + root = Path(temporary.name) + marker = root / "pwned" + payload = f"tryomarchy.locale=$(touch {marker});x" + result, locale_conf, inner = self.run_script(payload) + with inner: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + locale_conf.read_text(encoding="utf-8"), "LANG=en_US.UTF-8\n" + ) + self.assertFalse(marker.exists()) + + def test_running_twice_leaves_identical_contents(self) -> None: + temporary = tempfile.TemporaryDirectory() + with temporary: + root = Path(temporary.name) + cmdline = root / "cmdline" + cmdline.write_text( + "root=/dev/vda rw tryomarchy.locale=zh_TW.UTF-8\n", encoding="utf-8" + ) + locale_conf = root / "locale.conf" + env = { + "PATH": "/usr/bin:/bin", + "TRY_OMARCHY_LOCALE_CMDLINE_PATH": str(cmdline), + "TRY_OMARCHY_LOCALE_CONF_PATH": str(locale_conf), + } + for _ in range(2): + result = subprocess.run( + [str(SCRIPT)], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + locale_conf.read_text(encoding="utf-8"), "LANG=zh_TW.UTF-8\n" + ) + + def test_refuses_to_write_through_a_symlink(self) -> None: + temporary = tempfile.TemporaryDirectory() + with temporary: + root = Path(temporary.name) + cmdline = root / "cmdline" + cmdline.write_text( + "root=/dev/vda rw tryomarchy.locale=zh_TW.UTF-8\n", encoding="utf-8" + ) + target = root / "outside-target" + locale_conf = root / "locale.conf" + locale_conf.symlink_to(target) + result = subprocess.run( + [str(SCRIPT)], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env={ + "PATH": "/usr/bin:/bin", + "TRY_OMARCHY_LOCALE_CMDLINE_PATH": str(cmdline), + "TRY_OMARCHY_LOCALE_CONF_PATH": str(locale_conf), + }, + ) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(target.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/guest/tests/verify.py b/guest/tests/verify.py index cfac9174..f3e387c9 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -737,6 +737,14 @@ def main() -> None: "compat/ttfx-arm64" not in configure and not (GUEST / "compat/ttfx-arm64").exists(), "obsolete no-op ttfx compatibility command is absent", ) + check( + "en_US.UTF-8 UTF-8" in configure and "zh_TW.UTF-8 UTF-8" in configure, + "Traditional Chinese locale is generated alongside English so it can be opted into", + ) + check( + "LANG=en_US.UTF-8" in configure and "KEYMAP=us" in configure, + "default session language and keyboard layout stay English/US for a user who never opts into zh-TW", + ) check("omarchy-provision-owner.service" in configure, "first boot uses upstream owner provisioning") native_autologin = read( GUEST @@ -747,6 +755,34 @@ def main() -> None: and "omarchy-provision-autologin-once.service" in native_autologin, "native provisioning keeps direct graphical login across VM boots", ) + check( + 'install -d -m 0755 "$root/etc/skel/.config/fcitx5"' in configure + and "fragments/fcitx5-profile.ini" in configure + and '"$root/etc/skel/.config/fcitx5/profile"' in configure, + "every new user's skeleton home gets the fcitx5 input profile seeded, not just the package", + ) + fcitx5_profile = read(GUEST / "fragments/fcitx5-profile.ini") + check( + "[Groups/0/Items/0]\nName=keyboard-us" in fcitx5_profile + and "[Groups/0/Items/1]\nName=chewing" in fcitx5_profile, + "keyboard-us sits at item index 0 ahead of chewing, so a user who never triggers the IME " + "(an inactive input context) still lands on plain US input, even though fcitx5 resolves " + "and rewrites the group's actual default input method to chewing", + ) + check( + 'chromium_flags="$root/etc/skel/.config/chromium-flags.conf"' in configure + and "[[ -f $chromium_flags ]] || fail" in configure + and 'cat "$guest_dir/fragments/chromium-flags-wayland-ime.append.conf" >>"$chromium_flags"' + in configure, + "the wayland-ime flag is appended to Chromium's existing flags, never overwriting Basecamp's upstream ones", + ) + chromium_ime_flag = read( + GUEST / "fragments/chromium-flags-wayland-ime.append.conf" + ) + check( + "--enable-wayland-ime" in chromium_ime_flag, + "Chromium launches with the flag fcitx5 needs to reach Wayland text fields", + ) fcitx_guard = read( GUEST / "native-overlay/etc/systemd/user/omarchy-fcitx5.service.d/10-guard.conf" ) @@ -890,6 +926,43 @@ def main() -> None: and "compression-algorithm = lzo-rle" in zram_override, "factory zram uses the ARM kernel's supported lzo-rle backend", ) + cjk_fontconfig = read( + GUEST / "factory-overlay/etc/fonts/conf.d/30-try-omarchy.conf" + ) + check( + all(f"{lang}" in cjk_fontconfig for lang in ("zh-tw", "zh-hant")) + and all( + f"Noto {kind} CJK TC" in cjk_fontconfig + for kind in ("Sans", "Serif", "Sans Mono") + ), + "zh-TW and zh-Hant text prefers Traditional Chinese Han glyphs over Simplified or Japanese variants for sans, serif, and monospace", + ) + check( + 'mode="prepend" binding="strong"' in cjk_fontconfig + and all( + f"{family}" in cjk_fontconfig + for family in ("sans-serif", "serif", "monospace") + ), + "the Traditional Chinese font preference is scoped to generic sans/serif/monospace requests and wins over later fontconfig stages", + ) + environment_conf = read( + GUEST / "factory-overlay/usr/lib/environment.d/90-try-omarchy.conf" + ) + environment_assignments = [ + line.split("#", 1)[0].strip() for line in environment_conf.splitlines() + ] + environment_assignments = [line for line in environment_assignments if line] + check( + "XMODIFIERS=@im=fcitx" in environment_assignments, + "XWayland apps can still reach fcitx5, since they only speak the legacy XIM protocol", + ) + check( + not any( + re.match(r"(GTK_IM_MODULE|QT_IM_MODULE)\s*=", line) + for line in environment_assignments + ), + "GTK4/Qt6 apps stay on native Wayland text-input-v3 for fcitx5 instead of being forced onto the legacy im-module path globally", + ) check( '"$root/usr/bin/omarchy-audio-input-set-default"' in configure and '"$root/usr/bin/omarchy-screensaver"' in configure @@ -1180,6 +1253,110 @@ def main() -> None: "SSH generator requests only the boot-scoped vendor sshd unit", ) + old_locale_generator_path = ( + GUEST + / "native-overlay/usr/lib/systemd/system-generators/try-omarchy-locale" + ) + check( + not old_locale_generator_path.exists(), + "the old locale system-generator is gone -- only one mechanism may own LANG", + ) + + locale_script_path = GUEST / "native-overlay/usr/local/bin/try-omarchy-locale" + locale_script = read(locale_script_path) + locale_script_code = "\n".join( + line for line in locale_script.splitlines() if not line.strip().startswith("#") + ) + locale_unit_path = ( + GUEST / "native-overlay/usr/lib/systemd/system/try-omarchy-locale.service" + ) + locale_unit = read(locale_unit_path) + + locale_gen_format = re.search( + r"printf '([^']*)' >\"\$root/etc/locale\.gen\"", configure + ) + generated_locales = ( + sorted( + line.split(" ", 1)[0] + for line in locale_gen_format.group(1).split("\\n") + if line + ) + if locale_gen_format + else [] + ) + locale_allowlist_match = re.search( + r"case \$candidate in\n\s*([^\n]+)\) locale=\$candidate ;;\n", locale_script + ) + allowlisted_locales = ( + sorted(token.strip() for token in locale_allowlist_match.group(1).split("|")) + if locale_allowlist_match + else [] + ) + check( + locale_gen_format is not None + and locale_allowlist_match is not None + and generated_locales == allowlisted_locales, + "locale script's allowlist cannot drift from the locales configure-rootfs.sh actually " + "generates, or a chosen language silently gets no LANG", + ) + check( + locale_script_path.is_file() + and locale_script_path.stat().st_mode & stat.S_IXUSR != 0 + and "tryomarchy.locale=" in locale_script + and "TRY_OMARCHY_LOCALE_CMDLINE_PATH:-/proc/cmdline" in locale_script, + "locale script is an executable that reads the host-chosen locale from the kernel command line", + ) + check( + "eval" not in locale_script and "$(" not in locale_script, + "the kernel command line is never shell-interpolated", + ) + check( + 'TRY_OMARCHY_LOCALE_CONF_PATH:-/etc/locale.conf' in locale_script + and locale_script.count('>"$locale_conf"') == 1, + "locale script writes LANG to /etc/locale.conf, and to nowhere else, now that a real unit " + "(not a generator) is the one setting it", + ) + check( + "LANG=%s" in locale_script_code + and "LC_ALL" not in locale_script_code + and "KEYMAP" not in locale_script_code, + "locale script sets LANG only, never LC_ALL or the console keymap", + ) + check( + "locale=en_US.UTF-8" in locale_script and "exit 0" not in locale_script, + "an absent locale token still writes the image's default English locale -- unlike the old " + "generator, this script never exits early -- which is what lets switching back to English " + "in the launcher win on a persistent VM instead of leaving a stale locale behind", + ) + check( + '[ ! -L "$locale_conf" ] || exit 1' in locale_script, + "locale script refuses to write through a symlink", + ) + + check( + locale_unit_path.is_file() + and "Type=oneshot" in locale_unit + and "RemainAfterExit=yes" in locale_unit + and "ExecStart=/usr/local/bin/try-omarchy-locale" in locale_unit + and "WantedBy=multi-user.target" in locale_unit, + "try-omarchy-locale.service is a oneshot unit (not a generator) that runs the locale script", + ) + check( + "Before=sddm.service display-manager.service getty@tty1.service" in locale_unit, + "the unit orders itself before both entry points a login session can start from: SDDM " + "(sddm.service, aliased to display-manager.service once enabled) and a console login " + "(getty@tty1.service) -- the same two units omarchy-provision-owner.service, this project's " + "pinned upstream first-boot unit, already orders itself Before= (and briefly Conflicts=) for " + "the same reason, so this ordering is proven to work in this codebase, not merely asserted", + ) + check( + "multi-user.target.wants/try-omarchy-locale.service" in configure + and "ln -sfn /usr/lib/systemd/system/try-omarchy-locale.service" in configure, + "configure-rootfs.sh enables the unit itself: it runs before arch-chroot, with no " + "systemd/D-Bus available to run `systemctl enable` the way finalize-rootfs.sh does for " + "sddm.service and omarchy-provision-owner.service, so it links the .wants symlink directly", + ) + manifest_writer = read(GUEST / "scripts/write-guest-manifest.py") check('"kind": "try-omarchy-guest-artifacts"' in manifest_writer, "new artifacts use the native manifest identity") diff --git a/macos/Sources/OmarchyVMHelper/LanguagePreferences.swift b/macos/Sources/OmarchyVMHelper/LanguagePreferences.swift new file mode 100644 index 00000000..e317b9e0 --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/LanguagePreferences.swift @@ -0,0 +1,123 @@ +import Foundation + +/// A guest locale the launcher may ask the guest to boot into. +/// +/// `localeToken` becomes both the value of `tryomarchy.locale=` on the +/// guest kernel command line and, once the guest writes it into +/// `/etc/locale.conf`, the session `LANG`. It is kept byte-identical to the `LANG` values +/// `configure-rootfs.sh` writes into the guest's `/etc/locale.gen`, so the +/// same string names the locale on both sides of the boundary. +struct GuestLocale: Equatable { + let localeToken: String + /// Shown in the start menu. English is not listed here — it is the + /// system default and has no row entry of its own. + let displayName: String +} + +/// The guest-generated locales a user may opt into, beyond the guest's own +/// default. The guest image only ever generates the locales listed here, so +/// this is the complete allowlist: the launcher never forwards an arbitrary +/// string to the kernel command line, and `run-qemu-gpu.sh` re-checks against +/// its own copy of this same list before trusting its environment. +/// +/// Adding a locale later is a data change to `supported` — the preference +/// store, launch configuration, and menu presentation all read from this +/// list rather than naming a specific locale. +enum GuestLocaleCatalog { + static let traditionalChinese = GuestLocale( + localeToken: "zh_TW.UTF-8", + displayName: "Traditional Chinese (繁體中文)" + ) + + static let supported: [GuestLocale] = [traditionalChinese] + + static func locale(forToken token: String) -> GuestLocale? { + supported.first { $0.localeToken == token } + } +} + +/// The user's chosen guest language. `nil` leaves the guest at its own +/// default — English (`en_US.UTF-8`) — so a user who never opens this +/// setting sees no behavioral change. +struct LanguagePreference: Equatable { + var localeToken: String? + + static let systemDefault = Self(localeToken: nil) +} + +struct LanguagePreferenceStore { + static let key = "languagePreferences" + static let schemaVersion = 1 + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// Unknown, malformed, future-schema, or no-longer-supported tokens fail + /// closed to the system default rather than ever reaching the launcher + /// unvalidated. + func load() -> LanguagePreference { + guard let data = defaults.data(forKey: Self.key), + let payload = try? JSONDecoder().decode(Payload.self, from: data), + payload.schemaVersion == Self.schemaVersion else { + return .systemDefault + } + guard let token = payload.localeToken, GuestLocaleCatalog.locale(forToken: token) != nil else { + return .systemDefault + } + return LanguagePreference(localeToken: token) + } + + func save(_ preference: LanguagePreference) { + let payload = Payload(schemaVersion: Self.schemaVersion, localeToken: preference.localeToken) + guard let data = try? JSONEncoder().encode(payload) else { return } + defaults.set(data, forKey: Self.key) + } + + private struct Payload: Codable { + let schemaVersion: Int + let localeToken: String? + } +} + +struct LanguageLaunchConfiguration: Equatable { + static let environmentKey = "OMARCHY_QEMU_GPU_LOCALE" + + let environment: [String: String] + + /// Publishes the validated locale to the launcher script, or removes any + /// inherited value so an environment leak can never boot a locale the + /// user did not pick in this app. A token outside the allowlist — which + /// `load()` above should already prevent — is treated the same as no + /// preference at all rather than passed through. + static func make( + baseEnvironment: [String: String], + preference: LanguagePreference + ) -> Self { + var environment = baseEnvironment + environment.removeValue(forKey: environmentKey) + guard let token = preference.localeToken, + GuestLocaleCatalog.locale(forToken: token) != nil else { + return Self(environment: environment) + } + environment[environmentKey] = token + return Self(environment: environment) + } +} + +/// What the start menu shows for the language row. +struct LanguageMenuState: Equatable { + let selectedLocale: GuestLocale? + + static let systemDefault = Self(selectedLocale: nil) + + static func make(preference: LanguagePreference) -> Self { + guard let token = preference.localeToken, + let locale = GuestLocaleCatalog.locale(forToken: token) else { + return .systemDefault + } + return Self(selectedLocale: locale) + } +} diff --git a/macos/Sources/OmarchyVMHelper/StartMenuPresentation.swift b/macos/Sources/OmarchyVMHelper/StartMenuPresentation.swift index fa19112d..1723635c 100644 --- a/macos/Sources/OmarchyVMHelper/StartMenuPresentation.swift +++ b/macos/Sources/OmarchyVMHelper/StartMenuPresentation.swift @@ -37,6 +37,16 @@ struct StartMenuPortForwardingPresentation: Equatable { let grantedStatusLabel: String } +struct StartMenuLanguagePresentation: Equatable { + let detail: String + /// True once a non-default guest locale is selected. Only affects the + /// row's status dot color, mirroring how `sharedFolder` and + /// `portForwarding` treat "the optional setting is turned on." + let isNonDefault: Bool + let statusLabel: String + let actionTitle: String +} + /// Pure presentation rules for the start menu. Keeping user-visible state out /// of AppKit makes the important behavior testable without relying on window /// positions, font metrics, run-loop timing, or the current display size. @@ -225,4 +235,21 @@ enum StartMenuPresentation { ? "Omarchy opens Full Screen with the Mac menu bar and Dock hidden." : "Omarchy opens in a window with the Mac menu bar and Dock available." } + + static func language(state: LanguageMenuState) -> StartMenuLanguagePresentation { + guard let selected = state.selectedLocale else { + return StartMenuLanguagePresentation( + detail: "Omarchy boots in English, this Mac’s system default.", + isNonDefault: false, + statusLabel: "○ English", + actionTitle: "Switch to \(GuestLocaleCatalog.traditionalChinese.displayName)" + ) + } + return StartMenuLanguagePresentation( + detail: "Omarchy boots in \(selected.displayName).", + isNonDefault: true, + statusLabel: "● \(selected.displayName)", + actionTitle: "Use English (Default)" + ) + } } diff --git a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift index 12c21f3f..39d28a03 100644 --- a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift +++ b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift @@ -197,6 +197,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private var networkEditor: NetworkEditor? private let immersiveMode: () -> Bool private let setImmersiveMode: (Bool) -> Void + private let languageStatus: () -> LanguageMenuState + private let setLanguage: (String?) -> Void private let launch: () -> Void private let canResetStorage: Bool private let storageLocation: () -> String? @@ -284,6 +286,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { saveNetworkPreferences: @escaping (VMNetworkPreferences) -> String? = { _ in nil }, immersiveMode: @escaping () -> Bool = { true }, setImmersiveMode: @escaping (Bool) -> Void = { _ in }, + languageStatus: @escaping () -> LanguageMenuState = { .systemDefault }, + setLanguage: @escaping (String?) -> Void = { _ in }, launch: @escaping () -> Void ) { self.accessibilityStatus = accessibilityStatus @@ -313,6 +317,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { self.saveNetworkPreferences = saveNetworkPreferences self.immersiveMode = immersiveMode self.setImmersiveMode = setImmersiveMode + self.languageStatus = languageStatus + self.setLanguage = setLanguage self.launch = launch window = NSWindow( @@ -608,6 +614,24 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { minimumHeight: 72 ) + let languageState = languageStatus() + let languagePresentation = StartMenuPresentation.language(state: languageState) + let languageRow = permissionRow( + symbolName: "globe", + title: "Language", + detail: languagePresentation.detail, + granted: languagePresentation.isNonDefault, + statusLabels: (languagePresentation.statusLabel, languagePresentation.statusLabel), + actions: [ + ( + languagePresentation.actionTitle, + languagePresentation.isNonDefault + ? #selector(useDefaultLanguage) + : #selector(selectTraditionalChineseLanguage) + ), + ] + ) + let storageStatus = storageLocationStatus() var storageRow: NSView? if let storagePath = storageLocation() { @@ -664,7 +688,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { if let storageRow { integrationRowViews.append(storageRow) } - integrationRowViews.append(contentsOf: [resourceRow, networkingRow, portForwardingRow, immersiveRow]) + integrationRowViews.append(contentsOf: [resourceRow, networkingRow, portForwardingRow, immersiveRow, languageRow]) var permissionRowsAndSeparators: [NSView] = [] for (index, row) in permissionRowViews.enumerated() { @@ -1477,6 +1501,18 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { ) } + @objc private func selectTraditionalChineseLanguage() { + guard !launchInProgress, !resetInProgress else { return } + setLanguage(GuestLocaleCatalog.traditionalChinese.localeToken) + render() + } + + @objc private func useDefaultLanguage() { + guard !launchInProgress, !resetInProgress else { return } + setLanguage(nil) + render() + } + private func confirmReset() { guard canResetStorage, !launchInProgress, diff --git a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift index 50a44e7b..9d679bce 100644 --- a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift +++ b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift @@ -57,6 +57,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { private let fullscreenPreferenceStore: FullscreenPreferenceStore private let resourcePreferenceStore: VMResourcePreferenceStore private let resourceLimits: VMResourceLimits + private let languagePreferenceStore: LanguagePreferenceStore private let storageLocationStore: StorageLocationPreferenceStore private let volumeProbe: VolumeProbing private let volumeRootDetector: VolumeRootDetecting @@ -98,6 +99,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { fullscreenPreferenceStore: FullscreenPreferenceStore = FullscreenPreferenceStore(), resourcePreferenceStore: VMResourcePreferenceStore = VMResourcePreferenceStore(), resourceLimits: VMResourceLimits = .current, + languagePreferenceStore: LanguagePreferenceStore = LanguagePreferenceStore(), storageLocationStore: StorageLocationPreferenceStore = StorageLocationPreferenceStore(), volumeProbe: VolumeProbing = URLVolumeProbe(), volumeRootDetector: VolumeRootDetecting = FileManagerVolumeRootDetector(), @@ -115,6 +117,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { self.fullscreenPreferenceStore = fullscreenPreferenceStore self.resourcePreferenceStore = resourcePreferenceStore self.resourceLimits = resourceLimits + self.languagePreferenceStore = languagePreferenceStore self.storageLocationStore = storageLocationStore self.volumeProbe = volumeProbe self.volumeRootDetector = volumeRootDetector @@ -226,6 +229,12 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { FullscreenPreferences(isImmersive: isImmersive) ) }, + languageStatus: { [weak self] in + LanguageMenuState.make(preference: self?.languagePreferenceStore.load() ?? .systemDefault) + }, + setLanguage: { [weak self] localeToken in + self?.languagePreferenceStore.save(LanguagePreference(localeToken: localeToken)) + }, launch: { [weak self] in self?.startVirtualMachine() } @@ -450,8 +459,12 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { preferences: resourcePreferenceStore.load(), limits: resourceLimits ) - let storage = StorageLocationLaunchConfiguration.make( + let language = LanguageLaunchConfiguration.make( baseEnvironment: resources.environment, + preference: languagePreferenceStore.load() + ) + let storage = StorageLocationLaunchConfiguration.make( + baseEnvironment: language.environment, preference: storageLocationStore.load(), metrics: bundledMetrics, probe: volumeProbe, diff --git a/macos/Tests/OmarchyVMHelperTests/LanguagePreferencesTests.swift b/macos/Tests/OmarchyVMHelperTests/LanguagePreferencesTests.swift new file mode 100644 index 00000000..fe6be4e8 --- /dev/null +++ b/macos/Tests/OmarchyVMHelperTests/LanguagePreferencesTests.swift @@ -0,0 +1,139 @@ +import Foundation +import Testing +@testable import OmarchyVMHelper + +@Suite("Guest locale catalog") +struct GuestLocaleCatalogTests { + @Test("only the audited Traditional Chinese locale is recognized") + func recognizesOnlySupportedTokens() { + #expect(GuestLocaleCatalog.locale(forToken: "zh_TW.UTF-8") == GuestLocaleCatalog.traditionalChinese) + #expect(GuestLocaleCatalog.locale(forToken: "en_US.UTF-8") == nil) + #expect(GuestLocaleCatalog.locale(forToken: "zh_CN.UTF-8") == nil) + #expect(GuestLocaleCatalog.locale(forToken: "") == nil) + #expect(GuestLocaleCatalog.locale(forToken: "zh_TW.UTF-8; rm -rf /") == nil) + } +} + +@Suite("Language preferences") +struct LanguagePreferenceStoreTests { + @Test("defaults to the guest's own default with no stored preference") + func defaultsToSystemDefault() { + let fixture = DefaultsFixture() + #expect(fixture.store.load() == .systemDefault) + #expect(fixture.store.load().localeToken == nil) + } + + @Test("a chosen locale persists") + func savesChoice() { + let fixture = DefaultsFixture() + fixture.store.save(LanguagePreference(localeToken: GuestLocaleCatalog.traditionalChinese.localeToken)) + + let reopened = LanguagePreferenceStore(defaults: fixture.defaults) + #expect(reopened.load().localeToken == GuestLocaleCatalog.traditionalChinese.localeToken) + } + + @Test("switching back to the default clears the stored locale") + func clearsChoice() { + let fixture = DefaultsFixture() + fixture.store.save(LanguagePreference(localeToken: GuestLocaleCatalog.traditionalChinese.localeToken)) + fixture.store.save(.systemDefault) + + #expect(fixture.store.load() == .systemDefault) + } + + @Test("junk, future-schema, and no-longer-supported tokens fail closed to the default") + func invalidPreferencesUseDefault() throws { + let fixture = DefaultsFixture() + fixture.defaults.set(Data("junk".utf8), forKey: LanguagePreferenceStore.key) + #expect(fixture.store.load() == .systemDefault) + + let future = try JSONSerialization.data(withJSONObject: [ + "schemaVersion": LanguagePreferenceStore.schemaVersion + 1, + "localeToken": GuestLocaleCatalog.traditionalChinese.localeToken, + ]) + fixture.defaults.set(future, forKey: LanguagePreferenceStore.key) + #expect(fixture.store.load() == .systemDefault) + + let unsupported = try JSONSerialization.data(withJSONObject: [ + "schemaVersion": LanguagePreferenceStore.schemaVersion, + "localeToken": "fr_FR.UTF-8", + ]) + fixture.defaults.set(unsupported, forKey: LanguagePreferenceStore.key) + #expect(fixture.store.load() == .systemDefault) + } + + private final class DefaultsFixture { + let suiteName = "LanguagePreferenceStoreTests.\(UUID().uuidString)" + let defaults: UserDefaults + let store: LanguagePreferenceStore + + init() { + defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + store = LanguagePreferenceStore(defaults: defaults) + } + + deinit { + defaults.removePersistentDomain(forName: suiteName) + } + } +} + +@Suite("Language launch configuration") +struct LanguageLaunchConfigurationTests { + @Test("no preference emits no token and strips any inherited value") + func defaultEmitsNothing() { + let inherited = [ + "KEEP_ME": "yes", + LanguageLaunchConfiguration.environmentKey: "zh_TW.UTF-8", + ] + + let configuration = LanguageLaunchConfiguration.make( + baseEnvironment: inherited, + preference: .systemDefault + ) + #expect(configuration.environment["KEEP_ME"] == "yes") + #expect(configuration.environment[LanguageLaunchConfiguration.environmentKey] == nil) + } + + @Test("a supported locale publishes its token") + func supportedLocalePublishesToken() { + let configuration = LanguageLaunchConfiguration.make( + baseEnvironment: ["KEEP_ME": "yes"], + preference: LanguagePreference(localeToken: GuestLocaleCatalog.traditionalChinese.localeToken) + ) + #expect(configuration.environment["KEEP_ME"] == "yes") + #expect(configuration.environment[LanguageLaunchConfiguration.environmentKey] == "zh_TW.UTF-8") + } + + @Test("a token outside the allowlist is dropped rather than passed through") + func unsupportedTokenIsDropped() { + let configuration = LanguageLaunchConfiguration.make( + baseEnvironment: [:], + preference: LanguagePreference(localeToken: "fr_FR.UTF-8") + ) + #expect(configuration.environment[LanguageLaunchConfiguration.environmentKey] == nil) + } +} + +@Suite("Language menu state") +struct LanguageMenuStateTests { + @Test("no preference resolves to the system default") + func systemDefaultResolves() { + #expect(LanguageMenuState.make(preference: .systemDefault) == .systemDefault) + } + + @Test("a supported preference resolves to its catalog locale") + func supportedPreferenceResolves() { + let state = LanguageMenuState.make( + preference: LanguagePreference(localeToken: GuestLocaleCatalog.traditionalChinese.localeToken) + ) + #expect(state.selectedLocale == GuestLocaleCatalog.traditionalChinese) + } + + @Test("an unsupported preference fails closed to the system default") + func unsupportedPreferenceFailsClosed() { + let state = LanguageMenuState.make(preference: LanguagePreference(localeToken: "ja_JP.UTF-8")) + #expect(state == .systemDefault) + } +} diff --git a/macos/Tests/OmarchyVMHelperTests/StartMenuPresentationTests.swift b/macos/Tests/OmarchyVMHelperTests/StartMenuPresentationTests.swift index 1b62ba53..71389cd9 100644 --- a/macos/Tests/OmarchyVMHelperTests/StartMenuPresentationTests.swift +++ b/macos/Tests/OmarchyVMHelperTests/StartMenuPresentationTests.swift @@ -269,4 +269,21 @@ struct StartMenuPresentationTests { #expect(StartMenuPresentation.immersiveDetail(isEnabled: false) == "Omarchy opens in a window with the Mac menu bar and Dock available.") } + + @Test("language presentation distinguishes the system default from a chosen locale") + func languageStates() { + let systemDefault = StartMenuPresentation.language(state: .systemDefault) + #expect(!systemDefault.isNonDefault) + #expect(systemDefault.statusLabel == "○ English") + #expect(systemDefault.detail.contains("English")) + #expect(systemDefault.actionTitle.contains("繁體中文")) + + let traditionalChinese = StartMenuPresentation.language( + state: LanguageMenuState(selectedLocale: GuestLocaleCatalog.traditionalChinese) + ) + #expect(traditionalChinese.isNonDefault) + #expect(traditionalChinese.statusLabel == "● Traditional Chinese (繁體中文)") + #expect(traditionalChinese.detail.contains("Traditional Chinese")) + #expect(traditionalChinese.actionTitle == "Use English (Default)") + } } diff --git a/macos/run-qemu-gpu.sh b/macos/run-qemu-gpu.sh index dc01d5f9..1f02d346 100755 --- a/macos/run-qemu-gpu.sh +++ b/macos/run-qemu-gpu.sh @@ -1064,6 +1064,23 @@ if [[ -n $shared_folder ]]; then shared_folder_kernel_argument=" omarchy.shared_folder_name=$shared_folder_name_encoded" fi +# The launcher publishes an optional guest language opt-in. The Swift app +# validates the choice against its own locale allowlist first; re-check here +# so a stray environment value can never select a locale the guest image +# never generated. Empty means the guest's own default (English). +guest_locale=${OMARCHY_QEMU_GPU_LOCALE:-} +locale_kernel_argument="" +if [[ -n $guest_locale ]]; then + case $guest_locale in + zh_TW.UTF-8) + locale_kernel_argument=" tryomarchy.locale=$guest_locale" + ;; + *) + fail "unsupported guest locale: $guest_locale" + ;; + esac +fi + work_dir="" owner_marker="" owner_token="" @@ -1536,7 +1553,7 @@ qemu_args=( -qmp "unix:$qmp_socket,server=on,wait=off" -kernel "$launch_kernel" -initrd "$launch_initramfs" - -append "$launch_kernel_command_line omarchy.qemu_virgl=1$shared_folder_kernel_argument$ssh_kernel_argument" + -append "$launch_kernel_command_line omarchy.qemu_virgl=1$shared_folder_kernel_argument$ssh_kernel_argument$locale_kernel_argument" -drive "if=none,id=omarchy-root,file=$working_disk,format=raw,media=disk,cache=writeback" -device 'virtio-blk-pci,drive=omarchy-root,serial=omarchy-root' -device "$gpu_device"