fix(web)!: rework error handling (#975)

Improves the error handling in _iron-remote-desktop_ by
replacing the session events with throwing errors for terminated and
error events and callbacks for warnings and the clipboard remote update
event.
This commit is contained in:
Alex Yusiuk 2025-09-19 14:05:20 +03:00 committed by GitHub
parent 3182a018e2
commit 6c0014d5b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 167 additions and 253 deletions

View file

@ -1,9 +0,0 @@
export enum SessionEventType {
STARTED,
TERMINATED,
ERROR,
WARNING,
// Clipboard events
CLIPBOARD_REMOTE_UPDATE,
}

View file

@ -1,6 +1,4 @@
import type { SessionEventType } from '../enums/SessionEventType';
export enum IronErrorKind {
export enum IronErrorKind {
General = 0,
WrongPassword = 1,
LogonFailure = 2,
@ -14,8 +12,3 @@ export interface IronError {
backtrace: () => string;
kind: () => IronErrorKind;
}
export interface SessionEvent {
type: SessionEventType;
data: IronError | string;
}

View file

@ -1,7 +1,9 @@
import type { DesktopSize } from './DesktopSize';
import type { SessionTerminationInfo } from './SessionTerminationInfo';
export interface NewSessionInfo {
sessionId: number;
websocketPort: number;
initialDesktopSize: DesktopSize;
run: () => Promise<SessionTerminationInfo>;
}

View file

@ -1,6 +1,5 @@
import type { ScreenScale } from '../enums/ScreenScale';
import type { NewSessionInfo } from './NewSessionInfo';
import type { SessionEvent } from './session-event';
import { ConfigBuilder } from '../services/ConfigBuilder';
import type { Config } from '../services/Config';
import type { Extension } from './Extension';
@ -25,7 +24,9 @@ export interface UserInteraction {
setCursorStyleOverride(style: string | null): void;
onSessionEvent(callback: Callback<SessionEvent>): void;
onWarningCallback(callback: Callback<string>): void;
onClipboardRemoteUpdateCallback(callback: Callback<void>): void;
resize(width: number, height: number, scale?: number): void;
@ -33,9 +34,9 @@ export interface UserInteraction {
setEnableAutoClipboard(enable: boolean): void;
saveRemoteClipboardData(): Promise<boolean>;
saveRemoteClipboardData(): Promise<void>;
sendClipboardData(): Promise<boolean>;
sendClipboardData(): Promise<void>;
invokeExtension(ext: Extension): void;
}

View file

@ -1,8 +1,7 @@
export * as default from './iron-remote-desktop.svelte';
export type { ResizeEvent } from './interfaces/ResizeEvent';
export type { NewSessionInfo } from './interfaces/NewSessionInfo';
export type { SessionEvent, IronError, IronErrorKind } from './interfaces/session-event';
export type { SessionEventType } from './enums/SessionEventType';
export type { IronError, IronErrorKind } from './interfaces/Error';
export type { SessionTerminationInfo } from './interfaces/SessionTerminationInfo';
export type { ClipboardData } from './interfaces/ClipboardData';
export type { ClipboardItem } from './interfaces/ClipboardItem';

View file

@ -68,11 +68,19 @@ export class PublicAPI {
this.remoteDesktopService.setEnableAutoClipboard(enable);
}
private async saveRemoteClipboardData(): Promise<boolean> {
private setOnWarningCallback(callback: (data: string) => void) {
this.remoteDesktopService.setOnWarningCallback(callback);
}
private setOnClipboardRemoteUpdateCallback(callback: () => void) {
this.remoteDesktopService.setOnClipboardRemoteUpdate(callback);
}
private async saveRemoteClipboardData(): Promise<void> {
return await this.clipboardService.saveRemoteClipboardData();
}
private async sendClipboardData(): Promise<boolean> {
private async sendClipboardData(): Promise<void> {
return await this.clipboardService.sendClipboardData();
}
@ -85,10 +93,9 @@ export class PublicAPI {
setVisibility: this.setVisibility.bind(this),
configBuilder: this.configBuilder.bind(this),
connect: this.connect.bind(this),
onWarningCallback: this.setOnWarningCallback.bind(this),
onClipboardRemoteUpdateCallback: this.setOnClipboardRemoteUpdateCallback.bind(this),
setScale: this.setScale.bind(this),
onSessionEvent: (callback) => {
this.remoteDesktopService.sessionEventObservable.subscribe(callback);
},
ctrlAltDel: this.ctrlAltDel.bind(this),
metaKey: this.metaKey.bind(this),
shutdown: this.shutdown.bind(this),

View file

@ -4,11 +4,19 @@ import { get } from 'svelte/store';
import type { ClipboardData } from '../interfaces/ClipboardData';
import type { RemoteDesktopModule } from '../interfaces/RemoteDesktopModule';
import { runWhenFocusedQueue } from '../lib/stores/runWhenFocusedStore';
import { SessionEventType } from '../enums/SessionEventType';
import { ClipboardApiSupported } from '../enums/ClipboardApiSupported';
import { IronErrorKind } from '../interfaces/Error';
const CLIPBOARD_MONITORING_INTERVAL_MS = 100;
// Helper function to conveniently throw an `IronError`.
function throwIronError(message: string): never {
throw {
kind: () => IronErrorKind.General,
backtrace: () => message,
};
}
export class ClipboardService {
private remoteDesktopService: RemoteDesktopService;
private module: RemoteDesktopModule;
@ -29,10 +37,7 @@ export class ClipboardService {
initClipboard() {
// Clipboard API is available only in secure contexts (HTTPS).
if (!window.isSecureContext) {
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.WARNING,
data: 'Clipboard is available only in secure contexts (HTTPS).',
});
this.remoteDesktopService.emitWarningEvent('Clipboard is available only in secure contexts (HTTPS).');
return;
}
@ -42,26 +47,23 @@ export class ClipboardService {
this.ClipboardApiSupported = ClipboardApiSupported.Full;
} else if (navigator.clipboard.readText != undefined) {
this.ClipboardApiSupported = ClipboardApiSupported.TextOnly;
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.WARNING,
data: 'Clipboard is limited to text-only data types due to an outdated browser version!',
});
this.remoteDesktopService.emitWarningEvent(
'Clipboard is limited to text-only data types due to an outdated browser version!',
);
} else if (navigator.clipboard.writeText != undefined) {
this.ClipboardApiSupported = ClipboardApiSupported.TextOnlyServerOnly;
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.WARNING,
data: 'Clipboard reading is not supported and writing is limited to text-only data types due to an outdated browser version!',
});
this.remoteDesktopService.emitWarningEvent(
'Clipboard reading is not supported and writing is limited to text-only data types due to an outdated browser version!',
);
}
}
// The basic Clipboard API is widely supported in modern browsers,
// so this condition should never be true in practice.
if (this.ClipboardApiSupported === ClipboardApiSupported.None) {
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.WARNING,
data: 'Clipboard is not supported due to an outdated browser version!',
});
this.remoteDesktopService.emitWarningEvent(
'Clipboard is not supported due to an outdated browser version!',
);
return;
}
@ -84,17 +86,13 @@ export class ClipboardService {
// Copies clipboard content received from the server to the local clipboard.
// Returns the result of the operation. On failure, it additionally raises an error session event.
async saveRemoteClipboardData(): Promise<boolean> {
async saveRemoteClipboardData(): Promise<void> {
if (this.ClipboardApiSupported !== ClipboardApiSupported.Full) {
return await this.ffSaveRemoteClipboardData();
}
if (this.clipboardDataToSave == null) {
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.ERROR,
data: 'The server did not send the clipboard data.',
});
return false;
throwIronError('The server did not send the clipboard data.');
}
try {
@ -103,74 +101,53 @@ export class ClipboardService {
await navigator.clipboard.write([clipboard_item]);
this.clipboardDataToSave = null;
return true;
} catch (err) {
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.ERROR,
data: 'Failed to write to the clipboard: ' + err,
});
return false;
throwIronError('Failed to write to the clipboard: ' + err);
}
}
// Sends local clipboard's content to the server.
// Returns the result of the operation. On failure, it additionally raises an error session event.
async sendClipboardData(): Promise<boolean> {
async sendClipboardData(): Promise<void> {
if (this.ClipboardApiSupported !== ClipboardApiSupported.Full) {
return await this.ffSendClipboardData();
}
try {
const value = await navigator.clipboard.read();
const value = await navigator.clipboard.read().catch((err) => {
throwIronError('Failed to read from the clipboard: ' + err);
});
// Clipboard is empty
if (value.length == 0) {
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.ERROR,
data: 'The clipboard has no data.',
});
return false;
// Clipboard is empty
if (value.length == 0) {
throwIronError('The clipboard has no data.');
}
// We only support one item at a time
const item = value[0];
if (!item.types.some((type) => type.startsWith('text/') || type.startsWith('image/png'))) {
// Unsupported types
throwIronError('The clipboard has no data of supported type (text or image).');
}
const clipboardData = new this.module.ClipboardData();
for (const kind of item.types) {
// Get blob
const blobIsString = kind.startsWith('text/');
const blob = await item.getType(kind);
if (blobIsString) {
clipboardData.addText(kind, await blob.text());
} else {
clipboardData.addBinary(kind, new Uint8Array(await blob.arrayBuffer()));
}
}
// We only support one item at a time
const item = value[0];
if (!item.types.some((type) => type.startsWith('text/') || type.startsWith('image/png'))) {
// Unsupported types
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.ERROR,
data: 'The clipboard has no data of supported type (text or image).',
});
return false;
}
const clipboardData = new this.module.ClipboardData();
for (const kind of item.types) {
// Get blob
const blobIsString = kind.startsWith('text/');
const blob = await item.getType(kind);
if (blobIsString) {
clipboardData.addText(kind, await blob.text());
} else {
clipboardData.addBinary(kind, new Uint8Array(await blob.arrayBuffer()));
}
}
if (!clipboardData.isEmpty()) {
this.lastSentClipboardData = clipboardData;
// TODO(Fix): onClipboardChanged takes an ownership over clipboardData, so lastSentClipboardData will be nullptr.
await this.remoteDesktopService.onClipboardChanged(clipboardData);
}
return true;
} catch (err) {
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.ERROR,
data: 'Failed to read from the clipboard: ' + err,
});
return false;
if (!clipboardData.isEmpty()) {
this.lastSentClipboardData = clipboardData;
// TODO(Fix): onClipboardChanged takes an ownership over clipboardData, so lastSentClipboardData will be nullptr.
await this.remoteDesktopService.onClipboardChanged(clipboardData);
}
}
@ -223,10 +200,7 @@ export class ClipboardService {
// This callback is required to update client clipboard state when remote side has changed.
private onRemoteClipboardChangedManualMode(data: ClipboardData) {
this.clipboardDataToSave = data;
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.CLIPBOARD_REMOTE_UPDATE,
data: '',
});
this.remoteDesktopService.emitClipboardRemoteUpdateEvent();
}
// This callback is required to update client clipboard state when remote side has changed.
@ -244,7 +218,7 @@ export class ClipboardService {
}
// Called periodically to monitor clipboard changes
private async onMonitorClipboard() {
private async onMonitorClipboard(): Promise<void> {
try {
if (!document.hasFocus()) {
return;
@ -382,35 +356,23 @@ export class ClipboardService {
if (value === '') return;
this.ffClipboardDataToSave = value;
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.CLIPBOARD_REMOTE_UPDATE,
data: '',
});
this.remoteDesktopService.emitClipboardRemoteUpdateEvent();
}
// Firefox specific function. We are using text-only clipboard API here.
//
// Copies clipboard content received from the server to the local clipboard.
// Returns the result of the operation. On failure, it additionally raises an error session event.
private async ffSaveRemoteClipboardData(): Promise<boolean> {
private async ffSaveRemoteClipboardData(): Promise<void> {
if (this.ffClipboardDataToSave == null) {
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.ERROR,
data: 'The server did not send the clipboard data.',
});
return false;
throwIronError('The server did not send the clipboard data.');
}
try {
await navigator.clipboard.writeText(this.ffClipboardDataToSave);
this.ffClipboardDataToSave = null;
return true;
} catch (err) {
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.ERROR,
data: 'Failed to write to the clipboard: ' + err,
});
return false;
throwIronError('Failed to write to the clipboard: ' + err);
}
}
@ -418,43 +380,27 @@ export class ClipboardService {
//
// Sends local clipboard's content to the server.
// Returns the result of the operation. On failure, it additionally raises an error session event.
private async ffSendClipboardData(): Promise<boolean> {
private async ffSendClipboardData(): Promise<void> {
if (this.ClipboardApiSupported !== ClipboardApiSupported.TextOnly) {
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.ERROR,
data: 'The browser does not support clipboard read.',
});
return false;
throwIronError('The browser does not support clipboard read.');
}
try {
const value = await navigator.clipboard.readText();
const value = await navigator.clipboard.readText().catch((err) => {
throwIronError('Failed to read from the clipboard: ' + err);
});
// Clipboard is empty
if (value.length == 0) {
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.ERROR,
data: 'The clipboard has no data.',
});
return false;
}
// Clipboard is empty
if (value.length == 0) {
throwIronError('The clipboard has no data.');
}
const clipboardData = new this.module.ClipboardData();
clipboardData.addText('text/plain', value);
const clipboardData = new this.module.ClipboardData();
clipboardData.addText('text/plain', value);
if (!clipboardData.isEmpty()) {
this.lastSentClipboardData = clipboardData;
// TODO(Fix): onClipboardChanged takes an ownership over clipboardData, so lastSentClipboardData will be nullptr.
await this.remoteDesktopService.onClipboardChanged(clipboardData);
}
return true;
} catch (err) {
this.remoteDesktopService.raiseSessionEvent({
type: SessionEventType.ERROR,
data: 'Failed to read from the clipboard: ' + err,
});
return false;
if (!clipboardData.isEmpty()) {
this.lastSentClipboardData = clipboardData;
// TODO(Fix): onClipboardChanged takes an ownership over clipboardData, so lastSentClipboardData will be nullptr.
await this.remoteDesktopService.onClipboardChanged(clipboardData);
}
}
}

View file

@ -2,13 +2,11 @@ import { loggingService } from './logging.service';
import { scanCode } from '../lib/scancodes';
import { ModifierKey } from '../enums/ModifierKey';
import { LockKey } from '../enums/LockKey';
import { SessionEventType } from '../enums/SessionEventType';
import type { NewSessionInfo } from '../interfaces/NewSessionInfo';
import { SpecialCombination } from '../enums/SpecialCombination';
import type { ResizeEvent } from '../interfaces/ResizeEvent';
import { ScreenScale } from '../enums/ScreenScale';
import type { MousePosition } from '../interfaces/MousePosition';
import type { IronError, IronErrorKind, SessionEvent } from '../interfaces/session-event';
import type { ClipboardData } from '../interfaces/ClipboardData';
import type { Session } from '../interfaces/Session';
import { RotationUnit } from '../interfaces/DeviceEvent';
@ -24,6 +22,8 @@ type OnRemoteClipboardChanged = (data: ClipboardData) => void;
type OnRemoteReceivedFormatsList = () => void;
type OnForceClipboardUpdate = () => void;
type OnCanvasResized = () => void;
type OnWarning = (data: string) => void;
type OnClipboardRemoteUpdate = () => void;
export class RemoteDesktopService {
private module: RemoteDesktopModule;
@ -34,6 +34,8 @@ export class RemoteDesktopService {
private onRemoteReceivedFormatList?: OnRemoteReceivedFormatsList;
private onForceClipboardUpdate?: OnForceClipboardUpdate;
private onCanvasResized?: OnCanvasResized;
private onWarningCallback?: OnWarning;
private onClipboardRemoteUpdate?: OnClipboardRemoteUpdate;
private cursorHasOverride: boolean = false;
private lastCursorStyle: string = 'default';
private enableClipboard: boolean = true;
@ -46,7 +48,6 @@ export class RemoteDesktopService {
mousePositionObservable: Observable<MousePosition> = new Observable();
changeVisibilityObservable: Observable<boolean> = new Observable();
sessionEventObservable: Observable<SessionEvent> = new Observable();
scaleObservable: Observable<ScreenScale> = new Observable();
dynamicResizeObservable: Observable<{ width: number; height: number }> = new Observable();
@ -89,6 +90,16 @@ export class RemoteDesktopService {
this.onCanvasResized = callback;
}
/// Callback which is called when the warning event is emitted.
setOnWarningCallback(callback: OnWarning) {
this.onWarningCallback = callback;
}
/// Callback which is called when the clipboard remote update event is emitted.
setOnClipboardRemoteUpdate(callback: OnClipboardRemoteUpdate) {
this.onClipboardRemoteUpdate = callback;
}
mouseIn(event: MouseEvent) {
this.syncModifier(event);
}
@ -160,21 +171,7 @@ export class RemoteDesktopService {
);
}
const session = await sessionBuilder.connect().catch((err: IronError) => {
this.raiseSessionEvent({
type: SessionEventType.TERMINATED,
data: {
backtrace: () => err.backtrace(),
kind: () => err.kind() as number as IronErrorKind,
},
});
// The client must ignore this error and use session events for error handling.
throw new Error();
});
this.run(session);
loggingService.info('Session started.');
const session = await sessionBuilder.connect();
this.session = session;
@ -182,38 +179,24 @@ export class RemoteDesktopService {
desktopSize: session.desktopSize(),
sessionId: 0,
});
this.raiseSessionEvent({
type: SessionEventType.STARTED,
data: 'Session started',
});
const run = async (): Promise<SessionTerminationInfo> => {
try {
loggingService.info('Starting the session.');
return await session.run();
} finally {
this.setVisibility(false);
}
};
return {
sessionId: 0,
initialDesktopSize: session.desktopSize(),
websocketPort: 0,
run,
};
}
run(session: Session) {
session
.run()
.then((terminationInfo: SessionTerminationInfo) => {
this.setVisibility(false);
this.raiseSessionEvent({
type: SessionEventType.TERMINATED,
data: 'Session was terminated: ' + terminationInfo.reason() + '.',
});
})
.catch((err: IronError) => {
this.setVisibility(false);
this.raiseSessionEvent({
type: SessionEventType.TERMINATED,
data: 'Session was terminated with an error: ' + err.backtrace() + '.',
});
});
}
sendSpecialCombination(specialCombination: SpecialCombination): void {
switch (specialCombination) {
case SpecialCombination.CTRL_ALT_DEL:
@ -248,6 +231,14 @@ export class RemoteDesktopService {
]);
}
emitWarningEvent(data: string): void {
this.onWarningCallback?.(data);
}
emitClipboardRemoteUpdateEvent(): void {
this.onClipboardRemoteUpdate?.();
}
setVisibility(state: boolean) {
this.changeVisibilityObservable.publish(state);
}
@ -299,10 +290,6 @@ export class RemoteDesktopService {
this.session?.invokeExtension(ext);
}
raiseSessionEvent(event: SessionEvent) {
this.sessionEventObservable.publish(event);
}
private releaseAllInputs() {
this.session?.releaseAllInputs();
}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { currentSession, userInteractionService } from '../../services/session.service';
import type { UserInteraction } from '../../../static/iron-remote-desktop';
import { currentSession, setCurrentSessionActive, userInteractionService } from '../../services/session.service';
import type { IronError, UserInteraction } from '../../../static/iron-remote-desktop';
import type { Session } from '../../models/session';
import { preConnectionBlob, displayControl, kdcProxyUrl, init } from '../../../static/iron-remote-desktop-rdp';
import { toast } from '$lib/messages/message-store';
@ -21,31 +21,19 @@
let userInteraction: UserInteraction;
const initListeners = () => {
userInteraction.onSessionEvent((event) => {
if (event.type === 2) {
console.log('Error event', event.data);
toast.set({
type: 'error',
message: typeof event.data !== 'string' ? event.data.backtrace() : event.data,
});
} else {
toast.set({
type: 'info',
message: typeof event.data === 'string' ? event.data : event.data?.backtrace() ?? 'No info',
});
}
});
};
userInteractionService.subscribe((val) => {
userInteraction = val;
if (val != null) {
initListeners();
}
});
const isIronError = (error: unknown): error is IronError => {
return (
typeof error === 'object' &&
error !== null &&
typeof (error as IronError).backtrace === 'function' &&
typeof (error as IronError).kind === 'function'
);
};
const StartSession = async () => {
if (authtoken === '') {
const token_server_url = import.meta.env.VITE_IRON_TOKEN_SERVER_URL as string | undefined;
@ -155,8 +143,30 @@
currentSession.update(updater);
showLogin.set(false);
userInteraction.setVisibility(true);
const sessionTerminationInfo = await session_info.run();
toast.set({
type: 'info',
message: `Session terminated gracefully: ${sessionTerminationInfo.reason()}`,
});
} catch (err) {
console.error(`Error occurred: ${err}`);
setCurrentSessionActive(false);
showLogin.set(true);
if (isIronError(err)) {
toast.set({
type: 'error',
message: err.backtrace(),
});
} else {
toast.set({
type: 'error',
message: `${err}`,
});
}
}
};

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { setCurrentSessionActive, userInteractionService } from '../../services/session.service';
import type { UserInteraction, SessionEvent } from '../../../static/iron-remote-desktop';
import { userInteractionService } from '../../services/session.service';
import type { UserInteraction } from '../../../static/iron-remote-desktop';
import { Backend } from '../../../static/iron-remote-desktop-rdp';
import { preConnectionBlob, displayControl, kdcProxyUrl } from '../../../static/iron-remote-desktop-rdp';
@ -9,20 +9,6 @@
let cursorOverrideActive = false;
let showUtilityBar = false;
userInteractionService.subscribe((userInteraction) => {
if (userInteraction != null) {
const callback = (event: SessionEvent) => {
if (event.type === 0) {
userInteraction.setVisibility(true);
} else if (event.type === 1) {
setCurrentSessionActive(false);
}
};
userInteraction.onSessionEvent(callback);
}
});
userInteractionService.subscribe((uis) => {
if (uis != null) {
userInteraction = uis;

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { setCurrentSessionActive, userInteractionService } from '../../services/session.service';
import { userInteractionService } from '../../services/session.service';
import { showLogin } from '$lib/login/login-store';
import type { UserInteraction } from '../../../static/iron-remote-desktop';
import { Backend } from '../../../static/iron-remote-desktop-rdp';
@ -12,14 +12,6 @@
userInteractionService.subscribe((uis) => {
if (uis != null) {
uiService = uis;
uiService.onSessionEvent((event) => {
if (event.type === 0) {
uiService.setVisibility(true);
} else if (event.type === 1) {
setCurrentSessionActive(false);
showLogin.set(true);
}
});
}
});