@@ -23,6 +23,7 @@ use icu::{
2323 locale:: Locale ,
2424} ;
2525use locales_rs as locale;
26+ use regex:: Regex ;
2627use slotmap:: { DefaultKey , SlotMap } ;
2728
2829static GNOME_LANGUAGE_SELECTOR : & str = "gnome-language-selector" ;
@@ -242,9 +243,7 @@ impl Page {
242243 let region = region. lang_code . clone ( ) ;
243244
244245 return cosmic:: task:: future ( async move {
245- if let Ok ( exit_status) = set_locale ( lang, region. clone ( ) ) . await
246- && exit_status. success ( )
247- {
246+ if set_locale ( lang, region. clone ( ) ) . await . is_ok ( ) {
248247 update_time_settings_after_region_change ( region) ;
249248 }
250249
@@ -710,20 +709,26 @@ pub async fn page_reload() -> eyre::Result<PageRefresh> {
710709
711710 let mut available_languages_set = BTreeSet :: new ( ) ;
712711
713- let output = tokio:: process:: Command :: new ( "localectl" )
714- . arg ( "list-locales" )
712+ // Use 'locale -a' instead of 'localectl list-locales' for OpenRC compatibility
713+ let output_result = tokio:: process:: Command :: new ( "locale" )
714+ . arg ( "-a" )
715715 . output ( )
716- . await
717- . expect ( "Failed to run localectl" ) ;
716+ . await ;
718717
719- let output = String :: from_utf8 ( output. stdout ) . unwrap_or_default ( ) ;
720- for line in output. lines ( ) {
721- if line == "C.UTF-8" {
722- continue ;
718+ let locale_list = match output_result {
719+ Ok ( output) => {
720+ let output_str = String :: from_utf8 ( output. stdout ) . unwrap_or_default ( ) ;
721+ parse_locale_output ( & output_str)
722+ }
723+ Err ( why) => {
724+ tracing:: error!( ?why, "failed to list available locales using 'locale -a'" ) ;
725+ Vec :: new ( )
723726 }
727+ } ;
724728
725- if let Some ( locale) = registry. locale ( line) {
726- available_languages_set. insert ( localized_locale ( & locale, line. to_owned ( ) ) ) ;
729+ for line in locale_list {
730+ if let Some ( locale) = registry. locale ( & line) {
731+ available_languages_set. insert ( localized_locale ( & locale, line) ) ;
727732 }
728733 }
729734
@@ -841,27 +846,28 @@ fn popover_menu_row(
841846 . apply ( Element :: from)
842847}
843848
844- pub async fn set_locale (
845- lang : String ,
846- region : String ,
847- ) -> Result < std:: process:: ExitStatus , std:: io:: Error > {
848- eprintln ! ( "setting locale lang={lang}, region={region}" ) ;
849- tokio:: process:: Command :: new ( "localectl" )
850- . arg ( "set-locale" )
851- . args ( & [
852- [ "LANG=" , & lang] . concat ( ) ,
853- [ "LC_ADDRESS=" , & region] . concat ( ) ,
854- [ "LC_IDENTIFICATION=" , & region] . concat ( ) ,
855- [ "LC_MEASUREMENT=" , & region] . concat ( ) ,
856- [ "LC_MONETARY=" , & region] . concat ( ) ,
857- [ "LC_NAME=" , & region] . concat ( ) ,
858- [ "LC_NUMERIC=" , & region] . concat ( ) ,
859- [ "LC_PAPER=" , & region] . concat ( ) ,
860- [ "LC_TELEPHONE=" , & region] . concat ( ) ,
861- [ "LC_TIME=" , & region] . concat ( ) ,
862- ] )
863- . status ( )
849+ /// Sets the system locale using D-Bus instead of localectl for OpenRC compatibility.
850+ pub async fn set_locale ( lang : String , region : String ) -> eyre:: Result < ( ) > {
851+ tracing:: debug!( "setting locale lang={lang}, region={region}" ) ;
852+
853+ let conn = zbus:: Connection :: system ( )
864854 . await
855+ . wrap_err ( "failed to connect to system D-Bus" ) ?;
856+
857+ let proxy = locale1:: locale1Proxy:: new ( & conn)
858+ . await
859+ . wrap_err ( "failed to create locale1 D-Bus proxy" ) ?;
860+
861+ let locale_settings = build_locale_settings ( & lang, & region) ;
862+ let locale_strs: Vec < & str > = locale_settings. iter ( ) . map ( |s| s. as_str ( ) ) . collect ( ) ;
863+
864+ proxy
865+ . set_locale ( & locale_strs, true )
866+ . await
867+ . wrap_err ( "failed to set locale via D-Bus" ) ?;
868+
869+ tracing:: debug!( "successfully set locale via D-Bus" ) ;
870+ Ok ( ( ) )
865871}
866872
867873/// Sets the user's preferred language list via AccountsService D-Bus.
@@ -883,7 +889,7 @@ pub async fn set_user_language(language_list: String) -> eyre::Result<()> {
883889 . await
884890 . wrap_err ( "failed to set language via AccountsService" ) ?;
885891
886- eprintln ! ( "set user language via AccountsService: {language_list}" ) ;
892+ tracing :: debug !( "set user language via AccountsService: {language_list}" ) ;
887893 Ok ( ( ) )
888894}
889895
@@ -1016,3 +1022,206 @@ fn strip_locale_suffix(locale: &str) -> String {
10161022 . unwrap_or ( without_codeset)
10171023 . to_string ( )
10181024}
1025+
1026+ /// Parses the output from `locale -a` command and returns a vector of locale strings.
1027+ /// Filters out pseudo-locales (C, POSIX) and accepts only allowed character encodings.
1028+ fn parse_locale_output ( output : & str ) -> Vec < String > {
1029+ // Regex to match pseudo-locales: C or POSIX, optionally followed by .anything
1030+ let pseudo_locale_re = Regex :: new ( r"^(C|POSIX)(\.|$)" ) . unwrap ( ) ;
1031+
1032+ // Regex to match UTF-8 encoded locales (case-insensitive)
1033+ // Supports optional modifiers after encoding (e.g., @euro, @valencia)
1034+ let utf8_encoding_re = Regex :: new ( r"(?i)\.(utf-?8)(@.*)?$" ) . unwrap ( ) ;
1035+
1036+ output
1037+ . lines ( )
1038+ . map ( |line| line. trim ( ) )
1039+ . filter ( |line| !pseudo_locale_re. is_match ( line) )
1040+ . filter ( |line| utf8_encoding_re. is_match ( line) )
1041+ . map ( |line| line. to_string ( ) )
1042+ . collect ( )
1043+ }
1044+
1045+ /// Builds the locale settings array for D-Bus SetLocale call.
1046+ /// Sets LANG to the language parameter and all LC_* variables to the region parameter.
1047+ fn build_locale_settings ( lang : & str , region : & str ) -> Vec < String > {
1048+ vec ! [
1049+ format!( "LANG={}" , lang) ,
1050+ format!( "LC_ADDRESS={}" , region) ,
1051+ format!( "LC_IDENTIFICATION={}" , region) ,
1052+ format!( "LC_MEASUREMENT={}" , region) ,
1053+ format!( "LC_MONETARY={}" , region) ,
1054+ format!( "LC_NAME={}" , region) ,
1055+ format!( "LC_NUMERIC={}" , region) ,
1056+ format!( "LC_PAPER={}" , region) ,
1057+ format!( "LC_TELEPHONE={}" , region) ,
1058+ format!( "LC_TIME={}" , region) ,
1059+ ]
1060+ }
1061+
1062+ #[ cfg( test) ]
1063+ mod tests {
1064+ use super :: * ;
1065+
1066+ #[ test]
1067+ fn test_parse_locale_output_handles_empty_input ( ) {
1068+ let output = "" ;
1069+ let result = parse_locale_output ( output) ;
1070+ assert_eq ! ( result. len( ) , 0 ) ;
1071+ }
1072+
1073+ #[ test]
1074+ fn test_parse_locale_output_preserves_locale_strings ( ) {
1075+ let output = "en_US.utf8\n de_DE.utf8\n fr_FR.utf8\n " ;
1076+ let result = parse_locale_output ( output) ;
1077+ assert_eq ! ( result. len( ) , 3 ) ;
1078+ assert ! ( result. contains( & "en_US.utf8" . to_string( ) ) ) ;
1079+ }
1080+
1081+ #[ test]
1082+ fn test_build_locale_settings_includes_all_lc_variables ( ) {
1083+ let lang = "en_US.UTF-8" ;
1084+ let region = "de_DE.UTF-8" ;
1085+ let settings = build_locale_settings ( lang, region) ;
1086+
1087+ assert_eq ! ( settings. len( ) , 10 ) ;
1088+ assert ! ( settings. contains( & format!( "LANG={}" , lang) ) ) ;
1089+ assert ! ( settings. contains( & format!( "LC_ADDRESS={}" , region) ) ) ;
1090+ assert ! ( settings. contains( & format!( "LC_IDENTIFICATION={}" , region) ) ) ;
1091+ assert ! ( settings. contains( & format!( "LC_MEASUREMENT={}" , region) ) ) ;
1092+ assert ! ( settings. contains( & format!( "LC_MONETARY={}" , region) ) ) ;
1093+ assert ! ( settings. contains( & format!( "LC_NAME={}" , region) ) ) ;
1094+ assert ! ( settings. contains( & format!( "LC_NUMERIC={}" , region) ) ) ;
1095+ assert ! ( settings. contains( & format!( "LC_PAPER={}" , region) ) ) ;
1096+ assert ! ( settings. contains( & format!( "LC_TELEPHONE={}" , region) ) ) ;
1097+ assert ! ( settings. contains( & format!( "LC_TIME={}" , region) ) ) ;
1098+ }
1099+
1100+ #[ test]
1101+ fn test_build_locale_settings_uses_correct_values ( ) {
1102+ let lang = "fr_FR.UTF-8" ;
1103+ let region = "en_GB.UTF-8" ;
1104+ let settings = build_locale_settings ( lang, region) ;
1105+
1106+ // LANG should use the lang parameter
1107+ assert ! ( settings. iter( ) . any( |s| s == "LANG=fr_FR.UTF-8" ) ) ;
1108+ // LC_* variables should use the region parameter
1109+ assert ! ( settings. iter( ) . any( |s| s == "LC_TIME=en_GB.UTF-8" ) ) ;
1110+ }
1111+
1112+ #[ test]
1113+ fn test_parse_locale_output_filters_pseudo_locales ( ) {
1114+ let output = "C\n C.utf8\n C.UTF-8\n POSIX\n en_US.utf8\n de_DE.UTF-8\n " ;
1115+ let result = parse_locale_output ( output) ;
1116+
1117+ // Should filter out all C and POSIX variants
1118+ assert ! ( !result. contains( & "C" . to_string( ) ) ) ;
1119+ assert ! ( !result. contains( & "C.utf8" . to_string( ) ) ) ;
1120+ assert ! ( !result. contains( & "C.UTF-8" . to_string( ) ) ) ;
1121+ assert ! ( !result. contains( & "POSIX" . to_string( ) ) ) ;
1122+
1123+ // Should keep actual locales
1124+ assert ! ( result. contains( & "en_US.utf8" . to_string( ) ) ) ;
1125+ assert ! ( result. contains( & "de_DE.UTF-8" . to_string( ) ) ) ;
1126+ assert_eq ! ( result. len( ) , 2 ) ;
1127+ }
1128+
1129+ #[ test]
1130+ fn test_parse_locale_output_accepts_only_utf8_locales ( ) {
1131+ let output =
1132+ "en_US\n en_US.utf8\n en_US.UTF-8\n ar_IN\n ar_IN.utf8\n de_DE.iso88591\n fr_FR.UTF-8\n " ;
1133+ let result = parse_locale_output ( output) ;
1134+
1135+ // Should accept UTF-8 variants
1136+ assert ! ( result. contains( & "en_US.utf8" . to_string( ) ) ) ;
1137+ assert ! ( result. contains( & "en_US.UTF-8" . to_string( ) ) ) ;
1138+ assert ! ( result. contains( & "ar_IN.utf8" . to_string( ) ) ) ;
1139+ assert ! ( result. contains( & "fr_FR.UTF-8" . to_string( ) ) ) ;
1140+
1141+ // Should filter out non-UTF-8 encoded locales
1142+ assert ! ( !result. contains( & "en_US" . to_string( ) ) ) ;
1143+ assert ! ( !result. contains( & "ar_IN" . to_string( ) ) ) ;
1144+ assert ! ( !result. contains( & "de_DE.iso88591" . to_string( ) ) ) ;
1145+
1146+ assert_eq ! ( result. len( ) , 4 ) ;
1147+ }
1148+
1149+ #[ test]
1150+ fn test_parse_locale_output_filters_any_c_posix_variant ( ) {
1151+ let output = "C\n C.iso88591\n C.anything\n POSIX\n POSIX.utf8\n en_US.utf8\n " ;
1152+ let result = parse_locale_output ( output) ;
1153+
1154+ // Should filter out any C or POSIX variant regardless of encoding
1155+ assert ! ( !result. contains( & "C" . to_string( ) ) ) ;
1156+ assert ! ( !result. contains( & "C.iso88591" . to_string( ) ) ) ;
1157+ assert ! ( !result. contains( & "C.anything" . to_string( ) ) ) ;
1158+ assert ! ( !result. contains( & "POSIX" . to_string( ) ) ) ;
1159+ assert ! ( !result. contains( & "POSIX.utf8" . to_string( ) ) ) ;
1160+
1161+ // Should keep actual locales
1162+ assert ! ( result. contains( & "en_US.utf8" . to_string( ) ) ) ;
1163+ assert_eq ! ( result. len( ) , 1 ) ;
1164+ }
1165+
1166+ #[ test]
1167+ fn test_parse_locale_output_handles_whitespace ( ) {
1168+ let output = " en_US.utf8 \n \t de_DE.UTF-8\t \n fr_FR.utf8 \n " ;
1169+ let result = parse_locale_output ( output) ;
1170+
1171+ // Should handle leading/trailing whitespace
1172+ assert ! ( result. contains( & "en_US.utf8" . to_string( ) ) ) ;
1173+ assert ! ( result. contains( & "de_DE.UTF-8" . to_string( ) ) ) ;
1174+ assert ! ( result. contains( & "fr_FR.utf8" . to_string( ) ) ) ;
1175+ assert_eq ! ( result. len( ) , 3 ) ;
1176+ }
1177+
1178+ #[ test]
1179+ fn test_parse_locale_output_handles_empty_lines ( ) {
1180+ let output = "en_US.utf8\n \n \n de_DE.UTF-8\n \n " ;
1181+ let result = parse_locale_output ( output) ;
1182+
1183+ // Should skip empty lines
1184+ assert ! ( result. contains( & "en_US.utf8" . to_string( ) ) ) ;
1185+ assert ! ( result. contains( & "de_DE.UTF-8" . to_string( ) ) ) ;
1186+ assert_eq ! ( result. len( ) , 2 ) ;
1187+ }
1188+
1189+ #[ test]
1190+ fn test_parse_locale_output_catalan_not_filtered_as_pseudo ( ) {
1191+ let output = "C\n ca_ES.UTF-8\n ca_ES.utf8\n cs_CZ.UTF-8\n en_US.utf8\n " ;
1192+ let result = parse_locale_output ( output) ;
1193+
1194+ // Should filter out C but not Catalan (ca_*) or Czech (cs_*)
1195+ assert ! ( !result. contains( & "C" . to_string( ) ) ) ;
1196+ assert ! ( result. contains( & "ca_ES.UTF-8" . to_string( ) ) ) ;
1197+ assert ! ( result. contains( & "ca_ES.utf8" . to_string( ) ) ) ;
1198+ assert ! ( result. contains( & "cs_CZ.UTF-8" . to_string( ) ) ) ;
1199+ assert_eq ! ( result. len( ) , 4 ) ;
1200+ }
1201+
1202+ #[ test]
1203+ fn test_parse_locale_output_handles_locale_modifiers ( ) {
1204+ let output = "en_US.UTF-8@euro\n ca_ES.UTF-8@valencia\n de_DE.utf8\n " ;
1205+ let result = parse_locale_output ( output) ;
1206+
1207+ // Locales with modifiers should be accepted
1208+ assert ! ( result. contains( & "en_US.UTF-8@euro" . to_string( ) ) ) ;
1209+ assert ! ( result. contains( & "ca_ES.UTF-8@valencia" . to_string( ) ) ) ;
1210+ assert ! ( result. contains( & "de_DE.utf8" . to_string( ) ) ) ;
1211+ assert_eq ! ( result. len( ) , 3 ) ;
1212+ }
1213+
1214+ #[ test]
1215+ fn test_parse_locale_output_case_variations ( ) {
1216+ let output = "en_US.UTF-8\n en_US.utf-8\n en_US.utf8\n en_US.UTF8\n de_DE.Utf8\n " ;
1217+ let result = parse_locale_output ( output) ;
1218+
1219+ // All case variations should be accepted (case-insensitive regex)
1220+ assert ! ( result. contains( & "en_US.UTF-8" . to_string( ) ) ) ;
1221+ assert ! ( result. contains( & "en_US.utf-8" . to_string( ) ) ) ;
1222+ assert ! ( result. contains( & "en_US.utf8" . to_string( ) ) ) ;
1223+ assert ! ( result. contains( & "en_US.UTF8" . to_string( ) ) ) ;
1224+ assert ! ( result. contains( & "de_DE.Utf8" . to_string( ) ) ) ;
1225+ assert_eq ! ( result. len( ) , 5 ) ;
1226+ }
1227+ }
0 commit comments