diff --git a/Composer/packages/client/src/components/BotRuntimeController/OpenEmulatorButton.tsx b/Composer/packages/client/src/components/BotRuntimeController/OpenEmulatorButton.tsx index 0561664f53..457695c0df 100644 --- a/Composer/packages/client/src/components/BotRuntimeController/OpenEmulatorButton.tsx +++ b/Composer/packages/client/src/components/BotRuntimeController/OpenEmulatorButton.tsx @@ -22,7 +22,7 @@ export const OpenEmulatorButton: React.FC = ({ projectI const { openBotInEmulator } = useRecoilValue(dispatcherState); const currentBotStatus = useRecoilValue(botStatusState(projectId)); const botEndpoints = useRecoilValue(botEndpointsState); - const endpoint = botEndpoints[projectId]; + const endpoint = botEndpoints[projectId]?.url; const handleClick = () => { openBotInEmulator(projectId); diff --git a/Composer/packages/client/src/components/Notifications/NotificationCard.tsx b/Composer/packages/client/src/components/Notifications/NotificationCard.tsx index a39519b89f..a9c2274c4b 100644 --- a/Composer/packages/client/src/components/Notifications/NotificationCard.tsx +++ b/Composer/packages/client/src/components/Notifications/NotificationCard.tsx @@ -36,7 +36,7 @@ const cardContainer = (show: boolean, ref?: HTMLDivElement | null) => () => { border-left: 4px solid #0078d4; background: white; box-shadow: 0 6.4px 14.4px 0 rgba(0, 0, 0, 0.132), 0 1.2px 3.6px 0 rgba(0, 0, 0, 0.108); - width: 340px; + min-width: 340px; border-radius: 2px; display: flex; flex-direction: column; diff --git a/Composer/packages/client/src/components/Notifications/TunnelingSetupNotification.tsx b/Composer/packages/client/src/components/Notifications/TunnelingSetupNotification.tsx new file mode 100644 index 0000000000..6d3f0e7e87 --- /dev/null +++ b/Composer/packages/client/src/components/Notifications/TunnelingSetupNotification.tsx @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import React from 'react'; +import formatMessage from 'format-message'; +import { IconButton, IButtonStyles } from 'office-ui-fabric-react/lib/Button'; +import { NeutralColors, FontSizes, FluentTheme } from '@uifabric/fluent-theme'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { FontWeights } from '@uifabric/styling'; + +import { platform, OS } from '../../utils/os'; + +import { CardProps } from './NotificationCard'; + +const container = css` + padding: 0 16px 16px 40px; + position: relative; +`; + +const commandContainer = css` + display: flex; + flex-flow: row nowrap; + position: relative; + padding: 4px 28px 4px 8px; + background-color: ${NeutralColors.gray20}; + line-height: 22px; + margin: 1rem 0; +`; + +const copyContainer = css` + margin: 0; + margin-bottom: 4px; + font-size: ${FontSizes.size16}; + font-weight: ${FontWeights.semibold}; +`; + +const copyIconColor = FluentTheme.palette.themeDark; +const copyIconStyles: IButtonStyles = { + root: { position: 'absolute', right: 0, color: copyIconColor, height: '22px' }, + rootHovered: { backgroundColor: 'transparent', color: copyIconColor }, + rootPressed: { backgroundColor: 'transparent', color: copyIconColor }, +}; + +const linkContainer = css` + margin: 0; +`; + +const getNgrok = () => { + const os = platform(); + if (os === OS.Windows) { + return 'ngrok.exe'; + } + + return 'ngrok'; +}; + +export const TunnelingSetupNotification: React.FC = (props) => { + const { title, data } = props; + const port = data?.port; + const command = `${getNgrok()} http ${port} --host-header=localhost`; + + const copyLocationToClipboard = async () => { + try { + await window.navigator.clipboard.writeText(command); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Something went wrong when trying to copy the command to clipboard.', e); + } + }; + + return ( +
+

{title}

+

+ {formatMessage.rich('Install ngrok and run the following command to continue', { + a: ({ children }) => ( + + {children} + + ), + })} +

+
+ {command} + +
+

+ + {formatMessage('Learn more')} + +

+
+ ); +}; diff --git a/Composer/packages/client/src/constants.tsx b/Composer/packages/client/src/constants.tsx index 5a11a49a15..436a8ba723 100644 --- a/Composer/packages/client/src/constants.tsx +++ b/Composer/packages/client/src/constants.tsx @@ -519,3 +519,6 @@ export const defaultTeamsManifest: TeamsManifest = { permissions: ['identity', 'messageTeamMembers'], validDomains: ['token.botframework.com'], }; + +export const defaultBotPort = 3979; +export const defaultBotEndpoint = `http://localhost:${defaultBotPort}/api/messages`; diff --git a/Composer/packages/client/src/recoilModel/atoms/appState.ts b/Composer/packages/client/src/recoilModel/atoms/appState.ts index 1048781b47..41fa73da1d 100644 --- a/Composer/packages/client/src/recoilModel/atoms/appState.ts +++ b/Composer/packages/client/src/recoilModel/atoms/appState.ts @@ -184,7 +184,7 @@ export const runtimeSettingsState = atom<{ }, }); -export const botEndpointsState = atom>({ +export const botEndpointsState = atom>({ key: getFullyQualifiedKey('botEndpoints'), default: {}, }); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/skill.test.ts b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/skill.test.ts index 851e10e264..e47319d49a 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/skill.test.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/skill.test.ts @@ -184,8 +184,8 @@ describe('skill dispatcher', () => { it('should update setting.skill on local skills with "Composer Local" chosen as endpoint', async () => { await act(async () => { const botEndpoints = {}; - botEndpoints[`${skillIds[0]}`] = 'http://localhost:3978/api/messages'; - botEndpoints[`${skillIds[1]}`] = 'http://localhost:3979/api/messages'; + botEndpoints[`${skillIds[0]}`] = { url: 'http://localhost:3978/api/messages', port: 3978 }; + botEndpoints[`${skillIds[1]}`] = { url: 'http://localhost:3979/api/messages', port: 3979 }; renderedComponent.current.setters.setBotEndpoints(botEndpoints); renderedComponent.current.setters.setTodoSkillData({ location: '/Users/tester/Desktop/LoadedBotProject/Todo-Skill', diff --git a/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts b/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts index 41c8f6997d..ac05f62912 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts @@ -15,6 +15,7 @@ import { filePersistenceState, settingsState, runtimeStandardOutputDataState, + botProjectFileState, } from '../atoms/botState'; import { openInEmulator } from '../../utils/navigation'; import { botEndpointsState } from '../atoms'; @@ -30,11 +31,13 @@ import { ClientStorage } from '../../utils/storage'; import { RuntimeOutputData } from '../types'; import { checkIfFunctionsMissing, missingFunctionsError } from '../../utils/runtimeErrors'; import TelemetryClient from '../../telemetry/TelemetryClient'; +import { TunnelingSetupNotification } from '../../components/Notifications/TunnelingSetupNotification'; -import { BotStatus, Text } from './../../constants'; +import { BotStatus, Text, defaultBotEndpoint, defaultBotPort } from './../../constants'; import httpClient from './../../utils/httpUtil'; import { logMessage, setError } from './shared'; import { setRootBotSettingState } from './setting'; +import { createNotification, addNotificationInternal } from './notification'; const PUBLISH_SUCCESS = 200; const PUBLISH_PENDING = 202; @@ -62,11 +65,14 @@ export const publisherDispatcher = () => { const publishSuccess = async ({ set }: CallbackInterface, projectId: string, data: PublishResult, target) => { TelemetryClient.track('PublishSuccess'); - const { endpointURL, status } = data; + const { endpointURL, status, port } = data; if (target.name === defaultPublishConfig.name) { if (status === PUBLISH_SUCCESS && endpointURL) { set(botStatusState(projectId), BotStatus.connected); - set(botEndpointsState, (botEndpoints) => ({ ...botEndpoints, [projectId]: `${endpointURL}/api/messages` })); + set(botEndpointsState, (botEndpoints) => ({ + ...botEndpoints, + [projectId]: { url: `${endpointURL}/api/messages`, port: port || defaultBotPort }, + })); } else { set(botStatusState(projectId), BotStatus.starting); } @@ -95,7 +101,7 @@ export const publisherDispatcher = () => { ) => { if (data == null) return; const { set, snapshot } = callbackHelpers; - const { endpointURL, status } = data; + const { endpointURL, status, port } = data; // remove job id in publish storage if published if (status === PUBLISH_SUCCESS || status === PUBLISH_FAILED) { @@ -119,11 +125,34 @@ export const publisherDispatcher = () => { }; setRootBotSettingState(callbackHelpers, projectId, updatedSettings); } + + // display a notification for bots with remote skills the first time they are published + // for a given session. + const rootBotProjectFile = await snapshot.getPromise(botProjectFileState(rootBotId)); + const notificationCache = publishStorage.get('notifications') || {}; + if ( + !notificationCache[rootBotId] && + Object.values(rootBotProjectFile?.content?.skills ?? []).some((s) => s.remote) + ) { + const notification = createNotification({ + type: 'info', + title: formatMessage('Setup tunneling software to test your remote skill'), + onRenderCardContent: TunnelingSetupNotification, + data: { + port, + }, + }); + addNotificationInternal(callbackHelpers, notification); + publishStorage.set('notifications', { + ...notificationCache, + [rootBotId]: true, + }); + } } set(botStatusState(projectId), BotStatus.connected); set(botEndpointsState, (botEndpoints) => ({ ...botEndpoints, - [projectId]: `${endpointURL}/api/messages`, + [projectId]: { url: `${endpointURL}/api/messages`, port: port || defaultBotPort }, })); } else if (status === PUBLISH_PENDING) { set(botStatusState(projectId), BotStatus.starting); @@ -308,7 +337,7 @@ export const publisherDispatcher = () => { const settings = await snapshot.getPromise(settingsState(projectId)); try { openInEmulator( - botEndpoints[projectId] || 'http://localhost:3979/api/messages', + botEndpoints[projectId]?.url || defaultBotEndpoint, settings.MicrosoftAppId && settings.MicrosoftAppPassword ? { MicrosoftAppId: settings.MicrosoftAppId, MicrosoftAppPassword: settings.MicrosoftAppPassword } : { MicrosoftAppPassword: '', MicrosoftAppId: '' } diff --git a/Composer/packages/client/src/recoilModel/dispatchers/skill.ts b/Composer/packages/client/src/recoilModel/dispatchers/skill.ts index 137ff6c9d2..84487802b7 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/skill.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/skill.ts @@ -41,13 +41,13 @@ export const skillDispatcher = () => { const currentSetting = await snapshot.getPromise(settingsState(projectId)); // Update settings only for skills that have chosen the "Composer local" endpoint and not manifest endpoints - if (projectId && botEndpoints[projectId] && !botProjectSkill.endpointName) { + if (projectId && botEndpoints[projectId]?.url && !botProjectSkill.endpointName) { updatedSettings = produce(updatedSettings, (draftState) => { if (!draftState.skill) { draftState.skill = {}; } draftState.skill[skillNameIdentifier] = { - endpointUrl: botEndpoints[projectId], + endpointUrl: botEndpoints[projectId].url, msAppId: currentSetting.MicrosoftAppId ?? '', }; }); diff --git a/Composer/packages/client/src/recoilModel/selectors/project.ts b/Composer/packages/client/src/recoilModel/selectors/project.ts index ea0583e238..9cf5dae6eb 100644 --- a/Composer/packages/client/src/recoilModel/selectors/project.ts +++ b/Composer/packages/client/src/recoilModel/selectors/project.ts @@ -376,7 +376,7 @@ export const webChatEssentialsSelector = selectorFamily { - const userAgent = window.navigator.userAgent, - platform = window.navigator.platform, - macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'], - windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE'], - iosPlatforms = ['iPhone', 'iPad', 'iPod']; - const os = { - isMacintosh: false, - isLinux: false, - isWindows: false, - isAndroid: false, - isiOS: false, - }; - - if (macosPlatforms.indexOf(platform) !== -1) { - os.isMacintosh = true; - } else if (iosPlatforms.indexOf(platform) !== -1) { - os.isiOS = true; - } else if (windowsPlatforms.indexOf(platform) !== -1) { - os.isWindows = true; - } else if (/Android/.test(userAgent)) { - os.isAndroid = true; - } else if (/Linux/.test(platform)) { - os.isLinux = true; - } - return os; -}; +import { platform, OS } from '../../utils/os'; // font align with vscode https://github.com/microsoft/vscode/blob/main/src/vs/editor/common/config/editorOptions.ts#L3680 const DEFAULT_WINDOWS_FONT_FAMILY = "Consolas, 'Courier New', monospace"; @@ -35,13 +9,14 @@ const DEFAULT_MAC_FONT_FAMILY = "Menlo, Monaco, 'Courier New', monospace"; const DEFAULT_LINUX_FONT_FAMILY = "'Droid Sans Mono', 'monospace', monospace, 'Droid Sans Fallback'"; export const getDefaultFontSettings = () => { - const PLATFORM = getOS(); + const platformName = platform(); return { - fontFamily: PLATFORM.isMacintosh - ? DEFAULT_MAC_FONT_FAMILY - : PLATFORM.isLinux - ? DEFAULT_LINUX_FONT_FAMILY - : DEFAULT_WINDOWS_FONT_FAMILY, + fontFamily: + platformName === OS.MacOS + ? DEFAULT_MAC_FONT_FAMILY + : platformName === OS.Linux + ? DEFAULT_LINUX_FONT_FAMILY + : DEFAULT_WINDOWS_FONT_FAMILY, fontWeight: 'normal', fontSize: '14px', lineHeight: 0, diff --git a/Composer/packages/client/src/utils/__tests__/os.test.ts b/Composer/packages/client/src/utils/__tests__/os.test.ts new file mode 100644 index 0000000000..bdb8f1c77e --- /dev/null +++ b/Composer/packages/client/src/utils/__tests__/os.test.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { platform, OS } from '../os'; + +describe('platform', () => { + it.each([ + [ + OS.Windows, + '5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) @bfc/electron-server/1.4.0-nightly.237625.d1378c6 Chrome/80.0.3987.165 Electron/8.2.4 Safari/537.36', + ], + [ + OS.MacOS, + '5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.85 Safari/537.36 Edg/90.0.818.46', + ], + [ + OS.Linux, + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.157 Safari/537.36', + ], + [ + OS.Unix, + 'Mozilla/5.0 (X11; CrOS x86_64 13020.67.2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36', + ], + ])('%s', (expectedOS, userAgentString) => { + expect(platform(userAgentString)).toBe(expectedOS); + }); +}); diff --git a/Composer/packages/client/src/utils/os.ts b/Composer/packages/client/src/utils/os.ts new file mode 100644 index 0000000000..609e9a8a6a --- /dev/null +++ b/Composer/packages/client/src/utils/os.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export enum OS { + Windows = 'Windows', + MacOS = 'MacOS', + Linux = 'Linux', + Unix = 'Unix', + Unknown = 'Unknown', +} + +export function platform(userAgent: string = window.navigator.userAgent): OS { + if (userAgent.includes('Win')) { + return OS.Windows; + } + + if (userAgent.includes('Mac')) { + return OS.MacOS; + } + + if (userAgent.includes('Linux')) { + return OS.Linux; + } + + if (userAgent.includes('X11')) { + return OS.Unix; + } + + return OS.Unknown; +} diff --git a/Composer/packages/server/src/locales/en-US.json b/Composer/packages/server/src/locales/en-US.json index 2bf4c2466a..c7d839e1d2 100644 --- a/Composer/packages/server/src/locales/en-US.json +++ b/Composer/packages/server/src/locales/en-US.json @@ -32,6 +32,9 @@ "a_form_dialog_enables_your_bot_to_collect_pieces_o_fdd3fe56": { "message": "A form dialog enables your bot to collect pieces of information ." }, + "a_install_ngrok_a_and_run_the_following_command_to_634f3414": { + "message": "Install ngrok and run the following command to continue" + }, "a_knowledge_base_name_cannot_contain_spaces_or_spe_91dd53ac": { "message": "A knowledge base name cannot contain spaces or special characters. Use letters, numbers, -, or _." }, @@ -887,6 +890,9 @@ "copy_9748f9f": { "message": "Copy" }, + "copy_command_to_clipboard_4649910f": { + "message": "Copy command to clipboard" + }, "copy_content_for_translation_7affbcbb": { "message": "Copy content for translation" }, @@ -3365,6 +3371,9 @@ "settings_menu_c99ecc6d": { "message": "Settings menu" }, + "setup_tunneling_software_to_test_your_remote_skill_12c344c6": { + "message": "Setup tunneling software to test your remote skill" + }, "short_description_for_6abb9a1b": { "message": "short description for" }, diff --git a/Composer/packages/types/src/publish.ts b/Composer/packages/types/src/publish.ts index e4089261a0..d687c70a82 100644 --- a/Composer/packages/types/src/publish.ts +++ b/Composer/packages/types/src/publish.ts @@ -19,6 +19,7 @@ export type PublishResult = { status?: number; /** for local publish */ endpointURL?: string; + port?: number; /** for PVA publish */ action?: { href: string; diff --git a/Composer/packages/types/src/shell.ts b/Composer/packages/types/src/shell.ts index 6b85af4375..09ae8176cb 100644 --- a/Composer/packages/types/src/shell.ts +++ b/Composer/packages/types/src/shell.ts @@ -76,6 +76,7 @@ export type Notification = { read?: boolean; hidden?: boolean; onRenderCardContent?: ((props: Notification) => JSX.Element) | React.FC; + data?: Record; }; export type ApplicationContextApi = { diff --git a/extensions/localPublish/src/index.ts b/extensions/localPublish/src/index.ts index 8f6d673329..acb0bda604 100644 --- a/extensions/localPublish/src/index.ts +++ b/extensions/localPublish/src/index.ts @@ -200,6 +200,7 @@ class LocalPublisher implements PublishPlugin { status: 200, result: { message: 'Running', + port, endpointURL: url, }, };