chinese localization!!

This commit is contained in:
2026-09-03 16:21:01 +08:00
parent 34f35ef208
commit b731ec50d0
49 changed files with 1404 additions and 295 deletions
+1
View File
@@ -40,6 +40,7 @@ specta-typescript = { version = "0.0.9" }
uuid = { version = "1", features = ["v4"] }
webp = { version = "0.3.1", default-features = false }
friendolls-common = { path = "../src-common" }
sys-locale = "0.3.2"
[dev-dependencies]
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
@@ -0,0 +1,2 @@
ALTER TABLE app_settings
ADD COLUMN locale_preference TEXT NOT NULL DEFAULT 'system';
+1 -1
View File
@@ -10,7 +10,7 @@ struct ApplicationState {
pub async fn init(handle: &AppHandle) -> Result<(), String> {
handle.manage(ApplicationState::default());
let accessibility_permission_granted = crate::macos::init(handle).await?;
let settings = crate::onboarding::get(&handle.state())
let settings = crate::settings::get(&handle.state())
.await
.map_err(crate::db::command_error)?;
+9 -5
View File
@@ -74,12 +74,16 @@ async fn database_requires_newer_app(pool: &Pool<Sqlite>) -> Result<bool, sqlx::
fn show_outdated_app_dialog(app: &AppHandle) {
app.dialog()
.message(
"Your on-device data was created by a newer version of Friendolls and cannot be opened safely by this version.\n\nDownload and install the latest version of Friendolls, then reopen the app. Your data has not been changed.",
)
.title("Friendolls is out of date")
.message(crate::settings::system_text(
crate::settings::NativeText::OutdatedMessage,
))
.title(crate::settings::system_text(
crate::settings::NativeText::OutdatedTitle,
))
.kind(MessageDialogKind::Warning)
.buttons(MessageDialogButtons::OkCustom("Close Friendolls".into()))
.buttons(MessageDialogButtons::OkCustom(
crate::settings::system_text(crate::settings::NativeText::CloseFriendolls).into(),
))
.show(move |_| std::process::exit(0));
}
+4 -1
View File
@@ -33,7 +33,10 @@ pub async fn pick_and_send_image(
let Some(file) = app
.dialog()
.file()
.add_filter("Images", &["png", "jpg", "jpeg", "gif", "webp"])
.add_filter(
crate::settings::text(&app, crate::settings::NativeText::Images),
&["png", "jpg", "jpeg", "gif", "webp"],
)
.blocking_pick_file()
else {
return Ok(false);
+5
View File
@@ -13,6 +13,7 @@ mod profile;
mod puppet;
mod remotes;
mod scene_configuration;
mod settings;
mod skins;
mod ufa;
mod ui;
@@ -22,6 +23,7 @@ mod user;
async fn launch_app(handle: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
updater::init(handle).await;
db::init(handle).await?;
settings::init(handle).await?;
scene_configuration::init(handle).await?;
keypair::init(handle).await?;
interactions::init(handle);
@@ -102,6 +104,8 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
onboarding::get_onboarding_status,
onboarding::complete_onboarding,
macos::request_accessibility_permission,
settings::get_locale_settings,
settings::set_locale_preference,
ui::control_panel::open_action_window,
ui::scene::update_scene_hitboxes,
])
@@ -118,6 +122,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
puppet::PuppetStatesChanged,
scene_configuration::SceneConfigurationChanged,
onboarding::OnboardingStatus,
settings::LocaleChanged,
])
}
+18 -32
View File
@@ -4,13 +4,7 @@ use tauri::{AppHandle, State};
use tauri_specta::Event;
use crate::db::{self, AppDatabase};
const SETTINGS_ID: i64 = 1;
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub struct AppSettings {
pub onboarding_done: bool,
}
use crate::settings::AppSettings;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, Event)]
#[serde(rename_all = "camelCase")]
@@ -32,30 +26,16 @@ impl OnboardingStatus {
}
}
pub async fn get(database: &AppDatabase) -> Result<AppSettings, sqlx::Error> {
sqlx::query_as::<_, AppSettings>("SELECT onboarding_done FROM app_settings WHERE id = ?1")
.bind(SETTINGS_ID)
.fetch_one(database.pool())
.await
}
async fn set_onboarding_done(
database: &AppDatabase,
onboarding_done: bool,
) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE app_settings SET onboarding_done = ?1 WHERE id = ?2")
.bind(onboarding_done)
.bind(SETTINGS_ID)
.execute(database.pool())
.await?;
Ok(())
}
pub(crate) async fn emit_status(
handle: &AppHandle,
database: &AppDatabase,
) -> Result<OnboardingStatus, String> {
let status = OnboardingStatus::new(handle, get(database).await.map_err(db::command_error)?);
let status = OnboardingStatus::new(
handle,
crate::settings::get(database)
.await
.map_err(db::command_error)?,
);
status.clone().emit(handle).map_err(db::command_error)?;
Ok(status)
}
@@ -66,7 +46,9 @@ pub async fn get_onboarding_status(
handle: AppHandle,
database: State<'_, AppDatabase>,
) -> Result<OnboardingStatus, String> {
let settings = get(&database).await.map_err(db::command_error)?;
let settings = crate::settings::get(&database)
.await
.map_err(db::command_error)?;
Ok(OnboardingStatus::new(&handle, settings))
}
@@ -80,7 +62,7 @@ pub async fn complete_onboarding(
return Err("Accessibility access must be granted before setup can finish.".to_owned());
}
set_onboarding_done(&database, true)
crate::settings::set_onboarding_done(&database, true)
.await
.map_err(db::command_error)?;
emit_status(&handle, &database).await?;
@@ -111,17 +93,21 @@ mod tests {
async fn onboarding_completion_is_persisted() {
let database = database().await;
assert_eq!(
get(&database).await.unwrap(),
crate::settings::get(&database).await.unwrap(),
AppSettings {
onboarding_done: false,
locale_preference: "system".to_owned(),
}
);
set_onboarding_done(&database, true).await.unwrap();
crate::settings::set_onboarding_done(&database, true)
.await
.unwrap();
assert_eq!(
get(&database).await.unwrap(),
crate::settings::get(&database).await.unwrap(),
AppSettings {
onboarding_done: true,
locale_preference: "system".to_owned(),
}
);
}
+5
View File
@@ -23,6 +23,7 @@ pub async fn get(database: &AppDatabase, public_key: &str) -> Result<User, sqlx:
.fetch_one(database.pool())
.await?;
let display_name_configured = !display_name.is_empty();
Ok(User {
id: public_key.to_owned(),
display_name: if display_name.is_empty() {
@@ -30,6 +31,7 @@ pub async fn get(database: &AppDatabase, public_key: &str) -> Result<User, sqlx:
} else {
display_name
},
display_name_configured,
skin_hash,
})
}
@@ -151,6 +153,7 @@ mod tests {
User {
id: "public-key".to_owned(),
display_name: "Wind".to_owned(),
display_name_configured: true,
skin_hash: None,
}
);
@@ -159,6 +162,7 @@ mod tests {
User {
id: "rotated-public-key".to_owned(),
display_name: "Wind".to_owned(),
display_name_configured: true,
skin_hash: None,
}
);
@@ -209,6 +213,7 @@ mod tests {
User {
id: "public-key".to_owned(),
display_name: "Wind".to_owned(),
display_name_configured: true,
skin_hash: None,
}
);
+205
View File
@@ -0,0 +1,205 @@
use std::sync::RwLock;
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::{AppHandle, Manager, State};
use tauri_specta::Event;
use crate::db::{self, AppDatabase};
const SETTINGS_ID: i64 = 1;
pub const SYSTEM_LOCALE_PREFERENCE: &str = "system";
pub const BASE_LOCALE: &str = "en";
pub const SUPPORTED_LOCALES: &[&str] = &["en", "zh-CN"];
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub struct AppSettings {
pub onboarding_done: bool,
pub locale_preference: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, Event)]
#[serde(rename_all = "camelCase")]
pub struct LocaleChanged {
pub preference: String,
pub locale: String,
}
pub struct SettingsState {
locale: RwLock<LocaleChanged>,
}
#[derive(Debug, Clone, Copy)]
pub enum NativeText {
OpenControlPanel,
Quit,
OutdatedTitle,
OutdatedMessage,
CloseFriendolls,
Images,
OnboardingTitle,
SceneTitle,
}
pub async fn init(handle: &AppHandle) -> Result<(), String> {
let settings = get(&handle.state()).await.map_err(db::command_error)?;
handle.manage(SettingsState {
locale: RwLock::new(locale_settings(&settings.locale_preference)),
});
Ok(())
}
pub async fn get(database: &AppDatabase) -> Result<AppSettings, sqlx::Error> {
sqlx::query_as::<_, AppSettings>(
"SELECT onboarding_done, locale_preference FROM app_settings WHERE id = ?1",
)
.bind(SETTINGS_ID)
.fetch_one(database.pool())
.await
}
pub async fn set_onboarding_done(
database: &AppDatabase,
onboarding_done: bool,
) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE app_settings SET onboarding_done = ?1 WHERE id = ?2")
.bind(onboarding_done)
.bind(SETTINGS_ID)
.execute(database.pool())
.await?;
Ok(())
}
fn locale_settings(preference: &str) -> LocaleChanged {
let locale = if preference == SYSTEM_LOCALE_PREFERENCE {
resolve_locale(sys_locale::get_locale().as_deref())
} else {
resolve_locale(Some(preference))
};
LocaleChanged {
preference: preference.to_owned(),
locale,
}
}
fn resolve_locale(requested: Option<&str>) -> String {
let requested = requested.unwrap_or_default().replace('_', "-");
if let Some(locale) = SUPPORTED_LOCALES
.iter()
.find(|locale| locale.eq_ignore_ascii_case(&requested))
{
return (*locale).to_owned();
}
let requested_language = requested.split('-').next().unwrap_or_default();
SUPPORTED_LOCALES
.iter()
.find(|locale| {
locale
.split('-')
.next()
.is_some_and(|language| language.eq_ignore_ascii_case(requested_language))
})
.copied()
.unwrap_or(BASE_LOCALE)
.to_owned()
}
fn is_supported_preference(preference: &str) -> bool {
preference == SYSTEM_LOCALE_PREFERENCE || SUPPORTED_LOCALES.contains(&preference)
}
pub fn current(handle: &AppHandle) -> LocaleChanged {
handle
.state::<SettingsState>()
.locale
.read()
.unwrap_or_else(|error| error.into_inner())
.clone()
}
pub fn text(handle: &AppHandle, key: NativeText) -> &'static str {
localized_text(&current(handle).locale, key)
}
pub fn system_text(key: NativeText) -> &'static str {
localized_text(&resolve_locale(sys_locale::get_locale().as_deref()), key)
}
fn localized_text(locale: &str, key: NativeText) -> &'static str {
match (locale, key) {
("zh-CN", NativeText::OpenControlPanel) => "打开控制面板",
("zh-CN", NativeText::Quit) => "退出",
("zh-CN", NativeText::OutdatedTitle) => "Friendolls 版本过旧",
("zh-CN", NativeText::OutdatedMessage) => {
"此设备上的数据由较新版本的 Friendolls 创建,当前版本无法安全地打开这些数据。\n\n请下载并安装最新版 Friendolls,然后重新打开应用。你的数据未被更改。"
}
("zh-CN", NativeText::CloseFriendolls) => "关闭 Friendolls",
("zh-CN", NativeText::Images) => "图片",
("zh-CN", NativeText::OnboardingTitle) => "Friendolls 安装向导",
("zh-CN", NativeText::SceneTitle) => "场景",
(_, NativeText::OpenControlPanel) => "Open Control Panel",
(_, NativeText::Quit) => "Quit",
(_, NativeText::OutdatedTitle) => "Friendolls is out of date",
(_, NativeText::OutdatedMessage) => {
"Your on-device data was created by a newer version of Friendolls and cannot be opened safely by this version.\n\nDownload and install the latest version of Friendolls, then reopen the app. Your data has not been changed."
}
(_, NativeText::CloseFriendolls) => "Close Friendolls",
(_, NativeText::Images) => "Images",
(_, NativeText::OnboardingTitle) => "Friendolls Setup",
(_, NativeText::SceneTitle) => "Scene",
}
}
#[tauri::command]
#[specta::specta]
pub fn get_locale_settings(handle: AppHandle) -> LocaleChanged {
current(&handle)
}
#[tauri::command]
#[specta::specta]
pub async fn set_locale_preference(
handle: AppHandle,
database: State<'_, AppDatabase>,
preference: String,
) -> Result<LocaleChanged, String> {
if !is_supported_preference(&preference) {
return Err("Unsupported locale preference".to_owned());
}
sqlx::query("UPDATE app_settings SET locale_preference = ?1 WHERE id = ?2")
.bind(&preference)
.bind(SETTINGS_ID)
.execute(database.pool())
.await
.map_err(db::command_error)?;
let next = locale_settings(&preference);
*handle
.state::<SettingsState>()
.locale
.write()
.unwrap_or_else(|error| error.into_inner()) = next.clone();
next.clone().emit(&handle).map_err(db::command_error)?;
crate::ui::refresh_locale(&handle);
Ok(next)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_exact_and_language_only_locales() {
assert_eq!(resolve_locale(Some("zh-CN")), "zh-CN");
assert_eq!(resolve_locale(Some("zh_SG")), "zh-CN");
assert_eq!(resolve_locale(Some("en-GB")), "en");
}
#[test]
fn unsupported_locales_fall_back_to_english() {
assert_eq!(resolve_locale(Some("ja-JP")), "en");
assert_eq!(resolve_locale(None), "en");
}
}
+4
View File
@@ -15,3 +15,7 @@ pub fn init(app_handle: &AppHandle) {
control_panel::init(app_handle);
tray::init(app_handle);
}
pub fn refresh_locale(app_handle: &AppHandle) {
tray::refresh_locale(app_handle);
}
+4 -1
View File
@@ -21,7 +21,10 @@ fn show(handle: &AppHandle, accessibility_only: bool) -> Result<(), String> {
};
let window =
tauri::WebviewWindowBuilder::new(handle, WINDOW_LABEL, WebviewUrl::App(page.into()))
.title("Friendolls Setup")
.title(crate::settings::text(
handle,
crate::settings::NativeText::OnboardingTitle,
))
.inner_size(680.0, 520.0)
.min_inner_size(680.0, 520.0)
.resizable(false)
+4 -1
View File
@@ -160,7 +160,10 @@ pub fn open_window(app_handle: &AppHandle) -> Result<tauri::WebviewWindow, Strin
WINDOW_LABEL,
WebviewUrl::App("/scene".into()),
)
.title("Scene")
.title(crate::settings::text(
app_handle,
crate::settings::NativeText::SceneTitle,
))
.inner_size(800.0, 600.0)
.resizable(false)
.maximizable(false)
+33 -4
View File
@@ -1,5 +1,5 @@
use tauri::{
AppHandle,
AppHandle, Manager, Wry,
menu::{Menu, MenuItem},
tray::TrayIconBuilder,
};
@@ -9,17 +9,28 @@ use super::control_panel;
const OPEN_CONTROL_PANEL_ID: &str = "open-control-panel";
const QUIT_ID: &str = "quit";
struct TrayMenuState {
open_control_panel: MenuItem<Wry>,
quit: MenuItem<Wry>,
}
pub fn init(handle: &AppHandle) {
let open_control_panel = MenuItem::with_id(
handle,
OPEN_CONTROL_PANEL_ID,
"Open Control Panel",
crate::settings::text(handle, crate::settings::NativeText::OpenControlPanel),
true,
None::<&str>,
)
.expect("Open Control Panel tray menu item should be created");
let quit = MenuItem::with_id(handle, QUIT_ID, "Quit", true, None::<&str>)
.expect("Quit tray menu item should be created");
let quit = MenuItem::with_id(
handle,
QUIT_ID,
crate::settings::text(handle, crate::settings::NativeText::Quit),
true,
None::<&str>,
)
.expect("Quit tray menu item should be created");
let menu = Menu::with_items(handle, &[&open_control_panel, &quit])
.expect("Tray menu should be created");
@@ -42,4 +53,22 @@ pub fn init(handle: &AppHandle) {
}
builder.build(handle).expect("Tray icon should be created");
handle.manage(TrayMenuState {
open_control_panel,
quit,
});
}
pub fn refresh_locale(handle: &AppHandle) {
let Some(items) = handle.try_state::<TrayMenuState>() else {
return;
};
let _ = items.open_control_panel.set_text(crate::settings::text(
handle,
crate::settings::NativeText::OpenControlPanel,
));
let _ = items.quit.set_text(crate::settings::text(
handle,
crate::settings::NativeText::Quit,
));
}
+1
View File
@@ -11,6 +11,7 @@ use specta::Type;
pub struct User {
pub id: String,
pub display_name: String,
pub display_name_configured: bool,
pub skin_hash: Option<String>,
}