From 437d0e686dd7b0ed90fd50f2513f60c24395c981 Mon Sep 17 00:00:00 2001 From: Priya Patel Date: Fri, 24 Oct 2025 12:58:50 -0500 Subject: [PATCH 1/9] Create new RDP service (#3029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 added a new RDP service * fix: 🐛 rdp test * refactor: 💡 update error handling for fetching rdp clients * refactor: 💡 addressed comments * fix: 🐛 revert private field * refactor: 💡 addressed comments * refactor: 💡 updated rdp clients to array of strings --- ui/desktop/app/services/rdp.js | 117 ++++++++++++++++++++ ui/desktop/electron-app/src/ipc/handlers.js | 22 ++++ ui/desktop/tests/unit/services/rdp-test.js | 55 +++++++++ 3 files changed, 194 insertions(+) create mode 100644 ui/desktop/app/services/rdp.js create mode 100644 ui/desktop/tests/unit/services/rdp-test.js diff --git a/ui/desktop/app/services/rdp.js b/ui/desktop/app/services/rdp.js new file mode 100644 index 0000000000..8b11a0b59b --- /dev/null +++ b/ui/desktop/app/services/rdp.js @@ -0,0 +1,117 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Service from '@ember/service'; +import { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +const { __electronLog } = globalThis; + +export default class RdpService extends Service { + // =services + + @service ipc; + + // =properties + + /** + * The preferred RDP client set by the user. + * @type {string|null} + * @private + */ + @tracked preferredRdpClient = null; + + /** + * The list of available RDP clients fetched from the main process. + * @type {Array} + * @private + */ + @tracked rdpClients = []; + + // =attributes + + /** + * Helper to determine if a preferred RDP client is set and the value is not "none". This is used to + * conditionally show the "Open" button for RDP targets only when a preferred + * RDP client is configured. + * @returns {boolean} + */ + get isPreferredRdpClientSet() { + return ( + this.preferredRdpClient !== null && this.preferredRdpClient !== 'none' + ); + } + + // =methods + + /** + * Fetches the list of available RDP clients from the main process. + */ + async getRdpClients() { + // Return cached clients if already fetched + if (this.rdpClients.length > 0) { + return this.rdpClients; + } + try { + this.rdpClients = await this.ipc.invoke('getRdpClients'); + return this.rdpClients; + } catch (error) { + __electronLog?.error('Failed to fetch RDP clients', error.message); + // default to 'none' option if it fails + this.rdpClients = ['none']; + return this.rdpClients; + } + } + + /** + * Fetches the preferred RDP client from the main process. + * @returns {string} The preferred RDP client + */ + async getPreferredRdpClient() { + // Return cached preferred RDP client if already fetched + if (this.preferredRdpClient !== null) { + return this.preferredRdpClient; + } + try { + this.preferredRdpClient = await this.ipc.invoke('getPreferredRdpClient'); + return this.preferredRdpClient; + } catch (error) { + __electronLog?.error( + 'Failed to fetch preferred RDP client', + error.message, + ); + // default to 'none' if it fails + this.preferredRdpClient = 'none'; + return this.preferredRdpClient; + } + } + + /** + * Sets the preferred RDP client by the user. + * @param {string} rdpClient - The value of the preferred RDP client + * @returns {Promise} The updated preferred RDP client + */ + async setPreferredRdpClient(rdpClient) { + try { + await this.ipc.invoke('setPreferredRdpClient', rdpClient); + this.preferredRdpClient = rdpClient; + return this.preferredRdpClient; + } catch (error) { + __electronLog?.error('Failed to set preferred RDP client', error.message); + // set to 'none' if it fails + this.preferredRdpClient = 'none'; + } + } + + /** + * Launches the RDP client for a given session. + * The `sessionId` is passed to the main process, which securely retrieves + * the proxy details and constructs the appropriate RDP connection parameters. + * @param {string} sessionId - The ID of the active session + */ + async launchRdpClient(sessionId) { + await this.ipc.invoke('launchRdpClient', sessionId); + } +} diff --git a/ui/desktop/electron-app/src/ipc/handlers.js b/ui/desktop/electron-app/src/ipc/handlers.js index aa44c08d2f..39475ad224 100644 --- a/ui/desktop/electron-app/src/ipc/handlers.js +++ b/ui/desktop/electron-app/src/ipc/handlers.js @@ -269,6 +269,28 @@ handle('getLogPath', () => { } }); +/** + * Returns the available RDP clients + */ +handle('getRdpClients', async () => []); + +/** + * Returns the preferred RDP client + */ +handle('getPreferredRdpClient', async () => 'none'); + +/** + * Sets the preferred RDP client + */ +handle('setPreferredRdpClient', async (rdpClient) => rdpClient); + +/** + * Launches the RDP client with the provided session ID. + */ +handle('launchRdpClient', async (sessionId) => { + return; +}); + /** * 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/tests/unit/services/rdp-test.js b/ui/desktop/tests/unit/services/rdp-test.js new file mode 100644 index 0000000000..0c104bd1d0 --- /dev/null +++ b/ui/desktop/tests/unit/services/rdp-test.js @@ -0,0 +1,55 @@ +/** + * 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'; + +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'); + }); + + test('getRdpClients sets to fallback value on error', async function (assert) { + sinon.stub(ipcService, 'invoke').withArgs('getRdpClients').rejects(); + await service.getRdpClients(); + assert.deepEqual( + service.rdpClients, + ['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, + 'none', + 'preferredRdpClient fallback is set correctly', + ); + }); + + test('setPreferredRdpClient sets to fallback value on error', async function (assert) { + sinon + .stub(ipcService, 'invoke') + .withArgs('setPreferredRdpClient', 'mstsc') + .rejects(); + await service.setPreferredRdpClient('mstsc'); + assert.strictEqual( + service.preferredRdpClient, + 'none', + 'preferredRdpClient fallback is set correctly', + ); + }); +}); From 6554af11649d5f7be61a12e640da353a4fb18f41 Mon Sep 17 00:00:00 2001 From: Dharini Date: Mon, 27 Oct 2025 14:01:17 -0700 Subject: [PATCH 2/9] =?UTF-8?q?chore:=20=F0=9F=A4=96=20add=20ipc=20handler?= =?UTF-8?q?s=20for=20rdp=20(#3031)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../electron-app/src/helpers/spawn-promise.js | 10 +- ui/desktop/electron-app/src/index.js | 3 + ui/desktop/electron-app/src/ipc/handlers.js | 17 +- .../src/models/session-manager.js | 9 + ui/desktop/electron-app/src/models/session.js | 8 + .../src/services/rdp-client-manager.js | 168 ++++++++++++++++++ 6 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 ui/desktop/electron-app/src/services/rdp-client-manager.js 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..7222a96bb2 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'); @@ -297,6 +298,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 39475ad224..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 @@ -272,24 +273,28 @@ handle('getLogPath', () => { /** * Returns the available RDP clients */ -handle('getRdpClients', async () => []); +handle('getRdpClients', async () => rdpClientManager.getAvailableRdpClients()); /** * Returns the preferred RDP client */ -handle('getPreferredRdpClient', async () => 'none'); +handle('getPreferredRdpClient', async () => + rdpClientManager.getPreferredRdpClient(), +); /** * Sets the preferred RDP client */ -handle('setPreferredRdpClient', async (rdpClient) => rdpClient); +handle('setPreferredRdpClient', (preferredClient) => + rdpClientManager.setPreferredRdpClient(preferredClient), +); /** * Launches the RDP client with the provided session ID. */ -handle('launchRdpClient', async (sessionId) => { - return; -}); +handle('launchRdpClient', async (sessionId) => + rdpClientManager.launchRdpClient(sessionId, sessionManager), +); /** * Handler to help create terminal windows. We don't use the helper `handle` method 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..41b027e437 --- /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 Client Configuration +const RDP_CLIENTS = [ + { + value: 'mstsc', + isAvailable: async () => { + if (!isWindows()) return false; + try { + const mstscPath = await which('mstsc', { nothrow: true }); + return Boolean(mstscPath); + } catch { + return false; + } + }, + }, + { + value: '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: '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 best default RDP client (first available that's not 'none') + * @returns {Promise} Best RDP client value or 'none' + */ + async getBestDefaultRdpClient() { + const availableClients = await this.getAvailableRdpClients(); + const bestClient = availableClients.find((client) => client !== 'none'); + return bestClient ?? 'none'; + } + + /** + * Gets the user's preferred RDP client, auto-detecting if not set + * @returns {Promise} Preferred RDP client value + */ + async getPreferredRdpClient() { + let preferredClient = store.get('preferredRdpClient'); + + if (!preferredClient) { + // Auto-detect and set the best available client + preferredClient = await this.getBestDefaultRdpClient(); + store.set('preferredRdpClient', preferredClient); + } + return preferredClient; + } + + /** + * 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', '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(); From 27136eaa713514adc46ceed2eb0f3f6f65c260a9 Mon Sep 17 00:00:00 2001 From: Dharini Date: Tue, 28 Oct 2025 15:52:01 -0700 Subject: [PATCH 3/9] feat: Add launch functionality in targets list view (#3035) --- addons/core/translations/actions/en-us.yaml | 1 + .../scopes/scope/projects/targets/index.js | 22 ++++ .../scopes/scope/projects/targets/index.hbs | 27 +++-- .../acceptance/projects/targets/index-test.js | 112 +++++++++++++++++- 4 files changed, 153 insertions(+), 9 deletions(-) diff --git a/addons/core/translations/actions/en-us.yaml b/addons/core/translations/actions/en-us.yaml index 51178af71c..5bf9accd6b 100644 --- a/addons/core/translations/actions/en-us.yaml +++ b/addons/core/translations/actions/en-us.yaml @@ -68,3 +68,4 @@ show-errors: Show Errors hide-errors: Hide Errors edit-worker-filter: Edit Worker Filter add-worker-filter: Add Worker Filter +open: Open diff --git a/ui/desktop/app/controllers/scopes/scope/projects/targets/index.js b/ui/desktop/app/controllers/scopes/scope/projects/targets/index.js index e3470fa857..7634255882 100644 --- a/ui/desktop/app/controllers/scopes/scope/projects/targets/index.js +++ b/ui/desktop/app/controllers/scopes/scope/projects/targets/index.js @@ -26,6 +26,7 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle @service store; @service can; @service intl; + @service rdp; // =attributes @@ -244,6 +245,8 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle 'scopes.scope.projects.sessions.session', session_id, ); + + return session; } /** @@ -311,4 +314,23 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle async refresh() { await this.currentRoute.refreshAll(); } + + /** + * Quick connect method used to call main connect method and + * then launch RDP client + * @param {TargetModel} target + */ + @action + async quickConnectAndLaunchRdp(target) { + try { + const session = await this.connect(target); + // Launch RDP client + await this.rdp.launchRdpClient(session.id); + } catch (error) { + this.confirm + .confirm(error.message, { isConnectError: true }) + // Retry + .then(() => this.quickConnectAndLaunchRdp(target)); + } + } } diff --git a/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs b/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs index 6092a0c717..d51d832b64 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs @@ -152,14 +152,25 @@
{{#if (can 'connect target' B.data)}} {{#if (can 'read target' B.data)}} - + {{#if (and B.data.isRDP this.rdp.isPreferredRdpClientSet)}} + + {{else}} + + {{/if}} {{else}} `[data-test-targets-open-button="${id}"]`; + const TARGET_CONNECT_BUTTON = (id) => + `[data-test-targets-connect-button="${id}"]`; const instances = { scopes: { global: null, @@ -709,4 +712,111 @@ 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) { + let rdpService = this.owner.lookup('service:rdp'); + rdpService.preferredRdpClient = '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) { + let rdpService = this.owner.lookup('service:rdp'); + rdpService.preferredRdpClient = '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); + + const rdpService = this.owner.lookup('service:rdp'); + rdpService.preferredRdpClient = '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); + + const rdpService = this.owner.lookup('service:rdp'); + rdpService.preferredRdpClient = '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) { + let rdpService = this.owner.lookup('service:rdp'); + rdpService.preferredRdpClient = '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(); + }); }); From 9234dbae701d53a2a6e4a91e1b2c02708be14602 Mon Sep 17 00:00:00 2001 From: Dharini Date: Fri, 31 Oct 2025 13:45:34 -0700 Subject: [PATCH 4/9] Icu 17881 UI desktop add rdp launch button in target detail view (#3038) --- .../scopes/scope/projects/targets/target.js | 10 ++ .../scopes/scope/projects/targets/target.hbs | 42 +++++--- .../projects/targets/target-test.js | 98 +++++++++++++++++++ 3 files changed, 139 insertions(+), 11 deletions(-) diff --git a/ui/desktop/app/controllers/scopes/scope/projects/targets/target.js b/ui/desktop/app/controllers/scopes/scope/projects/targets/target.js index d060a8ef9a..adf1eb5148 100644 --- a/ui/desktop/app/controllers/scopes/scope/projects/targets/target.js +++ b/ui/desktop/app/controllers/scopes/scope/projects/targets/target.js @@ -15,6 +15,7 @@ export default class ScopesScopeProjectsTargetsTargetController extends Controll @service store; @service confirm; + @service rdp; // =attributes @@ -48,4 +49,13 @@ export default class ScopesScopeProjectsTargetsTargetController extends Controll }); } } + + /** + * Launch method that calls parent quickConnectAndLaunchRdp method + * @param {TargetModel} target + */ + @action + async connectAndLaunchRdp(target) { + await this.targets.quickConnectAndLaunchRdp(target); + } } diff --git a/ui/desktop/app/templates/scopes/scope/projects/targets/target.hbs b/ui/desktop/app/templates/scopes/scope/projects/targets/target.hbs index e151496f32..b9f5b3547a 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/targets/target.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/targets/target.hbs @@ -15,17 +15,37 @@ {{#if (can 'connect target' @model.target)}} - + {{#if (and @model.target.isRDP this.rdp.isPreferredRdpClientSet)}} + + + {{else}} + + {{/if}} {{/if}} diff --git a/ui/desktop/tests/acceptance/projects/targets/target-test.js b/ui/desktop/tests/acceptance/projects/targets/target-test.js index 5d4cac159b..67c22164cf 100644 --- a/ui/desktop/tests/acceptance/projects/targets/target-test.js +++ b/ui/desktop/tests/acceptance/projects/targets/target-test.js @@ -17,6 +17,7 @@ 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'; module('Acceptance | projects | targets | target', function (hooks) { setupApplicationTest(hooks); @@ -25,6 +26,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}]`; @@ -570,6 +572,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 +628,99 @@ 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) { + let rdpService = this.owner.lookup('service:rdp'); + rdpService.preferredRdpClient = '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) { + let rdpService = this.owner.lookup('service:rdp'); + 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) { + let rdpService = this.owner.lookup('service:rdp'); + rdpService.preferredRdpClient = '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) { + let rdpService = this.owner.lookup('service:rdp'); + 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) { + let rdpService = this.owner.lookup('service:rdp'); + rdpService.preferredRdpClient = '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(); + }); }); From e55ea0b629f77e89be6834691eb10f9d5c42e9df Mon Sep 17 00:00:00 2001 From: Priya Patel Date: Fri, 31 Oct 2025 16:52:49 -0500 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20preferred=20clients?= =?UTF-8?q?=20section=20on=20settings=20page=20(#3034)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 preferred clients section on settings page * chore: 🤖 update mock ipc with new handlers * refactor: 💡 moved windows rdp protocol to const * refactor: 💡 moved rdp calls to app route * test: 💍 fixed failings tests * refactor: 💡 addressed comments * fix: 🐛 fixed failing tests * refactor: 💡 add mock rdp service calls to tests * refactor: 💡 addressed comments * test: 💍 add additional tests * refactor: 💡 fixed test failure --- addons/api/mirage/scenarios/ipc.js | 14 +++ addons/core/translations/en-us.yaml | 15 +++ .../settings-card/preferred-clients/index.hbs | 78 ++++++++++++++++ .../settings-card/preferred-clients/index.js | 50 ++++++++++ ui/desktop/app/routes/application.js | 4 + ui/desktop/app/services/rdp.js | 65 +++++++++++-- .../scopes/scope/projects/settings/index.hbs | 3 +- ui/desktop/electron-app/src/index.js | 8 +- .../src/services/rdp-client-manager.js | 19 ++-- .../tests/acceptance/application-test.js | 4 + .../tests/acceptance/cluster-url-test.js | 4 + .../projects/sessions/index-test.js | 5 + .../projects/sessions/session-test.js | 5 + .../projects/settings/index-test.js | 65 +++++++++++++ .../acceptance/projects/targets/index-test.js | 5 + .../projects/targets/target-test.js | 20 ++-- ui/desktop/tests/acceptance/scopes-test.js | 5 + .../settings-card/preferred-clients-test.js | 91 +++++++++++++++++++ ui/desktop/tests/unit/services/rdp-test.js | 58 ++++++++++-- 19 files changed, 487 insertions(+), 31 deletions(-) create mode 100644 ui/desktop/app/components/settings-card/preferred-clients/index.hbs create mode 100644 ui/desktop/app/components/settings-card/preferred-clients/index.js create mode 100644 ui/desktop/tests/integration/components/settings-card/preferred-clients-test.js diff --git a/addons/api/mirage/scenarios/ipc.js b/addons/api/mirage/scenarios/ipc.js index 5b34bc5b74..0339764c85 100644 --- a/addons/api/mirage/scenarios/ipc.js +++ b/addons/api/mirage/scenarios/ipc.js @@ -248,6 +248,20 @@ export default function initializeMockIPC(server, config) { resumeClientAgent() {} hasRunningSessions() {} stopAll() {} + getRdpClients() { + return ['windows-app', 'none', 'mstsc']; + } + getPreferredRdpClient() { + return 'windows-app'; + } + getRecommendedRdpClient() { + return { + name: 'windows-app', + link: 'https://apps.apple.com/us/app/windows-app/id1295203466', + }; + } + setPreferredRdpClient() {} + launchRdpClient() {} } /** diff --git a/addons/core/translations/en-us.yaml b/addons/core/translations/en-us.yaml index a60074f455..a0c2a5a10d 100644 --- a/addons/core/translations/en-us.yaml +++ b/addons/core/translations/en-us.yaml @@ -142,6 +142,21 @@ settings: alerts: cache-daemon: There may be a problem with the cache daemon client-agent: There may be a problem with the client agent + preferred-clients: + title: Preferred Clients + description: Select the applications for Boundary to use when opening these target types. + table: + header: + protocol: Protocol + client: Client + data: + protocols: + windows-rdp: Windows RDP + clients: + none: None + mstsc: Remote Desktop Connection (mstsc) + windows-app: Windows App + none-detected: 'None detected. We recommend {rdpClientName}.' worker-filter-generator: title: Filter generator description: Choose what you want to format into a filter. diff --git a/ui/desktop/app/components/settings-card/preferred-clients/index.hbs b/ui/desktop/app/components/settings-card/preferred-clients/index.hbs new file mode 100644 index 0000000000..a9c6a7afa3 --- /dev/null +++ b/ui/desktop/app/components/settings-card/preferred-clients/index.hbs @@ -0,0 +1,78 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + + + <:body> + + <:body as |B|> + + {{t + (concat + 'settings.preferred-clients.table.data.protocols.' + B.data.protocolType + ) + }} + + + {{#if this.showRecommendedRdpClient}} + + {{t + 'settings.preferred-clients.table.data.clients.none-detected' + rdpClientLink=this.rdp.recommendedRdpClient.link + rdpClientName=(t + (concat + 'settings.preferred-clients.table.data.clients.' + this.rdp.recommendedRdpClient.name + ) + ) + htmlSafe=true + }} + + {{else}} + + + {{#each B.data.clients as |client|}} + + {{/each}} + + + {{/if}} + + + + + + \ No newline at end of file diff --git a/ui/desktop/app/components/settings-card/preferred-clients/index.js b/ui/desktop/app/components/settings-card/preferred-clients/index.js new file mode 100644 index 0000000000..bf42849f91 --- /dev/null +++ b/ui/desktop/app/components/settings-card/preferred-clients/index.js @@ -0,0 +1,50 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; + +const PROTOCOL_WINDOWS_RDP = 'windows-rdp'; + +export default class SettingsCardPreferredClientsComponent extends Component { + // =services + @service rdp; + + // =getters + + /** + * Returns the list of protocols and their available clients + * @returns {Array} + */ + get protocolClients() { + return [ + { + protocolType: PROTOCOL_WINDOWS_RDP, + clients: this.rdp.rdpClients, + preferredClient: this.rdp.preferredRdpClient, + updateClient: this.updatePreferredRDPClient, + }, + ]; + } + + get showRecommendedRdpClient() { + return ( + this.rdp.rdpClients.length === 1 && this.rdp.rdpClients[0] === 'none' + ); + } + + // =methods + + /** + * Updates the preferred RDP client + * @param value + * @return {Promise} + */ + @action + async updatePreferredRDPClient({ target: { value } }) { + await this.rdp.setPreferredRdpClient(value); + } +} diff --git a/ui/desktop/app/routes/application.js b/ui/desktop/app/routes/application.js index 6a1d9cfb6c..f64c43191f 100644 --- a/ui/desktop/app/routes/application.js +++ b/ui/desktop/app/routes/application.js @@ -16,6 +16,7 @@ export default class ApplicationRoute extends Route { @service clusterUrl; @service ipc; @service intl; + @service rdp; // =attributes @@ -52,6 +53,9 @@ export default class ApplicationRoute extends Route { await this.session.loadAuthenticatedAccount(); } + + // initialize RDP service with rdp client data + await this.rdp.initialize(); } /** diff --git a/ui/desktop/app/services/rdp.js b/ui/desktop/app/services/rdp.js index 8b11a0b59b..09d0371992 100644 --- a/ui/desktop/app/services/rdp.js +++ b/ui/desktop/app/services/rdp.js @@ -7,7 +7,23 @@ import Service from '@ember/service'; import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; +export const RDP_CLIENT_NONE = 'none'; +export const RDP_CLIENT_WINDOWS_APP = 'windows-app'; +export const RDP_CLIENT_MSTSC = 'mstsc'; +export const RDP_CLIENT_WINDOWS_APP_LINK = + 'https://apps.apple.com/us/app/windows-app/id1295203466'; +export const RDP_CLIENT_MSTSC_LINK = + 'https://learn.microsoft.com/windows-server/remote/remote-desktop-services/remotepc/uninstall-remote-desktop-connection'; + const { __electronLog } = globalThis; +const macRecommendedRdpClient = { + name: RDP_CLIENT_WINDOWS_APP, + link: RDP_CLIENT_WINDOWS_APP_LINK, +}; +const windowsRecommendedRdpClient = { + name: RDP_CLIENT_MSTSC, + link: RDP_CLIENT_MSTSC_LINK, +}; export default class RdpService extends Service { // =services @@ -19,17 +35,21 @@ export default class RdpService extends Service { /** * The preferred RDP client set by the user. * @type {string|null} - * @private */ @tracked preferredRdpClient = null; /** * The list of available RDP clients fetched from the main process. * @type {Array} - * @private */ @tracked rdpClients = []; + /** + * The recommended RDP client based on platform when only 'none' is available. + * @type {Object|null} + */ + @tracked recommendedRdpClient = null; + // =attributes /** @@ -40,12 +60,21 @@ export default class RdpService extends Service { */ get isPreferredRdpClientSet() { return ( - this.preferredRdpClient !== null && this.preferredRdpClient !== 'none' + this.preferredRdpClient !== null && + this.preferredRdpClient !== RDP_CLIENT_NONE ); } // =methods + async initialize() { + await Promise.all([ + this.getRdpClients(), + this.getPreferredRdpClient(), + this.getRecommendedRdpClient(), + ]); + } + /** * Fetches the list of available RDP clients from the main process. */ @@ -60,11 +89,35 @@ export default class RdpService extends Service { } catch (error) { __electronLog?.error('Failed to fetch RDP clients', error.message); // default to 'none' option if it fails - this.rdpClients = ['none']; + this.rdpClients = [RDP_CLIENT_NONE]; return this.rdpClients; } } + /** + * Gets the recommended RDP client based on OS platform. + */ + async getRecommendedRdpClient() { + try { + // return cached recommended client if already set + if (this.recommendedRdpClient !== null) { + return this.recommendedRdpClient; + } + const { isWindows, isMac } = await this.ipc.invoke('checkOS'); + if (isWindows) { + this.recommendedRdpClient = windowsRecommendedRdpClient; + } else if (isMac) { + this.recommendedRdpClient = macRecommendedRdpClient; + } + } catch (error) { + __electronLog?.error( + 'Failed to set recommended RDP client', + error.message, + ); + this.recommendedRdpClient = null; + } + } + /** * Fetches the preferred RDP client from the main process. * @returns {string} The preferred RDP client @@ -83,7 +136,7 @@ export default class RdpService extends Service { error.message, ); // default to 'none' if it fails - this.preferredRdpClient = 'none'; + this.preferredRdpClient = RDP_CLIENT_NONE; return this.preferredRdpClient; } } @@ -101,7 +154,7 @@ export default class RdpService extends Service { } catch (error) { __electronLog?.error('Failed to set preferred RDP client', error.message); // set to 'none' if it fails - this.preferredRdpClient = 'none'; + this.preferredRdpClient = RDP_CLIENT_NONE; } } diff --git a/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs b/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs index 53b9c073bb..1ef0e799d8 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/settings/index.hbs @@ -7,4 +7,5 @@ - \ No newline at end of file + + \ No newline at end of file diff --git a/ui/desktop/electron-app/src/index.js b/ui/desktop/electron-app/src/index.js index 7222a96bb2..a6016d28bc 100644 --- a/ui/desktop/electron-app/src/index.js +++ b/ui/desktop/electron-app/src/index.js @@ -164,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); } diff --git a/ui/desktop/electron-app/src/services/rdp-client-manager.js b/ui/desktop/electron-app/src/services/rdp-client-manager.js index 41b027e437..94f256711c 100644 --- a/ui/desktop/electron-app/src/services/rdp-client-manager.js +++ b/ui/desktop/electron-app/src/services/rdp-client-manager.js @@ -9,10 +9,15 @@ 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: 'mstsc', + value: RDP_CLIENT_MSTSC, isAvailable: async () => { if (!isWindows()) return false; try { @@ -24,7 +29,7 @@ const RDP_CLIENTS = [ }, }, { - value: 'windows-app', + value: RDP_CLIENT_WINDOWS_APP, isAvailable: () => { if (!isMac()) return false; try { @@ -50,7 +55,7 @@ const RDP_CLIENTS = [ }, }, { - value: 'none', + value: RDP_CLIENT_NONE, isAvailable: () => true, }, ]; @@ -78,8 +83,10 @@ class RdpClientManager { */ async getBestDefaultRdpClient() { const availableClients = await this.getAvailableRdpClients(); - const bestClient = availableClients.find((client) => client !== 'none'); - return bestClient ?? 'none'; + const bestClient = availableClients.find( + (client) => client !== RDP_CLIENT_NONE, + ); + return bestClient ?? RDP_CLIENT_NONE; } /** @@ -103,7 +110,7 @@ class RdpClientManager { */ setPreferredRdpClient(preferredClient) { if (!preferredClient) { - store.set('preferredRdpClient', 'none'); + store.set('preferredRdpClient', RDP_CLIENT_NONE); } else { store.set('preferredRdpClient', preferredClient); } 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..61e43a90cf 100644 --- a/ui/desktop/tests/acceptance/projects/sessions/session-test.js +++ b/ui/desktop/tests/acceptance/projects/sessions/session-test.js @@ -13,6 +13,7 @@ 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'; module('Acceptance | projects | sessions | session', function (hooks) { setupApplicationTest(hooks); @@ -136,6 +137,10 @@ module('Acceptance | projects | sessions | session', function (hooks) { setDefaultClusterUrl(this); this.ipcStub.withArgs('isCacheDaemonRunning').returns(false); + + // 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/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 1784590187..d75cf1aa5d 100644 --- a/ui/desktop/tests/acceptance/projects/targets/index-test.js +++ b/ui/desktop/tests/acceptance/projects/targets/index-test.js @@ -23,6 +23,7 @@ import { STATUS_SESSION_TERMINATED, } from 'api/models/session'; import { TYPE_TARGET_RDP } from 'api/models/target'; +import sinon from 'sinon'; module('Acceptance | projects | targets | index', function (hooks) { setupApplicationTest(hooks); @@ -162,6 +163,10 @@ module('Acceptance | projects | targets | index', 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 while unauthenticated redirects to global authenticate method', async function (assert) { diff --git a/ui/desktop/tests/acceptance/projects/targets/target-test.js b/ui/desktop/tests/acceptance/projects/targets/target-test.js index 67c22164cf..8df611fd2f 100644 --- a/ui/desktop/tests/acceptance/projects/targets/target-test.js +++ b/ui/desktop/tests/acceptance/projects/targets/target-test.js @@ -18,6 +18,7 @@ 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'; module('Acceptance | projects | targets | target', function (hooks) { setupApplicationTest(hooks); @@ -153,6 +154,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) { @@ -630,8 +635,7 @@ module('Acceptance | projects | targets | target', function (hooks) { }); test('shows `Open` and `Connect` button for RDP target with preferred client', async function (assert) { - let rdpService = this.owner.lookup('service:rdp'); - rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = 'windows-app'; instances.target.update({ type: TYPE_TARGET_RDP }); this.stubCacheDaemonSearch(); @@ -645,8 +649,7 @@ module('Acceptance | projects | targets | target', function (hooks) { }); test('shows "Connect" button for RDP target without preferred client', async function (assert) { - let rdpService = this.owner.lookup('service:rdp'); - rdpService.preferredRdpClient = null; + this.rdpService.preferredRdpClient = null; instances.target.update({ type: TYPE_TARGET_RDP }); this.stubCacheDaemonSearch(); @@ -658,8 +661,7 @@ module('Acceptance | projects | targets | target', function (hooks) { }); test('clicking `open` button for RDP target triggers launchRdpClient', async function (assert) { - let rdpService = this.owner.lookup('service:rdp'); - rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = 'windows-app'; instances.target.update({ type: TYPE_TARGET_RDP }); this.ipcStub.withArgs('cliExists').returns(true); @@ -683,8 +685,7 @@ module('Acceptance | projects | targets | target', function (hooks) { }); test('shows `Connect` button for rdp target without preferred client', async function (assert) { - let rdpService = this.owner.lookup('service:rdp'); - rdpService.preferredRdpClient = null; + this.rdpService.preferredRdpClient = null; instances.target.update({ type: TYPE_TARGET_RDP }); this.stubCacheDaemonSearch(); @@ -708,8 +709,7 @@ module('Acceptance | projects | targets | target', function (hooks) { }); test('shows confirm modal when quickConnectAndLaunchRdp fails', async function (assert) { - let rdpService = this.owner.lookup('service:rdp'); - rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = 'windows-app'; instances.target.update({ type: TYPE_TARGET_RDP }); this.stubCacheDaemonSearch(); 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 index 0c104bd1d0..d945b7b32b 100644 --- a/ui/desktop/tests/unit/services/rdp-test.js +++ b/ui/desktop/tests/unit/services/rdp-test.js @@ -6,6 +6,13 @@ 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); @@ -17,12 +24,19 @@ module('Unit | Service | rdp', function (hooks) { ipcService = this.owner.lookup('service:ipc'); }); + hooks.afterEach(function () { + sinon.restore(); + }); + test('getRdpClients sets to fallback value on error', async function (assert) { - sinon.stub(ipcService, 'invoke').withArgs('getRdpClients').rejects(); + const ipcStub = sinon.stub(ipcService, 'invoke'); + ipcStub.withArgs('getRdpClients').rejects(); + ipcStub.withArgs('checkOS').resolves({ isMac: true }); await service.getRdpClients(); + assert.deepEqual( service.rdpClients, - ['none'], + [RDP_CLIENT_NONE], 'rdpClients fallback is set correctly', ); }); @@ -33,9 +47,10 @@ module('Unit | Service | rdp', function (hooks) { .withArgs('getPreferredRdpClient') .rejects(); await service.getPreferredRdpClient(); + assert.strictEqual( service.preferredRdpClient, - 'none', + RDP_CLIENT_NONE, 'preferredRdpClient fallback is set correctly', ); }); @@ -43,13 +58,44 @@ module('Unit | Service | rdp', function (hooks) { test('setPreferredRdpClient sets to fallback value on error', async function (assert) { sinon .stub(ipcService, 'invoke') - .withArgs('setPreferredRdpClient', 'mstsc') + .withArgs('setPreferredRdpClient', RDP_CLIENT_MSTSC) .rejects(); - await service.setPreferredRdpClient('mstsc'); + await service.setPreferredRdpClient(RDP_CLIENT_MSTSC); + assert.strictEqual( service.preferredRdpClient, - 'none', + 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', + ); + }); }); From fb0020c3959127163bb59da77f1bc5f4d52bd829 Mon Sep 17 00:00:00 2001 From: Priya Patel Date: Mon, 3 Nov 2025 16:56:16 -0500 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20Add=20RDP=20client?= =?UTF-8?q?=20launch=20on=20session=20details=20page=20(#3037)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 preferred clients section on settings page * refactor: 💡 moved rdp calls to app route * test: 💍 fixed failings tests * refactor: 💡 addressed comments * fix: 🐛 fixed failing tests * refactor: 💡 add mock rdp service calls to tests * feat: 🎸 adds open button to session details page * fix: 🐛 translation string * refactor: 💡 address feedback * refactor: 💡 address comment * fix: 🐛 test failure --- .../scopes/scope/projects/sessions/session.js | 36 ++++++ .../scope/projects/sessions/session.hbs | 28 +++-- .../projects/sessions/session-test.js | 119 ++++++++++++++++-- .../acceptance/projects/targets/index-test.js | 19 ++- 4 files changed, 174 insertions(+), 28 deletions(-) diff --git a/ui/desktop/app/controllers/scopes/scope/projects/sessions/session.js b/ui/desktop/app/controllers/scopes/scope/projects/sessions/session.js index 3292e67457..2f65d56b60 100644 --- a/ui/desktop/app/controllers/scopes/scope/projects/sessions/session.js +++ b/ui/desktop/app/controllers/scopes/scope/projects/sessions/session.js @@ -4,7 +4,43 @@ */ import Controller, { inject as controller } from '@ember/controller'; +import { TYPE_TARGET_RDP } from 'api/models/target'; +import { service } from '@ember/service'; +import { action } from '@ember/object'; export default class ScopesScopeProjectsSessionsSessionController extends Controller { @controller('scopes/scope/projects/sessions/index') sessions; + + // =services + + @service rdp; + @service confirm; + + // =getters + + /** + * Whether to show the "Open" button for launching the RDP client. + * @returns {boolean} + */ + get showOpenButton() { + return ( + this.model.target?.type === TYPE_TARGET_RDP && + this.rdp.isPreferredRdpClientSet && + this.model.id + ); + } + + // =methods + + @action + async launchRdpClient() { + try { + await this.rdp.launchRdpClient(this.model.id); + } catch (error) { + this.confirm + .confirm(error.message, { isConnectError: true }) + // Retry + .then(() => this.launchRdpClient()); + } + } } diff --git a/ui/desktop/app/templates/scopes/scope/projects/sessions/session.hbs b/ui/desktop/app/templates/scopes/scope/projects/sessions/session.hbs index 19e27bd839..3df918ab4e 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/sessions/session.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/sessions/session.hbs @@ -19,15 +19,27 @@ @icon='loading' /> - {{#if (can 'cancel session' @model)}} + {{#if (or (can 'cancel session' @model) this.showOpenButton)}} - + {{#if (can 'cancel session' @model)}} + + {{/if}} + {{#if this.showOpenButton}} + + {{/if}} {{/if}} diff --git a/ui/desktop/tests/acceptance/projects/sessions/session-test.js b/ui/desktop/tests/acceptance/projects/sessions/session-test.js index 61e43a90cf..486ef4bb37 100644 --- a/ui/desktop/tests/acceptance/projects/sessions/session-test.js +++ b/ui/desktop/tests/acceptance/projects/sessions/session-test.js @@ -14,6 +14,7 @@ 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'; module('Acceptance | projects | sessions | session', function (hooks) { setupApplicationTest(hooks); @@ -24,6 +25,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: { @@ -36,6 +39,7 @@ module('Acceptance | projects | sessions | session', function (hooks) { }, user: null, session: null, + rdpSession: null, }; const urls = { @@ -139,8 +143,8 @@ module('Acceptance | projects | sessions | session', function (hooks) { this.ipcStub.withArgs('isCacheDaemonRunning').returns(false); // mock RDP service calls - let rdpService = this.owner.lookup('service:rdp'); - sinon.stub(rdpService, 'initialize').resolves(); + this.rdpService = this.owner.lookup('service:rdp'); + sinon.stub(this.rdpService, 'initialize').resolves(); }); hooks.afterEach(function () { @@ -486,7 +490,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) { @@ -504,7 +508,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) { @@ -521,7 +525,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') @@ -542,7 +546,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); }); @@ -562,7 +566,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') @@ -584,10 +588,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 = '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 = '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 = '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 = '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/targets/index-test.js b/ui/desktop/tests/acceptance/projects/targets/index-test.js index d75cf1aa5d..6008e5a4cd 100644 --- a/ui/desktop/tests/acceptance/projects/targets/index-test.js +++ b/ui/desktop/tests/acceptance/projects/targets/index-test.js @@ -165,8 +165,8 @@ module('Acceptance | projects | targets | index', function (hooks) { this.stubCacheDaemonSearch('sessions', 'targets', 'aliases', 'sessions'); // mock RDP service calls - let rdpService = this.owner.lookup('service:rdp'); - sinon.stub(rdpService, 'initialize').resolves(); + 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) { @@ -719,8 +719,7 @@ module('Acceptance | projects | targets | index', function (hooks) { }); test('shows `Open` button for RDP target with preferred client', async function (assert) { - let rdpService = this.owner.lookup('service:rdp'); - rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = 'windows-app'; instances.target.update({ type: TYPE_TARGET_RDP, }); @@ -732,8 +731,7 @@ module('Acceptance | projects | targets | index', function (hooks) { }); test('shows `Connect` button for RDP target with no preferred client', async function (assert) { - let rdpService = this.owner.lookup('service:rdp'); - rdpService.preferredRdpClient = 'none'; + this.rdpService.preferredRdpClient = 'none'; instances.target.update({ type: TYPE_TARGET_RDP, }); @@ -754,8 +752,7 @@ module('Acceptance | projects | targets | index', function (hooks) { test('clicking `Open` button for RDP target calls launchRdpClient IPC', async function (assert) { this.ipcStub.withArgs('cliExists').returns(true); - const rdpService = this.owner.lookup('service:rdp'); - rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = 'windows-app'; instances.target.update({ type: TYPE_TARGET_RDP }); this.ipcStub.withArgs('connect').returns({ @@ -779,8 +776,7 @@ module('Acceptance | projects | targets | index', function (hooks) { test('clicking `Connect` button for RDP target without preferred client calls connect IPC', async function (assert) { this.ipcStub.withArgs('cliExists').returns(true); - const rdpService = this.owner.lookup('service:rdp'); - rdpService.preferredRdpClient = 'none'; + this.rdpService.preferredRdpClient = 'none'; instances.target.update({ type: TYPE_TARGET_RDP }); this.ipcStub.withArgs('connect').returns({ @@ -801,8 +797,7 @@ module('Acceptance | projects | targets | index', function (hooks) { }); test('shows confirm modal when connection error occurs on launching rdp client', async function (assert) { - let rdpService = this.owner.lookup('service:rdp'); - rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = 'windows-app'; instances.target.update({ type: TYPE_TARGET_RDP }); this.ipcStub.withArgs('cliExists').returns(true); From d2cf82b85c88262058ab76730a5e9c8e4b259694 Mon Sep 17 00:00:00 2001 From: Priya Patel Date: Wed, 12 Nov 2025 12:33:25 -0500 Subject: [PATCH 7/9] e2e Rdp client tests (#3055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 💍 e2e test for rdp client launch * test: 💍 added rdp test helper * refactor: 💡 misc * refactor: 💡 addressed comments * refactor: 💡 removed similar test --- e2e-tests/desktop/tests/targets.spec.js | 40 ++++++++++++++++- e2e-tests/helpers/rdp.js | 60 +++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 e2e-tests/helpers/rdp.js diff --git a/e2e-tests/desktop/tests/targets.spec.js b/e2e-tests/desktop/tests/targets.spec.js index 6362fc5bf1..c3d8e260e7 100644 --- a/e2e-tests/desktop/tests/targets.spec.js +++ b/e2e-tests/desktop/tests/targets.spec.js @@ -6,6 +6,12 @@ import { expect, test } from '../fixtures/baseTest.js'; import * as boundaryHttp from '../../helpers/boundary-http.js'; import { textToMatch } from '../fixtures/tesseractTest.js'; +import { + isRdpClientInstalled, + isRdpRunning, + killRdpProcesses, + isOSForRdpSupported, +} from '../../helpers/rdp.js'; const hostName = 'Host name for test'; let org; @@ -117,7 +123,6 @@ test.beforeEach( }); // Create an RDP target and add host source and credential sources - // TODO: A test for RDP target connection will be added later when the Proxy is in place. rdpTarget = await boundaryHttp.createTarget(request, { scopeId: project.id, type: 'rdp', @@ -285,4 +290,37 @@ test.describe('Targets tests', () => { authedPage.getByRole('link', { name: targetWithHost.name }), ).toBeVisible(); }); + + test('Launches RDP client when connecting to an RDP target', async ({ + authedPage, + }) => { + const isRdpClientInstalledCheck = await isRdpClientInstalled(); + const isOSForRdpSupportedCheck = await isOSForRdpSupported(); + + test.skip( + !isRdpClientInstalledCheck || !isOSForRdpSupportedCheck, + 'RDP client is not installed/supported on this system', + ); + + const beforeLaunchRunning = await isRdpRunning(); + expect(beforeLaunchRunning).toBe(false); + + await authedPage.getByRole('link', { name: rdpTarget.name }).click(); + await authedPage.getByRole('button', { name: 'Open' }).click(); + + await expect( + authedPage.getByRole('heading', { name: 'Sessions' }), + ).toBeVisible(); + + const afterLaunchRunning = await isRdpRunning(); + expect(afterLaunchRunning).toBe(true); + + await authedPage.getByRole('button', { name: 'End Session' }).click(); + await expect(authedPage.getByText('Canceled successfully.')).toBeVisible(); + + killRdpProcesses(); + + const afterKillRunning = await isRdpRunning(); + expect(afterKillRunning).toBe(false); + }); }); diff --git a/e2e-tests/helpers/rdp.js b/e2e-tests/helpers/rdp.js new file mode 100644 index 0000000000..defac2550e --- /dev/null +++ b/e2e-tests/helpers/rdp.js @@ -0,0 +1,60 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { execSync } from 'node:child_process'; + +export async function isOSForRdpSupported() { + return process.platform === 'win32' || process.platform === 'darwin'; +} + +export async function isRdpRunning() { + try { + let result; + if (process.platform === 'win32') { + result = execSync('tasklist /FI "IMAGENAME eq mstsc.exe" /FO CSV /NH', { + encoding: 'utf-8', + }); + return result && result.includes('mstsc.exe'); + } else if (process.platform === 'darwin') { + result = execSync('pgrep -x "Windows App"', { + encoding: 'utf-8', + }); + return result && result.trim().length > 0; + } + return false; + } catch { + return false; + } +} + +export async function isRdpClientInstalled() { + try { + if (process.platform === 'win32') { + execSync('where mstsc', { stdio: 'ignore' }); + return true; + } else if (process.platform === 'darwin') { + const result = execSync( + 'mdfind "kMDItemCFBundleIdentifier == \'com.microsoft.rdc.macos\'"', + { encoding: 'utf-8' }, + ); + return result && result.trim().length > 0; + } + return false; + } catch { + return false; + } +} + +export function killRdpProcesses() { + try { + if (process.platform === 'win32') { + execSync('taskkill /F /IM mstsc.exe', { stdio: 'ignore' }); + } else if (process.platform === 'darwin') { + execSync('pkill -x "Windows App"', { stdio: 'ignore' }); + } + } catch { + // no op + } +} From 5faf830d4620c3e497e800dfec6c9aa377fea0d5 Mon Sep 17 00:00:00 2001 From: Priya Patel Date: Thu, 13 Nov 2025 13:15:54 -0500 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20=F0=9F=92=A1=20updated=20magic?= =?UTF-8?q?=20strings=20with=20const=20(#3064)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings-card/preferred-clients/index.js | 4 +++- .../acceptance/projects/sessions/session-test.js | 9 +++++---- .../tests/acceptance/projects/targets/index-test.js | 11 ++++++----- .../tests/acceptance/projects/targets/target-test.js | 7 ++++--- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/ui/desktop/app/components/settings-card/preferred-clients/index.js b/ui/desktop/app/components/settings-card/preferred-clients/index.js index bf42849f91..12ab116d47 100644 --- a/ui/desktop/app/components/settings-card/preferred-clients/index.js +++ b/ui/desktop/app/components/settings-card/preferred-clients/index.js @@ -6,6 +6,7 @@ import Component from '@glimmer/component'; import { action } from '@ember/object'; import { service } from '@ember/service'; +import { RDP_CLIENT_NONE } from 'desktop/services/rdp'; const PROTOCOL_WINDOWS_RDP = 'windows-rdp'; @@ -32,7 +33,8 @@ export default class SettingsCardPreferredClientsComponent extends Component { get showRecommendedRdpClient() { return ( - this.rdp.rdpClients.length === 1 && this.rdp.rdpClients[0] === 'none' + this.rdp.rdpClients.length === 1 && + this.rdp.rdpClients[0] === RDP_CLIENT_NONE ); } diff --git a/ui/desktop/tests/acceptance/projects/sessions/session-test.js b/ui/desktop/tests/acceptance/projects/sessions/session-test.js index 486ef4bb37..29d969e458 100644 --- a/ui/desktop/tests/acceptance/projects/sessions/session-test.js +++ b/ui/desktop/tests/acceptance/projects/sessions/session-test.js @@ -15,6 +15,7 @@ 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); @@ -598,7 +599,7 @@ module('Acceptance | projects | sessions | session', function (hooks) { 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 = 'windows-app'; + this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP; instances.target.update({ type: TYPE_TARGET_RDP }); this.ipcStub.withArgs('connect').returns({ @@ -625,7 +626,7 @@ module('Acceptance | projects | sessions | session', function (hooks) { 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 = 'none'; + this.rdpService.preferredRdpClient = RDP_CLIENT_NONE; instances.target.update({ type: TYPE_TARGET_RDP }); this.ipcStub.withArgs('connect').returns({ @@ -646,7 +647,7 @@ module('Acceptance | projects | sessions | session', function (hooks) { 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 = 'windows-app'; + this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP; instances.target.update({ type: TYPE_TARGET_RDP }); this.ipcStub.withArgs('connect').returns({ @@ -674,7 +675,7 @@ module('Acceptance | projects | sessions | session', function (hooks) { test('it displays open button without cancel session permission', async function (assert) { this.ipcStub.withArgs('cliExists').returns(true); - this.rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP; instances.target.update({ type: TYPE_TARGET_RDP }); instances.rdpSession.update({ authorized_actions: [] }); diff --git a/ui/desktop/tests/acceptance/projects/targets/index-test.js b/ui/desktop/tests/acceptance/projects/targets/index-test.js index 6008e5a4cd..483c8c2380 100644 --- a/ui/desktop/tests/acceptance/projects/targets/index-test.js +++ b/ui/desktop/tests/acceptance/projects/targets/index-test.js @@ -24,6 +24,7 @@ import { } 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); @@ -719,7 +720,7 @@ module('Acceptance | projects | targets | index', function (hooks) { }); test('shows `Open` button for RDP target with preferred client', async function (assert) { - this.rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP; instances.target.update({ type: TYPE_TARGET_RDP, }); @@ -731,7 +732,7 @@ module('Acceptance | projects | targets | index', function (hooks) { }); test('shows `Connect` button for RDP target with no preferred client', async function (assert) { - this.rdpService.preferredRdpClient = 'none'; + this.rdpService.preferredRdpClient = RDP_CLIENT_NONE; instances.target.update({ type: TYPE_TARGET_RDP, }); @@ -752,7 +753,7 @@ module('Acceptance | projects | targets | index', function (hooks) { test('clicking `Open` button for RDP target calls launchRdpClient IPC', async function (assert) { this.ipcStub.withArgs('cliExists').returns(true); - this.rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP; instances.target.update({ type: TYPE_TARGET_RDP }); this.ipcStub.withArgs('connect').returns({ @@ -776,7 +777,7 @@ module('Acceptance | projects | targets | index', function (hooks) { 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 = 'none'; + this.rdpService.preferredRdpClient = RDP_CLIENT_NONE; instances.target.update({ type: TYPE_TARGET_RDP }); this.ipcStub.withArgs('connect').returns({ @@ -797,7 +798,7 @@ module('Acceptance | projects | targets | index', function (hooks) { }); test('shows confirm modal when connection error occurs on launching rdp client', async function (assert) { - this.rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP; instances.target.update({ type: TYPE_TARGET_RDP }); this.ipcStub.withArgs('cliExists').returns(true); diff --git a/ui/desktop/tests/acceptance/projects/targets/target-test.js b/ui/desktop/tests/acceptance/projects/targets/target-test.js index 8df611fd2f..41932ad80e 100644 --- a/ui/desktop/tests/acceptance/projects/targets/target-test.js +++ b/ui/desktop/tests/acceptance/projects/targets/target-test.js @@ -19,6 +19,7 @@ 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); @@ -635,7 +636,7 @@ module('Acceptance | projects | targets | target', function (hooks) { }); test('shows `Open` and `Connect` button for RDP target with preferred client', async function (assert) { - this.rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP; instances.target.update({ type: TYPE_TARGET_RDP }); this.stubCacheDaemonSearch(); @@ -661,7 +662,7 @@ module('Acceptance | projects | targets | target', function (hooks) { }); test('clicking `open` button for RDP target triggers launchRdpClient', async function (assert) { - this.rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP; instances.target.update({ type: TYPE_TARGET_RDP }); this.ipcStub.withArgs('cliExists').returns(true); @@ -709,7 +710,7 @@ module('Acceptance | projects | targets | target', function (hooks) { }); test('shows confirm modal when quickConnectAndLaunchRdp fails', async function (assert) { - this.rdpService.preferredRdpClient = 'windows-app'; + this.rdpService.preferredRdpClient = RDP_CLIENT_WINDOWS_APP; instances.target.update({ type: TYPE_TARGET_RDP }); this.stubCacheDaemonSearch(); From 4fc3244049028a668728d696bd5c4e631b446d65 Mon Sep 17 00:00:00 2001 From: Priya Patel Date: Mon, 17 Nov 2025 17:59:23 -0500 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20=F0=9F=92=A1=20update=20logic?= =?UTF-8?q?=20to=20get=20preferred=20rdp=20client=20(#3066)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 💡 updated logic to store preferred rdp client * refactor: 💡 simplified preferred client logic --- .../src/services/rdp-client-manager.js | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/ui/desktop/electron-app/src/services/rdp-client-manager.js b/ui/desktop/electron-app/src/services/rdp-client-manager.js index 94f256711c..6ba4d047a9 100644 --- a/ui/desktop/electron-app/src/services/rdp-client-manager.js +++ b/ui/desktop/electron-app/src/services/rdp-client-manager.js @@ -78,30 +78,23 @@ class RdpClientManager { } /** - * Gets the best default RDP client (first available that's not 'none') - * @returns {Promise} Best RDP client value or 'none' + * Gets the user's preferred RDP client, auto-detecting if not set + * @returns {Promise} Preferred RDP client value */ - async getBestDefaultRdpClient() { + async getPreferredRdpClient() { const availableClients = await this.getAvailableRdpClients(); - const bestClient = availableClients.find( + const installedClients = availableClients.filter( (client) => client !== RDP_CLIENT_NONE, ); - return bestClient ?? RDP_CLIENT_NONE; - } - /** - * Gets the user's preferred RDP client, auto-detecting if not set - * @returns {Promise} Preferred RDP client value - */ - async getPreferredRdpClient() { - let preferredClient = store.get('preferredRdpClient'); + if (installedClients.length === 0) return RDP_CLIENT_NONE; - if (!preferredClient) { - // Auto-detect and set the best available client - preferredClient = await this.getBestDefaultRdpClient(); - store.set('preferredRdpClient', preferredClient); - } - return preferredClient; + const preferredClient = + store.get('preferredRdpClient') ?? installedClients[0]; + // check if preferred client is still installed + return installedClients.includes(preferredClient) + ? preferredClient + : RDP_CLIENT_NONE; } /**