Skip to content
14 changes: 14 additions & 0 deletions addons/api/mirage/scenarios/ipc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
}

/**
Expand Down
1 change: 1 addition & 0 deletions addons/core/translations/actions/en-us.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 15 additions & 0 deletions addons/core/translations/en-us.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href={rdpClientLink} target="_blank" rel="noopener noreferrer">{rdpClientName}</a>.'
worker-filter-generator:
title: Filter generator
description: Choose what you want to format into a filter.
Expand Down
40 changes: 39 additions & 1 deletion e2e-tests/desktop/tests/targets.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);
});
});
60 changes: 60 additions & 0 deletions e2e-tests/helpers/rdp.js
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}

<SettingsCard
@header={{t 'settings.preferred-clients.title'}}
@icon='external-link'
@description={{t 'settings.preferred-clients.description'}}
>
<:body>
<Hds::Table
class='full-width'
@model={{this.protocolClients}}
@columns={{array
(hash
label=(t 'settings.preferred-clients.table.header.protocol')
width='50%'
)
(hash
label=(t 'settings.preferred-clients.table.header.client') width='50%'
)
}}
>
<:body as |B|>
<B.Tr>
<B.Td>{{t
(concat
'settings.preferred-clients.table.data.protocols.'
B.data.protocolType
)
}}
</B.Td>
<B.Td>
{{#if this.showRecommendedRdpClient}}
<Hds::Text::Body data-test-recommended-rdp-client>
{{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
}}
</Hds::Text::Body>
{{else}}
<Hds::Form::Select::Field
@width='100%'
{{on 'change' B.data.updateClient}}
data-test-select-preferred-rdp-client
as |F|
>
<F.Options>
{{#each B.data.clients as |client|}}
<option
value={{client}}
selected={{eq B.data.preferredClient client}}
>
{{t
(concat
'settings.preferred-clients.table.data.clients.'
client
)
}}
</option>
{{/each}}
</F.Options>
</Hds::Form::Select::Field>
{{/if}}
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
</:body>
</SettingsCard>
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* 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';
import { RDP_CLIENT_NONE } from 'desktop/services/rdp';

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<Object>}
*/
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] === RDP_CLIENT_NONE
);
}

// =methods

/**
* Updates the preferred RDP client
* @param value
* @return {Promise<void>}
*/
@action
async updatePreferredRDPClient({ target: { value } }) {
await this.rdp.setPreferredRdpClient(value);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle
@service store;
@service can;
@service intl;
@service rdp;

// =attributes

Expand Down Expand Up @@ -244,6 +245,8 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle
'scopes.scope.projects.sessions.session',
session_id,
);

return session;
}

/**
Expand Down Expand Up @@ -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));
}
}
}
Loading
Loading