From 0218d2fdc9e062295455edeb1a062e5c4c18c004 Mon Sep 17 00:00:00 2001 From: Morten Olsrud Date: Tue, 4 Aug 2026 11:13:37 +0200 Subject: [PATCH] feat(v3): add cross-platform SystemLocale() API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add application.SystemLocale() returning the device/system locale as a BCP-47 language tag (e.g. "nb-NO", "en-US") on all platforms: Android: Locale.getDefault().toLanguageTag() via WailsBridge iOS: NSLocale.currentLocale.localeIdentifier (underscore→hyphen) macOS: NSLocale.currentLocale (Foundation framework) Windows: GetUserDefaultLocaleName (kernel32.dll) Linux: $LC_ALL / $LC_MESSAGES / $LANG parsed to BCP-47 Also adds SystemLocale() to the MobileManager interface, so it is accessible via application.Mobile.SystemLocale() on mobile platforms (delegates to the platform-specific implementation). Usage: locale := application.SystemLocale() // works everywhere // or on mobile: locale := application.Mobile.SystemLocale() --- .../main/java/com/wails/app/WailsBridge.java | 8 ++++ v3/pkg/application/locale_android.go | 14 +++++++ v3/pkg/application/locale_darwin.go | 42 +++++++++++++++++++ v3/pkg/application/locale_ios.go | 10 +++++ v3/pkg/application/locale_linux.go | 38 +++++++++++++++++ v3/pkg/application/locale_server.go | 34 +++++++++++++++ v3/pkg/application/locale_windows.go | 27 ++++++++++++ v3/pkg/application/mobile.go | 1 + v3/pkg/application/mobile_features_android.go | 10 +++++ v3/pkg/application/mobile_features_ios.go | 4 ++ v3/pkg/application/mobile_features_ios.h | 1 + v3/pkg/application/mobile_features_ios.m | 19 +++++++++ v3/pkg/application/mobile_stub.go | 1 + 13 files changed, 209 insertions(+) create mode 100644 v3/pkg/application/locale_android.go create mode 100644 v3/pkg/application/locale_darwin.go create mode 100644 v3/pkg/application/locale_ios.go create mode 100644 v3/pkg/application/locale_linux.go create mode 100644 v3/pkg/application/locale_server.go create mode 100644 v3/pkg/application/locale_windows.go diff --git a/v3/internal/commands/build_assets/android/app/src/main/java/com/wails/app/WailsBridge.java b/v3/internal/commands/build_assets/android/app/src/main/java/com/wails/app/WailsBridge.java index bdce3334c4c..19c5584fdd7 100644 --- a/v3/internal/commands/build_assets/android/app/src/main/java/com/wails/app/WailsBridge.java +++ b/v3/internal/commands/build_assets/android/app/src/main/java/com/wails/app/WailsBridge.java @@ -1092,6 +1092,14 @@ public String getNetworkJson() { } } + /** + * Returns the device's configured locale as a BCP-47 language tag + * (e.g. "nb-NO", "en-US"). + */ + public String getLocale() { + return java.util.Locale.getDefault().toLanguageTag(); + } + /** * Watch (1) / unwatch (0) the soft keyboard, emitting "common:keyboard" * {visible,height} (height in px) via an inset listener on the content view. diff --git a/v3/pkg/application/locale_android.go b/v3/pkg/application/locale_android.go new file mode 100644 index 00000000000..1a4e1368973 --- /dev/null +++ b/v3/pkg/application/locale_android.go @@ -0,0 +1,14 @@ +//go:build android + +package application + +// SystemLocale returns the device's configured locale as a BCP-47 language tag +// (e.g. "nb-NO", "en-US"). On Android this calls Locale.getDefault().toLanguageTag() +// via the WailsBridge. +func SystemLocale() string { + s, _ := androidBridgeString("getLocale") + if s == "" { + return "en" + } + return s +} diff --git a/v3/pkg/application/locale_darwin.go b/v3/pkg/application/locale_darwin.go new file mode 100644 index 00000000000..934271ae6cb --- /dev/null +++ b/v3/pkg/application/locale_darwin.go @@ -0,0 +1,42 @@ +//go:build darwin && !ios && !server + +package application + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation +#import +#include + +static const char* getSystemLocale() { + // Use languageIdentifier (macOS 13+ / iOS 16+) for clean BCP-47 output. + // localeIdentifier can include POSIX modifiers like @collation=pinyin. + if (@available(macOS 13, *)) { + NSString *tag = [[NSLocale currentLocale] languageIdentifier]; + return strdup([tag UTF8String]); + } + // Fallback for macOS < 13: build from components to preserve script subtags + // (e.g. zh-Hant-TW, not just zh-TW). + NSLocale *locale = [NSLocale currentLocale]; + NSString *lang = [locale languageCode]; + NSString *script = [locale scriptCode]; + NSString *country = [locale countryCode]; + NSMutableString *tag = [NSMutableString stringWithString:lang ?: @"en"]; + if (script.length > 0) [tag appendFormat:@"-%@", script]; + if (country.length > 0) [tag appendFormat:@"-%@", country]; + return strdup([tag UTF8String]); +} +*/ +import "C" +import "unsafe" + +// SystemLocale returns the system's configured locale as a BCP-47 language tag +// (e.g. "nb-NO", "en-US"). +func SystemLocale() string { + cStr := C.getSystemLocale() + if cStr == nil { + return "en" + } + defer C.free(unsafe.Pointer(cStr)) + return C.GoString(cStr) +} diff --git a/v3/pkg/application/locale_ios.go b/v3/pkg/application/locale_ios.go new file mode 100644 index 00000000000..68afe2e94b6 --- /dev/null +++ b/v3/pkg/application/locale_ios.go @@ -0,0 +1,10 @@ +//go:build ios + +package application + +// SystemLocale returns the device's configured locale as a BCP-47 language tag +// (e.g. "nb-NO", "en-US"). Delegates to the iOS mobile manager which calls +// ios_system_locale() via the CGO preamble in mobile_features_ios.go. +func SystemLocale() string { + return IOS.SystemLocale() +} diff --git a/v3/pkg/application/locale_linux.go b/v3/pkg/application/locale_linux.go new file mode 100644 index 00000000000..56541d645a4 --- /dev/null +++ b/v3/pkg/application/locale_linux.go @@ -0,0 +1,38 @@ +//go:build linux && !android && !server + +package application + +import ( + "os" + "strings" +) + +// SystemLocale returns the system's configured locale as a BCP-47 language tag +// (e.g. "nb-NO", "en-US"). Reads from LANG/LC_ALL/LC_MESSAGES environment +// variables and normalizes to BCP-47 format. +func SystemLocale() string { + // Check in order of specificity + for _, env := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} { + if val := os.Getenv(env); val != "" { + return parsePosixLocale(val) + } + } + return "en" +} + +// parsePosixLocale converts a POSIX locale (e.g. "nb_NO.UTF-8") to BCP-47 ("nb-NO"). +func parsePosixLocale(posix string) string { + // Strip encoding (.UTF-8) and modifier (@euro) + if i := strings.IndexByte(posix, '.'); i >= 0 { + posix = posix[:i] + } + if i := strings.IndexByte(posix, '@'); i >= 0 { + posix = posix[:i] + } + // Replace underscore with hyphen (nb_NO → nb-NO) + posix = strings.ReplaceAll(posix, "_", "-") + if posix == "" || posix == "C" || posix == "POSIX" { + return "en" + } + return posix +} diff --git a/v3/pkg/application/locale_server.go b/v3/pkg/application/locale_server.go new file mode 100644 index 00000000000..b7e96d160b7 --- /dev/null +++ b/v3/pkg/application/locale_server.go @@ -0,0 +1,34 @@ +//go:build server + +package application + +import "os" +import "strings" + +// SystemLocale returns the system's configured locale as a BCP-47 language tag. +// In server mode, reads from environment variables. +func SystemLocale() string { + for _, env := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} { + if val := os.Getenv(env); val != "" { + return parsePosixLocaleBCP47(val) + } + } + return "en" +} + +// parsePosixLocaleBCP47 converts a POSIX locale (e.g. "nb_NO.UTF-8@euro") to BCP-47 ("nb-NO"). +func parsePosixLocaleBCP47(posix string) string { + // Strip encoding (.UTF-8) and modifier (@euro) + if i := strings.IndexByte(posix, '.'); i >= 0 { + posix = posix[:i] + } + if i := strings.IndexByte(posix, '@'); i >= 0 { + posix = posix[:i] + } + // Replace underscore with hyphen (nb_NO → nb-NO) + posix = strings.ReplaceAll(posix, "_", "-") + if posix == "" || posix == "C" || posix == "POSIX" { + return "en" + } + return posix +} diff --git a/v3/pkg/application/locale_windows.go b/v3/pkg/application/locale_windows.go new file mode 100644 index 00000000000..b7dc8a248dc --- /dev/null +++ b/v3/pkg/application/locale_windows.go @@ -0,0 +1,27 @@ +//go:build windows && !server + +package application + +import ( + "syscall" + "unsafe" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procGetUserDefaultLocaleName = kernel32.NewProc("GetUserDefaultLocaleName") +) + +// SystemLocale returns the system's configured locale as a BCP-47 language tag +// (e.g. "nb-NO", "en-US"). +func SystemLocale() string { + buf := make([]uint16, 85) // LOCALE_NAME_MAX_LENGTH + r, _, _ := procGetUserDefaultLocaleName.Call( + uintptr(unsafe.Pointer(&buf[0])), + uintptr(len(buf)), + ) + if r == 0 { + return "en" + } + return syscall.UTF16ToString(buf) +} diff --git a/v3/pkg/application/mobile.go b/v3/pkg/application/mobile.go index 4d558de581e..a326f2665cc 100644 --- a/v3/pkg/application/mobile.go +++ b/v3/pkg/application/mobile.go @@ -33,6 +33,7 @@ type MobileManager interface { StoragePath() string PowerJSON() string NetworkJSON() string + SystemLocale() string // Permissions / async results (delivered as common:* events) BiometricAuthenticate(reason string) diff --git a/v3/pkg/application/mobile_features_android.go b/v3/pkg/application/mobile_features_android.go index a0421b3167b..ac01c2d0825 100644 --- a/v3/pkg/application/mobile_features_android.go +++ b/v3/pkg/application/mobile_features_android.go @@ -142,6 +142,16 @@ func (androidManager) PowerJSON() string { s, _ := androidBridgeString("getPower // NetworkJSON returns {"connected":bool,"type":"wifi|cellular|ethernet|none"}. func (androidManager) NetworkJSON() string { s, _ := androidBridgeString("getNetworkJson"); return s } +// SystemLocale returns the device's configured locale as a BCP-47 language tag +// (e.g. "nb-NO", "en-US"). Uses Locale.getDefault().toLanguageTag(). +func (androidManager) SystemLocale() string { + s, _ := androidBridgeString("getLocale") + if s == "" { + return "en" + } + return s +} + // SetKeyboardWatch starts/stops emitting "common:keyboard" // {visible,height} events as the soft keyboard shows and hides. func (androidManager) SetKeyboardWatch(enabled bool) { diff --git a/v3/pkg/application/mobile_features_ios.go b/v3/pkg/application/mobile_features_ios.go index 3f6f99ba78b..b6fb6cd54d4 100644 --- a/v3/pkg/application/mobile_features_ios.go +++ b/v3/pkg/application/mobile_features_ios.go @@ -191,6 +191,10 @@ func (iosManager) PowerJSON() string { return cStr(C.ios_power_json()) } // NetworkJSON returns {"connected":bool,"type":"wifi|cellular|none"}. func (iosManager) NetworkJSON() string { return cStr(C.ios_network_json()) } +// SystemLocale returns the device's configured locale as a BCP-47 language tag +// (e.g. "nb-NO", "en-US"). Uses NSLocale.currentLocale. +func (iosManager) SystemLocale() string { return cStr(C.ios_system_locale()) } + // SetKeyboardWatch starts/stops emitting "common:keyboard" {visible,height} // events as the software keyboard shows and hides. func (iosManager) SetKeyboardWatch(enabled bool) { C.ios_set_keyboard_watch(C.bool(enabled)) } diff --git a/v3/pkg/application/mobile_features_ios.h b/v3/pkg/application/mobile_features_ios.h index e9fc972cf9d..137c9c28122 100644 --- a/v3/pkg/application/mobile_features_ios.h +++ b/v3/pkg/application/mobile_features_ios.h @@ -37,6 +37,7 @@ const char* ios_storage_json(void); // {"free":bytes,"total":bytes} const char* ios_storage_path(void); // absolute path to the app's Application Support directory const char* ios_power_json(void); // {"level":0-1,"charging":bool,"lowPower":bool} const char* ios_network_json(void); // {"connected":bool,"type":"wifi|cellular|none"} +const char* ios_system_locale(void); // BCP-47 language tag (e.g. "nb-NO") void ios_set_keyboard_watch(bool enabled); // keyboard insets → "common:keyboard" {visible,height} void ios_set_screen_protect(bool enabled); // screenshot/recording detection → "common:screenCapture" diff --git a/v3/pkg/application/mobile_features_ios.m b/v3/pkg/application/mobile_features_ios.m index 08e29758e8f..f9e61ce42f6 100644 --- a/v3/pkg/application/mobile_features_ios.m +++ b/v3/pkg/application/mobile_features_ios.m @@ -696,6 +696,25 @@ void ios_stop_speak(void) { return mfDup(json); } +const char* ios_system_locale(void) { + // Use languageIdentifier which returns a clean BCP-47 tag (e.g. "en-US"), + // unlike localeIdentifier which can include POSIX modifiers and currency info. + if (@available(iOS 16, *)) { + NSString *tag = [[NSLocale currentLocale] languageIdentifier]; + return mfDup(tag); + } + // Fallback for iOS < 16: build from components to preserve script subtags + // (e.g. zh-Hant-TW, not just zh-TW). + NSLocale *locale = [NSLocale currentLocale]; + NSString *lang = [locale languageCode]; + NSString *script = [locale scriptCode]; + NSString *country = [locale countryCode]; + NSMutableString *tag = [NSMutableString stringWithString:lang ?: @"en"]; + if (script.length > 0) [tag appendFormat:@"-%@", script]; + if (country.length > 0) [tag appendFormat:@"-%@", country]; + return mfDup(tag); +} + // MARK: - Keyboard insets static id g_mfKeyboardShowObs = nil; diff --git a/v3/pkg/application/mobile_stub.go b/v3/pkg/application/mobile_stub.go index c6b3aa455b3..be5a7998a32 100644 --- a/v3/pkg/application/mobile_stub.go +++ b/v3/pkg/application/mobile_stub.go @@ -22,6 +22,7 @@ func (mobileStub) StorageJSON() string { return "" } func (mobileStub) StoragePath() string { return "" } func (mobileStub) PowerJSON() string { return "" } func (mobileStub) NetworkJSON() string { return "" } +func (mobileStub) SystemLocale() string { return SystemLocale() } func (mobileStub) BiometricAuthenticate(string) {} func (mobileStub) SecureGet(string) string { return "" } func (mobileStub) SecureDelete(string) {}