diff --git a/locales/version.d.ts b/locales/version.d.ts new file mode 100644 index 0000000000..54ceec7443 --- /dev/null +++ b/locales/version.d.ts @@ -0,0 +1 @@ +export const localesVersion: string; diff --git a/locales/version.js b/locales/version.js new file mode 100644 index 0000000000..e84414b74d --- /dev/null +++ b/locales/version.js @@ -0,0 +1,14 @@ +import { createHash } from 'crypto'; +import locales from './index.js'; + +// MD5 is acceptable because we don't need cryptographic security. +const hash = createHash('md5'); + +// Derive the version hash from locale content exclusively. +// This avoids the problem of "stuck" translations after modifying locale files. +const localesText = JSON.stringify(locales); +hash.update(localesText, 'utf8'); + +// We can't use regular base64 since this becomes part of a filename. +// Base64URL avoids special characters that would cause an issue. +export const localesVersion = hash.digest().toString('base64url'); diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 38e37ce093..193bfa9585 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -33,8 +33,17 @@ return; } + // Force update when locales change + const langsVersion = LANGS_VERSION; + const localeVersion = localStorage.getItem('localeVersion'); + if (localeVersion !== langsVersion) { + console.info(`Updating locales from version ${localeVersion ?? 'N/A'} to ${langsVersion}`); + localStorage.removeItem('localeVersion'); + localStorage.removeItem('locale'); + } + //#region Detect language & fetch translations - if (!localStorage.hasOwnProperty('locale')) { + if (!localStorage.getItem('locale')) { const supportedLangs = LANGS; let lang = localStorage.getItem('lang'); if (lang == null || !supportedLangs.includes(lang)) { @@ -48,37 +57,17 @@ } } - const metaRes = await window.fetch('/api/meta', { - method: 'POST', - body: JSON.stringify({}), - credentials: 'omit', - cache: 'no-cache', - headers: { - 'Content-Type': 'application/json', - }, - }); - if (metaRes.status !== 200) { - renderError('META_FETCH'); - return; - } - const meta = await metaRes.json(); - const v = meta.version; - if (v == null) { - renderError('META_FETCH_V'); - return; - } - // for https://github.com/misskey-dev/misskey/issues/10202 if (lang == null || lang.toString == null || lang.toString() === 'null') { console.error('invalid lang value detected!!!', typeof lang, lang); lang = 'en-US'; } - const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`); + const localRes = await window.fetch(`/assets/locales/${lang}.${langsVersion}.json`); if (localRes.status === 200) { localStorage.setItem('lang', lang); localStorage.setItem('locale', await localRes.text()); - localStorage.setItem('localeVersion', v); + localStorage.setItem('localeVersion', langsVersion); } else { renderError('LOCALE_FETCH'); return; diff --git a/packages/frontend/@types/global.d.ts b/packages/frontend/@types/global.d.ts index 1025d1bedb..15373cbd2d 100644 --- a/packages/frontend/@types/global.d.ts +++ b/packages/frontend/@types/global.d.ts @@ -6,6 +6,7 @@ type FIXME = any; declare const _LANGS_: string[][]; +declare const _LANGS_VERSION_: string; declare const _VERSION_: string; declare const _ENV_: string; declare const _DEV_: boolean; diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 94040c6413..9e1856cf35 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -8,7 +8,7 @@ import { compareVersions } from 'compare-versions'; import widgets from '@/widgets/index.js'; import directives from '@/directives/index.js'; import components from '@/components/index.js'; -import { version, lang, updateLocale, locale } from '@/config.js'; +import { version, lang, updateLocale, locale, langsVersion } from '@/config.js'; import { applyTheme } from '@/scripts/theme.js'; import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js'; import { updateI18n } from '@/i18n.js'; @@ -80,14 +80,15 @@ export async function common(createVue: () => App) { //#region Detect language & fetch translations const localeVersion = miLocalStorage.getItem('localeVersion'); - const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null); + const localeOutdated = (localeVersion == null || localeVersion !== langsVersion || locale == null); if (localeOutdated) { - const res = await window.fetch(`/assets/locales/${lang}.${version}.json`); + console.info(`Updating locales from version ${localeVersion ?? 'N/A'} to ${langsVersion}`); + const res = await window.fetch(`/assets/locales/${lang}.${langsVersion}.json`); if (res.status === 200) { const newLocale = await res.text(); const parsedNewLocale = JSON.parse(newLocale); miLocalStorage.setItem('locale', newLocale); - miLocalStorage.setItem('localeVersion', version); + miLocalStorage.setItem('localeVersion', langsVersion); updateLocale(parsedNewLocale); updateI18n(parsedNewLocale); } diff --git a/packages/frontend/src/config.ts b/packages/frontend/src/config.ts index e3922a0cd5..0ad356b7ed 100644 --- a/packages/frontend/src/config.ts +++ b/packages/frontend/src/config.ts @@ -15,6 +15,7 @@ export const apiUrl = location.origin + '/api'; export const wsOrigin = location.origin; export const lang = miLocalStorage.getItem('lang') ?? 'en-US'; export const langs = _LANGS_; +export const langsVersion = _LANGS_VERSION_; const preParseLocale = miLocalStorage.getItem('locale'); export let locale = preParseLocale ? JSON.parse(preParseLocale) : null; export const version = _VERSION_; diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 674fdbf680..513cb7e8bd 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -2,7 +2,7 @@ import path from 'path'; import pluginReplace from '@rollup/plugin-replace'; import pluginVue from '@vitejs/plugin-vue'; import { type UserConfig, defineConfig } from 'vite'; - +import { localesVersion } from '../../locales/version.js'; import locales from '../../locales/index.js'; import meta from '../../package.json'; import packageInfo from './package.json' with { type: 'json' }; @@ -110,6 +110,7 @@ export function getConfig(): UserConfig { define: { _VERSION_: JSON.stringify(meta.version), _LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])), + _LANGS_VERSION_: JSON.stringify(localesVersion), _ENV_: JSON.stringify(process.env.NODE_ENV), _DEV_: process.env.NODE_ENV !== 'production', _PERF_PREFIX_: JSON.stringify('Misskey:'), diff --git a/scripts/build-assets.mjs b/scripts/build-assets.mjs index fcf29cef22..b451dab28d 100644 --- a/scripts/build-assets.mjs +++ b/scripts/build-assets.mjs @@ -15,6 +15,7 @@ import { build as buildLocales } from '../locales/index.js'; import generateDTS from '../locales/generateDTS.js'; import meta from '../package.json' with { type: "json" }; import buildTarball from './tarball.mjs'; +import { localesVersion } from '../locales/version.js'; const configDir = fileURLToPath(new URL('../.config', import.meta.url)); const configPath = process.env.MISSKEY_CONFIG_YML @@ -56,10 +57,10 @@ async function copyFrontendLocales() { await fs.mkdir('./built/_frontend_dist_/locales', { recursive: true }); - const v = { '_version_': meta.version }; + const v = { '_version_': localesVersion }; for (const [lang, locale] of Object.entries(locales)) { - await fs.writeFile(`./built/_frontend_dist_/locales/${lang}.${meta.version}.json`, JSON.stringify({ ...locale, ...v }), 'utf-8'); + await fs.writeFile(`./built/_frontend_dist_/locales/${lang}.${localesVersion}.json`, JSON.stringify({ ...locale, ...v }), 'utf-8'); } } @@ -76,7 +77,8 @@ async function buildBackendScript() { './packages/backend/src/server/web/cli.js' ]) { let source = await fs.readFile(file, { encoding: 'utf-8' }); - source = source.replaceAll('LANGS', JSON.stringify(Object.keys(locales))); + source = source.replaceAll(/\bLANGS\b/g, JSON.stringify(Object.keys(locales))); + source = source.replaceAll(/\bLANGS_VERSION\b/g, JSON.stringify(localesVersion)); const { code } = await terser.minify(source, { toplevel: true }); await fs.writeFile(`./packages/backend/built/server/web/${path.basename(file)}`, code); }