diff --git a/src/localization/__tests__/resolveSystemLanguage.test.js b/src/localization/__tests__/resolveSystemLanguage.test.js new file mode 100644 index 00000000..7b052fe8 --- /dev/null +++ b/src/localization/__tests__/resolveSystemLanguage.test.js @@ -0,0 +1,116 @@ +import { describe, expect, test } from 'vitest'; + +import { resolveSystemLanguage } from '../index'; +import { languageCodes } from '../locales'; + +describe('resolveSystemLanguage', () => { + describe('returns null for invalid input', () => { + test('empty string', () => { + expect(resolveSystemLanguage('', languageCodes)).toBeNull(); + }); + + test('null', () => { + expect(resolveSystemLanguage(null, languageCodes)).toBeNull(); + }); + + test('undefined', () => { + expect(resolveSystemLanguage(undefined, languageCodes)).toBeNull(); + }); + + test('unsupported language', () => { + expect(resolveSystemLanguage('de-DE', languageCodes)).toBeNull(); + }); + }); + + describe('exact match', () => { + test('zh-CN matches zh-CN', () => { + expect(resolveSystemLanguage('zh-CN', languageCodes)).toBe('zh-CN'); + }); + + test('zh-TW matches zh-TW', () => { + expect(resolveSystemLanguage('zh-TW', languageCodes)).toBe('zh-TW'); + }); + + test('en matches en', () => { + expect(resolveSystemLanguage('en', languageCodes)).toBe('en'); + }); + }); + + describe('prefix match', () => { + test('ja-JP matches ja', () => { + expect(resolveSystemLanguage('ja-JP', languageCodes)).toBe('ja'); + }); + + test('ko-KR matches ko', () => { + expect(resolveSystemLanguage('ko-KR', languageCodes)).toBe('ko'); + }); + + test('fr-FR matches fr', () => { + expect(resolveSystemLanguage('fr-FR', languageCodes)).toBe('fr'); + }); + + test('en-US matches en', () => { + expect(resolveSystemLanguage('en-US', languageCodes)).toBe('en'); + }); + + test('es-MX matches es', () => { + expect(resolveSystemLanguage('es-MX', languageCodes)).toBe('es'); + }); + + test('pt-BR matches pt', () => { + expect(resolveSystemLanguage('pt-BR', languageCodes)).toBe('pt'); + }); + + test('ru-RU matches ru', () => { + expect(resolveSystemLanguage('ru-RU', languageCodes)).toBe('ru'); + }); + }); + + describe('Chinese region-aware mapping', () => { + test('zh-HK maps to zh-TW (traditional)', () => { + expect(resolveSystemLanguage('zh-HK', languageCodes)).toBe('zh-TW'); + }); + + test('zh-MO maps to zh-TW (traditional)', () => { + expect(resolveSystemLanguage('zh-MO', languageCodes)).toBe('zh-TW'); + }); + + test('zh-SG maps to zh-CN (simplified)', () => { + expect(resolveSystemLanguage('zh-SG', languageCodes)).toBe('zh-CN'); + }); + + test('bare zh maps to zh-CN', () => { + expect(resolveSystemLanguage('zh', languageCodes)).toBe('zh-CN'); + }); + + test('zh-Hant maps to zh-TW (traditional script tag)', () => { + expect(resolveSystemLanguage('zh-Hant', languageCodes)).toBe( + 'zh-TW' + ); + }); + + test('zh-Hans maps to zh-CN (simplified script tag)', () => { + expect(resolveSystemLanguage('zh-Hans', languageCodes)).toBe( + 'zh-CN' + ); + }); + + test('zh-Hant-HK maps to zh-TW (script + region)', () => { + expect(resolveSystemLanguage('zh-Hant-HK', languageCodes)).toBe( + 'zh-TW' + ); + }); + + test('zh-Hans-CN maps to zh-CN (script + region)', () => { + expect(resolveSystemLanguage('zh-Hans-CN', languageCodes)).toBe( + 'zh-CN' + ); + }); + + test('zh-Hant-MO maps to zh-TW (script + traditional region)', () => { + expect(resolveSystemLanguage('zh-Hant-MO', languageCodes)).toBe( + 'zh-TW' + ); + }); + }); +}); diff --git a/src/localization/cs.json b/src/localization/cs.json index 97ec0e36..646f0137 100644 --- a/src/localization/cs.json +++ b/src/localization/cs.json @@ -43,6 +43,10 @@ "devEndpoint": "Dev Endpoint", "endpoint": "Endpoint", "websocket": "WebSocket" + }, + "language_detect": { + "title": "Detekován jazyk", + "description": "Váš systémový jazyk je {language}. Chcete na něj přepnout?" } }, "feed": { diff --git a/src/localization/en.json b/src/localization/en.json index 61c1903a..1387c677 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -215,6 +215,10 @@ "devEndpoint": "Dev Endpoint", "endpoint": "Endpoint", "websocket": "WebSocket" + }, + "language_detect": { + "title": "Language Detected", + "description": "Your system language appears to be {language}. Would you like to switch to it?" } }, "feed": { @@ -2680,7 +2684,7 @@ "table": { "header_menu": { "reset_all": "Reset All", - "lock_column_order": "Lock Column Order" + "lock_column_order": "Lock Column" }, "pagination": { "rows_per_page": "Rows per page", diff --git a/src/localization/es.json b/src/localization/es.json index 319020a1..215d0a12 100644 --- a/src/localization/es.json +++ b/src/localization/es.json @@ -45,6 +45,10 @@ "devEndpoint": "Punto Final de Desarrollo", "endpoint": "Punto Final", "websocket": "WebSocket" + }, + "language_detect": { + "title": "Idioma detectado", + "description": "El idioma de su sistema parece ser {language}. ¿Le gustaría cambiar a él?" } }, "feed": { diff --git a/src/localization/fr.json b/src/localization/fr.json index 91cd7d43..d93cb3cd 100644 --- a/src/localization/fr.json +++ b/src/localization/fr.json @@ -43,6 +43,10 @@ "devEndpoint": "Dev Endpoint", "endpoint": "Endpoint", "websocket": "WebSocket" + }, + "language_detect": { + "title": "Langue détectée", + "description": "La langue de votre système semble être {language}. Souhaitez-vous y passer ?" } }, "feed": { diff --git a/src/localization/hu.json b/src/localization/hu.json index 64109f5b..37d50c0e 100644 --- a/src/localization/hu.json +++ b/src/localization/hu.json @@ -43,6 +43,10 @@ "devEndpoint": "Fejlesztői végpont", "endpoint": "Végpont", "websocket": "WebSocket" + }, + "language_detect": { + "title": "Nyelv észlelve", + "description": "A rendszernyelv úgy tűnik, hogy {language}. Szeretne átváltani rá?" } }, "feed": { diff --git a/src/localization/index.js b/src/localization/index.js index dccdb370..8e6995e0 100644 --- a/src/localization/index.js +++ b/src/localization/index.js @@ -4,29 +4,25 @@ const localizedStringsUrls = import.meta.glob('./*.json', { import: 'default' }); -async function fetchJson(url) { - const response = await fetch(url); - if (!response.ok) { - console.warn(`Failed to fetch localization: ${response.status}`); - } - return response.json(); -} - async function getLocalizedStrings(code) { const fallbackUrl = localizedStringsUrls['./en.json']; - const localizedStringsUrl = - localizedStringsUrls[`./${code}.json`] || fallbackUrl; + const url = localizedStringsUrls[`./${code}.json`] || fallbackUrl; - let localizedStrings = {}; try { - localizedStrings = await fetchJson(localizedStringsUrl); + const res = await fetch(url); + if (!res.ok) throw new Error(res.status); + return await res.json(); } catch { - if (localizedStringsUrl !== fallbackUrl) { - localizedStrings = await fetchJson(fallbackUrl).catch(() => ({})); + if (url !== fallbackUrl) { + try { + const res = await fetch(fallbackUrl); + return await res.json(); + } catch { + return {}; + } } + return {}; } - - return localizedStrings; } const languageNames = import.meta.glob('./*.json', { @@ -38,5 +34,45 @@ function getLanguageName(code) { return String(languageNames[`./${code}.json`] ?? code); } +/** + * @param {string} systemLanguage - BCP-47 code from AppApi.CurrentLanguage() + * @param {string[]} codes - supported language codes + * @returns {string | null} matched language code, or null + */ +function resolveSystemLanguage(systemLanguage, codes) { + if (!systemLanguage) return null; + + // Exact match (e.g. zh-CN → zh-CN) + if (codes.includes(systemLanguage)) { + return systemLanguage; + } + + const lang = systemLanguage.split('-')[0]; + + // Chinese: script-tag and region-aware mapping + // BCP-47 forms: zh-CN, zh-TW, zh-Hant, zh-Hans, zh-Hant-HK, zh-Hans-CN, etc. + if (lang === 'zh') { + const parts = systemLanguage.split('-').slice(1); // everything after 'zh' + const hasHant = parts.includes('Hant'); + const hasHans = parts.includes('Hans'); + const traditionalRegions = ['TW', 'HK', 'MO']; + const hasTraditionalRegion = parts.some((p) => + traditionalRegions.includes(p) + ); + + if (hasHant || hasTraditionalRegion) { + return codes.includes('zh-TW') ? 'zh-TW' : null; + } + if (hasHans) { + return codes.includes('zh-CN') ? 'zh-CN' : null; + } + // Bare 'zh' or unknown region (e.g. zh-SG) → simplified + return codes.includes('zh-CN') ? 'zh-CN' : null; + } + + // Generic prefix match (e.g. ja-JP → ja) + return codes.find((code) => code.split('-')[0] === lang) ?? null; +} + export * from './locales'; -export { getLanguageName, getLocalizedStrings }; +export { getLanguageName, getLocalizedStrings, resolveSystemLanguage }; diff --git a/src/localization/ja.json b/src/localization/ja.json index e6025efa..730c5b9f 100644 --- a/src/localization/ja.json +++ b/src/localization/ja.json @@ -99,6 +99,10 @@ "devEndpoint": "開発者用エンドポイント", "endpoint": "エンドポイント", "websocket": "ウェブソケット" + }, + "language_detect": { + "title": "言語を検出しました", + "description": "システム言語は {language} のようです。切り替えますか?" } }, "feed": { diff --git a/src/localization/ko.json b/src/localization/ko.json index c5c9b8f3..885159ea 100644 --- a/src/localization/ko.json +++ b/src/localization/ko.json @@ -43,6 +43,10 @@ "devEndpoint": "개발자용 API", "endpoint": "Endpoint", "websocket": "WebSocket" + }, + "language_detect": { + "title": "언어 감지됨", + "description": "시스템 언어가 {language}인 것 같습니다. 전환하시겠습니까?" } }, "feed": { diff --git a/src/localization/pl.json b/src/localization/pl.json index abce5108..4876fd96 100644 --- a/src/localization/pl.json +++ b/src/localization/pl.json @@ -79,6 +79,10 @@ "devEndpoint": "Tryb deweloperski", "endpoint": "Endpoint", "websocket": "WebSocket" + }, + "language_detect": { + "title": "Wykryto język", + "description": "Język systemu to {language}. Czy chcesz na niego przełączyć?" } }, "feed": { diff --git a/src/localization/pt.json b/src/localization/pt.json index 6dcf87c8..020fdaa3 100644 --- a/src/localization/pt.json +++ b/src/localization/pt.json @@ -43,6 +43,10 @@ "devEndpoint": "Dev Endpoint", "endpoint": "Ponto final", "websocket": "WebSocket" + }, + "language_detect": { + "title": "Idioma detectado", + "description": "O idioma do seu sistema parece ser {language}. Gostaria de mudar para ele?" } }, "feed": { diff --git a/src/localization/ru.json b/src/localization/ru.json index 2ea7c67d..7d1b4200 100644 --- a/src/localization/ru.json +++ b/src/localization/ru.json @@ -46,6 +46,10 @@ "devEndpoint": "Разработчик", "endpoint": "Endpoint", "websocket": "WebSocket" + }, + "language_detect": { + "title": "Обнаружен язык", + "description": "Язык вашей системы — {language}. Хотите переключиться на него?" } }, "feed": { diff --git a/src/localization/th.json b/src/localization/th.json index 788c8761..aece2ba8 100644 --- a/src/localization/th.json +++ b/src/localization/th.json @@ -45,6 +45,10 @@ "devEndpoint": "Dev Endpoint", "endpoint": "Endpoint", "websocket": "WebSocket" + }, + "language_detect": { + "title": "ตรวจพบภาษา", + "description": "ภาษาของระบบของคุณดูเหมือนจะเป็น {language} คุณต้องการเปลี่ยนไปใช้หรือไม่?" } }, "feed": { diff --git a/src/localization/vi.json b/src/localization/vi.json index 8eea2cc9..9992ccda 100644 --- a/src/localization/vi.json +++ b/src/localization/vi.json @@ -43,6 +43,10 @@ "devEndpoint": "Dev Endpoint", "endpoint": "Endpoint", "websocket": "WebSocket" + }, + "language_detect": { + "title": "Đã phát hiện ngôn ngữ", + "description": "Ngôn ngữ hệ thống của bạn có vẻ là {language}. Bạn có muốn chuyển sang không?" } }, "feed": { diff --git a/src/localization/zh-CN.json b/src/localization/zh-CN.json index 38f6dfda..1ca06dc5 100644 --- a/src/localization/zh-CN.json +++ b/src/localization/zh-CN.json @@ -99,6 +99,10 @@ "devEndpoint": "自定义 API 接口", "endpoint": "接口地址", "websocket": "WebSocket" + }, + "language_detect": { + "title": "检测到语言", + "description": "您的系统语言似乎是 {language}。是否切换到该语言?" } }, "feed": { diff --git a/src/localization/zh-TW.json b/src/localization/zh-TW.json index fb2042d7..d519cb1b 100644 --- a/src/localization/zh-TW.json +++ b/src/localization/zh-TW.json @@ -99,6 +99,10 @@ "devEndpoint": "開發介面", "endpoint": "Endpoint", "websocket": "WebSocket" + }, + "language_detect": { + "title": "偵測到語言", + "description": "您的系統語言似乎是 {language}。是否切換到該語言?" } }, "feed": { diff --git a/src/plugins/i18n.js b/src/plugins/i18n.js index 28b98b38..559e6c1c 100644 --- a/src/plugins/i18n.js +++ b/src/plugins/i18n.js @@ -28,4 +28,17 @@ async function updateLocalizedStrings() { await loadLocalizedStrings(i18n.global.locale.value); } -export { i18n, loadLocalizedStrings, updateLocalizedStrings }; +/** + * Translate a single key using a specific locale without switching global UI language. + * + * @param {string} locale + * @param {string} key + * @param {import('vue-i18n').NamedValue=} params + * @returns {Promise} + */ +async function tForLocale(locale, key, params = {}) { + await loadLocalizedStrings(locale); + return i18n.global.t(key, params, { locale }); +} + +export { i18n, loadLocalizedStrings, tForLocale, updateLocalizedStrings }; diff --git a/src/stores/settings/appearance.js b/src/stores/settings/appearance.js index 7426e86d..2deb4957 100644 --- a/src/stores/settings/appearance.js +++ b/src/stores/settings/appearance.js @@ -25,7 +25,7 @@ import { } from '../../shared/utils/base/ui'; import { computeTrustLevel, getNameColour } from '../../shared/utils'; import { database } from '../../services/database'; -import { languageCodes } from '../../localization'; + import { loadLocalizedStrings } from '../../plugins'; import { useFeedStore } from '../feed'; import { useGameLogStore } from '../gameLog'; @@ -262,19 +262,15 @@ export const useAppearanceSettingsStore = defineStore( ) ]); - if (!appLanguageConfig) { - const result = await AppApi.CurrentLanguage(); - - const lang = result.split('-')[0]; - - for (const ref of languageCodes) { - const refLang = ref.split('_')[0]; - if (refLang === lang) { - await changeAppLanguage(ref); - } - } - } else { + if (appLanguageConfig) { await changeAppLanguage(appLanguageConfig); + } else { + // First launch: load en in-memory only, do NOT persist. + // Login.vue detectAndPromptLanguage() will handle first-time language selection. + await loadLocalizedStrings('en'); + appLanguage.value = 'en'; + locale.value = 'en'; + changeHtmlLangAttribute('en'); } themeMode.value = initThemeMode; diff --git a/src/views/Login/Login.vue b/src/views/Login/Login.vue index 6e572125..80614659 100644 --- a/src/views/Login/Login.vue +++ b/src/views/Login/Login.vue @@ -202,9 +202,12 @@ import { useI18n } from 'vue-i18n'; import { z } from 'zod'; - import { useAppearanceSettingsStore, useAuthStore, useVrcStatusStore, useVRCXUpdaterStore } from '../../stores'; - import { getLanguageName, languageCodes } from '../../localization'; + import { useAppearanceSettingsStore, useAuthStore, useModalStore, useVrcStatusStore, useVRCXUpdaterStore } from '../../stores'; + import { getLanguageName, languageCodes, resolveSystemLanguage } from '../../localization'; + import { tForLocale } from '../../plugins'; import { openExternalLink } from '../../shared/utils'; + + import configRepository from '../../services/config'; import { useUserDisplay } from '../../composables/useUserDisplay'; import { watchState } from '../../services/watchState'; @@ -221,6 +224,7 @@ const appearanceSettingsStore = useAppearanceSettingsStore(); const { appLanguage } = storeToRefs(appearanceSettingsStore); const { changeAppLanguage } = appearanceSettingsStore; + const modalStore = useModalStore(); const vrcStatusStore = useVrcStatusStore(); @@ -311,12 +315,80 @@ } } ); + let isActive = true; + let isLanguagePromptOpen = false; + + async function detectAndPromptLanguage() { + try { + const savedLanguage = await configRepository.getString('VRCX_appLanguage'); + if (savedLanguage || !isActive) return; + + const systemLanguage = await AppApi.CurrentLanguage(); + if (!systemLanguage || !isActive) return; + + const matchedCode = resolveSystemLanguage(systemLanguage, languageCodes); + + if (!matchedCode || matchedCode === 'en') { + if (isActive) await changeAppLanguage('en'); + return; + } + + const languageName = getLanguageName(matchedCode); + const [ + promptTitle, + promptDescription, + promptConfirmText, + promptCancelText + ] = await Promise.all([ + tForLocale(matchedCode, 'view.login.language_detect.title'), + tForLocale( + matchedCode, + 'view.login.language_detect.description', + { + language: languageName + } + ), + tForLocale(matchedCode, 'dialog.alertdialog.confirm'), + tForLocale(matchedCode, 'dialog.alertdialog.cancel') + ]); + + isLanguagePromptOpen = true; + const { ok } = await modalStore.confirm({ + title: promptTitle, + description: promptDescription, + confirmText: promptConfirmText, + cancelText: promptCancelText + }); + isLanguagePromptOpen = false; + + if (!isActive) return; + + // Re-check: user may have manually switched language while the dialog was open + const currentLanguage = await configRepository.getString('VRCX_appLanguage'); + if (currentLanguage || !isActive) return; + + if (ok) { + await changeAppLanguage(matchedCode); + } else { + await changeAppLanguage('en'); + } + } catch (error) { + isLanguagePromptOpen = false; + console.error('Language detection failed:', error); + } + } onBeforeMount(async () => { updateSavedCredentials(); + detectAndPromptLanguage(); }); onBeforeUnmount(() => { + isActive = false; + if (isLanguagePromptOpen) { + modalStore.handleCancel(); + isLanguagePromptOpen = false; + } resetForm({ values: { username: '',