AzCon: improve input, usability, reliability (4 commits) (#4756)

* Azure: rewrite user input handler

This commit replaces the AzureConnection's input handler with one that
acts more like "getline()". Instead of the Read thread setting a state
and WriteInput filling in the right member variable, the reader blocks
on the user's input and receives it in an optional<string>.

This moves the input number parsing and error case handling closer to
the point where those inputs are used, as opposed to where they're
collected.

It also switches our input to be "line-based", which is a huge boon for
typing tenant numbers >9. This fixes #3233. A simple line editor
(supporting only backspace and CR) is included.

It also enables echo on user input, and prints it in a nice pretty green
color.

It also enables input queueing: if the user types anything before the
connection is established, it'll be sent once it is.

Fixes #3233.

* Azure: display the user's options and additional information in color

This commit colorizes parts of the AzCon's strings that include "user
options" -- things the user can type -- in yellow. This is to help with
accessibility.

The implementation here is based on a discussion with the team.
Alternative options for coloration were investigated, such as:

* Embedding escape sequences in the resource file.
  This would have been confusing for translators.
  The RESW file format doesn't support &#x1B; escapes, so we would need
  some magic post-processing.
* Embedding "markup" in the resource file (like #{93m}, ...)
  This still would have been annoying for translators.

We settled on an implementation that takes resource names, colorizes
them, and string-formats them into other resources.

* Azure: follow the user's shell choice from the online portal

Fixes #2266.

* Azure: remove all credentials instead of just the first one
This commit is contained in:
Dustin L. Howett (MSFT) 2020-03-04 11:30:20 -08:00 committed by GitHub
parent e58a648bd4
commit 44c4a8c925
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 240 additions and 197 deletions

View file

@ -12,6 +12,7 @@
#include <sstream>
#include <stdlib.h>
#include <LibraryResources.h>
#include <unicode.hpp>
#include "AzureConnection.g.cpp"
@ -32,6 +33,38 @@ static constexpr int CurrentCredentialVersion = 1;
static constexpr auto PasswordVaultResourceName = L"Terminal";
static constexpr auto HttpUserAgent = L"Terminal/0.0";
#define FAILOUT_IF_OPTIONAL_EMPTY(optional) \
do \
{ \
if (!((optional).has_value())) \
{ \
return E_FAIL; \
} \
} while (0, 0)
static constexpr int USER_INPUT_COLOR = 93; // yellow - the color of something the user can type
static constexpr int USER_INFO_COLOR = 97; // white - the color of clarifying information
static inline std::wstring _colorize(const unsigned int colorCode, const std::wstring_view text)
{
return wil::str_printf<std::wstring>(L"\x1b[%um%.*s\x1b[m", colorCode, gsl::narrow_cast<size_t>(text.size()), text.data());
}
// Takes N resource names, loads the first one as a format string, and then
// loads all the remaining ones into the %s arguments in the first one after
// colorizing them in the USER_INPUT_COLOR.
// This is intended to be used to drop UserEntry resources into an existing string.
template<typename... Args>
static inline std::wstring _formatResWithColoredUserInputOptions(const std::wstring_view resourceKey, Args&&... args)
{
return wil::str_printf<std::wstring>(GetLibraryResourceString(resourceKey).data(), (_colorize(USER_INPUT_COLOR, GetLibraryResourceString(args)).data())...);
}
static inline std::wstring _formatTenantLine(int tenantNumber, const std::wstring_view tenantName, const std::wstring_view tenantID)
{
return wil::str_printf<std::wstring>(RS_(L"AzureIthTenant").data(), _colorize(USER_INPUT_COLOR, std::to_wstring(tenantNumber)).data(), _colorize(USER_INFO_COLOR, tenantName).data(), tenantID.data());
}
namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
{
// This function exists because the clientID only gets added by the release pipelines
@ -45,8 +78,6 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
AzureConnection::AzureConnection(const uint32_t initialRows, const uint32_t initialCols) :
_initialRows{ initialRows },
_initialCols{ initialCols },
_maxStored{},
_maxSize{},
_expiry{}
{
}
@ -55,7 +86,7 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
// - helper that will write an unterminated string (generally, from a resource) to the output stream.
// Arguments:
// - str: the string to write.
void AzureConnection::_WriteStringWithNewline(const winrt::hstring& str)
void AzureConnection::_WriteStringWithNewline(const std::wstring_view str)
{
_TerminalOutputHandlers(str + L"\r\n");
}
@ -79,6 +110,35 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
_transitionToState(ConnectionState::Connecting);
}
std::optional<std::wstring> AzureConnection::_ReadUserInput(InputMode mode)
{
std::unique_lock<std::mutex> inputLock{ _inputMutex };
if (_isStateAtOrBeyond(ConnectionState::Closing))
{
return std::nullopt;
}
_currentInputMode = mode;
_TerminalOutputHandlers(L"\x1b[92m"); // Make prompted user input green
_inputEvent.wait(inputLock, [this, mode]() {
return _currentInputMode != mode || _isStateAtOrBeyond(ConnectionState::Closing);
});
_TerminalOutputHandlers(L"\x1b[m");
if (_isStateAtOrBeyond(ConnectionState::Closing))
{
return std::nullopt;
}
std::wstring readInput{};
_userInput.swap(readInput);
return readInput;
}
// Method description:
// - ascribes to the ITerminalConnection interface
// - handles the different possible inputs in the different states
@ -92,108 +152,46 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
return;
}
// Parse the input differently depending on which state we're in
switch (_state)
{
// The user has stored connection settings, let them choose one of them, create a new one or remove all stored ones
case AzureState::AccessStored:
{
const auto s = winrt::to_string(data);
int storeNum = -1;
try
{
storeNum = std::stoi(s);
}
catch (...)
{
std::lock_guard<std::mutex> lg{ _commonMutex };
if (data == RS_(L"AzureUserEntry_RemoveStored"))
{
_removeOrNew = true;
}
else if (data == RS_(L"AzureUserEntry_NewLogin"))
{
_removeOrNew = false;
}
if (_removeOrNew.has_value())
{
_canProceed.notify_one();
}
else
{
_WriteStringWithNewline(RS_(L"AzureInvalidAccessInput"));
}
return;
}
if (storeNum >= _maxStored)
{
_WriteStringWithNewline(RS_(L"AzureNumOutOfBoundsError"));
return;
}
std::lock_guard<std::mutex> lg{ _commonMutex };
_storedNumber = storeNum;
_canProceed.notify_one();
return;
}
// The user has multiple tenants in their Azure account, let them choose one of them
case AzureState::TenantChoice:
{
int tenantNum = -1;
try
{
tenantNum = std::stoi(winrt::to_string(data));
}
catch (...)
{
_WriteStringWithNewline(RS_(L"AzureNonNumberError"));
return;
}
if (tenantNum >= _maxSize)
{
_WriteStringWithNewline(RS_(L"AzureNumOutOfBoundsError"));
return;
}
std::lock_guard<std::mutex> lg{ _commonMutex };
_tenantNumber = tenantNum;
_canProceed.notify_one();
return;
}
// User has the option to save their connection settings for future logins
case AzureState::StoreTokens:
{
std::lock_guard<std::mutex> lg{ _commonMutex };
if (data == RS_(L"AzureUserEntry_Yes"))
{
_store = true;
}
else if (data == RS_(L"AzureUserEntry_No"))
{
_store = false;
}
if (_store.has_value())
{
_canProceed.notify_one();
}
else
{
_WriteStringWithNewline(RS_(L"AzureInvalidStoreInput"));
}
return;
}
// We are connected, send user's input over the websocket
case AzureState::TermConnected:
if (_state == AzureState::TermConnected)
{
// If we're connected, we don't need to do any fun input shenanigans.
websocket_outgoing_message msg;
const auto str = winrt::to_string(data);
msg.set_utf8_message(str);
_cloudShellSocket.send(msg).get();
}
default:
return;
}
std::lock_guard<std::mutex> lock{ _inputMutex };
if (data.size() > 0 && (gsl::at(data, 0) == UNICODE_BACKSPACE || gsl::at(data, 0) == UNICODE_DEL)) // BS or DEL
{
if (_userInput.size() > 0)
{
_userInput.pop_back();
_TerminalOutputHandlers(L"\x08 \x08"); // overstrike the character with a space
}
}
else
{
_TerminalOutputHandlers(data); // echo back
switch (_currentInputMode)
{
case InputMode::Line:
if (data.size() > 0 && gsl::at(data, 0) == UNICODE_CARRIAGERETURN)
{
_TerminalOutputHandlers(L"\r\n"); // we probably got a \r, so we need to advance to the next line.
_currentInputMode = InputMode::None; // toggling the mode indicates completion
_inputEvent.notify_one();
break;
}
[[fallthrough]];
default:
std::copy(data.cbegin(), data.cend(), std::back_inserter(_userInput));
break;
}
}
}
// Method description:
@ -231,7 +229,8 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
{
if (_transitionToState(ConnectionState::Closing))
{
_canProceed.notify_all();
_inputEvent.notify_all();
if (_state == AzureState::TermConnected)
{
// Close the websocket connection
@ -404,7 +403,8 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
_state = AzureState::DeviceFlow;
return S_FALSE;
}
_maxStored = 0;
int numTenants{ 0 };
for (const auto& entry : credList)
{
auto nameJson = json::value::parse(entry.UserName().c_str());
@ -422,12 +422,11 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
continue;
}
winrt::hstring tenantLine{ wil::str_printf<std::wstring>(RS_(L"AzureIthTenant").c_str(), _maxStored, nameJson.at(L"displayName").as_string().c_str(), nameJson.at(L"tenantID").as_string().c_str()) };
_WriteStringWithNewline(tenantLine);
_maxStored++;
_WriteStringWithNewline(_formatTenantLine(numTenants, nameJson.at(L"displayName").as_string(), nameJson.at(L"tenantID").as_string()));
numTenants++;
}
if (!_maxStored)
if (!numTenants)
{
if (oldVersionEncountered)
{
@ -439,33 +438,53 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
}
_WriteStringWithNewline(RS_(L"AzureEnterTenant"));
_WriteStringWithNewline(RS_(L"AzureNewLogin"));
_WriteStringWithNewline(RS_(L"AzureRemoveStored"));
_WriteStringWithNewline(_formatResWithColoredUserInputOptions(USES_RESOURCE(L"AzureNewLogin"), USES_RESOURCE(L"AzureUserEntry_NewLogin")));
_WriteStringWithNewline(_formatResWithColoredUserInputOptions(USES_RESOURCE(L"AzureRemoveStored"), USES_RESOURCE(L"AzureUserEntry_RemoveStored")));
std::unique_lock<std::mutex> storedLock{ _commonMutex };
_canProceed.wait(storedLock, [=]() {
return (_storedNumber >= 0 && _storedNumber < _maxStored) || _removeOrNew.has_value() || _isStateAtOrBeyond(ConnectionState::Closing);
});
// User might have closed the tab while we waited for input
if (_isStateAtOrBeyond(ConnectionState::Closing))
int selectedTenant{ -1 };
do
{
return E_FAIL;
}
else if (_removeOrNew.has_value() && _removeOrNew.value())
{
// User wants to remove the stored settings
_RemoveCredentials();
_state = AzureState::DeviceFlow;
return S_OK;
}
else if (_removeOrNew.has_value() && !_removeOrNew.value())
{
// User wants to login with a different account
_state = AzureState::DeviceFlow;
return S_OK;
}
auto maybeTenantSelection = _ReadUserInput(InputMode::Line);
FAILOUT_IF_OPTIONAL_EMPTY(maybeTenantSelection);
const auto& tenantSelection = maybeTenantSelection.value();
if (tenantSelection == RS_(L"AzureUserEntry_RemoveStored"))
{
// User wants to remove the stored settings
_RemoveCredentials();
_state = AzureState::DeviceFlow;
return S_OK;
}
else if (tenantSelection == RS_(L"AzureUserEntry_NewLogin"))
{
// User wants to login with a different account
_state = AzureState::DeviceFlow;
return S_OK;
}
else
{
try
{
selectedTenant = std::stoi(tenantSelection);
if (selectedTenant < 0 || selectedTenant >= numTenants)
{
_WriteStringWithNewline(RS_(L"AzureNumOutOfBoundsError"));
continue; // go 'round again
}
break;
}
catch (...)
{
// suppress exceptions in conversion
}
}
// if we got here, we didn't break out of the loop early and need to go 'round again
_WriteStringWithNewline(_formatResWithColoredUserInputOptions(USES_RESOURCE(L"AzureInvalidAccessInput"), USES_RESOURCE(L"AzureUserEntry_NewLogin"), USES_RESOURCE(L"AzureUserEntry_RemoveStored")));
} while (true);
// User wants to login with one of the saved connection settings
auto desiredCredential = credList.GetAt(_storedNumber);
auto desiredCredential = credList.GetAt(selectedTenant);
desiredCredential.RetrievePassword();
auto nameJson = json::value::parse(desiredCredential.UserName().c_str());
auto passWordJson = json::value::parse(desiredCredential.Password().c_str());
@ -567,27 +586,43 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
try
{
const auto tenantListAsArray = _tenantList.as_array();
_maxSize = gsl::narrow<int>(tenantListAsArray.size());
for (int i = 0; i < _maxSize; i++)
auto numTenants = gsl::narrow<int>(tenantListAsArray.size());
for (int i = 0; i < numTenants; i++)
{
const auto& tenant = tenantListAsArray.at(i);
const auto [tenantId, tenantDisplayName] = _crackTenant(tenant);
winrt::hstring tenantLine{ wil::str_printf<std::wstring>(RS_(L"AzureIthTenant").c_str(), i, tenantDisplayName.c_str(), tenantId.c_str()) };
_WriteStringWithNewline(tenantLine);
_WriteStringWithNewline(_formatTenantLine(i, tenantDisplayName, tenantId));
}
_WriteStringWithNewline(RS_(L"AzureEnterTenant"));
// Use a lock to wait for the user to input a valid number
std::unique_lock<std::mutex> tenantNumberLock{ _commonMutex };
_canProceed.wait(tenantNumberLock, [=]() {
return (_tenantNumber >= 0 && _tenantNumber < _maxSize) || _isStateAtOrBeyond(ConnectionState::Closing);
});
// User might have closed the tab while we waited for input
if (_isStateAtOrBeyond(ConnectionState::Closing))
{
return E_FAIL;
}
const auto& chosenTenant = tenantListAsArray.at(_tenantNumber);
int selectedTenant{ -1 };
do
{
auto maybeTenantSelection = _ReadUserInput(InputMode::Line);
FAILOUT_IF_OPTIONAL_EMPTY(maybeTenantSelection);
const auto& tenantSelection = maybeTenantSelection.value();
try
{
selectedTenant = std::stoi(tenantSelection);
if (selectedTenant < 0 || selectedTenant >= numTenants)
{
_WriteStringWithNewline(RS_(L"AzureNumOutOfBoundsError"));
continue;
}
break;
}
catch (...)
{
// suppress exceptions in conversion
}
// if we got here, we didn't break out of the loop early and need to go 'round again
_WriteStringWithNewline(RS_(L"AzureNonNumberError"));
} while (true);
const auto& chosenTenant = tenantListAsArray.at(selectedTenant);
std::tie(_tenantID, _displayName) = _crackTenant(chosenTenant);
// We have to refresh now that we have the tenantID
@ -609,24 +644,28 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
// - S_OK otherwise
HRESULT AzureConnection::_StoreHelper()
{
_WriteStringWithNewline(RS_(L"AzureStorePrompt"));
_WriteStringWithNewline(_formatResWithColoredUserInputOptions(USES_RESOURCE(L"AzureStorePrompt"), USES_RESOURCE(L"AzureUserEntry_Yes"), USES_RESOURCE(L"AzureUserEntry_No")));
// Wait for user input
std::unique_lock<std::mutex> storeLock{ _commonMutex };
_canProceed.wait(storeLock, [=]() {
return _store.has_value() || _isStateAtOrBeyond(ConnectionState::Closing);
});
// User might have closed the tab while we waited for input
if (_isStateAtOrBeyond(ConnectionState::Closing))
do
{
return E_FAIL;
}
auto maybeStoreCredentials = _ReadUserInput(InputMode::Line);
FAILOUT_IF_OPTIONAL_EMPTY(maybeStoreCredentials);
if (_store.value())
{
// User has opted to store the connection settings
_StoreCredential();
_WriteStringWithNewline(RS_(L"AzureTokensStored"));
}
const auto& storeCredentials = maybeStoreCredentials.value();
if (storeCredentials == RS_(L"AzureUserEntry_Yes"))
{
_StoreCredential();
_WriteStringWithNewline(RS_(L"AzureTokensStored"));
break;
}
else if (storeCredentials == RS_(L"AzureUserEntry_No"))
{
break; // we're done, but the user wants nothing.
}
// if we got here, we didn't break out of the loop early and need to go 'round again
_WriteStringWithNewline(_formatResWithColoredUserInputOptions(USES_RESOURCE(L"AzureInvalidStoreInput"), USES_RESOURCE(L"AzureUserEntry_Yes"), USES_RESOURCE(L"AzureUserEntry_No")));
} while (true);
_state = AzureState::TermConnecting;
return S_OK;
@ -654,11 +693,7 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
_WriteStringWithNewline(RS_(L"AzureSuccess"));
// Request for a terminal for said cloud shell
// We only support bash for now, so don't bother with the user's preferred shell
// fyi: we can't call powershell yet because it sends VT sequences we don't support yet
// TODO: GitHub #1883
//const auto shellType = settingsResponse.at(L"properties").at(L"preferredShellType").as_string();
const auto shellType = L"bash";
const auto shellType = settingsResponse.at(L"properties").at(L"preferredShellType").as_string();
_WriteStringWithNewline(RS_(L"AzureRequestingTerminal"));
const auto socketUri = _GetTerminal(shellType);
_TerminalOutputHandlers(L"\r\n");
@ -668,6 +703,14 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
connReqTask.wait();
_state = AzureState::TermConnected;
std::wstring queuedUserInput{};
std::swap(_userInput, queuedUserInput);
if (queuedUserInput.size() > 0)
{
WriteInput(static_cast<winrt::hstring>(queuedUserInput)); // send the user's queued up input back through
}
return S_OK;
}
@ -833,9 +876,8 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
http_request shellRequest(L"PUT");
shellRequest.set_request_uri(L"providers/Microsoft.Portal/consoles/default?api-version=2018-10-01");
_HeaderHelper(shellRequest);
const auto innerBody = json::value::parse(U("{ \"osType\" : \"linux\" }"));
json::value body;
body[U("properties")] = innerBody;
// { "properties": { "osType": "linux" } }
auto body = json::value::object({ { U("properties"), json::value::object({ { U("osType"), json::value::string(U("linux")) } }) } });
shellRequest.set_body(body);
// Send the request and get the response as a json value
@ -914,17 +956,15 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
_WriteStringWithNewline(RS_(L"AzureNoTokens"));
return;
}
while (credList.Size() > 0)
for (const auto& cred : credList)
{
try
{
vault.Remove(credList.GetAt(0));
}
catch (...)
{
_WriteStringWithNewline(RS_(L"AzureTokensRemoved"));
return;
vault.Remove(cred);
}
CATCH_LOG();
}
_WriteStringWithNewline(RS_(L"AzureTokensRemoved"));
}
}

View file

@ -31,12 +31,6 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
private:
uint32_t _initialRows{};
uint32_t _initialCols{};
int _storedNumber{ -1 };
int _maxStored;
int _tenantNumber{ -1 };
int _maxSize;
std::condition_variable _canProceed;
std::mutex _commonMutex;
enum class AzureState
{
@ -51,9 +45,6 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
AzureState _state{ AzureState::AccessStored };
std::optional<bool> _store;
std::optional<bool> _removeOrNew;
wil::unique_handle _hOutputThread;
static DWORD WINAPI StaticOutputThreadProc(LPVOID lpParameter);
@ -67,17 +58,17 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
const utility::string_t _loginUri{ U("https://login.microsoftonline.com/") };
const utility::string_t _resourceUri{ U("https://management.azure.com/") };
const utility::string_t _wantedResource{ U("https://management.core.windows.net/") };
int _expireLimit{ 2700 };
const int _expireLimit{ 2700 };
web::json::value _tenantList;
utility::string_t _displayName;
utility::string_t _tenantID;
utility::string_t _accessToken;
utility::string_t _refreshToken;
int _expiry;
int _expiry{ 0 };
utility::string_t _cloudShellUri;
utility::string_t _terminalID;
void _WriteStringWithNewline(const winrt::hstring& str);
void _WriteStringWithNewline(const std::wstring_view str);
web::json::value _RequestHelper(web::http::client::http_client theClient, web::http::http_request theRequest);
web::json::value _GetDeviceCode();
web::json::value _WaitForUser(utility::string_t deviceCode, int pollInterval, int expiresIn);
@ -90,6 +81,18 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation
void _StoreCredential();
void _RemoveCredentials();
enum class InputMode
{
None = 0,
Line
};
InputMode _currentInputMode{ InputMode::None };
std::wstring _userInput;
std::condition_variable _inputEvent;
std::mutex _inputMutex;
std::optional<std::wstring> _ReadUserInput(InputMode mode);
web::websockets::client::websocket_client _cloudShellSocket;
};
}

View file

@ -123,14 +123,14 @@
<data name="AzureEnterTenant" xml:space="preserve">
<value>Please enter the desired tenant number.</value>
</data>
<data name="AzureNewLogin" xml:space="preserve">
<value>Enter "n" to login with a different account.</value>
<data name="AzureNewLogin" xml:space="default">
<value>Enter %s to login with a different account</value>
</data>
<data name="AzureRemoveStored" xml:space="preserve">
<value>Enter "r" to remove the above saved connection settings.</value>
<data name="AzureRemoveStored" xml:space="default">
<value>Enter %s to remove the above saved connection settings.</value>
</data>
<data name="AzureInvalidAccessInput" xml:space="preserve">
<value>Please enter a valid number to access the stored connection settings, n to make a new one, or r to remove the stored ones.</value>
<data name="AzureInvalidAccessInput" xml:space="default">
<value>Please enter a valid number to access the stored connection settings, %s to make a new one, or %s to remove the stored ones.</value>
</data>
<data name="AzureNonNumberError" xml:space="preserve">
<value>Please enter a number.</value>
@ -144,11 +144,11 @@
<data name="AzureNoCloudAccount" xml:space="preserve">
<value>You have not set up your cloud shell account yet. Please go to https://shell.azure.com to set it up.</value>
</data>
<data name="AzureStorePrompt" xml:space="preserve">
<value>Do you want to save these connection settings for future logins? [y/n]</value>
<data name="AzureStorePrompt" xml:space="default">
<value>Do you want to save these connection settings for future logins? [%s/%s]</value>
</data>
<data name="AzureInvalidStoreInput" xml:space="preserve">
<value>Please enter y or n</value>
<data name="AzureInvalidStoreInput" xml:space="default">
<value>Please enter %s or %s</value>
</data>
<data name="AzureRequestingCloud" xml:space="preserve">
<value>Requesting a cloud shell instance...</value>
@ -183,8 +183,8 @@
<data name="AzureUnknownTenantName" xml:space="preserve">
<value>&lt;unknown tenant name&gt;</value>
</data>
<data name="AzureIthTenant" xml:space="preserve">
<value>Tenant %d: %s (%s)</value>
<data name="AzureIthTenant" xml:space="default">
<value>Tenant %s: %s (%s)</value>
</data>
<data name="AzureSuccessfullyAuthenticated" xml:space="preserve">
<value>Authenticated.</value>
@ -217,4 +217,4 @@ If this resource spans multiple lines, it will not be displayed properly. Yeah.<
<data name="TelnetInternetOrServerIssue" xml:space="preserve">
<value>Could not connect to telnet server.</value>
</data>
</root>
</root>