Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ for what works, what's in progress, and what's still planned.
| ✅ | **Encrypted passwords** | Stored passwords are encrypted with the OS keystore (DPAPI, Keychain, libsecret). |
| ✅ | **Light & dark theme** | |
| 🚧 | **Snippets & automations** *(in progress)* | Save commands as snippets and chain them into automations on a canvas, run locally or on a host, with parameters and the output of earlier steps. Usable, but still changing. |
| 🚧 | **Remote desktop (RDP)** *(in progress)* | RDP sessions as tabs inside the app, next to your terminals (IronRDP, no extra window), or in the OS's own client (Remote Desktop on Windows, FreeRDP on Linux and macOS), signed in automatically either way. Can tunnel through any SSH host, so machines behind a bastion work without exposing port 3389. Display, monitor, clipboard, drive and sound settings per profile. |
| 🚧 | **Remote desktop (RDP)** *(in progress)* | RDP sessions as tabs inside the app, next to your terminals (IronRDP, no extra window), or in the OS's own client (Remote Desktop on Windows, FreeRDP on Linux and macOS), signed in automatically either way. Drag files onto the session to copy them there, and save files copied on the remote desktop. Can tunnel through any SSH host, so machines behind a bastion work without exposing port 3389. Display, monitor, clipboard, drive and sound settings per profile. |
| 💡 | **AI integration** *(maybe in the future)* | Help with commands, explain output or errors, right in the terminal. |
| 💡 | **Plugin system** *(maybe in the future)* | Extend the app with your own features without touching the core. |
| 💡 | **1Password integration** *(maybe in the future)* | Use SSH keys and passwords straight from your 1Password vault. |
Expand All @@ -65,8 +65,8 @@ for what works, what's in progress, and what's still planned.
| Terminal | SFTP |
|:-:|:-:|
| <img src="assets/terminal.png" alt="Terminal tabs" width="440"> | <img src="assets/sftp.png" alt="Two-panel SFTP browser" width="440"> |
| **Automations** | **Settings** |
| <img src="assets/automations.png" alt="Automation canvas" width="440"> | <img src="assets/settings.png" alt="Settings screen" width="440"> |
| **Automations** | **Remote desktop** |
| <img src="assets/automations.png" alt="Automation canvas" width="440"> | <img src="assets/remote-desktop.png" alt="A Windows remote desktop open in a tab inside the app" width="440"> |

---

Expand Down
Binary file added assets/remote-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed assets/settings.png
Binary file not shown.
68 changes: 68 additions & 0 deletions packages/electron/src/core/rdp/savedFiles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { freePath, safeSegment, saveReceivedFile, targetPath } from './savedFiles.js';

describe('safeSegment', () => {
it('keeps ordinary names', () => {
expect(safeSegment('report 2026.pdf')).toBe('report 2026.pdf');
});

it('replaces characters no file system here would take', () => {
expect(safeSegment('a<b>c:d"e|f?g*h')).toBe('a_b_c_d_e_f_g_h');
expect(safeSegment('tab\there')).toBe('tab_here');
});

it('never yields an empty name, a dot entry or a Windows device name', () => {
expect(safeSegment('')).toBe('_');
expect(safeSegment('..')).toBe('_');
expect(safeSegment('name. ')).toBe('name');
expect(safeSegment('CON')).toBe('_CON');
expect(safeSegment('nul.txt')).toBe('_nul.txt');
});
});

describe('targetPath', () => {
const folder = resolve('/downloads');

it('keeps the folder structure of the copied collection', () => {
expect(targetPath(folder, 'docs\\images', 'a.png')).toBe(join(folder, 'docs', 'images', 'a.png'));
expect(targetPath(folder, undefined, 'a.png')).toBe(join(folder, 'a.png'));
});

it('refuses paths that would leave the folder', () => {
expect(() => targetPath(folder, '..\\..\\Windows', 'evil.dll')).toThrow('leaves the target folder');
expect(() => targetPath(folder, 'C:\\Windows', 'evil.dll')).toThrow('leaves the target folder');
// A name can't smuggle a path either.
expect(targetPath(folder, undefined, '..\\evil.txt')).toBe(join(folder, '.._evil.txt'));
});
});

describe('freePath', () => {
it('numbers a name that is taken instead of overwriting', () => {
const taken = new Set(['/d/a.txt', '/d/a (1).txt']);
expect(freePath('/d/a.txt', (p) => taken.has(p))).toBe('/d/a (2).txt');
expect(freePath('/d/b.txt', (p) => taken.has(p))).toBe('/d/b.txt');
});
});

describe('saveReceivedFile', () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'bssh-save-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});

it('writes into subfolders and never over an existing file', async () => {
const first = await saveReceivedFile(dir, 'docs', 'a.txt', new TextEncoder().encode('one'));
const second = await saveReceivedFile(dir, 'docs', 'a.txt', new TextEncoder().encode('two'));
expect(first).toBe(join(dir, 'docs', 'a.txt'));
expect(second).toBe(join(dir, 'docs', 'a (1).txt'));
expect(await readFile(first, 'utf8')).toBe('one');
expect(await readFile(second, 'utf8')).toBe('two');
});
});
58 changes: 58 additions & 0 deletions packages/electron/src/core/rdp/savedFiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { existsSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { extname, join, resolve, sep } from 'node:path';

/**
* Saving files copied on a remote desktop (clipboard file transfer) into a folder
* here. The names and folder paths come from the remote machine, so they are treated
* as untrusted: nothing may land outside the chosen folder, and nothing already there
* is overwritten.
*/

/** One path segment, made safe as a file or folder name on every OS. */
export function safeSegment(name: string): string {
const cleaned = name
// eslint-disable-next-line no-control-regex
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
.replace(/[. ]+$/, '') // Windows drops trailing dots and spaces
.trim();
if (cleaned === '' || cleaned === '.' || cleaned === '..') return '_';
// Reserved device names on Windows (CON, NUL, COM1…), with or without extension.
return /^(con|prn|aux|nul|com\d|lpt\d)(\..*)?$/i.test(cleaned) ? `_${cleaned}` : cleaned;
}

/**
* Where a file `name` inside the remote collection's folder `relativePath` (backslash-
* separated, as the RDP clipboard sends it) goes under `folder`. `..` and absolute
* paths are refused rather than cleaned up, since a legitimate copy never has them.
*/
export function targetPath(folder: string, relativePath: string | undefined, name: string): string {
const parts = (relativePath ?? '').split(/[\\/]+/).filter((p) => p !== '' && p !== '.');
if (parts.some((p) => p === '..') || /^[a-z]:$/i.test(parts[0] ?? '')) {
throw new Error(`refusing a file path that leaves the target folder: ${relativePath}`);
}
const root = resolve(folder);
const path = resolve(root, ...parts.map(safeSegment), safeSegment(name));
if (!path.startsWith(root + sep)) throw new Error(`refusing a file path that leaves the target folder: ${name}`);
return path;
}

/** `path`, or `name (1).ext`, `name (2).ext`… if it is taken. */
export function freePath(path: string, exists: (p: string) => boolean = existsSync): string {
if (!exists(path)) return path;
const ext = extname(path);
const base = path.slice(0, path.length - ext.length);
for (let n = 1; ; n++) {
const candidate = `${base} (${n})${ext}`;
if (!exists(candidate)) return candidate;
}
}

/** Writes one received file; returns where it went. */
export async function saveReceivedFile(folder: string, relativePath: string | undefined, name: string, bytes: Uint8Array): Promise<string> {
const wanted = targetPath(folder, relativePath, name);
await mkdir(join(wanted, '..'), { recursive: true });
const path = freePath(wanted);
await writeFile(path, bytes, { flag: 'wx' });
return path;
}
45 changes: 44 additions & 1 deletion packages/electron/src/ipc/rdpEmbedded.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { connect } from 'node:net';
import type { Duplex } from 'node:stream';
import type { IpcMain } from 'electron';
import { app, BrowserWindow, dialog, shell, type IpcMain } from 'electron';

import { loadRemoteDesktopConnections } from '../core/config/remoteDesktop.js';
import { RdpGateway } from '../core/rdp/gateway.js';
import { checkCertificate, forgetCertificate } from '../core/rdp/knownCerts.js';
import { saveReceivedFile } from '../core/rdp/savedFiles.js';
import { SshSession } from '../core/ssh/session.js';
import { toCommandError, type RdpCredentialsDto, type RdpEmbeddedOpenDto, type RdpEmbeddedStatusDto } from '../dto.js';
import type { GuiState } from '../state/guiState.js';
Expand All @@ -25,6 +26,8 @@ interface OpenSession {
notice?: string;
}
const open = new Map<string, OpenSession>();
/** Folders the user chose in `rdp_pick_save_folder` — the only ones `rdp_save_file` writes to. */
const pickedFolders = new Set<string>();

function openTcp(host: string, port: number): Promise<Duplex> {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -113,6 +116,37 @@ export function registerRdpEmbeddedIpc(ipcMain: IpcMain, state: GuiState): void
closeSession(token);
});

// Files copied on the remote desktop, saved here: only into a folder the user picked
// in this dialog, so a renderer can't write anywhere else.
ipcMain.handle('rdp_pick_save_folder', async (event): Promise<string | null> => {
const window = BrowserWindow.fromWebContents(event.sender) ?? undefined;
const options = {
title: 'Save files from the remote desktop',
defaultPath: defaultSaveFolder(),
properties: ['openDirectory', 'createDirectory'] as Array<'openDirectory' | 'createDirectory'>
};
const { canceled, filePaths } = window ? await dialog.showOpenDialog(window, options) : await dialog.showOpenDialog(options);
if (canceled || !filePaths[0]) return null;
pickedFolders.add(filePaths[0]);
return filePaths[0];
});

ipcMain.handle(
'rdp_save_file',
async (_event, folder: string, relativePath: string | undefined, name: string, bytes: Uint8Array): Promise<string> => {
try {
if (!pickedFolders.has(folder)) throw new Error('not a folder chosen for saving');
return await saveReceivedFile(folder, relativePath, name, bytes);
} catch (err) {
throw toCommandError(err);
}
}
);

ipcMain.handle('rdp_show_saved', (_event, path: string) => {
if ([...pickedFolders].some((f) => path.startsWith(f))) shell.showItemInFolder(path);
});

ipcMain.handle('rdp_forget_certificate', async (_event, connectionId: string) => {
try {
const connection = (await loadRemoteDesktopConnections()).find((c) => c.id === connectionId);
Expand All @@ -130,6 +164,15 @@ function closeSession(token: string): void {
open.delete(token);
}

/** Downloads, or the home folder where there is none (Electron throws then). */
function defaultSaveFolder(): string {
try {
return app.getPath('downloads');
} catch {
return app.getPath('home');
}
}

/** For `before-quit`. */
export function closeEmbeddedRdp(): void {
for (const token of [...open.keys()]) closeSession(token);
Expand Down
9 changes: 9 additions & 0 deletions packages/ui/e2e/remoteDesktop.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,12 @@ test('Connect opens the remote desktop in a tab, with the external app as the wa
await page.getByRole('button', { name: 'Connect to office-pc' }).click();
await expect(page.getByRole('button', { name: 'office-pc · rdp' })).toHaveCount(1);
});

test('reopened in Remote Desktop mode, the app shows Remote Desktop, not the SSH dashboard', async ({ page }) => {
// What the switch remembers from the last run.
await page.addInitScript(() => localStorage.setItem('better-ssh-client-sidebar-mode', 'remoteDesktop'));
await boot(page);
await expect(page.getByRole('heading', { name: 'Remote Desktop' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Switch to SSH' })).toBeVisible();
});
14 changes: 14 additions & 0 deletions packages/ui/src/lib/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,20 @@ export const commands = {
},
async rdpForgetCertificate(connectionId: string): Promise<Result<null, CommandError>> {
return call('rdp_forget_certificate', connectionId);
},
async rdpPickSaveFolder(): Promise<Result<string | null, CommandError>> {
return call('rdp_pick_save_folder');
},
async rdpSaveFile(
folder: string,
relativePath: string | undefined,
name: string,
bytes: Uint8Array
): Promise<Result<string, CommandError>> {
return call('rdp_save_file', folder, relativePath, name, bytes);
},
async rdpShowSaved(path: string): Promise<Result<null, CommandError>> {
return call('rdp_show_saved', path);
}
};

Expand Down
20 changes: 20 additions & 0 deletions packages/ui/src/lib/ipc/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,26 @@ export async function rdpEmbeddedClose(token: string): Promise<void> {
if (res.status === 'error') throw new Error(res.error.message);
}

/** Asks where to save files copied on a remote desktop; null if cancelled. */
export async function rdpPickSaveFolder(): Promise<string | null> {
const res = await commands.rdpPickSaveFolder();
if (res.status === 'error') throw new Error(res.error.message);
return res.data;
}

/** Saves one file received from a remote desktop into a folder picked with
* `rdpPickSaveFolder`; returns where it went (never over an existing file). */
export async function rdpSaveFile(folder: string, relativePath: string | undefined, name: string, bytes: Uint8Array): Promise<string> {
const res = await commands.rdpSaveFile(folder, relativePath, name, bytes);
if (res.status === 'error') throw new Error(res.error.message);
return res.data;
}

export async function rdpShowSaved(path: string): Promise<void> {
const res = await commands.rdpShowSaved(path);
if (res.status === 'error') throw new Error(res.error.message);
}

/** Forgets the remembered certificate of a connection's server (trust on first use). */
export async function rdpForgetCertificate(connectionId: string): Promise<void> {
const res = await commands.rdpForgetCertificate(connectionId);
Expand Down
Loading
Loading