Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Features
- The guest follows the Windows display language: the launcher passes it along with the time zone and keyboard layout, and the guest generates that locale and makes it the default for the next login. `-locale` overrides it. Existing guests gain this with the next guest-image update.
- A runtime archive that is unchanged between releases is kept instead of being downloaded and unpacked again.

### Fixes
- Choosing Suspend inside Omarchy no longer freezes the VM window. The guest can no longer enter the S3 or S4 sleep states; a suspend request falls through to suspend-to-idle and the lock screen, and new guests have Omarchy's suspend-off toggle on so the system menu does not offer it.
- New guest images include the Noto CJK fonts, so Chinese, Japanese, and Korean text renders instead of boxes.
Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,13 @@ Automatic sizing gives the guest all logical processors but two, between two
and eight, and a third of the machine's RAM between 4 and 8 GiB (6 GiB with GPU
rendering, the same as before), reduced to what Windows can spare at launch.

The guest follows the Windows time zone and default keyboard layout. Each is
applied inside Omarchy when it changes on the Windows side, so a layout or
zone chosen inside the guest stays until Windows changes. `-timezone` and
`-keyboard` override this for a launch: `keep` leaves the guest alone, or give
an IANA zone such as `Europe/Berlin` and an XKB layout such as `de` or
`us:intl`.
The guest follows the Windows time zone, default keyboard layout, and display
language. Each is applied inside Omarchy when it changes on the Windows side,
so a layout, zone, or language chosen inside the guest stays until Windows
changes. `-timezone`, `-keyboard`, and `-locale` override this for a launch:
`keep` leaves the guest alone, or give an IANA zone such as `Europe/Berlin`,
an XKB layout such as `de` or `us:intl`, or a locale such as `de_DE`. The
language takes effect at the next login inside Omarchy.

### Disk capacity

Expand Down
1 change: 1 addition & 0 deletions app/diagnostics_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func hostFacts() map[string]string {
}
facts["host.timeZone"] = hostTimeZoneKey()
facts["host.keyboardLayout"] = hostKeyboardLayoutID()
facts["host.locale"] = hostLocaleName()
total, avail := availMemMiB()
facts["host.memoryTotalMiB"] = fmt.Sprint(total)
facts["host.memoryAvailableMiB"] = fmt.Sprint(avail)
Expand Down
26 changes: 23 additions & 3 deletions app/locale.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ var keyboardLayoutForLanguage = map[string][2]string{
}

var (
validLocaleName = regexp.MustCompile(`^[a-z]{2,3}_[A-Z]{2}$`)
validZoneName = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_+./-]{0,63}$`)
validLayoutName = regexp.MustCompile(`^[a-z]{2,8}$`)
validVariantName = regexp.MustCompile(`^[A-Za-z0-9_-]{0,32}$`)
Expand Down Expand Up @@ -228,14 +229,33 @@ func xkbForKLID(klid string) (string, string) {
return "", ""
}

// posixLocaleForWindows turns a Windows locale name such as "de-DE" or
// "pt-BR" into the POSIX form the guest generates, "de_DE". Names without a
// region, or with script tags such as "sr-Latn-RS", yield "" and the guest
// keeps its default.
func posixLocaleForWindows(name string) string {
parts := strings.Split(strings.TrimSpace(name), "-")
if len(parts) != 2 {
return ""
}
locale := strings.ToLower(parts[0]) + "_" + strings.ToUpper(parts[1])
if !validLocaleName.MatchString(locale) {
return ""
}
return locale
}

// hostLocaleCmdline builds the kernel parameters that tell the guest which
// time zone and keyboard layout Windows uses. Unknown values add nothing, so
// the guest keeps whatever it has.
func hostLocaleCmdline(zone, layout, variant string) string {
// time zone, keyboard layout, and language Windows uses. Unknown values add
// nothing, so the guest keeps whatever it has.
func hostLocaleCmdline(zone, layout, variant, locale string) string {
words := ""
if zone != "" && validZoneName.MatchString(zone) {
words += " tryomarchy.tz=" + zone
}
if locale != "" && validLocaleName.MatchString(locale) {
words += " tryomarchy.locale=" + locale
}
if layout != "" && validLayoutName.MatchString(layout) && validVariantName.MatchString(variant) {
words += " tryomarchy.kb=" + layout
if variant != "" {
Expand Down
21 changes: 16 additions & 5 deletions app/locale_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,23 @@ func TestXKBForKLIDPrefersFullIdentifiers(t *testing.T) {
}

func TestHostLocaleCmdlineOnlyCarriesValidValues(t *testing.T) {
if got := hostLocaleCmdline("America/New_York", "de", ""); got != " tryomarchy.tz=America/New_York tryomarchy.kb=de" {
if got := hostLocaleCmdline("America/New_York", "de", "", ""); got != " tryomarchy.tz=America/New_York tryomarchy.kb=de" {
t.Fatalf("plain: %q", got)
}
if got := hostLocaleCmdline("Europe/Berlin", "us", "intl"); got != " tryomarchy.tz=Europe/Berlin tryomarchy.kb=us:intl" {
if got := hostLocaleCmdline("Europe/Berlin", "us", "intl", ""); got != " tryomarchy.tz=Europe/Berlin tryomarchy.kb=us:intl" {
t.Fatalf("variant: %q", got)
}
if got := hostLocaleCmdline("", "", ""); got != "" {
if got := hostLocaleCmdline("", "", "", ""); got != "" {
t.Fatalf("empty: %q", got)
}
for _, bad := range []string{"../etc", "us us", "a b", "Etc UTC", "us;rm"} {
for _, got := range []string{hostLocaleCmdline(bad, "us", ""), hostLocaleCmdline("Etc/UTC", bad, ""), hostLocaleCmdline("Etc/UTC", "us", bad)} {
for _, got := range []string{hostLocaleCmdline(bad, "us", "", ""), hostLocaleCmdline("Etc/UTC", bad, "", ""), hostLocaleCmdline("Etc/UTC", "us", bad, ""), hostLocaleCmdline("Etc/UTC", "us", "", bad)} {
if strings.Contains(got, bad) {
t.Errorf("unsafe value %q reached the command line: %q", bad, got)
}
}
}
if got := hostLocaleCmdline("../etc", "us", ""); got != " tryomarchy.kb=us" {
if got := hostLocaleCmdline("../etc", "us", "", ""); got != " tryomarchy.kb=us" {
t.Fatalf("a bad zone must not drop the keyboard: %q", got)
}
}
Expand All @@ -61,3 +61,14 @@ func TestSplitKeyboardSpec(t *testing.T) {
}
}
}

func TestPosixLocaleForWindows(t *testing.T) {
for name, want := range map[string]string{"de-DE": "de_DE", "pt-BR": "pt_BR", "en-US": "en_US", " ja-JP ": "ja_JP", "sr-Latn-RS": "", "de": "", "": "", "x-y": ""} {
if got := posixLocaleForWindows(name); got != want {
t.Errorf("%q: got %q, want %q", name, got, want)
}
}
if got := hostLocaleCmdline("Europe/Berlin", "de", "", "de_DE"); got != " tryomarchy.tz=Europe/Berlin tryomarchy.locale=de_DE tryomarchy.kb=de" {
t.Fatalf("locale on the command line: %q", got)
}
}
24 changes: 22 additions & 2 deletions app/locale_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"syscall"
"unsafe"
)

// hostTimeZoneKey reads the Windows time zone key name, for example
Expand All @@ -28,10 +29,29 @@ func hostKeyboardLayoutID() string {
return registryString(key, "1")
}

var procGetUserDefaultLocaleName = kernel32.NewProc("GetUserDefaultLocaleName")

// hostLocaleName reads the user's Windows display locale, for example "de-DE".
func hostLocaleName() string {
buf := make([]uint16, 85)
n, _, _ := procGetUserDefaultLocaleName.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
if n == 0 {
return ""
}
return syscall.UTF16ToString(buf)
}

// hostLocale resolves what the guest should follow, honoring the explicit
// overrides: "" follows Windows, "keep" leaves the guest alone, anything else
// is used as given.
func hostLocale(zoneOverride, keyboardOverride string) (zone, layout, variant string) {
func hostLocale(zoneOverride, keyboardOverride, localeOverride string) (zone, layout, variant, locale string) {
switch localeOverride {
case "":
locale = posixLocaleForWindows(hostLocaleName())
case "keep":
default:
locale = localeOverride
}
switch zoneOverride {
case "":
zone = ianaZoneForWindows(hostTimeZoneKey())
Expand All @@ -46,5 +66,5 @@ func hostLocale(zoneOverride, keyboardOverride string) (zone, layout, variant st
default:
layout, variant = splitKeyboardSpec(keyboardOverride)
}
return zone, layout, variant
return zone, layout, variant, locale
}
5 changes: 3 additions & 2 deletions app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ func main() {
renderFlag := flag.String("render", "", "rendering path: auto (default), gpu, or cpu")
timeZoneFlag := flag.String("timezone", "", "guest time zone: blank follows Windows, keep leaves the guest alone, or an IANA name such as Europe/Berlin")
keyboardFlag := flag.String("keyboard", "", "guest keyboard layout: blank follows Windows, keep leaves the guest alone, or an XKB layout such as de or us:intl")
localeFlag := flag.String("locale", "", "guest language: blank follows Windows, keep leaves the guest alone, or a locale such as de_DE")
flag.BoolVar(&cfg.hostCursor, "host-cursor", false, "force the legacy Windows cursor over the guest")
flag.BoolVar(&cfg.instant, "instant", false, "skip first-boot questions and use the trial account")
flag.BoolVar(&cfg.portable, "portable", false, "run entirely from data and payload folders beside the executable")
Expand Down Expand Up @@ -597,8 +598,8 @@ func main() {
}
cmdline += sshCmdline(cfg.forwards, cfg.sshKey)
cmdline += shareCmdline(cfg.share)
zone, layout, variant := hostLocale(*timeZoneFlag, *keyboardFlag)
if words := hostLocaleCmdline(zone, layout, variant); words != "" {
zone, layout, variant, locale := hostLocale(*timeZoneFlag, *keyboardFlag, *localeFlag)
if words := hostLocaleCmdline(zone, layout, variant, locale); words != "" {
cmdline += words
logf("guest follows Windows locale:%s", words)
}
Expand Down
156 changes: 156 additions & 0 deletions guest-build/0043-Follow-the-Windows-display-language.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
From c1f7c95278fd79b303e85b8af1dd3971b29c32f6 Mon Sep 17 00:00:00 2001
From: Tyler South <tsouth2@gmail.com>
Date: Sat, 5 Sep 2026 17:57:20 -0400
Subject: [PATCH] Follow the Windows display language

tryomarchy.locale=xx_YY on the kernel command line, set by the launcher from the Windows user locale, generates that UTF-8 locale and makes it the system default. Applied only when the value changes, like the zone and keyboard. Integration revision 11.
---
.../local/lib/try-omarchy/apply-host-locale | 36 +++++++++++++++++++
guest/scripts/finalize-rootfs.sh | 2 +-
guest/tests/test_overlay_scripts.py | 33 ++++++++++++++++-
guest/tests/verify.py | 2 +-
4 files changed, 70 insertions(+), 3 deletions(-)

diff --git a/guest/factory-overlay/usr/local/lib/try-omarchy/apply-host-locale b/guest/factory-overlay/usr/local/lib/try-omarchy/apply-host-locale
index dc75a59..51e27a3 100755
--- a/guest/factory-overlay/usr/local/lib/try-omarchy/apply-host-locale
+++ b/guest/factory-overlay/usr/local/lib/try-omarchy/apply-host-locale
@@ -12,13 +12,19 @@ state_dir=${TRY_OMARCHY_STATE_DIR:-/var/lib/try-omarchy}
zoneinfo=${TRY_OMARCHY_ZONEINFO:-/usr/share/zoneinfo}
localtime=${TRY_OMARCHY_LOCALTIME:-/etc/localtime}
vconsole=${TRY_OMARCHY_VCONSOLE:-/etc/vconsole.conf}
+locale_gen=${TRY_OMARCHY_LOCALE_GEN:-/etc/locale.gen}
+locale_conf=${TRY_OMARCHY_LOCALE_CONF:-/etc/locale.conf}
+locale_sources=${TRY_OMARCHY_LOCALE_SOURCES:-/usr/share/i18n/locales}
+locale_gen_cmd=${TRY_OMARCHY_LOCALE_GEN_CMD:-locale-gen}

zone=""
keyboard=""
+locale=""
for word in $(<"$cmdline_file"); do
case $word in
tryomarchy.tz=*) zone=${word#tryomarchy.tz=} ;;
tryomarchy.kb=*) keyboard=${word#tryomarchy.kb=} ;;
+ tryomarchy.locale=*) locale=${word#tryomarchy.locale=} ;;
esac
done

@@ -59,6 +65,36 @@ apply_keyboard() {
echo "keyboard layout set to $spec from Windows"
}

+# apply_locale generates the Windows display language's UTF-8 locale and
+# makes it the system default. Sessions pick LANG up at the next login.
+apply_locale() {
+ local name=$1 marker="$state_dir/host-locale" full tmp
+ [[ $name =~ ^[a-z]{2,3}_[A-Z]{2}$ ]] || { echo "ignoring an invalid locale on the kernel command line" >&2; return 0; }
+ [[ "$(cat "$marker" 2>/dev/null || true)" != "$name" ]] || return 0
+ if [[ ! -f $locale_sources/$name ]]; then
+ echo "locale $name is not available in the guest" >&2
+ return 0
+ fi
+ full="$name.UTF-8"
+ if ! grep -qx "$full UTF-8" "$locale_gen" 2>/dev/null; then
+ tmp=$(mktemp "$(dirname "$locale_gen")/.locale.gen.XXXXXX")
+ { if [[ -f $locale_gen ]]; then grep -v "^#\?$full UTF-8\$" "$locale_gen" || true; fi; printf '%s UTF-8\n' "$full"; } >"$tmp"
+ chmod 0644 "$tmp"
+ mv -f "$tmp" "$locale_gen"
+ fi
+ if ! $locale_gen_cmd >/dev/null 2>&1; then
+ echo "could not generate locale $full" >&2
+ return 0
+ fi
+ tmp=$(mktemp "$(dirname "$locale_conf")/.locale.conf.XXXXXX")
+ { if [[ -f $locale_conf ]]; then grep -v '^LANG=' "$locale_conf" || true; fi; printf 'LANG=%s\n' "$full"; } >"$tmp"
+ chmod 0644 "$tmp"
+ mv -f "$tmp" "$locale_conf"
+ printf '%s\n' "$name" >"$marker"
+ echo "language set to $full from Windows"
+}
+
[[ -z $zone ]] || apply_zone "$zone"
[[ -z $keyboard ]] || apply_keyboard "$keyboard"
+[[ -z $locale ]] || apply_locale "$locale"
exit 0
diff --git a/guest/scripts/finalize-rootfs.sh b/guest/scripts/finalize-rootfs.sh
index 0159324..eb23b44 100755
--- a/guest/scripts/finalize-rootfs.sh
+++ b/guest/scripts/finalize-rootfs.sh
@@ -86,7 +86,7 @@ kernel_release=$(find /usr/lib/modules -mindepth 1 -maxdepth 1 -type d -printf '
}
# Bump this revision whenever compat-overlay.tar changes in a way that must be
# applied to persistent disks created by an earlier launcher release.
-compat_revision=10
+compat_revision=11
printf '%s:%s\n' "$compat_revision" "$kernel_release" >/usr/share/try-omarchy/compat-version

# Never let the container host's hardware autodetection remove the virtual
diff --git a/guest/tests/test_overlay_scripts.py b/guest/tests/test_overlay_scripts.py
index 4d01bf2..c65bea4 100644
--- a/guest/tests/test_overlay_scripts.py
+++ b/guest/tests/test_overlay_scripts.py
@@ -341,9 +341,22 @@ class HostLocaleTests(OverlayCase):
self.vconsole.write_text("KEYMAP=us\n")

def apply(self, cmdline: str) -> subprocess.CompletedProcess:
+ self.locale_gen = self.root / "locale.gen"
+ if not self.locale_gen.exists():
+ self.locale_gen.write_text("en_US.UTF-8 UTF-8\n")
+ self.locale_conf = self.root / "locale.conf"
+ if not self.locale_conf.exists():
+ self.locale_conf.write_text("LANG=en_US.UTF-8\n")
+ sources = self.root / "locales"
+ sources.mkdir(exist_ok=True)
+ for name in ("en_US", "de_DE"):
+ (sources / name).write_text("")
+ self.fake("locale-gen", 'echo gen >> "$CALLS"')
return self.run_script(HOST_LOCALE, cmdline=cmdline, TRY_OMARCHY_STATE_DIR=str(self.state),
TRY_OMARCHY_ZONEINFO=str(self.zoneinfo), TRY_OMARCHY_LOCALTIME=str(self.localtime),
- TRY_OMARCHY_VCONSOLE=str(self.vconsole))
+ TRY_OMARCHY_VCONSOLE=str(self.vconsole), TRY_OMARCHY_LOCALE_GEN=str(self.locale_gen),
+ TRY_OMARCHY_LOCALE_CONF=str(self.locale_conf), TRY_OMARCHY_LOCALE_SOURCES=str(sources),
+ CALLS=str(self.log))

def test_applies_zone_and_keyboard_from_the_command_line(self) -> None:
result = self.apply("root=/dev/vda tryomarchy.tz=America/New_York tryomarchy.kb=de:nodeadkeys quiet")
@@ -376,6 +389,24 @@ class HostLocaleTests(OverlayCase):
self.assertIn("not installed", result.stderr)
self.assertEqual(os.readlink(self.localtime), str(self.zoneinfo / "UTC"))

+ def test_generates_and_selects_the_windows_language(self) -> None:
+ result = self.apply("tryomarchy.locale=de_DE")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(self.locale_gen.read_text(), "en_US.UTF-8 UTF-8\nde_DE.UTF-8 UTF-8\n")
+ self.assertEqual(self.locale_conf.read_text(), "LANG=de_DE.UTF-8\n")
+ self.assertEqual(self.log.read_text(), "gen\n")
+ self.assertIn("language set to de_DE.UTF-8", result.stdout)
+ # Same language again: nothing regenerated.
+ self.apply("tryomarchy.locale=de_DE")
+ self.assertEqual(self.log.read_text(), "gen\n")
+
+ def test_unavailable_or_unsafe_locale_is_ignored(self) -> None:
+ for value in ["xx_YY", "de_DE;id", "../de_DE", "DE_de"]:
+ result = self.apply(f"tryomarchy.locale={value}")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(self.locale_conf.read_text(), "LANG=en_US.UTF-8\n", value)
+ self.assertFalse(self.log.exists())
+
def test_no_parameters_change_nothing(self) -> None:
result = self.apply("root=/dev/vda quiet")
self.assertEqual(result.returncode, 0, result.stderr)
diff --git a/guest/tests/verify.py b/guest/tests/verify.py
index 72a49d0..19c5019 100755
--- a/guest/tests/verify.py
+++ b/guest/tests/verify.py
@@ -261,7 +261,7 @@ def main() -> None:

finalize_rootfs = read(GUEST / "scripts/finalize-rootfs.sh")
check(
- "compat_revision=10" in finalize_rootfs
+ "compat_revision=11" in finalize_rootfs
and "etc/systemd/user/omarchy-fcitx5.service.d/10-try-omarchy.conf" in finalize_rootfs
and "usr/local/lib/try-omarchy/agent" in finalize_rootfs
and "compat_revision" in finalize_rootfs.split("compat-version")[0]
--
2.55.0

2 changes: 1 addition & 1 deletion scripts/release/smoke-guest.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"sshd": "systemctl is-active sshd 2>/dev/null || true",
"omarchy-repo-signed": "grep -A2 '^\\[omarchy\\]' /etc/pacman.conf | grep -q TrustAll && echo no || echo yes",
"input-group": "id -nG | tr ' ' '\\n' | grep -qx input && echo yes || echo no",
"compat-version": "test \"$(cat /usr/share/try-omarchy/compat-version)\" = \"10:$(uname -r)\" && echo yes || echo no",
"compat-version": "test \"$(cat /usr/share/try-omarchy/compat-version)\" = \"11:$(uname -r)\" && echo yes || echo no",
"kernel-modules": "test -f /usr/lib/modules/$(uname -r)/modules.dep.bin && echo yes || echo no",
"ready-service": "systemctl is-enabled try-omarchy-ready.service 2>/dev/null || true",
}
Expand Down