diff --git a/src/js/i18n/i18n.ts b/src/js/i18n/i18n.ts index e0c80f70..893c082c 100644 --- a/src/js/i18n/i18n.ts +++ b/src/js/i18n/i18n.ts @@ -1,5 +1,8 @@ import i18next from 'i18next'; import HttpBackend from 'i18next-http-backend'; +import { getStoredItem, setStoredItem } from '../utils/safe-storage.js'; +import enCommon from '../../../public/locales/en/common.json'; +import enTools from '../../../public/locales/en/tools.json'; // Supported languages export const supportedLanguages = [ @@ -73,7 +76,7 @@ export const getLanguageFromUrl = (): SupportedLanguage => { return langMatch[1] as SupportedLanguage; } - const storedLang = localStorage.getItem('i18nextLng'); + const storedLang = getStoredItem('i18nextLng'); if ( storedLang && supportedLanguages.includes(storedLang as SupportedLanguage) @@ -110,7 +113,7 @@ export const initI18n = async (): Promise => { const currentLang = getLanguageFromUrl(); - localStorage.setItem('i18nextLng', currentLang); + setStoredItem('i18nextLng', currentLang); await i18next.use(HttpBackend).init({ lng: currentLang, @@ -119,6 +122,13 @@ export const initI18n = async (): Promise => { ns: ['common', 'tools'], defaultNS: 'common', preload: [currentLang], + partialBundledLanguages: true, + resources: { + en: { + common: enCommon, + tools: enTools, + }, + }, backend: { loadPath: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}locales/{{lng}}/{{ns}}.json`, }, @@ -139,7 +149,7 @@ export const t = (key: string, options?: Record): string => { export const changeLanguage = (lang: SupportedLanguage): void => { if (!supportedLanguages.includes(lang)) return; - localStorage.setItem('i18nextLng', lang); + setStoredItem('i18nextLng', lang); const basePath = import.meta.env.BASE_URL.replace(/\/$/, ''); let relativePath = window.location.pathname; diff --git a/src/js/main.ts b/src/js/main.ts index 755aae23..2e3411b1 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -22,6 +22,11 @@ import { isToolDisabled, isCurrentPageDisabled, } from './utils/disabled-tools.js'; +import { + getStoredItem, + setStoredItem, + removeStoredItem, +} from './utils/safe-storage.js'; declare const __BRAND_NAME__: string; const init = async () => { @@ -290,17 +295,14 @@ const init = async () => { let collapsedCategories: string[] = []; try { - const stored = localStorage.getItem('collapsedCategories'); + const stored = getStoredItem('collapsedCategories'); if (stored) collapsedCategories = JSON.parse(stored); } catch { - localStorage.removeItem('collapsedCategories'); + removeStoredItem('collapsedCategories'); } function saveCollapsedCategories() { - localStorage.setItem( - 'collapsedCategories', - JSON.stringify(collapsedCategories) - ); + setStoredItem('collapsedCategories', JSON.stringify(collapsedCategories)); } const filteredCategories = categories @@ -615,7 +617,7 @@ const init = async () => { ) as HTMLInputElement; const toolInterface = document.getElementById('tool-interface'); - const savedFullWidth = localStorage.getItem('fullWidthMode') !== 'false'; + const savedFullWidth = getStoredItem('fullWidthMode') !== 'false'; if (fullWidthToggle) { fullWidthToggle.checked = savedFullWidth; applyFullWidthMode(savedFullWidth); @@ -652,7 +654,7 @@ const init = async () => { if (fullWidthToggle) { fullWidthToggle.addEventListener('change', (e) => { const enabled = (e.target as HTMLInputElement).checked; - localStorage.setItem('fullWidthMode', enabled.toString()); + setStoredItem('fullWidthMode', enabled.toString()); applyFullWidthMode(enabled); }); } @@ -661,7 +663,7 @@ const init = async () => { 'compact-mode-toggle' ) as HTMLInputElement; - const savedCompactMode = localStorage.getItem('compactMode') === 'true'; + const savedCompactMode = getStoredItem('compactMode') === 'true'; if (compactModeToggle) { compactModeToggle.checked = savedCompactMode; } @@ -681,7 +683,7 @@ const init = async () => { if (compactModeToggle) { compactModeToggle.addEventListener('change', (e) => { const enabled = (e.target as HTMLInputElement).checked; - localStorage.setItem('compactMode', enabled.toString()); + setStoredItem('compactMode', enabled.toString()); applyCompactMode(enabled); }); } diff --git a/src/js/utils/safe-storage.ts b/src/js/utils/safe-storage.ts new file mode 100644 index 00000000..a8149d6d --- /dev/null +++ b/src/js/utils/safe-storage.ts @@ -0,0 +1,25 @@ +export const getStoredItem = (key: string): string | null => { + try { + return localStorage.getItem(key); + } catch { + return null; + } +}; + +export const setStoredItem = (key: string, value: string): boolean => { + try { + localStorage.setItem(key, value); + return true; + } catch { + return false; + } +}; + +export const removeStoredItem = (key: string): boolean => { + try { + localStorage.removeItem(key); + return true; + } catch { + return false; + } +}; diff --git a/src/tests/i18n-resilience.test.ts b/src/tests/i18n-resilience.test.ts new file mode 100644 index 00000000..0eb1a990 --- /dev/null +++ b/src/tests/i18n-resilience.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const blockStorage = () => { + Object.defineProperty(window, 'localStorage', { + configurable: true, + get() { + throw new DOMException('The operation is insecure.', 'SecurityError'); + }, + }); +}; + +const failLocaleFetches = () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new TypeError('NetworkError'))) + ); +}; + +describe('i18n resilience on hardened browsers', () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + }); + + it('getLanguageFromUrl does not throw when localStorage access is blocked', async () => { + blockStorage(); + const { getLanguageFromUrl } = await import('../js/i18n/i18n'); + expect(() => getLanguageFromUrl()).not.toThrow(); + expect(getLanguageFromUrl()).toBe('en'); + }); + + it('initI18n succeeds with blocked storage and failing locale fetches', async () => { + blockStorage(); + failLocaleFetches(); + const { initI18n, t } = await import('../js/i18n/i18n'); + await expect(initI18n()).resolves.toBeDefined(); + expect(t('simpleMode.title')).not.toBe('simpleMode.title'); + }); + + it('t() serves bundled English instead of raw keys when fetches fail', async () => { + failLocaleFetches(); + const { initI18n, t } = await import('../js/i18n/i18n'); + await initI18n(); + expect(t('simpleMode.title')).toBe('PDF Tools'); + expect(t('tools:pdfToTiff.name')).toBe('PDF to TIFF'); + expect(t('common.error')).not.toBe('common.error'); + }); + + it('changeLanguage does not throw when storage is blocked', async () => { + blockStorage(); + const { changeLanguage } = await import('../js/i18n/i18n'); + expect(() => changeLanguage('de')).not.toThrow(); + }); +});