diff --git a/Composer/package.json b/Composer/package.json index 11519b32ac..f73d7a06f0 100644 --- a/Composer/package.json +++ b/Composer/package.json @@ -41,7 +41,7 @@ "build:dev": "yarn build:test && yarn build:lib && yarn build:tools && yarn build:extensions && yarn build:plugins && yarn l10n", "build:test": "yarn workspace @bfc/test-utils build", "build:lib": "yarn workspace @bfc/libs build:all", - "build:electron": "yarn workspace @bfc/electron-server build", + "build:electron": "yarn workspace @bfc/electron-server build && yarn workspace @bfc/electron-server l10n", "build:extensions": "wsrun -lt -p @bfc/extension @bfc/intellisense @bfc/extension-client @bfc/adaptive-form @bfc/adaptive-flow @bfc/ui-plugin-* -c build", "build:server": "yarn workspace @bfc/server build", "build:client": "yarn workspace @bfc/client build", @@ -74,7 +74,7 @@ "l10n:extract": "cross-env NODE_ENV=production format-message extract -g underscored_crc32 -o packages/server/src/locales/en-US.json l10ntemp/**/*.js", "l10n:extractJson": "node scripts/l10n-extractJson.js", "l10n:transform": "node scripts/l10n-transform.js", - "l10n:babel": "babel ./packages --extensions \".ts,.tsx,.jsx,.js\" --out-dir l10ntemp --presets=@babel/react,@babel/typescript --plugins=@babel/plugin-proposal-class-properties --ignore \"packages/**/__tests__\",\"packages/**/node_modules\",\"packages/**/build/**/*.js\"", + "l10n:babel": "babel ./packages --extensions \".ts,.tsx,.jsx,.js\" --out-dir l10ntemp --presets=@babel/react,@babel/typescript --plugins=@babel/plugin-proposal-class-properties --ignore \"packages/electron-server\",\"packages/**/__tests__\",\"packages/**/node_modules\",\"packages/**/build/**/*.js\"", "l10n": "yarn l10n:babel && yarn l10n:extract && yarn l10n:transform packages/server/src/locales/en-US.json && yarn l10n:extractJson packages/server/schemas" }, "husky": { diff --git a/Composer/packages/client/src/recoilModel/dispatchers/user.ts b/Composer/packages/client/src/recoilModel/dispatchers/user.ts index 6b2256cd91..257d4235b0 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/user.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/user.ts @@ -10,6 +10,7 @@ import { getUserTokenFromCache, loginPopup, refreshToken } from '../../utils/aut import storage from '../../utils/storage'; import { loadLocale } from '../../utils/fileUtil'; import { UserSettingsPayload } from '../types'; +import { isElectron } from '../../utils/electronUtil'; enum ClaimNames { upn = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn', @@ -89,6 +90,12 @@ export const userDispatcher = () => { } } storage.set('userSettings', newSettings); + + if (isElectron()) { + // push the settings to the electron main process + window.ipcRenderer.send('update-user-settings', newSettings); + } + return newSettings; }); } diff --git a/Composer/packages/electron-server/.gitignore b/Composer/packages/electron-server/.gitignore index edd9d60a7a..03a0ab5993 100644 --- a/Composer/packages/electron-server/.gitignore +++ b/Composer/packages/electron-server/.gitignore @@ -1,2 +1,4 @@ build/ dist/ +locales/en-US-pseudo.json +l10ntemp/ diff --git a/Composer/packages/electron-server/electron-builder-config.json b/Composer/packages/electron-server/electron-builder-config.json index dd065273b9..580e5fa3e9 100644 --- a/Composer/packages/electron-server/electron-builder-config.json +++ b/Composer/packages/electron-server/electron-builder-config.json @@ -18,6 +18,7 @@ ], "files": [ "build", + "locales", "resources/composerIcon_1024x1024.png", "resources/ms_logo.svg" ], diff --git a/Composer/packages/electron-server/locales/en-US.json b/Composer/packages/electron-server/locales/en-US.json new file mode 100644 index 0000000000..fae3220af4 --- /dev/null +++ b/Composer/packages/electron-server/locales/en-US.json @@ -0,0 +1,125 @@ +{ + "about_70c18bba": { + "message": "About" + }, + "actual_zoom_211ec99": { + "message": "Actual Zoom" + }, + "bring_all_to_front_207a7ad1": { + "message": "Bring All to Front" + }, + "check_for_updates_12a70061": { + "message": "Check for Updates" + }, + "close_d634289d": { + "message": "Close" + }, + "close_window_4bdc79d7": { + "message": "Close Window" + }, + "composer_on_github_1e3782ef": { + "message": "Composer on GitHub" + }, + "copy_9748f9f": { + "message": "Copy" + }, + "cut_c8c92681": { + "message": "Cut" + }, + "delete_a6efa79d": { + "message": "Delete" + }, + "documentation_d82f6eec": { + "message": "Documentation" + }, + "edit_c5fbea07": { + "message": "Edit" + }, + "exit_d0c7b013": { + "message": "Exit" + }, + "file_c851020d": { + "message": "File" + }, + "help_4c4968b1": { + "message": "Help" + }, + "hide_bot_framework_composer_eeba3ea8": { + "message": "Hide Bot Framework Composer" + }, + "hide_others_4204b552": { + "message": "Hide Others" + }, + "initializing_4e34f7d": { + "message": "Initializing..." + }, + "learn_more_about_bot_framework_2daca065": { + "message": "Learn More About Bot Framework" + }, + "minimize_4f999e30": { + "message": "Minimize" + }, + "paste_5963d1c1": { + "message": "Paste" + }, + "privacy_statement_58986965": { + "message": "Privacy Statement" + }, + "quit_bot_framework_composer_455ceb74": { + "message": "Quit Bot Framework Composer" + }, + "redo_363c58b7": { + "message": "Redo" + }, + "report_an_issue_f33f3ba5": { + "message": "Report an Issue" + }, + "select_all_cf7e0248": { + "message": "Select All" + }, + "services_ccd7bca0": { + "message": "Services" + }, + "show_all_ae37d610": { + "message": "Show All" + }, + "speech_16063aed": { + "message": "Speech" + }, + "start_speaking_f8d406ff": { + "message": "Start Speaking" + }, + "starting_server_721c37fd": { + "message": "Starting server..." + }, + "stop_speaking_7b4b9834": { + "message": "Stop Speaking" + }, + "toggle_developer_tools_d2ef8ea7": { + "message": "Toggle Developer Tools" + }, + "toggle_full_screen_272a0c5": { + "message": "Toggle Full Screen" + }, + "undo_a7be8fef": { + "message": "Undo" + }, + "view_ba339f93": { + "message": "View" + }, + "view_license_720d341a": { + "message": "View License" + }, + "window_171922c7": { + "message": "Window" + }, + "zoom_f3e54d69": { + "message": "Zoom" + }, + "zoom_in_a781ccc7": { + "message": "Zoom In" + }, + "zoom_out_dc7d60d2": { + "message": "Zoom Out" + } +} \ No newline at end of file diff --git a/Composer/packages/electron-server/package.json b/Composer/packages/electron-server/package.json index cda4e29bfc..2e9a057412 100644 --- a/Composer/packages/electron-server/package.json +++ b/Composer/packages/electron-server/package.json @@ -10,7 +10,7 @@ }, "scripts": { "build": "tsc -p tsconfig.build.json && ncp src/preload.js build/preload.js", - "clean": "rimraf build && rimraf dist", + "clean": "rimraf build && rimraf dist && rimraf l10ntemp", "copy-plugins": "node scripts/copy-plugins.js", "copy-runtime": "node scripts/copy-runtime.js", "dist": "node scripts/electronBuilderDist.js", @@ -22,7 +22,12 @@ "start:electron": "./node_modules/.bin/electron --inspect=7777 --remote-debugging-port=7778 .", "start:electron:dev": "cross-env NODE_ENV=development ELECTRON_TARGET_URL=http://localhost:3000/ npm run start:electron", "test": "jest", - "test:watch": "jest --watch" + "test:watch": "jest --watch", + "l10n:extract": "cross-env NODE_ENV=production format-message extract -g underscored_crc32 -o locales/en-US.json l10ntemp/**/*.js", + "l10n:extractJson": "node ../../scripts/l10n-extractJson.js", + "l10n:transform": "node ../../scripts/l10n-transform.js", + "l10n:babel": "babel . --extensions \".ts,.tsx,.jsx,.js\" --out-dir l10ntemp --presets=@babel/typescript --plugins=@babel/plugin-proposal-class-properties --ignore \"**/__tests__\",\"**/node_modules\",\"**/build/**/*.js\"", + "l10n": "yarn l10n:babel && yarn l10n:extract && yarn l10n:transform locales/en-US.json && rimraf l10ntemp" }, "devDependencies": { "@types/archiver": "^3.1.0", @@ -59,6 +64,8 @@ "debug": "4.1.1", "electron-updater": "4.2.5", "fix-path": "^3.0.0", + "format-message": "^6.2.3", + "format-message-generate-id": "^6.2.3", "fs-extra": "^9.0.0", "lodash": "^4.17.19", "semver": "7.3.2" diff --git a/Composer/packages/electron-server/src/appMenu.ts b/Composer/packages/electron-server/src/appMenu.ts index c45fc570f5..edeccb18e5 100644 --- a/Composer/packages/electron-server/src/appMenu.ts +++ b/Composer/packages/electron-server/src/appMenu.ts @@ -1,10 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { app, dialog, Menu, MenuItemConstructorOptions, shell, ipcMain } from 'electron'; +import { app, dialog, ipcMain, Menu, MenuItemConstructorOptions, shell } from 'electron'; +import formatMessage from 'format-message'; -import { isMac } from './utility/platform'; import { AppUpdater } from './appUpdater'; +import { isMac } from './utility/platform'; function getAppMenu(): MenuItemConstructorOptions[] { if (isMac()) { @@ -12,13 +13,13 @@ function getAppMenu(): MenuItemConstructorOptions[] { { label: 'Bot Framework Composer', submenu: [ - { role: 'services' }, + { role: 'services', label: formatMessage('Services') }, { type: 'separator' }, - { label: 'Hide Bot Framework Composer', role: 'hide' }, - { role: 'hideOthers' }, - { role: 'unhide' }, + { role: 'hide', label: formatMessage('Hide Bot Framework Composer') }, + { role: 'hideOthers', label: formatMessage('Hide Others') }, + { role: 'unhide', label: formatMessage('Show All') }, { type: 'separator' }, - { label: 'Quit Bot Framework Composer', role: 'quit' }, + { label: formatMessage('Quit Bot Framework Composer'), role: 'quit' }, ], }, ]; @@ -31,19 +32,27 @@ function getRestOfEditMenu(): MenuItemConstructorOptions[] { return [ { type: 'separator' }, { - label: 'Speech', - submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }], + label: formatMessage('Speech'), + submenu: [ + { role: 'startSpeaking', label: formatMessage('Start Speaking') }, + { role: 'stopSpeaking', label: formatMessage('Stop Speaking') }, + ], }, ]; } - return [{ type: 'separator' }, { role: 'selectAll' }]; + return [{ type: 'separator' }, { role: 'selectAll', label: formatMessage('Select All') }]; } function getRestOfWindowMenu(): MenuItemConstructorOptions[] { if (isMac()) { - return [{ type: 'separator' }, { role: 'front' }, { type: 'separator' }, { role: 'window' }]; + return [ + { type: 'separator' }, + { role: 'front', label: formatMessage('Bring All to Front') }, + { type: 'separator' }, + { role: 'window', label: formatMessage('Window') }, + ]; } - return [{ role: 'close' }]; + return [{ role: 'close', label: formatMessage('Close') }]; } export function initAppMenu(win?: Electron.BrowserWindow) { @@ -59,53 +68,57 @@ export function initAppMenu(win?: Electron.BrowserWindow) { ...getAppMenu(), // File { - label: 'File', - submenu: [isMac() ? { role: 'close' } : { role: 'quit' }], + label: formatMessage('File'), + submenu: [ + isMac() + ? { role: 'close', label: formatMessage('Close Window') } + : { role: 'quit', label: formatMessage('Exit') }, + ], }, // Edit { - label: 'Edit', + label: formatMessage('Edit'), submenu: [ { id: 'Undo', - label: 'Undo', + label: formatMessage('Undo'), enabled: false, accelerator: 'CmdOrCtrl+Z', click: () => handleMenuEvents('undo'), }, { id: 'Redo', - label: 'Redo', + label: formatMessage('Redo'), enabled: false, accelerator: 'CmdOrCtrl+Shift+Z', click: () => handleMenuEvents('redo'), }, { type: 'separator' }, - // Native mode shorcuts + // Native mode shortcuts { id: 'Cut-native', - label: 'Cut', + label: formatMessage('Cut'), role: 'cut', }, { id: 'Copy-native', - label: 'Copy', + label: formatMessage('Copy'), role: 'copy', }, { id: 'Paste-native', - label: 'Paste', + label: formatMessage('Paste'), role: 'paste', }, { id: 'Delete-native', - label: 'Delete', + label: formatMessage('Delete'), role: 'delete', }, // Action editing mode shortcuts { id: 'Cut', - label: 'Cut', + label: formatMessage('Cut'), enabled: false, visible: false, accelerator: 'CmdOrCtrl+X', @@ -113,7 +126,7 @@ export function initAppMenu(win?: Electron.BrowserWindow) { }, { id: 'Copy', - label: 'Copy', + label: formatMessage('Copy'), enabled: false, visible: false, accelerator: 'CmdOrCtrl+C', @@ -121,7 +134,7 @@ export function initAppMenu(win?: Electron.BrowserWindow) { }, { id: 'Delete', - label: 'Delete', + label: formatMessage('Delete'), enabled: false, visible: false, accelerator: 'Delete', @@ -132,73 +145,77 @@ export function initAppMenu(win?: Electron.BrowserWindow) { }, // View { - label: 'View', + label: formatMessage('View'), submenu: [ - { role: 'toggleDevTools' }, + { role: 'toggleDevTools', label: formatMessage('Toggle Developer Tools') }, { type: 'separator' }, - { role: 'resetZoom' }, - { role: 'zoomIn' }, - { role: 'zoomOut' }, + { role: 'resetZoom', label: formatMessage('Actual Zoom') }, + { role: 'zoomIn', label: formatMessage('Zoom In') }, + { role: 'zoomOut', label: formatMessage('Zoom Out') }, { type: 'separator' }, - { role: 'togglefullscreen' }, + { role: 'togglefullscreen', label: formatMessage('Toggle Full Screen') }, ], }, // Window { - label: 'Window', - submenu: [{ role: 'minimize' }, { role: 'zoom' }, ...getRestOfWindowMenu()], + label: formatMessage('Window'), + submenu: [ + { role: 'minimize', label: formatMessage('Minimize') }, + { role: 'zoom', label: formatMessage('Zoom') }, + ...getRestOfWindowMenu(), + ], }, { - label: 'Help', + label: formatMessage('Help'), submenu: [ { - label: 'Documentation', + label: formatMessage('Documentation'), click: async () => { await shell.openExternal('https://docs.microsoft.com/en-us/composer/'); }, }, { - label: 'Composer on GitHub', + label: formatMessage('Composer on GitHub'), click: async () => { await shell.openExternal('https://aka.ms/BotFrameworkComposer'); }, }, { - label: 'Learn More About Bot Framework', + label: formatMessage('Learn More About Bot Framework'), click: async () => { await shell.openExternal('https://dev.botframework.com/'); }, }, { type: 'separator' }, { - label: 'Report an Issue', + label: formatMessage('Report an Issue'), click: async () => { await shell.openExternal('https://github.com/microsoft/BotFramework-Composer/issues/new/choose'); }, }, { type: 'separator' }, { - label: 'View License', + label: formatMessage('View License'), click: async () => { await shell.openExternal('https://aka.ms/bfcomposer-license'); }, }, { - label: 'Privacy Statement', + label: formatMessage('Privacy Statement'), click: async () => { await shell.openExternal('https://aka.ms/bfcomposer-privacy'); }, }, { type: 'separator' }, { - label: 'Check for Updates', + label: formatMessage('Check for Updates'), click: () => { AppUpdater.getInstance().checkForUpdates(true); }, }, { type: 'separator' }, { - label: 'About', + label: formatMessage('About'), click: async () => { // show dialog with name and version dialog.showMessageBox({ diff --git a/Composer/packages/electron-server/src/main.ts b/Composer/packages/electron-server/src/main.ts index 875f8f555e..a8158bf41d 100644 --- a/Composer/packages/electron-server/src/main.ts +++ b/Composer/packages/electron-server/src/main.ts @@ -8,6 +8,7 @@ import { app, ipcMain } from 'electron'; import { UpdateInfo } from 'electron-updater'; import fixPath from 'fix-path'; import { mkdirp } from 'fs-extra'; +import formatMessage from 'format-message'; import { initAppMenu } from './appMenu'; import { AppUpdater } from './appUpdater'; @@ -16,12 +17,14 @@ import ElectronWindow from './electronWindow'; import { initSplashScreen } from './splash/splashScreen'; import { isDevelopment } from './utility/env'; import { getUnpackedAsarPath } from './utility/getUnpackedAsarPath'; +import { loadLocale, getAppLocale, updateAppLocale } from './utility/locale'; import log from './utility/logger'; import { getAccessToken, loginAndGetIdToken, OAuthLoginOptions } from './utility/oauthImplicitFlowHelper'; import { isMac, isWindows } from './utility/platform'; import { parseDeepLinkUrl } from './utility/url'; const microsoftLogoPath = join(__dirname, '../resources/ms_logo.svg'); +let currentAppLocale = getAppLocale().appLocale; const error = log.extend('error'); let deeplinkUrl = ''; @@ -100,7 +103,7 @@ function initializeAppUpdater(settings: AppUpdaterSettings) { appUpdater.quitAndInstall(); } }); - ipcMain.on('update-user-settings', (_ev, settings: UserSettings) => { + ipcMain.on('update-user-settings', async (_ev, settings: UserSettings) => { appUpdater.setSettings(settings.appUpdater); }); app.once('quit', () => { @@ -179,6 +182,18 @@ async function main(show = false) { } } +const checkAppLocale = (newAppLocale: string) => { + // If the app locale changes, load the new locale, re-create the menu and persist the new value. + if (currentAppLocale !== newAppLocale) { + log('Reloading locale'); + loadLocale(newAppLocale); + initAppMenu(ElectronWindow.getInstance().browserWindow); + + updateAppLocale(newAppLocale); + currentAppLocale = newAppLocale; + } +}; + async function run() { fixPath(); // required PATH fix for Mac (https://github.com/electron/electron/issues/5626) @@ -205,6 +220,10 @@ async function run() { app.on('ready', async () => { log('App ready'); + + log('Loading latest known locale'); + loadLocale(currentAppLocale); + const getMainWindow = () => ElectronWindow.getInstance().browserWindow; const { startApp, updateStatus } = await initSplashScreen({ getMainWindow, @@ -212,23 +231,29 @@ async function run() { logo: `file://${microsoftLogoPath}`, productName: 'Bot Framework Composer', productFamily: 'Microsoft Azure', - status: 'Initializing...', + status: formatMessage('Initializing...'), website: 'www.botframework.com', width: 500, height: 300, }); - updateStatus('Starting server...'); + updateStatus(formatMessage('Starting server...')); await loadServer(); await main(); - setTimeout(startApp, 500); - ipcMain.once('init-user-settings', (_ev, settings: UserSettings) => { + // Check app locale for changes + checkAppLocale(settings.appLocale); // we can't synchronously call the main process (due to deadlocks) // so we wait for the initial settings to be loaded from the client initializeAppUpdater(settings.appUpdater); }); + + ipcMain.on('update-user-settings', (_ev, settings: UserSettings) => { + checkAppLocale(settings.appLocale); + }); + + setTimeout(startApp, 500); }); // Quit when all windows are closed. diff --git a/Composer/packages/electron-server/src/utility/fs.ts b/Composer/packages/electron-server/src/utility/fs.ts new file mode 100644 index 0000000000..f64650da38 --- /dev/null +++ b/Composer/packages/electron-server/src/utility/fs.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as path from 'path'; + +import { existsSync, mkdirp, mkdirpSync, readFileSync, writeFile, writeFileSync } from 'fs-extra'; + +export const ensureDirectory = async (dirPath: string) => await mkdirp(dirPath); + +export const ensureJsonFileSync = (filePath: string, defaultContent: { [key: string]: {} }) => { + if (!existsSync(filePath)) { + mkdirpSync(path.dirname(filePath)); + writeFileSync(filePath, JSON.stringify(defaultContent, null, 4), { encoding: 'utf8' }); + } +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const writeJsonFileSync = async (jsonFilePath: string, content: Record) => { + await ensureDirectory(path.dirname(jsonFilePath)); + return await writeFile(jsonFilePath, JSON.stringify(content, null, 4), { encoding: 'utf8' }); +}; + +export const readTextFileSync = (filePath: string) => readFileSync(filePath, { encoding: 'utf8' }); diff --git a/Composer/packages/electron-server/src/utility/locale.ts b/Composer/packages/electron-server/src/utility/locale.ts new file mode 100644 index 0000000000..690044c7b3 --- /dev/null +++ b/Composer/packages/electron-server/src/utility/locale.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as path from 'path'; + +import formatMessage from 'format-message'; +import generate from 'format-message-generate-id'; +import { UserSettings } from '@bfc/shared'; +import { app } from 'electron'; + +import { ensureDirectory, ensureJsonFileSync, readTextFileSync, writeJsonFileSync } from './fs'; +import log from './logger'; + +const defaultAppLocale = { appLocale: 'en-US' }; +export const appLocaleFilePath = path.join(app.getPath('userData'), 'appLocale.json'); + +/** + * Returns the persisted app locale or en-US as default. + */ +export const getAppLocale = (): Pick => { + ensureJsonFileSync(appLocaleFilePath, defaultAppLocale); + const raw = readTextFileSync(appLocaleFilePath); + return JSON.parse(raw) as UserSettings; +}; + +/** + * Updates persisted app locale. + * @param appLocale New app locale. + */ +export const updateAppLocale = async (appLocale: string) => { + const directory = path.parse(appLocaleFilePath).dir; + await ensureDirectory(directory); + await writeJsonFileSync(appLocaleFilePath, { appLocale }); +}; + +/** + * Initializes the format-message library with the translations for the given locale. + * @param locale New locale to be used. + */ +export const loadLocale = (locale: string) => { + if (locale) { + // we're changing the locale, which might fail if we can't load it + const raw = readTextFileSync(path.join(__dirname, `../../locales/${locale}.json`)); + const data = raw ? JSON.parse(raw) : raw; + if (typeof data === 'object' && data !== null) { + // We don't care about the return value except in our unit tests + return formatMessage.setup({ + locale: locale, + generateId: generate.underscored_crc32, + missingTranslation: process.env.NODE_ENV === 'development' ? 'warning' : 'ignore', + translations: { + [locale]: data, + }, + }); + } + } + + // this is an invalid locale, so don't set anything + log('Tried to read an invalid locale'); + return null; +}; diff --git a/Composer/packages/server/src/router/api.ts b/Composer/packages/server/src/router/api.ts index 2137b815b9..9b2b90aa67 100644 --- a/Composer/packages/server/src/router/api.ts +++ b/Composer/packages/server/src/router/api.ts @@ -68,7 +68,7 @@ router.post('/runtime/eject/:projectId/:template', EjectController.eject); //assets router.get('/assets/projectTemplates', AssetController.getProjTemplates); -router.use('/assets/locales/', express.static(path.join(__dirname, '..', '/locales'))); +router.use('/assets/locales/', express.static(path.join(__dirname, '..', '..', 'src', 'locales'))); //help api router.get('/utilities/qna/parse', UtilitiesController.getQnaContent);