diff --git a/ui/desktop/electron-app/src/helpers/spawn-promise.js b/ui/desktop/electron-app/src/helpers/spawn-promise.js index 0fe95afeb..ea4ab83c3 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 28eea08af..7222a96bb 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 39475ad22..c0e08323f 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 10cd6d41d..c54cd96e0 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 6885037d6..278038324 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 000000000..41b027e43 --- /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();