Skip to content

Commit 7738308

Browse files
authored
feat(webapp): add new floating session toolbar for web client sessions (#1756)
## Summary Introduces a new floating session toolbar for web client sessions. This toolbar replaces the previous implementation with a more flexible, reusable, and protocol-agnostic design, supporting multiple session types and improved user interaction. ## Key features ### Floating toolbar - New floating toolbar UI for active sessions - Supports: - docking (top, bottom, left, right) - free positioning (drag) - auto-hide behavior - Designed as a presentation-focused component ### Protocol support - Works across all session types: - RDP - VNC - ARD - SSH - Telnet - Protocol-specific behavior handled outside the base toolbar ### Interaction controls - Session control actions (e.g. close, special keys where applicable) - Feature toggles: - dynamic resize - unicode keyboard - cursor crosshair - wheel speed (where supported) ### Session info popover - Added session info popover to display: - Session ID - Gateway URL - Username - Default key/value renderer with: - stable ordering - hidden row support - empty value handling - boolean formatting (Yes/No) - Row-based model (`ToolbarSessionInfo`) for protocol-provided data ### Extensibility - Optional `sessionInfoTemplate` input for future custom rendering - Base toolbar remains protocol-agnostic and presentation-focused ## Styling - New SCSS structure for the toolbar - Ensured compliance with Angular `anyComponentStyle` budget - Improved theme consistency using CSS variables ## Notes - Gateway URL currently uses existing connection data - `sessionInfoTemplate` is not yet used by built-in protocols ## Validation - Verified across all supported protocols
1 parent fe4324e commit 7738308

35 files changed

Lines changed: 3556 additions & 611 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Component, EventEmitter, Input, Output, TemplateRef } from '@angular/core';
2+
import { FloatingSessionToolbarComponent } from '@shared/components/floating-session-toolbar/floating-session-toolbar.component';
3+
import { ToolbarAction } from '@shared/components/floating-session-toolbar/models/floating-session-toolbar-action.model';
4+
import {
5+
ScreenMode,
6+
ToolbarFeatures,
7+
ToolbarInitialState,
8+
WheelSpeedControl,
9+
} from '@shared/components/floating-session-toolbar/models/floating-session-toolbar-config.model';
10+
import {
11+
ToolbarSessionInfo,
12+
ToolbarSessionInfoTemplateContext,
13+
} from '@shared/components/floating-session-toolbar/models/session-info.model';
14+
15+
/** Thin integration shell for ARD sessions. */
16+
@Component({
17+
selector: 'ard-toolbar-wrapper',
18+
standalone: true,
19+
imports: [FloatingSessionToolbarComponent],
20+
template: `
21+
<floating-session-toolbar
22+
[features]="features"
23+
[initialCursorCrosshair]="initialState?.cursorCrosshair ?? true"
24+
[initialWheelSpeed]="initialState?.wheelSpeed ?? 1"
25+
[wheelSpeedControl]="wheelSpeedControl"
26+
[clipboardActionButtons]="actions"
27+
[sessionInfo]="sessionInfo"
28+
[sessionInfoTemplate]="sessionInfoTemplate"
29+
(sessionClose)="sessionClose.emit()"
30+
(screenModeChange)="screenModeChange.emit($event)"
31+
(cursorCrosshairChange)="cursorCrosshairChange.emit($event)"
32+
(wheelSpeedChange)="wheelSpeedChange.emit($event)">
33+
</floating-session-toolbar>
34+
`,
35+
})
36+
export class ArdToolbarWrapperComponent {
37+
@Input() actions: ToolbarAction[] = [];
38+
@Input() sessionInfo: ToolbarSessionInfo | null = null;
39+
@Input() sessionInfoTemplate: TemplateRef<ToolbarSessionInfoTemplateContext> | null = null;
40+
@Input() initialState?: ToolbarInitialState;
41+
@Input() wheelSpeedControl!: WheelSpeedControl;
42+
43+
@Output() readonly sessionClose = new EventEmitter<void>();
44+
@Output() readonly screenModeChange = new EventEmitter<ScreenMode>();
45+
@Output() readonly cursorCrosshairChange = new EventEmitter<boolean>();
46+
@Output() readonly wheelSpeedChange = new EventEmitter<number>();
47+
48+
protected readonly features: ToolbarFeatures = {
49+
sessionInfo: true,
50+
screenMode: true,
51+
cursorCrosshair: true,
52+
wheelSpeed: true,
53+
};
54+
}
Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,4 @@
1-
<div #sessionArdContainer class="session-ard-container">
2-
<session-toolbar
3-
[sessionContainerParent]="sessionContainerElement"
4-
[middleButtons]="middleToolbarButtons"
5-
[middleToggleButtons]="middleToolbarToggleButtons"
6-
[rightButtons]="rightToolbarButtons"
7-
[sliders]="sliders"
8-
[clipboardActionButtons]="clipboardActionButtons">
9-
</session-toolbar>
10-
1+
<div class="session-ard-container">
112
@if (loading) {
123
<div class="loading-info-container">
134
<div class="loading-info">
@@ -20,4 +11,18 @@
2011

2112
<iron-remote-desktop #ironRemoteDesktopElement targetplatform="web" verbose="true" scale="fit" flexcenter="true" [module]="backendRef"></iron-remote-desktop>
2213

14+
@if (!loading && !currentStatus.isDisabled) {
15+
<div class="toolbar-overlay-anchor">
16+
<ard-toolbar-wrapper
17+
[actions]="clipboardActionButtons"
18+
[sessionInfo]="sessionInfo"
19+
[initialState]="{ cursorCrosshair: cursorOverrideActive, wheelSpeed: wheelSpeed }"
20+
[wheelSpeedControl]="wheelSpeedControl"
21+
(sessionClose)="startTerminationProcess()"
22+
(screenModeChange)="handleScreenModeChange($event)"
23+
(cursorCrosshairChange)="onCursorCrosshairChange($event)"
24+
(wheelSpeedChange)="onWheelSpeedChange($event)">
25+
</ard-toolbar-wrapper>
26+
</div>
27+
}
2328
</div>

webapp/apps/gateway-ui/src/client/app/modules/web-client/ard/web-client-ard.component.ts

Lines changed: 39 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, Renderer2, ViewChild } from '@angular/core';
1+
import { Component, OnDestroy, OnInit, Renderer2 } from '@angular/core';
22
import { DesktopWebClientBaseComponent } from '@shared/bases/desktop-web-client-base.component';
33
import { GatewayAlertMessageService } from '@shared/components/gateway-alert-message/gateway-alert-message.service';
44
import { ScreenScale } from '@shared/enums/screen-scale.enum';
@@ -14,6 +14,7 @@ import '@devolutions/iron-remote-desktop/iron-remote-desktop.js';
1414
import { ardQualityMode, Backend, resolutionQuality, wheelSpeedFactor } from '@devolutions/iron-remote-desktop-vnc';
1515
import { DVL_ARD_ICON, JET_ARD_URL } from '@gateway/app.constants';
1616
import { AnalyticService, ProtocolString } from '@gateway/shared/services/analytic.service';
17+
import { WheelSpeedControl } from '@shared/components/floating-session-toolbar/models/floating-session-toolbar-config.model';
1718
import { ExtractedHostnamePort } from '@shared/services/utils/string.service';
1819
import { v4 as uuidv4 } from 'uuid';
1920

@@ -25,57 +26,20 @@ import { v4 as uuidv4 } from 'uuid';
2526
})
2627
export class WebClientArdComponent
2728
extends DesktopWebClientBaseComponent<ArdFormDataInput>
28-
implements OnInit, AfterViewInit, OnDestroy
29+
implements OnInit, OnDestroy
2930
{
30-
@ViewChild('sessionArdContainer') sessionContainerElement: ElementRef;
31-
3231
backendRef = Backend;
3332

34-
middleToolbarButtons = [
35-
{
36-
label: 'Full Screen',
37-
icon: 'dvl-icon dvl-icon-fullscreen',
38-
action: () => this.toggleFullscreen(),
39-
},
40-
{
41-
label: 'Fit to Screen',
42-
icon: 'dvl-icon dvl-icon-minimize',
43-
action: () => this.scaleTo(ScreenScale.Fit),
44-
},
45-
{
46-
label: 'Actual Size',
47-
icon: 'dvl-icon dvl-icon-screen',
48-
action: () => this.scaleTo(ScreenScale.Real),
49-
},
50-
];
51-
52-
middleToolbarToggleButtons = [
53-
{
54-
label: 'Toggle Cursor Kind',
55-
icon: 'dvl-icon dvl-icon-cursor',
56-
action: () => this.toggleCursorKind(),
57-
isActive: () => !this.cursorOverrideActive,
58-
},
59-
];
60-
61-
rightToolbarButtons = [
62-
{
63-
label: 'Close Session',
64-
icon: 'dvl-icon dvl-icon-close',
65-
action: () => this.startTerminationProcess(),
66-
},
67-
];
68-
69-
sliders = [
70-
{
71-
label: 'Wheel Speed',
72-
value: 1,
73-
onChange: (value: number) => this.setWheelSpeedFactor(value),
74-
min: 0.1,
75-
max: 3,
76-
step: 0.1,
77-
},
78-
];
33+
// ── Floating toolbar state ─────────────────────────────────────────────────
34+
wheelSpeed = 1;
35+
// sessionInfo / sessionInfoUrl / sessionInfoUsername / refreshSessionInfo() inherited from WebClientBaseComponent
36+
readonly wheelSpeedControl: WheelSpeedControl = {
37+
label: 'Wheel speed',
38+
min: 0.1,
39+
max: 3,
40+
step: 0.1,
41+
};
42+
// ──
7943

8044
constructor(
8145
protected renderer: Renderer2,
@@ -90,26 +54,35 @@ export class WebClientArdComponent
9054

9155
ngOnInit(): void {
9256
this.webSessionIcon = DVL_ARD_ICON;
57+
this.refreshSessionInfo();
9358

9459
super.ngOnInit();
9560
}
9661

97-
setWheelSpeedFactor(factor: number): void {
98-
this.remoteClient.invokeExtension(wheelSpeedFactor(factor));
62+
// ── Floating toolbar handlers ─────────────────────────────────────────────
63+
onCursorCrosshairChange(enabled: boolean): void {
64+
if (enabled !== this.cursorOverrideActive) {
65+
this.toggleCursorKind();
66+
}
9967
}
10068

101-
protected handleExitFullScreenEvent(): void {
102-
this.isFullScreenMode = false;
103-
104-
const sessionContainerElement = this.sessionContainerElement.nativeElement;
105-
const sessionToolbarElement = sessionContainerElement.querySelector('#sessionToolbar');
69+
onWheelSpeedChange(factor: number): void {
70+
this.setWheelSpeedFactor(factor);
71+
}
10672

107-
if (sessionToolbarElement) {
108-
this.renderer.removeClass(sessionToolbarElement, 'session-toolbar-layer');
73+
private setWheelSpeedFactor(factor: number): void {
74+
this.wheelSpeed = factor;
75+
if (this.remoteClient) {
76+
this.remoteClient.invokeExtension(wheelSpeedFactor(factor));
10977
}
78+
}
79+
80+
protected handleExitFullScreenEvent(): void {
81+
this.isFullScreenMode = false;
11082

11183
this.scaleTo(ScreenScale.Fit);
11284
}
85+
// ──
11386

11487
protected startConnectionProcess(): void {
11588
this.getFormData()
@@ -132,6 +105,9 @@ export class WebClientArdComponent
132105
return from(this.webSessionService.getWebSession(this.webSessionId)).pipe(
133106
map((currentWebSession) => {
134107
this.formData = currentWebSession.data as ArdFormDataInput;
108+
this.wheelSpeed = this.formData.wheelSpeedFactor ?? 1;
109+
this.sessionInfoUsername = this.formData.username || null;
110+
this.refreshSessionInfo();
135111
}),
136112
);
137113
}
@@ -142,6 +118,9 @@ export class WebClientArdComponent
142118

143119
const sessionId: string = uuidv4();
144120
const gatewayAddress = this.getGatewayWebSocketUrl(JET_ARD_URL, sessionId);
121+
this.sessionInfoUrl = this.toUserFacingUrl(gatewayAddress);
122+
this.sessionInfoUsername = username || null;
123+
this.refreshSessionInfo();
145124

146125
const connectionParameters: IronARDConnectionParameters = {
147126
username,
@@ -178,6 +157,8 @@ export class WebClientArdComponent
178157
configBuilder.withExtension(ardQualityMode(connectionParameters.ardQualityMode));
179158
}
180159

160+
configBuilder.withExtension(wheelSpeedFactor(connectionParameters.wheelSpeedFactor));
161+
181162
const config = configBuilder.build();
182163

183164
this.setKeyboardUnicodeMode(true);
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { Component, EventEmitter, Input, Output, TemplateRef } from '@angular/core';
2+
import { UserInteraction } from '@devolutions/iron-remote-desktop';
3+
import { FloatingSessionToolbarComponent } from '@shared/components/floating-session-toolbar/floating-session-toolbar.component';
4+
import { ToolbarAction } from '@shared/components/floating-session-toolbar/models/floating-session-toolbar-action.model';
5+
import {
6+
ScreenMode,
7+
ToolbarFeatures,
8+
ToolbarInitialState,
9+
} from '@shared/components/floating-session-toolbar/models/floating-session-toolbar-config.model';
10+
import {
11+
ToolbarSessionInfo,
12+
ToolbarSessionInfoTemplateContext,
13+
} from '@shared/components/floating-session-toolbar/models/session-info.model';
14+
15+
/**
16+
* Thin integration shell for the floating toolbar in RDP sessions.
17+
* Owns the static RDP feature config and absorbs remoteClient-direct calls
18+
* (Windows key, Ctrl+Alt+Del, unicode keyboard mode).
19+
* Everything else is bubbled up as @Output() for the protocol component to handle.
20+
*/
21+
@Component({
22+
selector: 'rdp-toolbar-wrapper',
23+
standalone: true,
24+
imports: [FloatingSessionToolbarComponent],
25+
template: `
26+
<floating-session-toolbar
27+
[features]="features"
28+
[dynamicResizeSupported]="dynamicResizeSupported"
29+
[initialDynamicResize]="initialState?.dynamicResize ?? true"
30+
[initialUnicodeKeyboard]="initialState?.unicodeKeyboard ?? true"
31+
[initialCursorCrosshair]="initialState?.cursorCrosshair ?? true"
32+
[clipboardActionButtons]="actions"
33+
[sessionInfo]="sessionInfo"
34+
[sessionInfoTemplate]="sessionInfoTemplate"
35+
(windowsKeyPress)="remoteClient.metaKey()"
36+
(ctrlAltDelPress)="remoteClient.ctrlAltDel()"
37+
(unicodeKeyboardChange)="remoteClient.setKeyboardUnicodeMode($event)"
38+
(sessionClose)="sessionClose.emit()"
39+
(screenModeChange)="screenModeChange.emit($event)"
40+
(dynamicResizeChange)="dynamicResizeChange.emit($event)"
41+
(cursorCrosshairChange)="cursorCrosshairChange.emit($event)">
42+
</floating-session-toolbar>
43+
`,
44+
})
45+
export class RdpToolbarWrapperComponent {
46+
/** The active remote client — available after the 'ready' event fires. */
47+
@Input() remoteClient!: UserInteraction;
48+
49+
/** Clipboard actions built by the protocol component; passed straight through. */
50+
@Input() actions: ToolbarAction[] = [];
51+
@Input() dynamicResizeSupported = true;
52+
@Input() sessionInfo: ToolbarSessionInfo | null = null;
53+
@Input() sessionInfoTemplate: TemplateRef<ToolbarSessionInfoTemplateContext> | null = null;
54+
55+
/** Seed values for stateful toolbar toggles (written once on first bind). */
56+
@Input() initialState?: ToolbarInitialState;
57+
58+
@Output() readonly sessionClose = new EventEmitter<void>();
59+
@Output() readonly screenModeChange = new EventEmitter<ScreenMode>();
60+
@Output() readonly dynamicResizeChange = new EventEmitter<boolean>();
61+
@Output() readonly cursorCrosshairChange = new EventEmitter<boolean>();
62+
63+
/** Static RDP feature set — defined once here, never scattered across callers. */
64+
protected readonly features: ToolbarFeatures = {
65+
windowsKey: true,
66+
sessionInfo: true,
67+
ctrlAltDel: true,
68+
screenMode: true,
69+
dynamicResize: true,
70+
unicodeKeyboard: true,
71+
cursorCrosshair: true,
72+
};
73+
}
Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,4 @@
1-
<div #sessionRdpContainer class="session-rdp-container">
2-
<session-toolbar
3-
[sessionContainerParent]="sessionContainerElement"
4-
[leftButtons]="leftToolbarButtons"
5-
[middleButtons]="middleToolbarButtons"
6-
[middleToggleButtons]="middleToolbarToggleButtons"
7-
[rightButtons]="rightToolbarButtons"
8-
[checkboxes]="checkboxes"
9-
[clipboardActionButtons]="clipboardActionButtons">
10-
</session-toolbar>
11-
1+
<div class="session-rdp-container">
122
@if (loading) {
133
<div class="loading-info-container">
144
<div class="loading-info">
@@ -21,4 +11,24 @@
2111

2212
<iron-remote-desktop #ironRemoteDesktopElement targetplatform="web" verbose="true" scale="fit" flexcenter="true" [module]="backendRef"></iron-remote-desktop>
2313

14+
<!-- Floating toolbar — only rendered once the session is live.
15+
Keeping it out of the DOM during the initial connect and any
16+
reconnect cycle prevents the toolbar from being positioned
17+
against a wider ancestor while the tab panel is still being
18+
laid out (which caused it to jump to the viewport center). -->
19+
@if (!loading && !currentStatus.isDisabled) {
20+
<div class="toolbar-overlay-anchor">
21+
<rdp-toolbar-wrapper
22+
[remoteClient]="remoteClient"
23+
[actions]="clipboardActionButtons"
24+
[sessionInfo]="sessionInfo"
25+
[dynamicResizeSupported]="dynamicResizeSupported"
26+
[initialState]="{ dynamicResize: dynamicResizeEnabled, unicodeKeyboard: useUnicodeKeyboard, cursorCrosshair: cursorOverrideActive }"
27+
(sessionClose)="startTerminationProcess()"
28+
(screenModeChange)="handleScreenModeChange($event)"
29+
(dynamicResizeChange)="onDynamicResizeChange($event)"
30+
(cursorCrosshairChange)="onCursorCrosshairChange($event)">
31+
</rdp-toolbar-wrapper>
32+
</div>
33+
}
2434
</div>

0 commit comments

Comments
 (0)