{{#if (can 'connect target' B.data)}}
{{#if (can 'read target' B.data)}}
-
+ {{#if (and B.data.isRDP this.rdp.isPreferredRdpClientSet)}}
+
+ {{else}}
+
+ {{/if}}
{{else}}
{{#if (can 'connect target' @model.target)}}
-
+ {{#if (and @model.target.isRDP this.rdp.isPreferredRdpClientSet)}}
+
+
+ {{else}}
+
+ {{/if}}
{{/if}}
diff --git a/ui/desktop/electron-app/src/helpers/spawn-promise.js b/ui/desktop/electron-app/src/helpers/spawn-promise.js
index 0fe95afebe..ea4ab83c30 100644
--- a/ui/desktop/electron-app/src/helpers/spawn-promise.js
+++ b/ui/desktop/electron-app/src/helpers/spawn-promise.js
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1
*/
-const { path } = require('../cli/path.js');
+const { path: boundaryPath } = require('../cli/path.js');
const { spawn, spawnSync } = require('child_process');
const jsonify = require('../utils/jsonify.js');
@@ -34,7 +34,7 @@ module.exports = {
*/
spawnAsyncJSONPromise(command, token, timeout) {
return new Promise((resolve, reject) => {
- const childProcess = spawn(path, command, {
+ const childProcess = spawn(boundaryPath, command, {
env: {
...process.env,
BOUNDARY_TOKEN: token,
@@ -78,8 +78,9 @@ module.exports = {
* This function is intended for non-connection related tasks.
* @param {string[]} args
* @param {object} envVars
+ * @param {string} path
* @returns {{stdout: string | undefined, stderr: string | undefined}} */
- spawnSync(args, envVars = {}) {
+ spawnSync(args, envVars = {}, path = boundaryPath) {
const childProcess = spawnSync(path, args, {
// Some of our outputs (namely cache daemon searching) can be very large.
// This an undocumented hack to allow for an unlimited buffer size which
@@ -103,8 +104,9 @@ module.exports = {
* Resolves on any output from stdout or stderr.
* @param command
* @param options
+ * @param path
*/
- spawn(command, options) {
+ spawn(command, options, path = boundaryPath) {
return new Promise((resolve, reject) => {
const childProcess = spawn(path, command, options);
childProcess.stdout.on('data', (data) => {
diff --git a/ui/desktop/electron-app/src/index.js b/ui/desktop/electron-app/src/index.js
index 28eea08afd..a6016d28bc 100644
--- a/ui/desktop/electron-app/src/index.js
+++ b/ui/desktop/electron-app/src/index.js
@@ -26,6 +26,7 @@ const { generateCSPHeader } = require('./config/content-security-policy.js');
const runtimeSettings = require('./services/runtime-settings.js');
const sessionManager = require('./services/session-manager.js');
const cacheDaemonManager = require('./services/cache-daemon-manager');
+const rdpClientManager = require('./services/rdp-client-manager');
const store = require('./services/electron-store-manager');
const menu = require('./config/menu.js');
@@ -163,13 +164,17 @@ const createWindow = async (partition, closeWindowCB) => {
// Opens external links in the host default browser.
// We allow developer.hashicorp.com domain to open on an external window
// and releases.hashicorp.com domain to download the desktop app or
- // link to the release page for the desktop app.
+ // link to the release page for the desktop app or
+ // apps.apple.com domain to open the app store page to download the Windows App or
+ // learn.microsoft.com domain to open the documentation for installing Remote Desktop Connection.
browserWindow.webContents.setWindowOpenHandler(({ url }) => {
if (
isLocalhost(url) ||
url.startsWith('https://developer.hashicorp.com/') ||
url.startsWith('https://releases.hashicorp.com/boundary-desktop/') ||
- url.startsWith('https://support.hashicorp.com/hc/en-us')
+ url.startsWith('https://support.hashicorp.com/hc/en-us') ||
+ url.startsWith('https://learn.microsoft.com/') ||
+ url.startsWith('https://apps.apple.com/')
) {
shell.openExternal(url);
}
@@ -297,6 +302,8 @@ app.on('before-quit', async (event) => {
app.on('quit', () => {
cacheDaemonManager.stop();
+ // we should stop any active RDP client processes
+ rdpClientManager.stopAll();
});
// Handle an unhandled error in the main thread
diff --git a/ui/desktop/electron-app/src/ipc/handlers.js b/ui/desktop/electron-app/src/ipc/handlers.js
index aa44c08d2f..c0e08323f3 100644
--- a/ui/desktop/electron-app/src/ipc/handlers.js
+++ b/ui/desktop/electron-app/src/ipc/handlers.js
@@ -19,6 +19,7 @@ const cacheDaemonManager = require('../services/cache-daemon-manager');
const clientAgentDaemonManager = require('../services/client-agent-daemon-manager');
const { releaseVersion } = require('../../config/config.js');
const store = require('../services/electron-store-manager');
+const rdpClientManager = require('../services/rdp-client-manager');
/**
* Returns the current runtime clusterUrl, which is used by the main thread to
@@ -269,6 +270,32 @@ handle('getLogPath', () => {
}
});
+/**
+ * Returns the available RDP clients
+ */
+handle('getRdpClients', async () => rdpClientManager.getAvailableRdpClients());
+
+/**
+ * Returns the preferred RDP client
+ */
+handle('getPreferredRdpClient', async () =>
+ rdpClientManager.getPreferredRdpClient(),
+);
+
+/**
+ * Sets the preferred RDP client
+ */
+handle('setPreferredRdpClient', (preferredClient) =>
+ rdpClientManager.setPreferredRdpClient(preferredClient),
+);
+
+/**
+ * Launches the RDP client with the provided session ID.
+ */
+handle('launchRdpClient', async (sessionId) =>
+ rdpClientManager.launchRdpClient(sessionId, sessionManager),
+);
+
/**
* Handler to help create terminal windows. We don't use the helper `handle` method
* as we need access to the event and don't need to be using `ipcMain.handle`.
diff --git a/ui/desktop/electron-app/src/models/session-manager.js b/ui/desktop/electron-app/src/models/session-manager.js
index 10cd6d41df..c54cd96e0e 100644
--- a/ui/desktop/electron-app/src/models/session-manager.js
+++ b/ui/desktop/electron-app/src/models/session-manager.js
@@ -46,6 +46,15 @@ class SessionManager {
return session?.stop?.();
}
+ /**
+ * Get session by identifier.
+ * @param {string} sessionId
+ * @returns {Session} The session object
+ */
+ getSessionById(sessionId) {
+ return this.#sessions.find((session) => session.id === sessionId);
+ }
+
/**
* Stop all active and pending target sessions
* Returning Promise.all() ensures all sessions in the list have been
diff --git a/ui/desktop/electron-app/src/models/session.js b/ui/desktop/electron-app/src/models/session.js
index 6885037d65..2780383241 100644
--- a/ui/desktop/electron-app/src/models/session.js
+++ b/ui/desktop/electron-app/src/models/session.js
@@ -52,6 +52,14 @@ class Session {
return this.#process && !this.#process.killed;
}
+ /**
+ * Get proxy details for the session
+ * @return {Object}
+ */
+ get proxyDetails() {
+ return this.#proxyDetails;
+ }
+
/**
* Generate cli command for session.
* @returns {string[]}
diff --git a/ui/desktop/electron-app/src/services/rdp-client-manager.js b/ui/desktop/electron-app/src/services/rdp-client-manager.js
new file mode 100644
index 0000000000..6ba4d047a9
--- /dev/null
+++ b/ui/desktop/electron-app/src/services/rdp-client-manager.js
@@ -0,0 +1,168 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+const { spawn, spawnSync } = require('../helpers/spawn-promise');
+const { shell } = require('electron');
+const fs = require('fs');
+const which = require('which');
+const { isMac, isWindows } = require('../helpers/platform.js');
+const store = require('./electron-store-manager');
+
+// RDP Clients
+const RDP_CLIENT_NONE = 'none';
+const RDP_CLIENT_WINDOWS_APP = 'windows-app';
+const RDP_CLIENT_MSTSC = 'mstsc';
+
+// RDP Client Configuration
+const RDP_CLIENTS = [
+ {
+ value: RDP_CLIENT_MSTSC,
+ isAvailable: async () => {
+ if (!isWindows()) return false;
+ try {
+ const mstscPath = await which('mstsc', { nothrow: true });
+ return Boolean(mstscPath);
+ } catch {
+ return false;
+ }
+ },
+ },
+ {
+ value: RDP_CLIENT_WINDOWS_APP,
+ isAvailable: () => {
+ if (!isMac()) return false;
+ try {
+ // Check for Windows App
+ if (fs.existsSync('/Applications/Windows App.app')) {
+ return true;
+ }
+
+ // Fallback: Use mdfind for Microsoft Remote Desktop
+ else {
+ const result = spawnSync(
+ ['kMDItemCFBundleIdentifier == "com.microsoft.rdc.macos"'],
+ {},
+ 'mdfind',
+ );
+ // `mdfind` returns an object with stdout containing the path of the app if found
+ // Example: '/Applications/Windows App.app\n'
+ return result.stdout && result.stdout.trim().length > 0;
+ }
+ } catch {
+ return false;
+ }
+ },
+ },
+ {
+ value: RDP_CLIENT_NONE,
+ isAvailable: () => true,
+ },
+];
+
+class RdpClientManager {
+ // Track active RDP processes for cleanup
+ #activeProcesses = [];
+ /**
+ * Gets all available RDP clients on the current system
+ * @returns {Promise} Array of available RDP client values
+ */
+ async getAvailableRdpClients() {
+ const available = [];
+ for (const client of RDP_CLIENTS) {
+ if (await client.isAvailable()) {
+ available.push(client.value);
+ }
+ }
+ return available;
+ }
+
+ /**
+ * Gets the user's preferred RDP client, auto-detecting if not set
+ * @returns {Promise} Preferred RDP client value
+ */
+ async getPreferredRdpClient() {
+ const availableClients = await this.getAvailableRdpClients();
+ const installedClients = availableClients.filter(
+ (client) => client !== RDP_CLIENT_NONE,
+ );
+
+ if (installedClients.length === 0) return RDP_CLIENT_NONE;
+
+ const preferredClient =
+ store.get('preferredRdpClient') ?? installedClients[0];
+ // check if preferred client is still installed
+ return installedClients.includes(preferredClient)
+ ? preferredClient
+ : RDP_CLIENT_NONE;
+ }
+
+ /**
+ * Sets the user's preferred RDP client
+ * @param {string} preferredClient - The RDP client value to set as preferred
+ */
+ setPreferredRdpClient(preferredClient) {
+ if (!preferredClient) {
+ store.set('preferredRdpClient', RDP_CLIENT_NONE);
+ } else {
+ store.set('preferredRdpClient', preferredClient);
+ }
+ }
+
+ /**
+ * Launches RDP connection with the specified address and port
+ * @param {string} address - Target address
+ * @param {number} port - Target port
+ */
+ async launchRdpConnection(address, port) {
+ if (isWindows()) {
+ // Launch Windows mstsc and track it for cleanup
+ const mstscArgs = [`/v:${address}:${port}`];
+ const { childProcess } = await spawn(mstscArgs, {}, 'mstsc');
+ // Add to activeProcesses array for cleanup
+ this.#activeProcesses.push(childProcess);
+ } else if (isMac()) {
+ // Launch macOS RDP URL - no process to track as it's handled by the system
+ const fullAddress = `${address}:${port}`;
+ const encoded = encodeURIComponent(`full address=s:${fullAddress}`);
+ const rdpUrl = `rdp://${encoded}`;
+ await shell.openExternal(rdpUrl);
+ }
+ }
+
+ /**
+ * Launches RDP client using session ID
+ * Retrieves session object from session manager and launches appropriate RDP client
+ * @param {string} sessionId - The session ID to get session for
+ * @param {Object} sessionManager - Session manager instance to get session from
+ */
+ async launchRdpClient(sessionId, sessionManager) {
+ // Get session object from session manager
+ const session = sessionManager.getSessionById(sessionId);
+
+ if (!session) {
+ return;
+ }
+
+ const {
+ proxyDetails: { address, port },
+ } = session;
+ // Launch RDP connection
+ await this.launchRdpConnection(address, port);
+ }
+
+ /**
+ * Stop all active RDP processes
+ */
+ stopAll() {
+ for (const process of this.#activeProcesses) {
+ if (!process.killed) {
+ process.kill();
+ }
+ }
+ // Clear the active processes array after stopping all processes
+ this.#activeProcesses = [];
+ }
+}
+
+module.exports = new RdpClientManager();
diff --git a/ui/desktop/tests/acceptance/application-test.js b/ui/desktop/tests/acceptance/application-test.js
index 7dc6ae47a2..92828fbe15 100644
--- a/ui/desktop/tests/acceptance/application-test.js
+++ b/ui/desktop/tests/acceptance/application-test.js
@@ -34,6 +34,10 @@ module('Acceptance | Application', function (hooks) {
const ipcService = this.owner.lookup('service:ipc');
stubs.ipcService = sinon.stub(ipcService, 'invoke');
+
+ // mock RDP service calls
+ let rdpService = this.owner.lookup('service:rdp');
+ sinon.stub(rdpService, 'initialize').resolves();
});
hooks.afterEach(function () {
diff --git a/ui/desktop/tests/acceptance/cluster-url-test.js b/ui/desktop/tests/acceptance/cluster-url-test.js
index dd57cd56b7..f6d0e358c4 100644
--- a/ui/desktop/tests/acceptance/cluster-url-test.js
+++ b/ui/desktop/tests/acceptance/cluster-url-test.js
@@ -94,6 +94,10 @@ module('Acceptance | clusterUrl', function (hooks) {
urls.authenticate.methods.global = `${urls.authenticate.global}/${instances.authMethods.global.id}`;
urls.projects = `${urls.scopes.global}/projects`;
urls.targets = `${urls.projects}/targets`;
+
+ // mock RDP service calls
+ let rdpService = this.owner.lookup('service:rdp');
+ sinon.stub(rdpService, 'initialize').resolves();
});
hooks.afterEach(function () {
diff --git a/ui/desktop/tests/acceptance/projects/sessions/index-test.js b/ui/desktop/tests/acceptance/projects/sessions/index-test.js
index ffa21f3f8d..8524f08565 100644
--- a/ui/desktop/tests/acceptance/projects/sessions/index-test.js
+++ b/ui/desktop/tests/acceptance/projects/sessions/index-test.js
@@ -20,6 +20,7 @@ import {
} from 'api/models/session';
import setupStubs from 'api/test-support/handlers/cache-daemon-search';
import { setRunOptions } from 'ember-a11y-testing/test-support';
+import sinon from 'sinon';
module('Acceptance | projects | sessions | index', function (hooks) {
setupApplicationTest(hooks);
@@ -147,6 +148,10 @@ module('Acceptance | projects | sessions | index', function (hooks) {
this.ipcStub.withArgs('isCacheDaemonRunning').returns(true);
this.stubCacheDaemonSearch('sessions', 'sessions', 'targets');
+
+ // mock RDP service calls
+ let rdpService = this.owner.lookup('service:rdp');
+ sinon.stub(rdpService, 'initialize').resolves();
});
test('visiting index while unauthenticated redirects to global authenticate method', async function (assert) {
diff --git a/ui/desktop/tests/acceptance/projects/sessions/session-test.js b/ui/desktop/tests/acceptance/projects/sessions/session-test.js
index 0e40cbb5c7..29d969e458 100644
--- a/ui/desktop/tests/acceptance/projects/sessions/session-test.js
+++ b/ui/desktop/tests/acceptance/projects/sessions/session-test.js
@@ -13,6 +13,9 @@ import WindowMockIPC from '../../../helpers/window-mock-ipc';
import { STATUS_SESSION_ACTIVE } from 'api/models/session';
import setupStubs from 'api/test-support/handlers/cache-daemon-search';
import { setRunOptions } from 'ember-a11y-testing/test-support';
+import sinon from 'sinon';
+import { TYPE_TARGET_RDP } from 'api/models/target';
+import { RDP_CLIENT_WINDOWS_APP, RDP_CLIENT_NONE } from 'desktop/services/rdp';
module('Acceptance | projects | sessions | session', function (hooks) {
setupApplicationTest(hooks);
@@ -23,6 +26,8 @@ module('Acceptance | projects | sessions | session', function (hooks) {
const TOAST_DO_NOT_SHOW_AGAIN_BUTTON =
'[data-test-toast-notification] button';
const TOAST_DISMISS_BUTTON = '[aria-label="Dismiss"]';
+ const RDP_OPEN_BUTTON = '[data-test-session-detail-open-button]';
+ const CANCEL_SESSION_BUTTON = '[data-test-session-detail-cancel-button]';
const instances = {
scopes: {
@@ -35,6 +40,7 @@ module('Acceptance | projects | sessions | session', function (hooks) {
},
user: null,
session: null,
+ rdpSession: null,
};
const urls = {
@@ -136,6 +142,10 @@ module('Acceptance | projects | sessions | session', function (hooks) {
setDefaultClusterUrl(this);
this.ipcStub.withArgs('isCacheDaemonRunning').returns(false);
+
+ // mock RDP service calls
+ this.rdpService = this.owner.lookup('service:rdp');
+ sinon.stub(this.rdpService, 'initialize').resolves();
});
hooks.afterEach(function () {
@@ -481,7 +491,7 @@ module('Acceptance | projects | sessions | session', function (hooks) {
await visit(urls.session);
- assert.dom('[data-test-session-detail-cancel-button]').isVisible();
+ assert.dom(CANCEL_SESSION_BUTTON).isVisible();
});
test('cannot cancel a session without cancel permissions', async function (assert) {
@@ -499,7 +509,7 @@ module('Acceptance | projects | sessions | session', function (hooks) {
await visit(urls.session);
- assert.dom('[data-test-session-detail-cancel-button]').isNotVisible();
+ assert.dom(CANCEL_SESSION_BUTTON).isNotVisible();
});
test('cancelling a session shows success alert', async function (assert) {
@@ -516,7 +526,7 @@ module('Acceptance | projects | sessions | session', function (hooks) {
await visit(urls.session);
- await click('[data-test-session-detail-cancel-button]');
+ await click(CANCEL_SESSION_BUTTON);
assert
.dom('[data-test-toast-notification].hds-alert--color-success')
@@ -537,7 +547,7 @@ module('Acceptance | projects | sessions | session', function (hooks) {
await visit(urls.session);
- await click('[data-test-session-detail-cancel-button]');
+ await click(CANCEL_SESSION_BUTTON);
assert.strictEqual(currentURL(), urls.targets);
});
@@ -557,7 +567,7 @@ module('Acceptance | projects | sessions | session', function (hooks) {
await visit(urls.session);
- await click('[data-test-session-detail-cancel-button]');
+ await click(CANCEL_SESSION_BUTTON);
assert
.dom('[data-test-toast-notification].hds-alert--color-critical')
@@ -579,10 +589,109 @@ module('Acceptance | projects | sessions | session', function (hooks) {
await visit(urls.session);
- await click('[data-test-session-detail-cancel-button]');
+ await click(CANCEL_SESSION_BUTTON);
assert
.dom('[data-test-toast-notification].hds-alert--color-critical')
.isVisible();
});
+
+ test('visiting an RDP session should display "open" button when preferred client is set', async function (assert) {
+ this.ipcStub.withArgs('cliExists').returns(true);
+
+ this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP;
+ instances.target.update({ type: TYPE_TARGET_RDP });
+
+ this.ipcStub.withArgs('connect').returns({
+ session_id: instances.rdpSession.id,
+ host_id: 'h_123',
+ address: 'a_123',
+ port: 'p_123',
+ protocol: 'rdp',
+ });
+
+ await visit(urls.rdpTarget);
+ await click(TARGET_CONNECT_BUTTON);
+
+ assert.strictEqual(currentURL(), urls.rdpSession);
+ assert.dom(RDP_OPEN_BUTTON).isVisible();
+
+ await click(RDP_OPEN_BUTTON);
+
+ assert.ok(
+ this.ipcStub.calledWith('launchRdpClient', instances.rdpSession.id),
+ );
+ });
+
+ test('visiting an RDP session should not display "open" button when preferred client is set to none', async function (assert) {
+ this.ipcStub.withArgs('cliExists').returns(true);
+
+ this.rdpService.preferredRdpClient = RDP_CLIENT_NONE;
+ instances.target.update({ type: TYPE_TARGET_RDP });
+
+ this.ipcStub.withArgs('connect').returns({
+ session_id: instances.rdpSession.id,
+ host_id: 'h_123',
+ address: 'a_123',
+ port: 'p_123',
+ protocol: 'rdp',
+ });
+
+ await visit(urls.rdpTarget);
+ await click(TARGET_CONNECT_BUTTON);
+
+ assert.strictEqual(currentURL(), urls.rdpSession);
+ assert.dom(RDP_OPEN_BUTTON).doesNotExist();
+ });
+
+ test('it shows confirm modal when connection error occurs on launching rdp client', async function (assert) {
+ this.ipcStub.withArgs('cliExists').returns(true);
+
+ this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP;
+ instances.target.update({ type: TYPE_TARGET_RDP });
+
+ this.ipcStub.withArgs('connect').returns({
+ session_id: instances.rdpSession.id,
+ host_id: 'h_123',
+ address: 'a_123',
+ port: 'p_123',
+ protocol: 'rdp',
+ });
+ this.ipcStub.withArgs('launchRdpClient', instances.rdpSession.id).rejects();
+
+ const confirmService = this.owner.lookup('service:confirm');
+ confirmService.enabled = true;
+
+ await visit(urls.rdpTarget);
+ await click(TARGET_CONNECT_BUTTON);
+
+ assert.strictEqual(currentURL(), urls.rdpSession);
+ assert.dom(RDP_OPEN_BUTTON).isVisible();
+
+ await click(RDP_OPEN_BUTTON);
+
+ assert.dom('.hds-modal').isVisible();
+ });
+
+ test('it displays open button without cancel session permission', async function (assert) {
+ this.ipcStub.withArgs('cliExists').returns(true);
+ this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP;
+ instances.target.update({ type: TYPE_TARGET_RDP });
+ instances.rdpSession.update({ authorized_actions: [] });
+
+ this.ipcStub.withArgs('connect').returns({
+ session_id: instances.rdpSession.id,
+ host_id: 'h_123',
+ address: 'a_123',
+ port: 'p_123',
+ protocol: 'rdp',
+ });
+
+ await visit(urls.rdpTarget);
+ await click(TARGET_CONNECT_BUTTON);
+
+ assert.strictEqual(currentURL(), urls.rdpSession);
+ assert.dom(RDP_OPEN_BUTTON).isVisible();
+ assert.dom(CANCEL_SESSION_BUTTON).isNotVisible();
+ });
});
diff --git a/ui/desktop/tests/acceptance/projects/settings/index-test.js b/ui/desktop/tests/acceptance/projects/settings/index-test.js
index 648c931254..03405e5433 100644
--- a/ui/desktop/tests/acceptance/projects/settings/index-test.js
+++ b/ui/desktop/tests/acceptance/projects/settings/index-test.js
@@ -19,6 +19,12 @@ import {
import WindowMockIPC from '../../../helpers/window-mock-ipc';
import setupStubs from 'api/test-support/handlers/cache-daemon-search';
import { setRunOptions } from 'ember-a11y-testing/test-support';
+import {
+ RDP_CLIENT_MSTSC_LINK,
+ RDP_CLIENT_MSTSC,
+ RDP_CLIENT_NONE,
+ RDP_CLIENT_WINDOWS_APP,
+} from 'desktop/services/rdp';
module('Acceptance | projects | settings | index', function (hooks) {
setupApplicationTest(hooks);
@@ -58,6 +64,9 @@ module('Acceptance | projects | settings | index', function (hooks) {
const SIGNOUT_BTN = '[data-test-settings-signout-btn]';
const MODAL_CLOSE_SESSIONS = '[data-test-close-sessions-modal]';
const MODAL_CONFIRM_BTN = '.hds-modal__footer .hds-button--color-primary';
+ const RDP_PREFERRED_CLIENT = '[data-test-select-preferred-rdp-client]';
+ const RDP_RECOMMENDED_CLIENT = '[data-test-recommended-rdp-client]';
+ const RDP_RECOMMENDED_CLIENT_LINK = '[data-test-recommended-rdp-client] a';
const setDefaultClusterUrl = (test) => {
const windowOrigin = window.location.origin;
@@ -98,6 +107,13 @@ module('Acceptance | projects | settings | index', function (hooks) {
this.ipcStub
.withArgs('cacheDaemonStatus')
.returns({ version: 'Boundary CLI v0.1.0' });
+
+ // mock RDP client data
+ this.ipcStub
+ .withArgs('getRdpClients')
+ .returns([RDP_CLIENT_MSTSC, RDP_CLIENT_NONE]);
+ this.ipcStub.withArgs('getPreferredRdpClient').returns(RDP_CLIENT_MSTSC);
+ this.ipcStub.withArgs('checkOS').returns({ isWindows: true, isMac: false });
});
test('can navigate to the settings page', async function (assert) {
@@ -226,4 +242,53 @@ module('Acceptance | projects | settings | index', function (hooks) {
assert.ok(stopAllSessions.calledOnce);
assert.notOk(currentSession().isAuthenticated);
});
+
+ test('preferred RDP client is selected correctly for Windows', async function (assert) {
+ await visit(urls.settings);
+
+ assert.dom(RDP_PREFERRED_CLIENT).isVisible().hasValue(RDP_CLIENT_MSTSC);
+ });
+
+ test('preferred RDP client is selected correctly for Mac', async function (assert) {
+ // update IPC stub fo mac
+ this.ipcStub.withArgs('checkOS').returns({ isWindows: false, isMac: true });
+ this.ipcStub
+ .withArgs('getRdpClients')
+ .returns([RDP_CLIENT_WINDOWS_APP, RDP_CLIENT_NONE]);
+ this.ipcStub
+ .withArgs('getPreferredRdpClient')
+ .returns(RDP_CLIENT_WINDOWS_APP);
+ await visit(urls.settings);
+
+ assert
+ .dom(RDP_PREFERRED_CLIENT)
+ .isVisible()
+ .hasValue(RDP_CLIENT_WINDOWS_APP);
+ });
+
+ test('recommended RDP client is shown when no RDP clients are detected', async function (assert) {
+ // update IPC stub for no RDP clients
+ this.ipcStub.withArgs('getRdpClients').returns([RDP_CLIENT_NONE]);
+ this.ipcStub.withArgs('getPreferredRdpClient').returns(RDP_CLIENT_NONE);
+ await visit(urls.settings);
+
+ assert.dom(RDP_RECOMMENDED_CLIENT).isVisible();
+ assert
+ .dom(RDP_RECOMMENDED_CLIENT_LINK)
+ .hasAttribute('href', RDP_CLIENT_MSTSC_LINK)
+ .hasText('Remote Desktop Connection (mstsc)');
+ });
+
+ test('preferred RDP client is updated correctly', async function (assert) {
+ const rdpService = this.owner.lookup('service:rdp');
+ this.ipcStub.withArgs('setPreferredRdpClient').resolves();
+ await visit(urls.settings);
+
+ await select(RDP_PREFERRED_CLIENT, RDP_CLIENT_NONE);
+
+ assert.ok(
+ this.ipcStub.calledWith('setPreferredRdpClient', RDP_CLIENT_NONE),
+ );
+ assert.strictEqual(rdpService.preferredRdpClient, RDP_CLIENT_NONE);
+ });
});
diff --git a/ui/desktop/tests/acceptance/projects/targets/index-test.js b/ui/desktop/tests/acceptance/projects/targets/index-test.js
index 82bc071f7e..483c8c2380 100644
--- a/ui/desktop/tests/acceptance/projects/targets/index-test.js
+++ b/ui/desktop/tests/acceptance/projects/targets/index-test.js
@@ -22,6 +22,9 @@ import {
STATUS_SESSION_ACTIVE,
STATUS_SESSION_TERMINATED,
} from 'api/models/session';
+import { TYPE_TARGET_RDP } from 'api/models/target';
+import sinon from 'sinon';
+import { RDP_CLIENT_NONE, RDP_CLIENT_WINDOWS_APP } from 'desktop/services/rdp';
module('Acceptance | projects | targets | index', function (hooks) {
setupApplicationTest(hooks);
@@ -43,7 +46,9 @@ module('Acceptance | projects | targets | index', function (hooks) {
'[data-test-targets-sessions-flyout] .hds-flyout__title';
const SESSIONS_FLYOUT_CLOSE_BUTTON =
'[data-test-targets-sessions-flyout] .hds-flyout__dismiss';
-
+ const TARGET_OPEN_BUTTON = (id) => `[data-test-targets-open-button="${id}"]`;
+ const TARGET_CONNECT_BUTTON = (id) =>
+ `[data-test-targets-connect-button="${id}"]`;
const instances = {
scopes: {
global: null,
@@ -159,6 +164,10 @@ module('Acceptance | projects | targets | index', function (hooks) {
this.ipcStub.withArgs('isCacheDaemonRunning').returns(true);
this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions');
+
+ // mock RDP service calls
+ this.rdpService = this.owner.lookup('service:rdp');
+ sinon.stub(this.rdpService, 'initialize').resolves();
});
test('visiting index while unauthenticated redirects to global authenticate method', async function (assert) {
@@ -709,4 +718,106 @@ module('Acceptance | projects | targets | index', function (hooks) {
.dom(activeSessionFlyoutButtonSelector(instances.session.targetId))
.doesNotExist();
});
+
+ test('shows `Open` button for RDP target with preferred client', async function (assert) {
+ this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP;
+ instances.target.update({
+ type: TYPE_TARGET_RDP,
+ });
+ await visit(urls.targets);
+
+ assert.dom(TARGET_OPEN_BUTTON(instances.target.id)).exists();
+ assert.dom(TARGET_OPEN_BUTTON(instances.target.id)).hasText('Open');
+ assert.dom('[data-test-icon=external-link]').exists();
+ });
+
+ test('shows `Connect` button for RDP target with no preferred client', async function (assert) {
+ this.rdpService.preferredRdpClient = RDP_CLIENT_NONE;
+ instances.target.update({
+ type: TYPE_TARGET_RDP,
+ });
+ await visit(urls.targets);
+
+ assert.dom(TARGET_CONNECT_BUTTON(instances.target.id)).exists();
+ assert.dom(TARGET_CONNECT_BUTTON(instances.target.id)).hasText('Connect');
+ assert.dom('[data-test-icon=external-link]').doesNotExist();
+ });
+
+ test('shows `Connect` button for non-RDP target', async function (assert) {
+ await visit(urls.targets);
+
+ assert.dom(TARGET_CONNECT_BUTTON(instances.target.id)).exists();
+ assert.dom(TARGET_CONNECT_BUTTON(instances.target.id)).hasText('Connect');
+ });
+
+ test('clicking `Open` button for RDP target calls launchRdpClient IPC', async function (assert) {
+ this.ipcStub.withArgs('cliExists').returns(true);
+
+ this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP;
+ instances.target.update({ type: TYPE_TARGET_RDP });
+
+ this.ipcStub.withArgs('connect').returns({
+ session_id: instances.session.id,
+ address: 'a_123',
+ port: 'p_123',
+ protocol: 'rdp',
+ });
+ this.ipcStub.withArgs('launchRdpClient').resolves();
+
+ // visit targets page
+ await visit(urls.targets);
+
+ assert.dom(TARGET_OPEN_BUTTON(instances.target.id)).exists();
+
+ await click(TARGET_OPEN_BUTTON(instances.target.id));
+
+ assert.ok(this.ipcStub.calledWith('launchRdpClient', instances.session.id));
+ });
+
+ test('clicking `Connect` button for RDP target without preferred client calls connect IPC', async function (assert) {
+ this.ipcStub.withArgs('cliExists').returns(true);
+
+ this.rdpService.preferredRdpClient = RDP_CLIENT_NONE;
+ instances.target.update({ type: TYPE_TARGET_RDP });
+
+ this.ipcStub.withArgs('connect').returns({
+ session_id: instances.session.id,
+ address: 'a_123',
+ port: 'p_123',
+ protocol: 'rdp',
+ });
+
+ // visit targets page
+ await visit(urls.targets);
+
+ assert.dom(TARGET_CONNECT_BUTTON(instances.target.id)).exists();
+
+ await click(TARGET_CONNECT_BUTTON(instances.target.id));
+
+ assert.ok(this.ipcStub.calledWith('connect'));
+ });
+
+ test('shows confirm modal when connection error occurs on launching rdp client', async function (assert) {
+ this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP;
+ instances.target.update({ type: TYPE_TARGET_RDP });
+
+ this.ipcStub.withArgs('cliExists').returns(true);
+ // target quick connection is a success but launching RDP client fails
+ this.ipcStub.withArgs('connect').returns({
+ session_id: instances.session.id,
+ address: 'a_123',
+ port: 'p_123',
+ protocol: 'rdp',
+ });
+ this.ipcStub.withArgs('launchRdpClient').rejects();
+
+ const confirmService = this.owner.lookup('service:confirm');
+ confirmService.enabled = true;
+
+ await visit(urls.targets);
+ await click(`[data-test-targets-open-button="${instances.target.id}"]`);
+
+ // Assert that the confirm modal appears
+ assert.dom(HDS_DIALOG_MODAL).isVisible();
+ });
});
diff --git a/ui/desktop/tests/acceptance/projects/targets/target-test.js b/ui/desktop/tests/acceptance/projects/targets/target-test.js
index 5d4cac159b..41932ad80e 100644
--- a/ui/desktop/tests/acceptance/projects/targets/target-test.js
+++ b/ui/desktop/tests/acceptance/projects/targets/target-test.js
@@ -17,6 +17,9 @@ import { setupApplicationTest } from 'desktop/tests/helpers';
import WindowMockIPC from '../../../helpers/window-mock-ipc';
import setupStubs from 'api/test-support/handlers/cache-daemon-search';
import { setRunOptions } from 'ember-a11y-testing/test-support';
+import { TYPE_TARGET_RDP } from 'api/models/target';
+import sinon from 'sinon';
+import { RDP_CLIENT_WINDOWS_APP } from 'desktop/services/rdp';
module('Acceptance | projects | targets | target', function (hooks) {
setupApplicationTest(hooks);
@@ -25,6 +28,7 @@ module('Acceptance | projects | targets | target', function (hooks) {
const TARGET_RESOURCE_LINK = (id) => `[data-test-visit-target="${id}"]`;
const TARGET_TABLE_CONNECT_BUTTON = (id) =>
`[data-test-targets-connect-button="${id}"]`;
+ const TARGET_OPEN_BUTTON = `[data-test-target-detail-open-button]`;
const TARGET_CONNECT_BUTTON = '[data-test-target-detail-connect-button]';
const TARGET_HOST_SOURCE_CONNECT_BUTTON = (id) =>
`[data-test-target-connect-button=${id}]`;
@@ -151,6 +155,10 @@ module('Acceptance | projects | targets | target', function (hooks) {
this.ipcStub.withArgs('isCacheDaemonRunning').returns(true);
this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions');
+
+ // mock RDP service calls
+ this.rdpService = this.owner.lookup('service:rdp');
+ sinon.stub(this.rdpService, 'initialize').resolves();
});
test('user can connect to a target with an address', async function (assert) {
@@ -570,6 +578,7 @@ module('Acceptance | projects | targets | target', function (hooks) {
assert.strictEqual(currentURL(), urls.targetWithOneHost);
assert.dom('.aliases').exists();
});
+
test('user can connect to a target without read permissions for host-set', async function (assert) {
setRunOptions({
rules: {
@@ -625,4 +634,94 @@ module('Acceptance | projects | targets | target', function (hooks) {
assert.dom(APP_STATE_TITLE).hasText('Connected');
});
+
+ test('shows `Open` and `Connect` button for RDP target with preferred client', async function (assert) {
+ this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP;
+ instances.target.update({ type: TYPE_TARGET_RDP });
+
+ this.stubCacheDaemonSearch();
+
+ await visit(urls.target);
+
+ assert.dom(TARGET_OPEN_BUTTON).exists();
+ assert.dom(TARGET_OPEN_BUTTON).hasText('Open');
+ assert.dom(TARGET_CONNECT_BUTTON).exists();
+ assert.dom(TARGET_CONNECT_BUTTON).hasText('Connect');
+ });
+
+ test('shows "Connect" button for RDP target without preferred client', async function (assert) {
+ this.rdpService.preferredRdpClient = null;
+ instances.target.update({ type: TYPE_TARGET_RDP });
+
+ this.stubCacheDaemonSearch();
+
+ await visit(urls.target);
+
+ assert.dom(TARGET_CONNECT_BUTTON).exists();
+ assert.dom(TARGET_CONNECT_BUTTON).hasText('Connect');
+ });
+
+ test('clicking `open` button for RDP target triggers launchRdpClient', async function (assert) {
+ this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP;
+ instances.target.update({ type: TYPE_TARGET_RDP });
+
+ this.ipcStub.withArgs('cliExists').returns(true);
+ this.ipcStub.withArgs('connect').returns({
+ session_id: instances.session.id,
+ address: 'a_123',
+ port: 'p_123',
+ protocol: 'rdp',
+ });
+ this.stubCacheDaemonSearch();
+ this.ipcStub.withArgs('launchRdpClient').resolves();
+
+ const confirmService = this.owner.lookup('service:confirm');
+ confirmService.enabled = true;
+
+ await visit(urls.target);
+
+ await click(TARGET_OPEN_BUTTON);
+
+ assert.ok(this.ipcStub.calledWith('launchRdpClient', instances.session.id));
+ });
+
+ test('shows `Connect` button for rdp target without preferred client', async function (assert) {
+ this.rdpService.preferredRdpClient = null;
+ instances.target.update({ type: TYPE_TARGET_RDP });
+
+ this.stubCacheDaemonSearch();
+ this.ipcStub.withArgs('cliExists').returns(true);
+ this.ipcStub.withArgs('connect').returns({
+ session_id: instances.session.id,
+ address: 'a_123',
+ port: 'p_123',
+ protocol: 'rdp',
+ });
+ await visit(urls.target);
+
+ assert.dom(TARGET_CONNECT_BUTTON).exists();
+ assert.dom(TARGET_CONNECT_BUTTON).hasText('Connect');
+ assert.dom(TARGET_OPEN_BUTTON).doesNotExist();
+
+ await click(TARGET_CONNECT_BUTTON);
+
+ assert.ok(this.ipcStub.calledWith('connect'));
+ assert.notOk(this.ipcStub.calledWith('launchRdpClient'));
+ });
+
+ test('shows confirm modal when quickConnectAndLaunchRdp fails', async function (assert) {
+ this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP;
+ instances.target.update({ type: TYPE_TARGET_RDP });
+ this.stubCacheDaemonSearch();
+
+ const confirmService = this.owner.lookup('service:confirm');
+ confirmService.enabled = true;
+
+ await visit(urls.target);
+
+ await click('[data-test-target-detail-open-button]');
+
+ // The modal should be visible because cliExists was not stubbed, and the connection failed
+ assert.dom(HDS_DIALOG_MODAL).isVisible();
+ });
});
diff --git a/ui/desktop/tests/acceptance/scopes-test.js b/ui/desktop/tests/acceptance/scopes-test.js
index 46c30db094..660c967db2 100644
--- a/ui/desktop/tests/acceptance/scopes-test.js
+++ b/ui/desktop/tests/acceptance/scopes-test.js
@@ -21,6 +21,7 @@ import {
import WindowMockIPC from '../helpers/window-mock-ipc';
import setupStubs from 'api/test-support/handlers/cache-daemon-search';
import { setRunOptions } from 'ember-a11y-testing/test-support';
+import sinon from 'sinon';
module('Acceptance | scopes', function (hooks) {
setupApplicationTest(hooks);
@@ -138,6 +139,10 @@ module('Acceptance | scopes', function (hooks) {
this.ipcStub.withArgs('isCacheDaemonRunning').returns(true);
this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions');
+
+ // mock RDP service calls
+ let rdpService = this.owner.lookup('service:rdp');
+ sinon.stub(rdpService, 'initialize').resolves();
});
test('visiting index', async function (assert) {
diff --git a/ui/desktop/tests/integration/components/settings-card/preferred-clients-test.js b/ui/desktop/tests/integration/components/settings-card/preferred-clients-test.js
new file mode 100644
index 0000000000..d91012c8ad
--- /dev/null
+++ b/ui/desktop/tests/integration/components/settings-card/preferred-clients-test.js
@@ -0,0 +1,91 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, select } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupIntl } from 'ember-intl/test-support';
+import Service from '@ember/service';
+import {
+ RDP_CLIENT_WINDOWS_APP,
+ RDP_CLIENT_NONE,
+ RDP_CLIENT_WINDOWS_APP_LINK,
+} from 'desktop/services/rdp';
+
+module(
+ 'Integration | Component | settings-card/preferred-clients',
+ function (hooks) {
+ setupRenderingTest(hooks);
+ setupIntl(hooks, 'en-us');
+
+ test('it renders protocol and preferred client correctly', async function (assert) {
+ let updatedPreferredRDPClient;
+ this.owner.register(
+ 'service:rdp',
+ class extends Service {
+ rdpClients = [RDP_CLIENT_WINDOWS_APP, RDP_CLIENT_NONE];
+ preferredRdpClient = RDP_CLIENT_WINDOWS_APP;
+ setPreferredRdpClient = (value) => {
+ updatedPreferredRDPClient = value;
+ };
+ },
+ );
+ await render(hbs``);
+
+ assert
+ .dom('.hds-table tbody tr td:first-child')
+ .hasText('Windows RDP', 'Protocol is rendered');
+ assert
+ .dom('select option:checked')
+ .hasText('Windows App', 'Preferred client is selected');
+
+ await select('select', RDP_CLIENT_NONE);
+
+ assert
+ .dom('select option:checked')
+ .hasText('None', 'Preferred client is updated');
+
+ assert.strictEqual(
+ updatedPreferredRDPClient,
+ RDP_CLIENT_NONE,
+ 'setPreferredRdpClient is called with correct value',
+ );
+ });
+
+ test('it renders recommended client link only when none is detected', async function (assert) {
+ this.owner.register(
+ 'service:rdp',
+ class extends Service {
+ rdpClients = [RDP_CLIENT_NONE];
+ recommendedRdpClient = {
+ name: RDP_CLIENT_WINDOWS_APP,
+ link: RDP_CLIENT_WINDOWS_APP_LINK,
+ };
+ },
+ );
+ await render(hbs``);
+
+ assert
+ .dom('.hds-table tbody tr td:first-child')
+ .hasText('Windows RDP', 'Protocol is rendered');
+
+ assert
+ .dom('.hds-table tbody tr td:last-child')
+ .includesText(
+ 'None detected. We recommend Windows App',
+ 'None detected message is rendered',
+ );
+
+ assert
+ .dom('.hds-table tbody tr td:last-child a')
+ .hasAttribute(
+ 'href',
+ RDP_CLIENT_WINDOWS_APP_LINK,
+ 'windows app download link is rendered',
+ );
+ });
+ },
+);
diff --git a/ui/desktop/tests/unit/services/rdp-test.js b/ui/desktop/tests/unit/services/rdp-test.js
new file mode 100644
index 0000000000..d945b7b32b
--- /dev/null
+++ b/ui/desktop/tests/unit/services/rdp-test.js
@@ -0,0 +1,101 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { module, test } from 'qunit';
+import { setupTest } from 'desktop/tests/helpers';
+import sinon from 'sinon';
+import {
+ RDP_CLIENT_NONE,
+ RDP_CLIENT_WINDOWS_APP,
+ RDP_CLIENT_MSTSC,
+ RDP_CLIENT_WINDOWS_APP_LINK,
+ RDP_CLIENT_MSTSC_LINK,
+} from 'desktop/services/rdp';
+
+module('Unit | Service | rdp', function (hooks) {
+ setupTest(hooks);
+
+ let service, ipcService;
+
+ hooks.beforeEach(function () {
+ service = this.owner.lookup('service:rdp');
+ ipcService = this.owner.lookup('service:ipc');
+ });
+
+ hooks.afterEach(function () {
+ sinon.restore();
+ });
+
+ test('getRdpClients sets to fallback value on error', async function (assert) {
+ const ipcStub = sinon.stub(ipcService, 'invoke');
+ ipcStub.withArgs('getRdpClients').rejects();
+ ipcStub.withArgs('checkOS').resolves({ isMac: true });
+ await service.getRdpClients();
+
+ assert.deepEqual(
+ service.rdpClients,
+ [RDP_CLIENT_NONE],
+ 'rdpClients fallback is set correctly',
+ );
+ });
+
+ test('getPreferredRdpClient sets to fallback value on error', async function (assert) {
+ sinon
+ .stub(ipcService, 'invoke')
+ .withArgs('getPreferredRdpClient')
+ .rejects();
+ await service.getPreferredRdpClient();
+
+ assert.strictEqual(
+ service.preferredRdpClient,
+ RDP_CLIENT_NONE,
+ 'preferredRdpClient fallback is set correctly',
+ );
+ });
+
+ test('setPreferredRdpClient sets to fallback value on error', async function (assert) {
+ sinon
+ .stub(ipcService, 'invoke')
+ .withArgs('setPreferredRdpClient', RDP_CLIENT_MSTSC)
+ .rejects();
+ await service.setPreferredRdpClient(RDP_CLIENT_MSTSC);
+
+ assert.strictEqual(
+ service.preferredRdpClient,
+ RDP_CLIENT_NONE,
+ 'preferredRdpClient fallback is set correctly',
+ );
+ });
+
+ test('sets recommendedRdpClient correctly based on OS', async function (assert) {
+ sinon
+ .stub(ipcService, 'invoke')
+ .withArgs('checkOS')
+ .resolves({ isWindows: true });
+ await service.getRecommendedRdpClient();
+
+ assert.deepEqual(
+ service.recommendedRdpClient,
+ {
+ name: RDP_CLIENT_MSTSC,
+ link: RDP_CLIENT_MSTSC_LINK,
+ },
+ 'recommendedRdpClient is set correctly for windows',
+ );
+
+ ipcService.invoke.withArgs('checkOS').resolves({ isMac: true });
+ service.recommendedRdpClient = null;
+ await service.getRecommendedRdpClient();
+
+ assert.deepEqual(
+ service.recommendedRdpClient,
+ {
+ name: RDP_CLIENT_WINDOWS_APP,
+ link: RDP_CLIENT_WINDOWS_APP_LINK,
+ },
+ 'recommendedRdpClient is set correctly for mac',
+ );
+ });
+});