chinese localization!!
This commit is contained in:
@@ -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';
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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(¤t(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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user