diff --git a/package.json b/package.json index b19a79d..d651e48 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "auto-launch": "^5.0.5", + "counterpart": "^0.18.6", "electron-store": "^6.0.1", "electron-window-state": "^5.0.3", "minimist": "^1.2.3", diff --git a/src/electron-main.js b/src/electron-main.js index d60e01e..82d0c5b 100644 --- a/src/electron-main.js +++ b/src/electron-main.js @@ -34,7 +34,7 @@ const AutoLaunch = require('auto-launch'); const path = require('path'); const tray = require('./tray'); -const vectorMenu = require('./vectormenu'); +const buildMenuTemplate = require('./vectormenu'); const webContentsHandler = require('./webcontents-handler'); const updater = require('./updater'); const {getProfileFromDeeplink, protocolInit, recordSSOSession} = require('./protocol'); @@ -57,7 +57,7 @@ try { } } -const { _td } = require('./language-helper'); +const { _td, AppLocalization } = require('./language-helper'); let seshatSupported = false; let Seshat; @@ -88,6 +88,7 @@ let vectorConfig; let iconPath; let trayConfig; let launcher; +let appLocalization; if (argv["help"]) { console.log("Options:"); @@ -368,6 +369,9 @@ ipcMain.on('ipcCall', async function(ev, payload) { launcher.disable(); } break; + case 'setLanguage': + appLocalization.setAppLocale(args[0]); + break; case 'shouldWarnBeforeExit': ret = store.get('warnBeforeExit', true); break; @@ -942,7 +946,6 @@ app.on('ready', async () => { }, }); mainWindow.loadURL('vector://vector/webapp/'); - Menu.setApplicationMenu(vectorMenu); // Handle spellchecker // For some reason spellCheckerEnabled isn't persisted so we have to use the store here @@ -991,6 +994,14 @@ app.on('ready', async () => { } webContentsHandler(mainWindow.webContents); + + appLocalization = new AppLocalization({ + store, + components: [ + () => tray.initApplicationMenu(), + () => Menu.setApplicationMenu(buildMenuTemplate()), + ], + }); }); app.on('window-all-closed', () => { diff --git a/src/language-helper.js b/src/language-helper.js index 8fb03eb..58bb6c9 100644 --- a/src/language-helper.js +++ b/src/language-helper.js @@ -1,11 +1,113 @@ -// Function which only purpose is to mark that a string is translatable -// Does not actually do anything. It's helpful for automatic extraction of translatable strings +const counterpart = require('counterpart'); -function _td(s) { - return s; +const DEFAULT_LOCALE = "en"; + +function _td(text) { + return _t(text); +} + +function _t(text, variables = {}) { + const args = Object.assign({ interpolate: false }, variables); + + const { count } = args; + + // Horrible hack to avoid https://github.com/vector-im/element-web/issues/4191 + // The interpolation library that counterpart uses does not support undefined/null + // values and instead will throw an error. This is a problem since everywhere else + // in JS land passing undefined/null will simply stringify instead, and when converting + // valid ES6 template strings to i18n strings it's extremely easy to pass undefined/null + // if there are no existing null guards. To avoid this making the app completely inoperable, + // we'll check all the values for undefined/null and stringify them here. + Object.keys(args).forEach((key) => { + if (args[key] === undefined) { + console.warn("safeCounterpartTranslate called with undefined interpolation name: " + key); + args[key] = 'undefined'; + } + if (args[key] === null) { + console.warn("safeCounterpartTranslate called with null interpolation name: " + key); + args[key] = 'null'; + } + }); + let translated = counterpart.translate(text, args); + if (translated === undefined && count !== undefined) { + // counterpart does not do fallback if no pluralisation exists + // in the preferred language, so do it here + translated = counterpart.translate(text, Object.assign({}, args, {locale: DEFAULT_LOCALE})); + } + + // The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution) + return translated; +} + +class AppLocalization { + static STORE_KEY = "locale" + #store = null + + constructor({ store, components = [] }) { + counterpart.registerTranslations("en", this.fetchTranslationJson("en_EN")); + counterpart.setFallbackLocale('en'); + counterpart.setSeparator('|'); + + if (Array.isArray(components)) { + this.localizedComponents = new Set(components); + } + + this.#store = store; + if (this.#store.has(AppLocalization.STORE_KEY)) { + const locales = this.#store.get(AppLocalization.STORE_KEY); + this.setAppLocale(locales); + } + + this.resetLocalizedUI(); + } + + fetchTranslationJson(locale) { + try { + console.log("Fetching translation json for locale: " + locale); + return require(`./i18n/strings/${locale}.json`); + } catch (e) { + console.log(`Could not fetch translation json for locale: '${locale}'`, e); + return null; + } + } + + get languageTranslationJson() { + return this.translationJsonMap.get(this.language); + } + + setAppLocale(locales) { + console.log(`Changing application language to ${locales}`); + + if (!Array.isArray(locales)) { + locales = [locales]; + } + + locales.forEach(locale => { + const translations = this.fetchTranslationJson(locale); + if (translations !== null) { + counterpart.registerTranslations(locale, translations); + } + }); + + counterpart.setLocale(locales); + this.#store.set(AppLocalization.STORE_KEY, locales); + + this.resetLocalizedUI(); + } + + resetLocalizedUI() { + console.log("Resetting the UI components after locale change"); + this.localizedComponents.forEach(componentSetup => { + if (typeof componentSetup === "function") { + componentSetup(); + } + }); + } } module.exports = { + AppLocalization, + _t, _td, }; diff --git a/src/tray.js b/src/tray.js index 86e75ba..8e03462 100644 --- a/src/tray.js +++ b/src/tray.js @@ -34,39 +34,24 @@ exports.destroy = function() { } }; +const toggleWin = function() { + if (global.mainWindow.isVisible() && !global.mainWindow.isMinimized()) { + global.mainWindow.hide(); + } else { + if (global.mainWindow.isMinimized()) global.mainWindow.restore(); + if (!global.mainWindow.isVisible()) global.mainWindow.show(); + global.mainWindow.focus(); + } +}; + exports.create = function(config) { // no trays on darwin if (process.platform === 'darwin' || trayIcon) return; - - const toggleWin = function() { - if (global.mainWindow.isVisible() && !global.mainWindow.isMinimized()) { - global.mainWindow.hide(); - } else { - if (global.mainWindow.isMinimized()) global.mainWindow.restore(); - if (!global.mainWindow.isVisible()) global.mainWindow.show(); - global.mainWindow.focus(); - } - }; - - const contextMenu = Menu.buildFromTemplate([ - { - label: _td('Show/Hide'), - click: toggleWin, - }, - { type: 'separator' }, - { - label: _td('Quit'), - click: function() { - app.quit(); - }, - }, - ]); - const defaultIcon = nativeImage.createFromPath(config.icon_path); trayIcon = new Tray(defaultIcon); trayIcon.setToolTip(config.brand); - trayIcon.setContextMenu(contextMenu); + initApplicationMenu(); trayIcon.on('click', toggleWin); let lastFavicon = null; @@ -105,3 +90,27 @@ exports.create = function(config) { trayIcon.setToolTip(title); }); }; + +function initApplicationMenu() { + if (!trayIcon) { + return; + } + + const contextMenu = Menu.buildFromTemplate([ + { + label: _td('Show/Hide'), + click: toggleWin, + }, + { type: 'separator' }, + { + label: _td('Quit'), + click: function() { + app.quit(); + }, + }, + ]); + + trayIcon.setContextMenu(contextMenu); +} + +exports.initApplicationMenu = initApplicationMenu; diff --git a/src/vectormenu.js b/src/vectormenu.js index 1171d00..223d404 100644 --- a/src/vectormenu.js +++ b/src/vectormenu.js @@ -17,133 +17,137 @@ limitations under the License. const {app, shell, Menu} = require('electron'); const { _td } = require('./language-helper'); -// Menu template from http://electron.atom.io/docs/api/menu/, edited -const template = [ - { - label: _td('Edit'), - accelerator: 'e', - submenu: [ - { role: 'undo' }, - { role: 'redo' }, - { type: 'separator' }, - { role: 'cut' }, - { role: 'copy' }, - { role: 'paste' }, - { role: 'pasteandmatchstyle' }, - { role: 'delete' }, - { role: 'selectall' }, - ], - }, - { - label: _td('View'), - accelerator: 'V', - submenu: [ - { type: 'separator' }, - { role: 'resetzoom' }, - { role: 'zoomin', accelerator: 'CommandOrControl+=' }, - { role: 'zoomout' }, - { type: 'separator' }, - { - label: _td('Preferences'), - accelerator: 'Command+,', // Mac-only accelerator - click() { global.mainWindow.webContents.send('preferences'); }, - }, - { role: 'togglefullscreen' }, - { role: 'toggledevtools' }, - ], - }, - { - label: _td('Window'), - accelerator: 'w', - role: 'window', - submenu: [ - { role: 'minimize' }, - { role: 'close' }, - ], - }, - { - label: _td('Help'), - accelerator: 'h', - role: 'help', - submenu: [ - { - label: _td('Element Help'), - click() { shell.openExternal('https://element.io/help'); }, - }, - ], - }, -]; - -// macOS has specific menu conventions... -if (process.platform === 'darwin') { - template.unshift({ - // first macOS menu is the name of the app - label: app.name, - submenu: [ - { role: 'about' }, - { type: 'separator' }, - { - role: 'services', - submenu: [], - }, - { type: 'separator' }, - { role: 'hide' }, - { role: 'hideothers' }, - { role: 'unhide' }, - { type: 'separator' }, - { role: 'quit' }, - ], - }); - // Edit menu. - // This has a 'speech' section on macOS - template[1].submenu.push( - { type: 'separator' }, +function buildMenuTemplate() { + // Menu template from http://electron.atom.io/docs/api/menu/, edited + const template = [ { - label: _td('Speech'), + label: _td('Edit'), + accelerator: 'e', submenu: [ - { role: 'startspeaking' }, - { role: 'stopspeaking' }, + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'pasteandmatchstyle' }, + { role: 'delete' }, + { role: 'selectall' }, ], - }); - - // Window menu. - // This also has specific functionality on macOS - template[3].submenu = [ - { - label: _td('Close'), - accelerator: 'CmdOrCtrl+W', - role: 'close', }, { - label: _td('Minimize'), - accelerator: 'CmdOrCtrl+M', - role: 'minimize', + label: _td('View'), + accelerator: 'V', + submenu: [ + { type: 'separator' }, + { role: 'resetzoom' }, + { role: 'zoomin', accelerator: 'CommandOrControl+=' }, + { role: 'zoomout' }, + { type: 'separator' }, + { + label: _td('Preferences'), + accelerator: 'Command+,', // Mac-only accelerator + click() { global.mainWindow.webContents.send('preferences'); }, + }, + { role: 'togglefullscreen' }, + { role: 'toggledevtools' }, + ], }, { - label: _td('Zoom'), - role: 'zoom', + label: _td('Window'), + accelerator: 'w', + role: 'window', + submenu: [ + { role: 'minimize' }, + { role: 'close' }, + ], }, { - type: 'separator', - }, - { - label: _td('Bring All to Front'), - role: 'front', + label: _td('Help'), + accelerator: 'h', + role: 'help', + submenu: [ + { + label: _td('Element Help'), + click() { shell.openExternal('https://element.io/help'); }, + }, + ], }, ]; -} else { - template.unshift({ - label: _td('File'), - accelerator: 'f', - submenu: [ - // For some reason, 'about' does not seem to work on windows. - /*{ - role: 'about' - },*/ - { role: 'quit' }, - ], - }); + + // macOS has specific menu conventions... + if (process.platform === 'darwin') { + template.unshift({ + // first macOS menu is the name of the app + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { + role: 'services', + submenu: [], + }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideothers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }); + // Edit menu. + // This has a 'speech' section on macOS + template[1].submenu.push( + { type: 'separator' }, + { + label: _td('Speech'), + submenu: [ + { role: 'startspeaking' }, + { role: 'stopspeaking' }, + ], + }); + + // Window menu. + // This also has specific functionality on macOS + template[3].submenu = [ + { + label: _td('Close'), + accelerator: 'CmdOrCtrl+W', + role: 'close', + }, + { + label: _td('Minimize'), + accelerator: 'CmdOrCtrl+M', + role: 'minimize', + }, + { + label: _td('Zoom'), + role: 'zoom', + }, + { + type: 'separator', + }, + { + label: _td('Bring All to Front'), + role: 'front', + }, + ]; + } else { + template.unshift({ + label: _td('File'), + accelerator: 'f', + submenu: [ + // For some reason, 'about' does not seem to work on windows. + /*{ + role: 'about' + },*/ + { role: 'quit' }, + ], + }); + } + + return Menu.buildFromTemplate(template); } -module.exports = Menu.buildFromTemplate(template); +module.exports = buildMenuTemplate; diff --git a/yarn.lock b/yarn.lock index 3ad5c5e..d8b4e5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1597,6 +1597,17 @@ core-util-is@1.0.2, core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= +counterpart@^0.18.6: + version "0.18.6" + resolved "https://registry.yarnpkg.com/counterpart/-/counterpart-0.18.6.tgz#cf6b60d8ef99a4b44b8bf6445fa99b4bd1b2f9dd" + integrity sha512-cAIDAYbC3x8S2DDbvFEJ4TzPtPYXma25/kfAkfmprNLlkPWeX4SdUp1c2xklfphqCU3HnDaivR4R3BrAYf5OMA== + dependencies: + date-names "^0.1.11" + except "^0.1.3" + extend "^3.0.0" + pluralizers "^0.1.7" + sprintf-js "^1.0.3" + crc-32@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.0.tgz#cb2db6e29b88508e32d9dd0ec1693e7b41a18208" @@ -1683,6 +1694,11 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" +date-names@^0.1.11: + version "0.1.13" + resolved "https://registry.yarnpkg.com/date-names/-/date-names-0.1.13.tgz#c4358f6f77c8056e2f5ea68fdbb05f0bf1e53bd0" + integrity sha512-IxxoeD9tdx8pXVcmqaRlPvrXIsSrSrIZzfzlOkm9u+hyzKp5Wk/odt9O/gd7Ockzy8n/WHeEpTVJ2bF3mMV4LA== + debounce-fn@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/debounce-fn/-/debounce-fn-4.0.0.tgz#ed76d206d8a50e60de0dd66d494d82835ffe61c7" @@ -2472,6 +2488,13 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +except@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/except/-/except-0.1.3.tgz#98261c91958551536b44482238e9783fb73d292a" + integrity sha1-mCYckZWFUVNrREgiOOl4P7c9KSo= + dependencies: + indexof "0.0.1" + execa@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" @@ -2495,7 +2518,7 @@ exit-on-epipe@~1.0.1: resolved "https://registry.yarnpkg.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz#0bdd92e87d5285d267daa8171d0eb06159689692" integrity sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw== -extend@~3.0.2: +extend@^3.0.0, extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== @@ -3123,6 +3146,11 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= +indexof@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d" + integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10= + infer-owner@^1.0.3, infer-owner@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467" @@ -4948,6 +4976,11 @@ plist@^3.0.1: xmlbuilder "^9.0.7" xmldom "^0.5.0" +pluralizers@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/pluralizers/-/pluralizers-0.1.7.tgz#8d38dd0a1b660e739b10ab2eab10b684c9d50142" + integrity sha512-mw6AejUiCaMQ6uPN9ObjJDTnR5AnBSmnHHy3uVTbxrSFSxO5scfwpTs8Dxyb6T2v7GSulhvOq+pm9y+hXUvtOA== + png-to-ico@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/png-to-ico/-/png-to-ico-2.1.1.tgz#35be46f93c1ac8d77025f6f4b60c1fa567c1d47c" @@ -5708,6 +5741,11 @@ split-on-first@^1.0.0: resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f" integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw== +sprintf-js@^1.0.3: + version "1.1.2" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.2.tgz#da1765262bf8c0f571749f2ad6c26300207ae673" + integrity sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"