From 967370193293cf04ea7e48cdaddc8338ab7db106 Mon Sep 17 00:00:00 2001 From: k5953837 Date: Wed, 9 Sep 2026 17:21:27 +0800 Subject: [PATCH 01/12] Seed zh-TW locale, fcitx5 profile, and CJK font preference Generate zh_TW.UTF-8 alongside en_US.UTF-8 without changing the default LANG or KEYMAP, seed an fcitx5 profile that keeps the US keyboard first and Chewing second, append --enable-wayland-ime to upstream's Chromium flags, and prefer the Traditional Chinese Noto CJK face for zh-tw and zh-hant text. XMODIFIERS is set for XWayland clients only. GTK_IM_MODULE and QT_IM_MODULE stay unset so native Wayland GTK4 and Qt6 applications keep using text-input-v3, which Hyprland implements. Co-Authored-By: Claude Opus 5 (1M context) --- .../etc/fonts/conf.d/30-try-omarchy.conf | 104 ++++++++++++++++++ .../usr/lib/environment.d/90-try-omarchy.conf | 9 ++ .../chromium-flags-wayland-ime.append.conf | 1 + guest/fragments/fcitx5-profile.ini | 15 +++ guest/scripts/configure-rootfs.sh | 23 +++- 5 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 guest/factory-overlay/etc/fonts/conf.d/30-try-omarchy.conf create mode 100644 guest/fragments/chromium-flags-wayland-ime.append.conf create mode 100644 guest/fragments/fcitx5-profile.ini 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..bc22fea0 --- /dev/null +++ b/guest/factory-overlay/etc/fonts/conf.d/30-try-omarchy.conf @@ -0,0 +1,104 @@ + + + + + + + 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..5f8005af --- /dev/null +++ b/guest/fragments/fcitx5-profile.ini @@ -0,0 +1,15 @@ +[Groups/0] +Name=Default +Default Layout=us +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/scripts/configure-rootfs.sh b/guest/scripts/configure-rootfs.sh index 3603a7f7..9d6314e0 100755 --- a/guest/scripts/configure-rootfs.sh +++ b/guest/scripts/configure-rootfs.sh @@ -141,7 +141,11 @@ 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" # An unprovisioned machine receives a new identity from systemd on first boot. @@ -165,6 +169,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" From b5df46c8b3ebd7e5b4ea67b7322736afe0e4da88 Mon Sep 17 00:00:00 2001 From: k5953837 Date: Wed, 9 Sep 2026 17:53:30 +0800 Subject: [PATCH 02/12] Cover the zh-TW guest contract in the native guest tests Assert that zh_TW.UTF-8 is generated without displacing the English default, that the seeded fcitx5 profile keeps the US keyboard first, that Chromium's upstream flags are appended to rather than replaced, that Traditional Chinese glyphs win for zh-tw and zh-hant text, and that GTK_IM_MODULE and QT_IM_MODULE are never assigned. The last check parses non-comment lines so it keeps passing while the environment file explains in prose why those variables stay unset. Co-Authored-By: Claude Opus 5 (1M context) --- guest/tests/verify.py | 71 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/guest/tests/verify.py b/guest/tests/verify.py index cfac9174..4d6f8029 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,32 @@ 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( + fcitx5_profile.index("Name=keyboard-us") < fcitx5_profile.index("Name=chewing") + and "DefaultIM=keyboard-us" in fcitx5_profile, + "US keyboard stays the default input method, so a user who never triggers the IME sees no change", + ) + 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 +924,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 From 054002f8fbaaeab119145397bb85f0a9ec57a1ab Mon Sep 17 00:00:00 2001 From: k5953837 Date: Wed, 9 Sep 2026 20:24:07 +0800 Subject: [PATCH 03/12] Register the Chromium flags path and document the zh-TW guest changes configure-rootfs.sh appends to upstream's chromium-flags.conf, so declare it in authenticity.requiredPaths the way config/hypr/monitors.lua already is. An upstream rename is then caught during source verification instead of part way through a build. Also add the architecture note the other guest overlay additions have, and condense the fontconfig comment to the two reasons that carry weight. Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture.md | 6 ++++ .../etc/fonts/conf.d/30-try-omarchy.conf | 36 +++++-------------- guest/spec.json | 1 + 3 files changed, 15 insertions(+), 28 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 1b678d84..5e62b3f4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -184,6 +184,12 @@ 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 without changing the default + session: `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. + `LANG` and `KEYMAP` stay `en_US`/`us` for a user who never touches the IME. 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 index bc22fea0..534d9cad 100644 --- a/guest/factory-overlay/etc/fonts/conf.d/30-try-omarchy.conf +++ b/guest/factory-overlay/etc/fonts/conf.d/30-try-omarchy.conf @@ -2,35 +2,15 @@ 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", From 4f65341f703b45af30ffda872fb872c11787e232 Mon Sep 17 00:00:00 2001 From: k5953837 Date: Wed, 9 Sep 2026 20:56:59 +0800 Subject: [PATCH 04/12] Pin the fcitx5 item order rather than the inert DefaultIM string fcitx5 substitutes the second item when a group's requested default names the first, and rewrites DefaultIM to that resolved value on save, so DefaultIM=keyboard-us has no effect here. What keeps an untouched session on plain US input is keyboard-us occupying item index 0, combined with input contexts starting inactive. Assert the section and name pair for both items, and record the reasoning in the profile fragment so the ordering is not "corrected" later. Co-Authored-By: Claude Opus 5 (1M context) --- guest/fragments/fcitx5-profile.ini | 4 ++++ guest/tests/verify.py | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/guest/fragments/fcitx5-profile.ini b/guest/fragments/fcitx5-profile.ini index 5f8005af..10694f07 100644 --- a/guest/fragments/fcitx5-profile.ini +++ b/guest/fragments/fcitx5-profile.ini @@ -1,6 +1,10 @@ [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] diff --git a/guest/tests/verify.py b/guest/tests/verify.py index 4d6f8029..b14a8b86 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -763,9 +763,11 @@ def main() -> None: ) fcitx5_profile = read(GUEST / "fragments/fcitx5-profile.ini") check( - fcitx5_profile.index("Name=keyboard-us") < fcitx5_profile.index("Name=chewing") - and "DefaultIM=keyboard-us" in fcitx5_profile, - "US keyboard stays the default input method, so a user who never triggers the IME sees no change", + "[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 From 38614779bf24485cb994effd244a5413c00b9505 Mon Sep 17 00:00:00 2001 From: k5953837 Date: Thu, 10 Sep 2026 00:30:51 +0800 Subject: [PATCH 05/12] Apply a host-selected UI locale to the graphical session Read tryomarchy.locale from the kernel command line and write LANG into /run/environment.d, the runtime tier of the same mechanism the factory overlay already uses to deliver XMODIFIERS. Without the token nothing is written, so an English boot is unchanged. The value reaches the session environment, so only the two locales configure-rootfs.sh generates are accepted; anything else is ignored rather than interpolated. /etc/locale.conf, LC_ALL and the console keymap are left alone. Co-Authored-By: Claude Opus 5 (1M context) --- .../system-generators/try-omarchy-locale | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100755 guest/native-overlay/usr/lib/systemd/system-generators/try-omarchy-locale diff --git a/guest/native-overlay/usr/lib/systemd/system-generators/try-omarchy-locale b/guest/native-overlay/usr/lib/systemd/system-generators/try-omarchy-locale new file mode 100755 index 00000000..f56a36df --- /dev/null +++ b/guest/native-overlay/usr/lib/systemd/system-generators/try-omarchy-locale @@ -0,0 +1,46 @@ +#!/bin/sh + +# Fold a host-chosen UI locale (tryomarchy.locale= on the kernel +# command line) into this boot's graphical session, without ever touching +# /etc/locale.conf: LANG stays en_US.UTF-8 for anyone who never sets the +# token. /run/environment.d is the runtime tier of environment.d (see +# environment.d(5)): systemd-environment-d-generator folds it into the user +# manager's environment the same way factory-overlay's 90-try-omarchy.conf +# already delivers XMODIFIERS, and /run is cleared every boot, so this +# generator still never writes persistent state. + +set -eu + +[ "$#" -eq 3 ] || exit 64 +normal_output=$1 +[ -d "$normal_output" ] && [ ! -L "$normal_output" ] || exit 1 + +command_line="" +IFS= read -r command_line "$environment_d/91-try-omarchy-locale.conf" From 6d3c15a9aaf0124b6d5940c3811a567dea3019c8 Mon Sep 17 00:00:00 2001 From: k5953837 Date: Thu, 10 Sep 2026 00:37:39 +0800 Subject: [PATCH 06/12] Let the start menu choose the guest UI language Add a Language row beside the other integrations. Choosing Traditional Chinese stores the locale in the same preference shape the other settings use, threads OMARCHY_QEMU_GPU_LOCALE through the launch-configuration chain, and run-qemu-gpu.sh turns it into the tryomarchy.locale kernel argument the guest generator reads. The launcher re-validates the value against the locales the guest image generates rather than trusting the environment, and fails loudly on an unknown one: unlike the kernel command line, that value comes from our own code, so a bad one is a bug rather than something to shrug off. Leaving the setting alone emits no environment variable and no kernel argument, so an English launch is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../OmarchyVMHelper/LanguagePreferences.swift | 123 ++++++++++++++++ .../StartMenuPresentation.swift | 27 ++++ .../OmarchyVMHelper/StartMenuWindow.swift | 38 ++++- .../VMApplicationController.swift | 15 +- .../LanguagePreferencesTests.swift | 139 ++++++++++++++++++ .../StartMenuPresentationTests.swift | 17 +++ macos/run-qemu-gpu.sh | 19 ++- 7 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 macos/Sources/OmarchyVMHelper/LanguagePreferences.swift create mode 100644 macos/Tests/OmarchyVMHelperTests/LanguagePreferencesTests.swift diff --git a/macos/Sources/OmarchyVMHelper/LanguagePreferences.swift b/macos/Sources/OmarchyVMHelper/LanguagePreferences.swift new file mode 100644 index 00000000..d979534d --- /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's own generator reads it, +/// 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" From 156ae3adfc65e879aa0a9fb4be5ff49129c6d646 Mon Sep 17 00:00:00 2001 From: k5953837 Date: Thu, 10 Sep 2026 00:43:31 +0800 Subject: [PATCH 07/12] Document choosing Traditional Chinese and what stays English Give the feature its own README section alongside the other opt-in start-menu integrations, and widen the architecture note to cover the host-side choice and the kernel argument that carries it. State plainly that Omarchy's own setup wizard and menus remain English: upstream has no translation mechanism and those strings are hardcoded in its shell scripts, so a reader should know before switching rather than after. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 13 +++++++++++++ docs/architecture.md | 15 +++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) 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 5e62b3f4..678b9a31 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -184,12 +184,15 @@ 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 without changing the default - session: `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. - `LANG` and `KEYMAP` stay `en_US`/`us` for a user who never touches the IME. +- 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 systemd + generator consumes it to set `LANG` in `/run/environment.d`. `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 From 8a51cfb89062f849b92f5acef18d03eadec9b605 Mon Sep 17 00:00:00 2001 From: k5953837 Date: Thu, 10 Sep 2026 00:48:53 +0800 Subject: [PATCH 08/12] Cover the locale generator's contract in the guest tests Derive both sides of the allowlist check from source rather than restating a locale list, so the generator and configure-rootfs.sh cannot drift apart and leave a chosen language with no LANG. Add behavioural tests that run the generator as a subprocess with /proc/cmdline and its output directory redirected into a temporary directory: an off-allowlist value, an empty value and a value carrying shell metacharacters must each write nothing, which string matching alone cannot establish. Co-Authored-By: Claude Opus 5 (1M context) --- guest/tests/test_locale.py | 116 +++++++++++++++++++++++++++++++++++++ guest/tests/verify.py | 63 ++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 guest/tests/test_locale.py diff --git a/guest/tests/test_locale.py b/guest/tests/test_locale.py new file mode 100644 index 00000000..8156b509 --- /dev/null +++ b/guest/tests/test_locale.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Isolated contracts for the host-locale systemd generator.""" + +from __future__ import annotations + +from pathlib import Path +import subprocess +import tempfile +import unittest + + +GUEST = Path(__file__).resolve().parents[1] +GENERATOR = ( + GUEST / "native-overlay/usr/lib/systemd/system-generators/try-omarchy-locale" +) + + +class LocaleGeneratorTests(unittest.TestCase): + def run_generator( + self, command_line: str + ) -> tuple[ + subprocess.CompletedProcess[str], Path, tempfile.TemporaryDirectory[str] + ]: + temporary = tempfile.TemporaryDirectory() + root = Path(temporary.name) + normal_output = root / "normal" + normal_output.mkdir() + cmdline = root / "cmdline" + cmdline.write_text(command_line + "\n", encoding="utf-8") + environment_d = root / "environment.d" + test_generator = root / "generator" + source = GENERATOR.read_text(encoding="utf-8") + source = source.replace("/proc/cmdline", str(cmdline)).replace( + "/run/environment.d", str(environment_d) + ) + test_generator.write_text(source, encoding="utf-8") + test_generator.chmod(0o755) + result = subprocess.run( + [ + str(test_generator), + str(normal_output), + str(root / "early"), + str(root / "late"), + ], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return result, environment_d, temporary + + def assert_nothing_written( + self, + result: subprocess.CompletedProcess[str], + environment_d: Path, + ) -> None: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(environment_d.exists()) + + def test_no_token_writes_nothing(self) -> None: + result, environment_d, temporary = self.run_generator("root=/dev/vda rw") + with temporary: + self.assert_nothing_written(result, environment_d) + + def test_each_allowlisted_locale_writes_the_matching_lang_line(self) -> None: + for locale in ("en_US.UTF-8", "zh_TW.UTF-8"): + result, environment_d, temporary = self.run_generator( + f"root=/dev/vda rw tryomarchy.locale={locale}" + ) + with temporary: + conf = environment_d / "91-try-omarchy-locale.conf" + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(conf.read_text(encoding="utf-8"), f"LANG={locale}\n") + self.assertEqual( + [ + path.relative_to(environment_d).as_posix() + for path in environment_d.rglob("*") + ], + ["91-try-omarchy-locale.conf"], + ) + + def test_locale_not_on_allowlist_writes_nothing(self) -> None: + for locale in ("fr_FR.UTF-8", "en_US", "zh_CN.UTF-8", "en_US.UTF-8x"): + result, environment_d, temporary = self.run_generator( + f"root=/dev/vda rw tryomarchy.locale={locale}" + ) + with temporary: + self.assert_nothing_written(result, environment_d) + + def test_empty_token_value_writes_nothing(self) -> None: + result, environment_d, temporary = self.run_generator( + "root=/dev/vda rw tryomarchy.locale=" + ) + with temporary: + self.assert_nothing_written(result, environment_d) + + def test_shell_metacharacter_value_writes_nothing_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, environment_d, inner = self.run_generator(payload) + with inner: + self.assert_nothing_written(result, environment_d) + self.assertFalse(marker.exists()) + + def test_malformed_invocation_fails(self) -> None: + result = subprocess.run([str(GENERATOR)], check=False) + self.assertEqual(result.returncode, 64) + + +if __name__ == "__main__": + unittest.main() diff --git a/guest/tests/verify.py b/guest/tests/verify.py index b14a8b86..66c59c68 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -1253,6 +1253,69 @@ def main() -> None: "SSH generator requests only the boot-scoped vendor sshd unit", ) + locale_generator_path = ( + GUEST + / "native-overlay/usr/lib/systemd/system-generators/try-omarchy-locale" + ) + locale_generator = read(locale_generator_path) + locale_generator_code = "\n".join( + line + for line in locale_generator.splitlines() + if not line.strip().startswith("#") + ) + 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 \$locale in\n\s*([^\n]+)\) ;;\n\s*\*\) exit 0 ;;\n", locale_generator + ) + 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 generator's allowlist cannot drift from the locales configure-rootfs.sh actually " + "generates, or a chosen language silently gets no LANG", + ) + check( + locale_generator_path.is_file() + and locale_generator_path.stat().st_mode & stat.S_IXUSR != 0 + and "tryomarchy.locale=" in locale_generator + and "/proc/cmdline" in locale_generator, + "locale generator is an executable that reads the host-chosen locale from the kernel command line", + ) + check( + "/run/environment.d" in locale_generator_code + and "/etc" not in locale_generator_code, + "locale generator writes only to the runtime tier, never to /etc, like its sibling ssh generator", + ) + check( + "LANG=%s" in locale_generator + and "LC_ALL" not in locale_generator + and "KEYMAP" not in locale_generator, + "locale generator sets LANG only, never LC_ALL or the console keymap", + ) + check( + locale_generator.index('[ -n "$locale" ] || exit 0') + < locale_generator.index( + 'printf \'LANG=%s\\n\' "$locale" >"$environment_d/91-try-omarchy-locale.conf"' + ), + "an absent locale token leaves an english boot untouched: the generator exits before writing anything", + ) + 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") From 06c65fbd852d04b54e378659721ad87ca0cfa78b Mon Sep 17 00:00:00 2001 From: k5953837 Date: Thu, 10 Sep 2026 01:06:34 +0800 Subject: [PATCH 09/12] Restore the CJK font and fcitx5 packages the ARM64 trim dropped Upstream Omarchy already ships noto-fonts-cjk, fcitx5, fcitx5-gtk and fcitx5-qt in install/omarchy-base.packages; this fork's trimmed transaction drops all four, which is why Chinese renders as tofu and there is no way to type it. Put them back and add fcitx5-chewing for Bopomofo. fcitx5-configtool is deliberately left out: upstream does not ship it either, and it drags in a KDE dependency chain for a GUI the seeded profile makes unnecessary. The refreshed lock adds exactly nine packages and changes no existing version. Co-Authored-By: Claude Opus 5 (1M context) --- guest/packages.lock.json | 11 ++++++++++- guest/packages.txt | 5 +++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/guest/packages.lock.json b/guest/packages.lock.json index 0f1eec7a..5791f7b0 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", @@ -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", @@ -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 From 189de473134fdc02a96b122441cd655bd3fb6527 Mon Sep 17 00:00:00 2001 From: k5953837 Date: Thu, 10 Sep 2026 10:23:26 +0800 Subject: [PATCH 10/12] Set the chosen locale where the login shell reads it The generator wrote LANG into /run/environment.d, and measurement in a booted guest showed why that never took effect: Omarchy starts its session through uwsm, which merges the login shell's environment back into the user manager after the environment.d generators have run. The login shell takes LANG from /etc/locale.conf via PAM, so English always won. Write /etc/locale.conf from a oneshot unit ordered before both session entry points instead. An absent or unrecognised token writes the image default rather than nothing, so switching back to English in the start menu also takes effect on a VM that has already run in Chinese. Co-Authored-By: Claude Opus 5 (1M context) --- .../system-generators/try-omarchy-locale | 46 ------ .../systemd/system/try-omarchy-locale.service | 24 +++ .../usr/local/bin/try-omarchy-locale | 55 +++++++ guest/scripts/configure-rootfs.sh | 9 ++ guest/tests/test_locale.py | 145 +++++++++++------- guest/tests/verify.py | 91 ++++++++--- 6 files changed, 245 insertions(+), 125 deletions(-) delete mode 100755 guest/native-overlay/usr/lib/systemd/system-generators/try-omarchy-locale create mode 100644 guest/native-overlay/usr/lib/systemd/system/try-omarchy-locale.service create mode 100755 guest/native-overlay/usr/local/bin/try-omarchy-locale diff --git a/guest/native-overlay/usr/lib/systemd/system-generators/try-omarchy-locale b/guest/native-overlay/usr/lib/systemd/system-generators/try-omarchy-locale deleted file mode 100755 index f56a36df..00000000 --- a/guest/native-overlay/usr/lib/systemd/system-generators/try-omarchy-locale +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/sh - -# Fold a host-chosen UI locale (tryomarchy.locale= on the kernel -# command line) into this boot's graphical session, without ever touching -# /etc/locale.conf: LANG stays en_US.UTF-8 for anyone who never sets the -# token. /run/environment.d is the runtime tier of environment.d (see -# environment.d(5)): systemd-environment-d-generator folds it into the user -# manager's environment the same way factory-overlay's 90-try-omarchy.conf -# already delivers XMODIFIERS, and /run is cleared every boot, so this -# generator still never writes persistent state. - -set -eu - -[ "$#" -eq 3 ] || exit 64 -normal_output=$1 -[ -d "$normal_output" ] && [ ! -L "$normal_output" ] || exit 1 - -command_line="" -IFS= read -r command_line "$environment_d/91-try-omarchy-locale.conf" 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/scripts/configure-rootfs.sh b/guest/scripts/configure-rootfs.sh index 9d6314e0..35552f3a 100755 --- a/guest/scripts/configure-rootfs.sh +++ b/guest/scripts/configure-rootfs.sh @@ -148,6 +148,15 @@ EOF 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" diff --git a/guest/tests/test_locale.py b/guest/tests/test_locale.py index 8156b509..bbdaffc1 100644 --- a/guest/tests/test_locale.py +++ b/guest/tests/test_locale.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Isolated contracts for the host-locale systemd generator.""" +"""Isolated contracts for the host-locale boot script.""" from __future__ import annotations @@ -10,91 +10,75 @@ GUEST = Path(__file__).resolve().parents[1] -GENERATOR = ( - GUEST / "native-overlay/usr/lib/systemd/system-generators/try-omarchy-locale" -) +SCRIPT = GUEST / "native-overlay/usr/local/bin/try-omarchy-locale" -class LocaleGeneratorTests(unittest.TestCase): - def run_generator( +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) - normal_output = root / "normal" - normal_output.mkdir() cmdline = root / "cmdline" cmdline.write_text(command_line + "\n", encoding="utf-8") - environment_d = root / "environment.d" - test_generator = root / "generator" - source = GENERATOR.read_text(encoding="utf-8") - source = source.replace("/proc/cmdline", str(cmdline)).replace( - "/run/environment.d", str(environment_d) - ) - test_generator.write_text(source, encoding="utf-8") - test_generator.chmod(0o755) + locale_conf = root / "locale.conf" result = subprocess.run( - [ - str(test_generator), - str(normal_output), - str(root / "early"), - str(root / "late"), - ], + [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, environment_d, temporary - - def assert_nothing_written( - self, - result: subprocess.CompletedProcess[str], - environment_d: Path, - ) -> None: - self.assertEqual(result.returncode, 0, result.stderr) - self.assertFalse(environment_d.exists()) + return result, locale_conf, temporary - def test_no_token_writes_nothing(self) -> None: - result, environment_d, temporary = self.run_generator("root=/dev/vda rw") + 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.assert_nothing_written(result, environment_d) + 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, environment_d, temporary = self.run_generator( + result, locale_conf, temporary = self.run_script( f"root=/dev/vda rw tryomarchy.locale={locale}" ) with temporary: - conf = environment_d / "91-try-omarchy-locale.conf" self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(conf.read_text(encoding="utf-8"), f"LANG={locale}\n") self.assertEqual( - [ - path.relative_to(environment_d).as_posix() - for path in environment_d.rglob("*") - ], - ["91-try-omarchy-locale.conf"], + locale_conf.read_text(encoding="utf-8"), f"LANG={locale}\n" ) - def test_locale_not_on_allowlist_writes_nothing(self) -> None: + 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, environment_d, temporary = self.run_generator( + result, locale_conf, temporary = self.run_script( f"root=/dev/vda rw tryomarchy.locale={locale}" ) with temporary: - self.assert_nothing_written(result, environment_d) + 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_writes_nothing(self) -> None: - result, environment_d, temporary = self.run_generator( + 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.assert_nothing_written(result, environment_d) + 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_writes_nothing_and_has_no_side_effect( + def test_shell_metacharacter_value_falls_back_and_has_no_side_effect( self, ) -> None: temporary = tempfile.TemporaryDirectory() @@ -102,14 +86,67 @@ def test_shell_metacharacter_value_writes_nothing_and_has_no_side_effect( root = Path(temporary.name) marker = root / "pwned" payload = f"tryomarchy.locale=$(touch {marker});x" - result, environment_d, inner = self.run_generator(payload) + result, locale_conf, inner = self.run_script(payload) with inner: - self.assert_nothing_written(result, environment_d) + 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_malformed_invocation_fails(self) -> None: - result = subprocess.run([str(GENERATOR)], check=False) - self.assertEqual(result.returncode, 64) + 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__": diff --git a/guest/tests/verify.py b/guest/tests/verify.py index 66c59c68..f3e387c9 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -1253,16 +1253,25 @@ def main() -> None: "SSH generator requests only the boot-scoped vendor sshd unit", ) - locale_generator_path = ( + old_locale_generator_path = ( GUEST / "native-overlay/usr/lib/systemd/system-generators/try-omarchy-locale" ) - locale_generator = read(locale_generator_path) - locale_generator_code = "\n".join( - line - for line in locale_generator.splitlines() - if not line.strip().startswith("#") + 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 ) @@ -1276,7 +1285,7 @@ def main() -> None: else [] ) locale_allowlist_match = re.search( - r"case \$locale in\n\s*([^\n]+)\) ;;\n\s*\*\) exit 0 ;;\n", locale_generator + 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("|")) @@ -1287,33 +1296,65 @@ def main() -> None: locale_gen_format is not None and locale_allowlist_match is not None and generated_locales == allowlisted_locales, - "locale generator's allowlist cannot drift from the locales configure-rootfs.sh actually " + "locale script's allowlist cannot drift from the locales configure-rootfs.sh actually " "generates, or a chosen language silently gets no LANG", ) check( - locale_generator_path.is_file() - and locale_generator_path.stat().st_mode & stat.S_IXUSR != 0 - and "tryomarchy.locale=" in locale_generator - and "/proc/cmdline" in locale_generator, - "locale generator is an executable that reads the host-chosen locale from the kernel command line", + 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( - "/run/environment.d" in locale_generator_code - and "/etc" not in locale_generator_code, - "locale generator writes only to the runtime tier, never to /etc, like its sibling ssh generator", + "eval" not in locale_script and "$(" not in locale_script, + "the kernel command line is never shell-interpolated", ) check( - "LANG=%s" in locale_generator - and "LC_ALL" not in locale_generator - and "KEYMAP" not in locale_generator, - "locale generator sets LANG only, never LC_ALL or the console keymap", + '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( - locale_generator.index('[ -n "$locale" ] || exit 0') - < locale_generator.index( - 'printf \'LANG=%s\\n\' "$locale" >"$environment_d/91-try-omarchy-locale.conf"' - ), - "an absent locale token leaves an english boot untouched: the generator exits before writing anything", + "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") From 042d546fd7863707fa615fd72f819521ce1448a3 Mon Sep 17 00:00:00 2001 From: k5953837 Date: Thu, 10 Sep 2026 22:28:18 +0800 Subject: [PATCH 11/12] Describe the locale mechanism that shipped Both notes still described the systemd generator this branch replaced, and named /run/environment.d rather than the file the login shell actually reads. Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture.md | 5 +++-- macos/Sources/OmarchyVMHelper/LanguagePreferences.swift | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 678b9a31..b4dba26c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -186,8 +186,9 @@ creates the account on first boot. 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 systemd - generator consumes it to set `LANG` in `/run/environment.d`. `zh_TW.UTF-8` + `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 diff --git a/macos/Sources/OmarchyVMHelper/LanguagePreferences.swift b/macos/Sources/OmarchyVMHelper/LanguagePreferences.swift index d979534d..e317b9e0 100644 --- a/macos/Sources/OmarchyVMHelper/LanguagePreferences.swift +++ b/macos/Sources/OmarchyVMHelper/LanguagePreferences.swift @@ -3,8 +3,8 @@ 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's own generator reads it, -/// the session `LANG`. It is kept byte-identical to the `LANG` values +/// 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 { From 1a52422f190eafbd9110c4de9308bc9b6f73596f Mon Sep 17 00:00:00 2001 From: k5953837 Date: Fri, 11 Sep 2026 02:14:35 +0800 Subject: [PATCH 12/12] Refresh the lock against the current mirror Arch Linux ARM rotated the kernel and orc while this branch was in progress, and the versions the previous lock named are no longer served, so a build from it fails before it starts. These three are what the same --refresh-package-lock run resolves today; the nine packages this branch adds are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- guest/packages.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/guest/packages.lock.json b/guest/packages.lock.json index 5791f7b0..796217b5 100644 --- a/guest/packages.lock.json +++ b/guest/packages.lock.json @@ -404,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", @@ -460,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",