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"