Skip to main content

futu_opend/config/
runtime_language.rs

1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2pub(crate) enum RuntimeLanguageSource {
3    Cli,
4    Config,
5    OsNative,
6    PosixEnv,
7    EnglishFallback,
8}
9
10impl RuntimeLanguageSource {
11    pub(crate) const fn as_str(self) -> &'static str {
12        match self {
13            Self::Cli => "cli",
14            Self::Config => "config",
15            Self::OsNative => "os_native",
16            Self::PosixEnv => "posix_env",
17            Self::EnglishFallback => "english_fallback",
18        }
19    }
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub(crate) struct RuntimeLanguageResolution {
24    pub(crate) lang: String,
25    pub(crate) source: RuntimeLanguageSource,
26}
27
28// Ref: FTGatewayGui/Tools/ConfigManager.cpp:127-136 uses the system locale as
29// the default product language; FTGateway/FTGateway.cpp:1108-1109 maps
30// chs/cht to application-language values. These are product-language constants,
31// not data that can be dynamically delivered by the server.
32pub(crate) fn normalize_runtime_lang(raw: &str) -> &'static str {
33    let lang = raw.trim().to_ascii_lowercase().replace('-', "_");
34    match lang.as_str() {
35        "chs" | "zh" | "zh_cn" | "zh_sg" | "zh_hans" | "zh_hans_cn" | "zh_hans_sg" => "chs",
36        "cht" | "zh_tw" | "zh_hk" | "zh_mo" | "zh_hant" | "zh_hant_tw" | "zh_hant_hk"
37        | "zh_hant_mo" => "cht",
38        "en" | "en_us" | "en_gb" | "en_au" | "en_ca" => "en",
39        _ => "en",
40    }
41}
42
43fn first_locale_candidate(locale: Option<&str>) -> Option<&str> {
44    locale?
45        .split(':')
46        .map(str::trim)
47        .find(|value| !value.is_empty())
48}
49
50pub(crate) fn runtime_lang_from_locale(locale: Option<&str>) -> &'static str {
51    let Some(locale) = first_locale_candidate(locale) else {
52        return "en";
53    };
54    let primary = locale
55        .split(['.', '@'])
56        .next()
57        .unwrap_or(locale)
58        .replace('-', "_")
59        .to_ascii_lowercase();
60    let mut subtags = primary.split('_');
61    let language = subtags.next().unwrap_or_default();
62    let next = subtags.next();
63    let (script, region_candidate) = match next {
64        Some(value) if value.len() == 4 && value.bytes().all(|byte| byte.is_ascii_alphabetic()) => {
65            (Some(value), subtags.next())
66        }
67        value => (None, value),
68    };
69    let region = region_candidate.filter(|value| {
70        (value.len() == 2 && value.bytes().all(|byte| byte.is_ascii_alphabetic()))
71            || (value.len() == 3 && value.bytes().all(|byte| byte.is_ascii_digit()))
72    });
73
74    match language {
75        "zh" => {
76            if script == Some("hant") || matches!(region, Some("tw" | "hk" | "mo")) {
77                "cht"
78            } else {
79                "chs"
80            }
81        }
82        "en" => "en",
83        _ => "en",
84    }
85}
86
87fn resolution(lang: &'static str, source: RuntimeLanguageSource) -> RuntimeLanguageResolution {
88    RuntimeLanguageResolution {
89        lang: lang.to_string(),
90        source,
91    }
92}
93
94pub(crate) fn resolve_runtime_language(
95    cli_lang: Option<&str>,
96    config_lang: Option<&str>,
97    os_locale: Option<&str>,
98    posix_locales: [Option<&str>; 4],
99) -> RuntimeLanguageResolution {
100    if let Some(raw) = cli_lang {
101        return resolution(normalize_runtime_lang(raw), RuntimeLanguageSource::Cli);
102    }
103    if let Some(raw) = config_lang {
104        return resolution(normalize_runtime_lang(raw), RuntimeLanguageSource::Config);
105    }
106    if let Some(raw) = first_locale_candidate(os_locale) {
107        return resolution(
108            runtime_lang_from_locale(Some(raw)),
109            RuntimeLanguageSource::OsNative,
110        );
111    }
112    for raw in posix_locales.into_iter().flatten() {
113        if let Some(raw) = first_locale_candidate(Some(raw)) {
114            return resolution(
115                runtime_lang_from_locale(Some(raw)),
116                RuntimeLanguageSource::PosixEnv,
117            );
118        }
119    }
120    resolution("en", RuntimeLanguageSource::EnglishFallback)
121}
122
123pub(super) fn unicode_env_value(value: Option<std::ffi::OsString>) -> Option<String> {
124    value
125        .and_then(|value| value.into_string().ok())
126        .map(|value| value.trim().to_string())
127        .filter(|value| !value.is_empty())
128}
129
130#[cfg(any(test, target_os = "macos", target_os = "windows"))]
131pub(super) fn normalize_native_locale(value: Option<String>) -> Option<String> {
132    value
133        .map(|value| value.trim().to_string())
134        .filter(|value| !value.is_empty())
135}
136
137#[cfg(any(test, target_os = "macos"))]
138pub(super) fn validated_native_locale_buffer_len(capacity: isize, out_len: isize) -> Option<usize> {
139    if capacity <= 0 || out_len < 0 || out_len > capacity {
140        return None;
141    }
142    usize::try_from(out_len).ok()
143}
144
145#[cfg(target_os = "macos")]
146mod macos_native_locale {
147    use super::validated_native_locale_buffer_len;
148    use std::ffi::c_void;
149
150    type CfIndex = isize;
151    type CfStringEncoding = u32;
152    type CfTypeRef = *const c_void;
153    type CfArrayRef = *const c_void;
154    type CfStringRef = *const c_void;
155
156    // Ref: Apple CoreFoundation/CFString.h defines kCFStringEncodingUTF8 as
157    // 0x08000100. This is a fixed public ABI constant, not server/config data;
158    // replace it only if the CoreFoundation ABI or selected encoding changes.
159    const CF_STRING_ENCODING_UTF8: CfStringEncoding = 0x0800_0100;
160
161    #[repr(C)]
162    #[derive(Clone, Copy)]
163    struct CfRange {
164        location: CfIndex,
165        length: CfIndex,
166    }
167
168    #[link(name = "CoreFoundation", kind = "framework")]
169    unsafe extern "C" {
170        fn CFArrayGetCount(array: CfArrayRef) -> CfIndex;
171        fn CFArrayGetValueAtIndex(array: CfArrayRef, index: CfIndex) -> *const c_void;
172        fn CFStringGetLength(string: CfStringRef) -> CfIndex;
173        fn CFStringGetBytes(
174            string: CfStringRef,
175            range: CfRange,
176            encoding: CfStringEncoding,
177            loss_byte: u8,
178            is_external_representation: u8,
179            buffer: *mut u8,
180            max_buffer_length: CfIndex,
181            used_buffer_length: *mut CfIndex,
182        ) -> CfIndex;
183        fn CFRelease(value: CfTypeRef);
184        fn CFLocaleCopyPreferredLanguages() -> CfArrayRef;
185    }
186
187    struct OwnedCfArray(CfArrayRef);
188
189    impl OwnedCfArray {
190        fn copy_preferred_languages() -> Option<Self> {
191            // SAFETY: CoreFoundation returns either a null pointer or an owned CFArrayRef.
192            let raw = unsafe { CFLocaleCopyPreferredLanguages() };
193            (!raw.is_null()).then_some(Self(raw))
194        }
195    }
196
197    impl Drop for OwnedCfArray {
198        fn drop(&mut self) {
199            // SAFETY: construction only accepts the owned non-null value returned by
200            // CFLocaleCopyPreferredLanguages, so the create-rule reference is released once.
201            unsafe { CFRelease(self.0.cast()) };
202        }
203    }
204
205    pub(super) fn preferred_locale() -> Option<String> {
206        let languages = OwnedCfArray::copy_preferred_languages()?;
207        // SAFETY: languages owns a valid CFArrayRef for this scope.
208        let count = unsafe { CFArrayGetCount(languages.0) };
209        if count <= 0 {
210            return None;
211        }
212
213        // SAFETY: count > 0 proves index zero is inside the array. Apple's API contract
214        // says this array contains CFString values.
215        let locale = unsafe { CFArrayGetValueAtIndex(languages.0, 0) } as CfStringRef;
216        if locale.is_null() {
217            return None;
218        }
219        // SAFETY: locale is the first CFString borrowed from the live languages array.
220        let string_length = unsafe { CFStringGetLength(locale) };
221        if string_length <= 0 {
222            return None;
223        }
224        let range = CfRange {
225            location: 0,
226            length: string_length,
227        };
228
229        let mut capacity = 0;
230        // SAFETY: a null output buffer with length zero asks CoreFoundation for the UTF-8
231        // byte count; range covers the complete borrowed CFString.
232        let measured_characters = unsafe {
233            CFStringGetBytes(
234                locale,
235                range,
236                CF_STRING_ENCODING_UTF8,
237                0,
238                0,
239                std::ptr::null_mut(),
240                0,
241                &mut capacity,
242            )
243        };
244        if measured_characters != string_length {
245            return None;
246        }
247        let buffer_capacity = validated_native_locale_buffer_len(capacity, capacity)?;
248        let mut buffer = Vec::new();
249        buffer.try_reserve_exact(buffer_capacity).ok()?;
250
251        let mut out_len = 0;
252        // SAFETY: try_reserve_exact established at least buffer_capacity writable bytes;
253        // CoreFoundation receives that exact capacity and the same complete string range.
254        let written_characters = unsafe {
255            CFStringGetBytes(
256                locale,
257                range,
258                CF_STRING_ENCODING_UTF8,
259                0,
260                0,
261                buffer.as_mut_ptr(),
262                capacity,
263                &mut out_len,
264            )
265        };
266        if written_characters != string_length {
267            return None;
268        }
269        let initialized_len = validated_native_locale_buffer_len(capacity, out_len)?;
270        // SAFETY: CoreFoundation initialized out_len bytes, and the checked helper proves
271        // that range is non-negative and does not exceed the reserved capacity.
272        unsafe { buffer.set_len(initialized_len) };
273        String::from_utf8(buffer).ok()
274    }
275}
276
277#[cfg(target_os = "macos")]
278pub(super) fn os_preferred_locale() -> Option<String> {
279    normalize_native_locale(macos_native_locale::preferred_locale())
280}
281
282#[cfg(target_os = "windows")]
283fn os_preferred_locale() -> Option<String> {
284    normalize_native_locale(sys_locale::get_locale())
285}
286
287#[cfg(not(any(target_os = "macos", target_os = "windows")))]
288fn os_preferred_locale() -> Option<String> {
289    None
290}
291
292fn posix_locale_candidates() -> [Option<String>; 4] {
293    ["LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"]
294        .map(|key| unicode_env_value(std::env::var_os(key)))
295}
296
297pub(crate) fn resolve_process_runtime_language(
298    cli_lang: Option<&str>,
299    config_lang: Option<&str>,
300) -> RuntimeLanguageResolution {
301    if cli_lang.is_some() || config_lang.is_some() {
302        return resolve_runtime_language(cli_lang, config_lang, None, [None, None, None, None]);
303    }
304
305    if let Some(os_locale) = os_preferred_locale() {
306        return resolve_runtime_language(
307            None,
308            None,
309            Some(os_locale.as_str()),
310            [None, None, None, None],
311        );
312    }
313
314    let posix = posix_locale_candidates();
315    resolve_runtime_language(
316        None,
317        None,
318        None,
319        [
320            posix[0].as_deref(),
321            posix[1].as_deref(),
322            posix[2].as_deref(),
323            posix[3].as_deref(),
324        ],
325    )
326}
327
328pub(crate) fn app_lang_from_runtime_lang(raw: &str) -> i32 {
329    match normalize_runtime_lang(raw) {
330        "chs" => 0,
331        "cht" => 1,
332        "en" => 2,
333        _ => 2,
334    }
335}