[feat] add system language support across desktop and mobile

This commit is contained in:
dijunkun
2026-10-27 12:35:47 +08:00
parent b4acf0b597
commit b07f457e76
105 changed files with 21711 additions and 1627 deletions
+39
View File
@@ -0,0 +1,39 @@
# CrossDesk 界面本地化
桌面端和 Android、iOS、HarmonyOS 移动端共用 `catalog.json` 中的界面文案,支持以下 10 种语言版本:
| 语言 | 标识 |
| --- | --- |
| 简体中文 | `zh-Hans` |
| English | `en` |
| Русский | `ru` |
| 繁體中文 | `zh-Hant` |
| 日本語 | `ja` |
| 한국어 | `ko` |
| Deutsch | `de` |
| Français | `fr` |
| Español | `es` |
| Português (Brasil) | `pt-BR` |
## 语言选择
- 新安装的桌面端默认“跟随系统”,也可以在设置中手动指定语言。已有配置中的中文、英文、俄文选择继续保留。
- 移动端使用系统语言;Android 和 iOS 同时支持操作系统提供的应用语言设置。
- 地区变体匹配相应语言;中文优先识别 `Hans` / `Hant`,台湾、香港、澳门使用繁体。葡萄牙语地区变体共用巴西葡萄牙语文案。
- 支持系统首选语言列表的平台使用其中可用的语言,没有匹配项时回退到英文。修改系统语言后重新打开应用可确保所有页面与状态文案更新。
- 只翻译应用界面。远端设备名称、文件名、用户输入、服务器公告和协议内容不作翻译。隐私政策正文使用项目已发布的中文或英文版本,第三方许可证保留原文。
## 维护资源
`catalog.json` 是唯一编辑入口,生成结果提交到各平台目录,应用运行时不依赖翻译服务。
```sh
python3 localization/generate.py
python3 localization/generate.py --check
```
生成器使用 Python 标准库,输出桌面 C++ 文案表、Android 原生资源及语言声明、iOS `.lproj` 资源,以及 HarmonyOS 文案表。`--check` 检查语言覆盖、格式占位符和生成文件是否同步。
新增文案时,在 `strings` 中提供所有语言的译文,并加入 `platforms` 中对应的平台列表。桌面命名键同时加入 `desktop`;Slint 使用的键追加到 `slint` 列表末尾,已有索引不能重排。桌面语言顺序中的前 3 项与历史配置兼容,修改顺序需要配置迁移。
占位符须完整保留:Android/HarmonyOS 使用 `{0}` 等位置占位符;iOS 使用 `%1$@` 等位置格式;已有桌面文案使用其原有格式。不同语言可以调整占位符位置,但不能更改数量或类型。新语言的初始译文包含机器辅助翻译,发布前应由对应语言使用者复核术语、语气及长文本。
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Generate offline UI resources from catalog.json. No network access required."""
import argparse
from collections import Counter
import hashlib
import json
import re
import sys
from pathlib import Path
from xml.sax.saxutils import escape
ROOT = Path(__file__).resolve().parents[1]
CATALOG = Path(__file__).with_name('catalog.json')
def quoted(value):
return json.dumps(value, ensure_ascii=False)
def resource_id(key):
return 'text_' + hashlib.sha256(key.encode()).hexdigest()[:16]
def render(data):
languages = data['languages']
strings = data['strings']
def translated(key, language):
return strings[key].get(language, strings[key].get('en', key))
outputs = {}
header = '''/*
* @Author: DI JUNKUN
* @Date: 2024-05-29
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _LOCALIZATION_DATA_H_
#define _LOCALIZATION_DATA_H_
// Generated by localization/generate.py. Edit localization/catalog.json.
namespace crossdesk {
namespace localization {
namespace detail {
struct TranslationRow { const char* key; const char* text[10]; };
inline constexpr TranslationRow kTranslationRows[] = {
'''
for key in data['platforms']['desktop']:
header += ' {' + quoted(key) + ', {' + ', '.join(quoted(translated(key, lang)) for lang in languages) + '}},\n'
header += '};\n\nstruct NamedTranslation { const char* name; const char* literal; };\ninline constexpr NamedTranslation kNamedTranslations[] = {\n'
for name,key in data['desktop'].items():
header += ' {' + quoted(name) + ', ' + quoted(key) + '},\n'
header += '};\ninline constexpr const char* kSlintLiterals[] = {\n'
header += ''.join(' ' + quoted(key) + ',\n' for key in data['slint'])
header += '};\n} // namespace detail\n\n#define CROSSDESK_LOCALIZATION_ALL(X) \\\n'
header += ' \\\n'.join(' X(' + name + ')' for name in data['desktop'])
header += '\n\n} // namespace localization\n} // namespace crossdesk\n\n#endif\n'
outputs['apps/desktop/src/gui/assets/localization/localization_data.h'] = header
# Android resources participate in native locale matching and per-app language settings.
qualifiers = {'en':'values','zh-Hans':'values-b+zh+Hans','zh-Hant':'values-b+zh+Hant','pt-BR':'values-pt'}
for language in languages:
text = '<?xml version="1.0" encoding="utf-8"?>\n<!-- Generated by localization/generate.py. -->\n<resources>\n'
for key in data['platforms']['android']:
value = translated(key,language).replace('\\','\\\\').replace('\n','\\n').replace('\t','\\t').replace('"','\\"').replace("'","\\'")
text += ' <string name="'+resource_id(key)+'" formatted="false">"'+escape(value)+'"</string>\n'
text += '</resources>\n'
qualifier = qualifiers.get(language,'values-'+language)
outputs['apps/android/app/src/main/res/'+qualifier+'/strings.xml'] = text
android = '''package cn.crossdesk.mobile;
import java.util.HashMap;
import java.util.Map;
/** Generated resource keys; text and locale selection live in Android resources. */
final class LocalizationKeys {
static final Map<String, Integer> IDS = new HashMap<>();
static {
'''
android += ''.join(' IDS.put('+quoted(key)+', R.string.'+resource_id(key)+');\n' for key in data['platforms']['android'])
android += ' }\n private LocalizationKeys() {}\n}\n'
outputs['apps/android/app/src/main/java/cn/crossdesk/mobile/LocalizationKeys.java'] = android
locales = ['en','zh-Hans','zh-Hant','ja','ko','de','fr','es','pt-BR','ru']
outputs['apps/android/app/src/main/res/xml/locales_config.xml'] = '<?xml version="1.0" encoding="utf-8"?>\n<locale-config xmlns:android="http://schemas.android.com/apk/res/android">\n' + ''.join(' <locale android:name="'+code+'" />\n' for code in locales) + '</locale-config>\n'
# iOS uses Foundation/SwiftUI localization directly, including strings from models.
permission = 'CrossDesk 使用局域网发现并建立原生 P2P 远程控制连接。'
for language in languages:
directory = 'apps/ios/CrossDeskMobile/Resources/'+language+'.lproj/'
outputs[directory+'Localizable.strings'] = '// Generated by localization/generate.py.\n' + ''.join(quoted(key)+' = '+quoted(translated(key,language))+';\n' for key in data['platforms']['ios'])
outputs[directory+'InfoPlist.strings'] = '"NSLocalNetworkUsageDescription" = '+quoted(translated(permission,language))+';\n'
# A generic Portuguese fallback also serves Portuguese locales outside Brazil.
for name in ['Localizable.strings','InfoPlist.strings']:
outputs['apps/ios/CrossDeskMobile/Resources/pt.lproj/'+name] = outputs['apps/ios/CrossDeskMobile/Resources/pt-BR.lproj/'+name]
# Harmony keeps lookup pure; EntryAbility injects the system locale provider.
harmony = '// Generated by localization/generate.py.\nexport const translations: Record<string, string[]> = {\n'
for key in data['platforms']['harmony']:
harmony += ' '+quoted(key)+': ['+', '.join(quoted(translated(key,lang)) for lang in languages)+'],\n'
harmony += '};\n'
outputs['apps/harmony/entry/src/main/ets/mobile/LocalizationData.ets'] = harmony
return outputs
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check',action='store_true',help='check catalog completeness and generated resources without writing')
args=parser.parse_args()
data=json.loads(CATALOG.read_text())
missing=[(key,lang) for key,values in data['strings'].items() for lang in data['languages'] if lang not in values]
if missing:
print(f'{len(missing)} translations missing',file=sys.stderr)
return 1
token = re.compile(r'%(?:\d+\$)?[-+0 #]*\d*(?:\.\d+)?(?:ll|l|z)?[sduif@]|%%|\{\d+\}')
for key, values in data['strings'].items():
expected = Counter(token.findall(key))
for language, value in values.items():
if Counter(token.findall(value)) != expected:
raise ValueError(f'Format placeholders differ: {language}: {key}')
if re.search(r'ZXQ\d+QXZ|[【[]\d+[】]]', value):
raise ValueError(f'Translation marker left in {language}: {key}')
for keys in data['platforms'].values():
if len(keys) != len(set(keys)):
raise ValueError('Duplicate platform resource key')
for key in keys:
if key not in data['strings']: raise ValueError(f'Unknown resource: {key}')
mismatches=[]
for relative,content in render(data).items():
path=ROOT/relative
if args.check:
if not path.exists() or path.read_text()!=content:mismatches.append(relative)
else:
if not path.exists() or path.read_text()!=content:
path.parent.mkdir(parents=True,exist_ok=True)
path.write_text(content)
if mismatches:
print('Stale generated resources:\n'+'\n'.join(mismatches),file=sys.stderr)
return 1
print(f'{len(data["strings"])} strings, {len(data["languages"])} language variants; resources '+('verified' if args.check else 'generated'))
return 0
if __name__=='__main__':
raise SystemExit(main())