diff --git a/README.md b/README.md index b21a503..da3f116 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -65,8 +65,8 @@ for what works, what's in progress, and what's still planned. | Terminal | SFTP | |:-:|:-:| | Terminal tabs | Two-panel SFTP browser | -| **Automations** | **Settings** | -| Automation canvas | Settings screen | +| **Automations** | **Remote desktop** | +| Automation canvas | A Windows remote desktop open in a tab inside the app | --- diff --git a/assets/remote-desktop.png b/assets/remote-desktop.png new file mode 100644 index 0000000..df1dd3f Binary files /dev/null and b/assets/remote-desktop.png differ diff --git a/assets/settings.png b/assets/settings.png deleted file mode 100644 index 65b1459..0000000 Binary files a/assets/settings.png and /dev/null differ diff --git a/packages/electron/src/core/rdp/savedFiles.test.ts b/packages/electron/src/core/rdp/savedFiles.test.ts new file mode 100644 index 0000000..72b55ea --- /dev/null +++ b/packages/electron/src/core/rdp/savedFiles.test.ts @@ -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('ac: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'); + }); +}); diff --git a/packages/electron/src/core/rdp/savedFiles.ts b/packages/electron/src/core/rdp/savedFiles.ts new file mode 100644 index 0000000..d5d9ac7 --- /dev/null +++ b/packages/electron/src/core/rdp/savedFiles.ts @@ -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 { + const wanted = targetPath(folder, relativePath, name); + await mkdir(join(wanted, '..'), { recursive: true }); + const path = freePath(wanted); + await writeFile(path, bytes, { flag: 'wx' }); + return path; +} diff --git a/packages/electron/src/ipc/rdpEmbedded.ts b/packages/electron/src/ipc/rdpEmbedded.ts index c45f080..f89a6ff 100644 --- a/packages/electron/src/ipc/rdpEmbedded.ts +++ b/packages/electron/src/ipc/rdpEmbedded.ts @@ -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'; @@ -25,6 +26,8 @@ interface OpenSession { notice?: string; } const open = new Map(); +/** Folders the user chose in `rdp_pick_save_folder` — the only ones `rdp_save_file` writes to. */ +const pickedFolders = new Set(); function openTcp(host: string, port: number): Promise { return new Promise((resolve, reject) => { @@ -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 => { + 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 => { + 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); @@ -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); diff --git a/packages/ui/e2e/remoteDesktop.spec.ts b/packages/ui/e2e/remoteDesktop.spec.ts index 76ffdd7..96e8adc 100644 --- a/packages/ui/e2e/remoteDesktop.spec.ts +++ b/packages/ui/e2e/remoteDesktop.spec.ts @@ -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(); +}); diff --git a/packages/ui/src/lib/bindings.ts b/packages/ui/src/lib/bindings.ts index 38ae1c5..de25824 100644 --- a/packages/ui/src/lib/bindings.ts +++ b/packages/ui/src/lib/bindings.ts @@ -167,6 +167,20 @@ export const commands = { }, async rdpForgetCertificate(connectionId: string): Promise> { return call('rdp_forget_certificate', connectionId); + }, + async rdpPickSaveFolder(): Promise> { + return call('rdp_pick_save_folder'); + }, + async rdpSaveFile( + folder: string, + relativePath: string | undefined, + name: string, + bytes: Uint8Array + ): Promise> { + return call('rdp_save_file', folder, relativePath, name, bytes); + }, + async rdpShowSaved(path: string): Promise> { + return call('rdp_show_saved', path); } }; diff --git a/packages/ui/src/lib/ipc/commands.ts b/packages/ui/src/lib/ipc/commands.ts index c0c180e..0c1d61c 100644 --- a/packages/ui/src/lib/ipc/commands.ts +++ b/packages/ui/src/lib/ipc/commands.ts @@ -357,6 +357,26 @@ export async function rdpEmbeddedClose(token: string): Promise { 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 { + 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 { + 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 { + 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 { const res = await commands.rdpForgetCertificate(connectionId); diff --git a/packages/ui/src/lib/screens/RdpView.svelte b/packages/ui/src/lib/screens/RdpView.svelte index 8e10526..7f86831 100644 --- a/packages/ui/src/lib/screens/RdpView.svelte +++ b/packages/ui/src/lib/screens/RdpView.svelte @@ -12,6 +12,7 @@ import { rdpEmbeddedClose, rdpEmbeddedOpen, rdpEmbeddedStatus, rdpForgetCertificate, rdpLaunch } from '$lib/ipc/commands'; import { Icon } from '$lib/theme'; import { explainRdpError, loadIronRdp, type IronUserInteraction } from './rdpEmbedded'; + import { formatBytes, RdpTransfers } from './rdpTransfers.svelte'; let { session, active }: { session: Session; active: boolean } = $props(); @@ -25,6 +26,10 @@ let problem = $state(null); let notice = $state(null); let certificateChanged = $state(false); + // File transfer over the clipboard channel (drop files on the tab / copy them there). + let transfers = $state(null); + let dragging = $state(false); + let dragDepth = 0; let ui: IronUserInteraction | undefined; let token: string | undefined; @@ -49,7 +54,7 @@ certificateChanged = false; sessions.setStatus(session.id, 'connecting'); try { - const { Backend, displayControl } = await loadIronRdp(); + const { Backend, displayControl, RdpFileTransferProvider } = await loadIronRdp(); if (destroyed) return; element?.remove(); @@ -80,6 +85,20 @@ token = dto.token; ui.setEnableClipboard(connection?.clipboard !== false); ui.setEnableAutoClipboard(connection?.clipboard !== false); + // Files travel over the clipboard channel, so they go with the clipboard setting. + transfers?.dispose(); + transfers = null; + if (connection?.clipboard !== false) { + const provider = new RdpFileTransferProvider(); + // The two packages' typings disagree on the provider's hooks (private in one, + // public in the other); it is the object the component expects, per its docs. + ui.enableFileTransfer(provider as unknown as Parameters[0]); + const sync = ui; + transfers = new RdpTransfers(provider, { + pause: () => sync.setEnableAutoClipboard(false), + resume: () => sync.setEnableAutoClipboard(connection?.clipboard !== false) + }); + } const config = ui .configBuilder() .withUsername(dto.username) @@ -176,9 +195,41 @@ // already gone } if (token) void rdpEmbeddedClose(token).catch(() => {}); + transfers?.dispose(); element?.remove(); }); + // Dropping files on the tab puts them on the remote clipboard. + const hasFiles = (e: DragEvent): boolean => Boolean(e.dataTransfer?.types.includes('Files')); + function onDragEnter(e: DragEvent): void { + if (!hasFiles(e) || phase !== 'connected' || !transfers) return; + e.preventDefault(); + dragDepth++; + dragging = true; + } + function onDragOver(e: DragEvent): void { + if (!hasFiles(e)) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = phase === 'connected' && transfers ? 'copy' : 'none'; + } + function onDragLeave(): void { + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) dragging = false; + } + function onDrop(e: DragEvent): void { + if (!hasFiles(e)) return; + e.preventDefault(); + dragDepth = 0; + dragging = false; + if (phase !== 'connected' || !transfers) return; + void transfers.drop(e).catch((err) => lastError.set(err instanceof Error ? err.message : String(err))); + focus(); + } + + function percent(done: number, total: number): number { + return total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0; + } + $effect(() => { if (active && phase === 'connected') { scheduleResize(); @@ -210,6 +261,17 @@
{#if phase === 'connected'} + {#if transfers} + + {/if} {:else if phase !== 'connecting' && phase !== 'credentials'}
{/if} -
+ +
+ + {#if dragging} +
+
+ +

Drop to copy to {session.hostName}

+

Then paste them into a folder there with Ctrl+V.

+
+
+ {/if} + + {#if phase === 'connected' && transfers && (transfers.upload || transfers.remote)} +
+ {#if transfers.upload} + {@const up = transfers.upload} +
+
+ +
+

{up.label}

+

+ {#if up.phase === 'waiting'} + Ready — paste into a folder on {session.hostName} with Ctrl+V. + {:else if up.phase === 'copying'} + Copying… {formatBytes(up.transferred)} of {formatBytes(up.bytes)} + {:else if up.phase === 'done'} + Copied to {session.hostName}. + {:else} + Failed: {up.error} + {/if} +

+ {#if up.phase === 'copying'} +
+
+
+ {/if} +
+ +
+
+ {/if} + {#if transfers.remote} + {@const rf = transfers.remote} +
+
+ +
+

Copied on {session.hostName}: {rf.label}

+

+ {#if rf.phase === 'available'} + Save them on this computer? + {:else if rf.phase === 'saving'} + Saving… {rf.saved} of {rf.total} + {:else if rf.phase === 'saved'} + Saved {rf.saved} {rf.saved === 1 ? 'file' : 'files'}. + {:else} + Failed: {rf.error} + {/if} +

+ {#if rf.phase === 'saving'} +
+
+
+ {/if} +
+ {#if rf.phase === 'available' || rf.phase === 'failed'} + + {:else if rf.phase === 'saved'} + + {/if} +
+
+ +
+
+ {/if} +
+ {/if} {#if phase !== 'connected'}
diff --git a/packages/ui/src/lib/screens/rdpTransfers.svelte.ts b/packages/ui/src/lib/screens/rdpTransfers.svelte.ts new file mode 100644 index 0000000..3717710 --- /dev/null +++ b/packages/ui/src/lib/screens/rdpTransfers.svelte.ts @@ -0,0 +1,195 @@ +// File transfer for an embedded RDP session, over RDP's clipboard channel (IronRDP's +// RdpFileTransferProvider) — the way mstsc does it: +// into the session: drop files on the tab (or pick them); they go on the remote +// clipboard, and pasting in a remote folder (Ctrl+V) copies them there; +// out of it: copying files on the remote desktop offers them here, to save into a +// folder picked on this machine. + +import type { DroppedFile, FileInfo, RdpFileTransferProvider } from '@devolutions/iron-remote-desktop-rdp'; +import { rdpPickSaveFolder, rdpSaveFile, rdpShowSaved } from '$lib/ipc/commands'; + +type Entry = { size: number; isDirectory?: boolean }; + +/** Files (not folders) in a collection, and their total size. */ +export function summarize(entries: Entry[]): { files: number; bytes: number } { + const files = entries.filter((e) => !e.isDirectory); + return { files: files.length, bytes: files.reduce((n, f) => n + (f.size || 0), 0) }; +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let n = bytes / 1024; + let i = 0; + for (; n >= 1024 && i < units.length - 1; i++) n /= 1024; + return `${n >= 10 ? Math.round(n) : n.toFixed(1)} ${units[i]}`; +} + +/** "3 files, 12 MB" / "report.pdf, 2.1 MB". */ +export function describeCollection(entries: Array): string { + const { files, bytes } = summarize(entries); + const single = entries.filter((e) => !e.isDirectory); + const what = files === 1 ? single[0].name : `${files} files`; + return `${what}, ${formatBytes(bytes)}`; +} + +export interface UploadState { + label: string; + bytes: number; + /** Waiting for the paste on the remote side, copying, or finished. */ + phase: 'waiting' | 'copying' | 'done' | 'failed'; + transferred: number; + error?: string; +} + +export interface RemoteFilesState { + files: FileInfo[]; + label: string; + phase: 'available' | 'saving' | 'saved' | 'failed'; + saved: number; + total: number; + firstSaved?: string; + folder?: string; + error?: string; +} + +export class RdpTransfers { + upload = $state(null); + remote = $state(null); + + private uploadProgress = new Map(); + /** When the download in flight last moved, for the stall check. */ + private lastDownloadActivity = 0; + + /** + * `clipboardSync` pauses the web client's own clipboard sync while files are fetched: + * it pushes this machine's clipboard to the remote one whenever that changes, which + * would replace the file list being downloaded from (found against a real Windows). + */ + constructor( + private readonly provider: RdpFileTransferProvider, + private readonly clipboardSync: { pause(): void; resume(): void } = { pause() {}, resume() {} } + ) { + provider.on('download-progress', () => { + this.lastDownloadActivity = Date.now(); + }); + // Offering the files already starts a "batch"; only bytes flowing mean the remote + // side has pasted them. + provider.on('upload-progress', (p) => { + this.uploadProgress.set(p.transferId, p.bytesTransferred); + const transferred = [...this.uploadProgress.values()].reduce((a, b) => a + b, 0); + if (this.upload && this.upload.phase !== 'done' && transferred > 0) { + this.upload = { ...this.upload, phase: 'copying', transferred }; + } + }); + provider.on('files-available', (files) => { + if (this.remote?.phase === 'saving') return; // one save at a time + const { files: count } = summarize(files); + // Something other than files copied there: the offer stands until dismissed (a + // save then says the files are gone), rather than vanishing under the pointer. + if (count === 0) return; + this.remote = { files, label: describeCollection(files), phase: 'available', saved: 0, total: count }; + }); + provider.on('error', (e) => { + if (e.direction === 'download' && this.remote) this.remote = { ...this.remote, phase: 'failed', error: e.message }; + else if (this.upload) this.upload = { ...this.upload, phase: 'failed', error: e.message }; + }); + } + + /** Starts a drop: the entries must be read while the event is still live. */ + async drop(event: DragEvent): Promise { + const files = await this.provider.handleDrop(event); + this.send(files); + } + + async pick(): Promise { + const files = await this.provider.showFilePicker({ multiple: true }); + if (files.length) this.send(files); + } + + private send(files: File[] | DroppedFile[]): void { + if (files.length === 0) return; + const entries = (files as Array).map((f) => ({ + name: f.name, + size: f.size, + isDirectory: 'isDirectory' in f ? f.isDirectory : false + })); + const { bytes } = summarize(entries); + this.uploadProgress.clear(); + this.upload = { label: describeCollection(entries), bytes, phase: 'waiting', transferred: 0 }; + const handle = this.provider.uploadFiles(files); + handle.completion.then( + () => { + if (this.upload) this.upload = { ...this.upload, phase: 'done', transferred: bytes }; + }, + (err: unknown) => { + if (this.upload) this.upload = { ...this.upload, phase: 'failed', error: err instanceof Error ? err.message : String(err) }; + } + ); + } + + /** Saves the files copied on the remote desktop into a folder picked here. */ + async save(): Promise { + const remote = this.remote; + if (!remote || remote.phase === 'saving') return; + let folder: string | null; + try { + folder = await rdpPickSaveFolder(); + } catch (err) { + this.remote = { ...remote, phase: 'failed', error: err instanceof Error ? err.message : String(err) }; + return; + } + if (!folder) return; + this.remote = { ...remote, phase: 'saving', saved: 0, folder, error: undefined }; + this.clipboardSync.pause(); + try { + for (const [index, file] of remote.files.entries()) { + if (file.isDirectory) continue; + const blob = await this.withStallCheck(this.provider.downloadFile(file, index).completion, file.name); + const path = await rdpSaveFile(folder, file.path, file.name, new Uint8Array(await blob.arrayBuffer())); + this.remote = { ...this.remote!, saved: this.remote!.saved + 1, firstSaved: this.remote!.firstSaved ?? path }; + } + this.remote = { ...this.remote!, phase: 'saved' }; + } catch (err) { + this.remote = { ...(this.remote ?? remote), phase: 'failed', error: err instanceof Error ? err.message : String(err) }; + } finally { + this.clipboardSync.resume(); + } + } + + /** A download that stops moving for this long has lost its source (the remote + * clipboard changed, say) — the web client would otherwise wait forever. */ + static readonly STALL_MS = 30_000; + + private withStallCheck(completion: Promise, name: string): Promise { + this.lastDownloadActivity = Date.now(); + return new Promise((resolve, reject) => { + const timer = setInterval(() => { + if (Date.now() - this.lastDownloadActivity > RdpTransfers.STALL_MS) { + clearInterval(timer); + reject(new Error(`${name} stopped arriving — copy the files on the remote desktop again`)); + } + }, 1000); + completion.then( + (v) => (clearInterval(timer), resolve(v)), + (e) => (clearInterval(timer), reject(e)) + ); + }); + } + + showSaved(): void { + if (this.remote?.firstSaved) void rdpShowSaved(this.remote.firstSaved); + } + + dismissUpload(): void { + this.upload = null; + } + + dismissRemote(): void { + this.remote = null; + } + + dispose(): void { + this.provider.dispose(); + } +} diff --git a/packages/ui/src/lib/screens/rdpTransfers.test.ts b/packages/ui/src/lib/screens/rdpTransfers.test.ts new file mode 100644 index 0000000..bde3171 --- /dev/null +++ b/packages/ui/src/lib/screens/rdpTransfers.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('$lib/ipc/commands', () => ({ + rdpPickSaveFolder: vi.fn(), + rdpSaveFile: vi.fn(), + rdpShowSaved: vi.fn() +})); + +const { rdpPickSaveFolder, rdpSaveFile } = await import('$lib/ipc/commands'); +const { RdpTransfers, describeCollection, formatBytes, summarize } = await import('./rdpTransfers.svelte'); + +type Handler = (...args: unknown[]) => void; + +/** A stand-in for IronRDP's provider: records listeners, lets tests fire events. */ +function fakeProvider() { + const handlers: Record = {}; + let finishUpload!: () => void; + const provider = { + on: (event: string, h: Handler) => void (handlers[event] ??= []).push(h), + emit: (event: string, ...args: unknown[]) => handlers[event]?.forEach((h) => h(...args)), + uploadFiles: vi.fn(() => ({ transferIds: new Map(), completion: new Promise((r) => (finishUpload = r)) })), + downloadFile: vi.fn((file: { name: string }) => ({ transferId: 1, completion: Promise.resolve(new Blob([`bytes of ${file.name}`])) })), + handleDrop: vi.fn(), + showFilePicker: vi.fn(), + dispose: vi.fn() + }; + return { provider, finish: () => finishUpload() }; +} + +describe('collection summaries', () => { + it('count files, not folders, and add up their size', () => { + expect(summarize([{ size: 10 }, { size: 0, isDirectory: true }, { size: 5 }])).toEqual({ files: 2, bytes: 15 }); + }); + + it('read naturally', () => { + expect(formatBytes(512)).toBe('512 B'); + expect(formatBytes(1536)).toBe('1.5 KB'); + expect(formatBytes(20 * 1024 * 1024)).toBe('20 MB'); + expect(describeCollection([{ name: 'a.pdf', size: 2048 }])).toBe('a.pdf, 2.0 KB'); + expect(describeCollection([{ name: 'a', size: 1 }, { name: 'b', size: 1 }])).toBe('2 files, 2 B'); + }); +}); + +describe('RdpTransfers', () => { + it('offers files, waits for the paste, then counts bytes until done', async () => { + const { provider, finish } = fakeProvider(); + const t = new RdpTransfers(provider as never); + provider.showFilePicker.mockResolvedValue([new File(['hello'], 'a.txt')]); + + await t.pick(); + expect(t.upload).toMatchObject({ label: 'a.txt, 5 B', phase: 'waiting' }); + + // Offering starts a "batch" already — still waiting until bytes flow. + provider.emit('upload-batch-started', new Map(), []); + expect(t.upload?.phase).toBe('waiting'); + provider.emit('upload-progress', { transferId: 1, bytesTransferred: 3 }); + expect(t.upload).toMatchObject({ phase: 'copying', transferred: 3 }); + + finish(); + await Promise.resolve(); + expect(t.upload).toMatchObject({ phase: 'done', transferred: 5 }); + }); + + it('offers files copied on the remote side and keeps the offer when the clipboard changes to text', () => { + const { provider } = fakeProvider(); + const t = new RdpTransfers(provider as never); + provider.emit('files-available', [{ name: 'r.txt', size: 4, lastModified: 0 }]); + expect(t.remote).toMatchObject({ label: 'r.txt, 4 B', phase: 'available', total: 1 }); + provider.emit('files-available', []); + expect(t.remote?.label).toBe('r.txt, 4 B'); + }); + + it('saves into the picked folder, keeping folders, pausing the clipboard sync meanwhile', async () => { + const { provider } = fakeProvider(); + const sync = { pause: vi.fn(), resume: vi.fn() }; + const t = new RdpTransfers(provider as never, sync); + vi.mocked(rdpPickSaveFolder).mockResolvedValue('/dl'); + vi.mocked(rdpSaveFile).mockImplementation(async (folder, rel, name) => `${folder}/${rel ?? ''}/${name}`); + provider.emit('files-available', [ + { name: 'docs', size: 0, lastModified: 0, isDirectory: true }, + { name: 'a.txt', path: 'docs', size: 1, lastModified: 0 } + ]); + + await t.save(); + + expect(sync.pause).toHaveBeenCalledTimes(1); + expect(sync.resume).toHaveBeenCalledTimes(1); + expect(provider.downloadFile).toHaveBeenCalledTimes(1); + expect(vi.mocked(rdpSaveFile).mock.calls[0].slice(0, 3)).toEqual(['/dl', 'docs', 'a.txt']); + expect(t.remote).toMatchObject({ phase: 'saved', saved: 1, firstSaved: '/dl/docs/a.txt' }); + }); + + it('says why when saving fails — including a folder dialog that fails', async () => { + const { provider } = fakeProvider(); + const t = new RdpTransfers(provider as never); + provider.emit('files-available', [{ name: 'a.txt', size: 1, lastModified: 0 }]); + vi.mocked(rdpPickSaveFolder).mockRejectedValue(new Error('no dialog')); + await t.save(); + expect(t.remote).toMatchObject({ phase: 'failed', error: 'no dialog' }); + }); + + it('does nothing when the folder dialog is cancelled', async () => { + const { provider } = fakeProvider(); + const t = new RdpTransfers(provider as never); + provider.emit('files-available', [{ name: 'a.txt', size: 1, lastModified: 0 }]); + vi.mocked(rdpPickSaveFolder).mockResolvedValue(null); + await t.save(); + expect(t.remote?.phase).toBe('available'); + }); +}); diff --git a/packages/ui/src/lib/stores/navigation.test.ts b/packages/ui/src/lib/stores/navigation.test.ts index 093b04e..69a5c20 100644 --- a/packages/ui/src/lib/stores/navigation.test.ts +++ b/packages/ui/src/lib/stores/navigation.test.ts @@ -44,3 +44,41 @@ describe('navigation actions', () => { expect(get(activeEntity)).toEqual({ kind: 'session', id: b.id }); }); }); + +describe('followSidebarMode', () => { + it('starts on the Remote Desktop screen when the app was left in Remote Desktop mode', async () => { + const { followSidebarMode, activeEntity } = await fresh(); + const { sidebarMode } = await import('./sidebarMode'); + sidebarMode.set('remoteDesktop'); + const stop = followSidebarMode(); + expect(get(activeEntity)).toEqual({ kind: 'remoteDesktop' }); + stop(); + }); + + it('follows the saved mode arriving after startup, back to SSH too', async () => { + const { followSidebarMode, activeEntity } = await fresh(); + const { sidebarMode } = await import('./sidebarMode'); + sidebarMode.set('ssh'); + const stop = followSidebarMode(); + expect(get(activeEntity)).toEqual({ kind: 'dashboard' }); + sidebarMode.set('remoteDesktop'); + expect(get(activeEntity)).toEqual({ kind: 'remoteDesktop' }); + sidebarMode.set('ssh'); + expect(get(activeEntity)).toEqual({ kind: 'dashboard' }); + stop(); + }); + + it('leaves open sessions and Settings alone', async () => { + const { followSidebarMode, spawnSession, activeEntity } = await fresh(); + const { sidebarMode } = await import('./sidebarMode'); + sidebarMode.set('ssh'); + const stop = followSidebarMode(); + const s = spawnSession('terminal', 'web-1'); + sidebarMode.set('remoteDesktop'); + expect(get(activeEntity)).toEqual({ kind: 'session', id: s.id }); + activeEntity.selectSettings(); + sidebarMode.set('ssh'); + expect(get(activeEntity)).toEqual({ kind: 'settings' }); + stop(); + }); +}); diff --git a/packages/ui/src/lib/stores/navigation.ts b/packages/ui/src/lib/stores/navigation.ts index 887a4f4..4f332d4 100644 --- a/packages/ui/src/lib/stores/navigation.ts +++ b/packages/ui/src/lib/stores/navigation.ts @@ -1,6 +1,7 @@ import { get } from 'svelte/store'; import { activeEntity } from './activeEntity'; import { sessions, type Session, type SessionKind } from './sessions'; +import { sidebarMode } from './sidebarMode'; // Composed navigation actions that keep the sessions list and the active entity in // step (tech-gui.md §2). A spawn appends a session and makes it active (both spawn @@ -26,3 +27,23 @@ export function closeSession(id: number): void { } sessions.close(id); } + +/** + * Keeps the selector screen in step with the SSH / Remote Desktop switch. The switch + * comes back from the last run, but the content always starts on the Dashboard — so + * after closing the app in Remote Desktop it showed the SSH dashboard under a switch + * saying Remote Desktop. A screen that doesn't belong to the mode now goes to that + * mode's own: at startup, and when the saved mode is read back a moment later. + * Settings belong to both modes and open sessions are never touched. Returns the + * unsubscribe. + */ +export function followSidebarMode(): () => void { + return sidebarMode.subscribe((mode) => { + const active = get(activeEntity); + if (mode === 'remoteDesktop' && (active.kind === 'dashboard' || active.kind === 'automations' || active.kind === 'automation')) { + activeEntity.selectRemoteDesktop(); + } else if (mode === 'ssh' && active.kind === 'remoteDesktop') { + activeEntity.selectDashboard(); + } + }); +} diff --git a/packages/ui/src/routes/+layout.svelte b/packages/ui/src/routes/+layout.svelte index 0362a13..05cbd20 100644 --- a/packages/ui/src/routes/+layout.svelte +++ b/packages/ui/src/routes/+layout.svelte @@ -6,6 +6,7 @@ import { theme } from '$lib/stores/theme'; import { sidebarCollapsed } from '$lib/stores/ui'; import { sidebarMode } from '$lib/stores/sidebarMode'; + import { followSidebarMode } from '$lib/stores/navigation'; import { terminalCopyOnSelect, terminalGpu, terminalRightClick } from '$lib/stores/terminalPrefs'; import { streamerMode } from '$lib/stores/streamer'; import { refreshInterval, driveMetricsRefresh } from '$lib/stores/settings'; @@ -20,6 +21,7 @@ // the synchronous localStorage mirrors already seeded the first paint (§5.1, §2). void theme.hydrate(); void sidebarCollapsed.hydrate(); + const stopFollowingMode = followSidebarMode(); void sidebarMode.hydrate(); void terminalRightClick.hydrate(); void terminalCopyOnSelect.hydrate(); @@ -44,6 +46,7 @@ disposed = true; stop?.(); stopRefresh(); + stopFollowingMode(); }; }); diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts index d94d1e9..0fde952 100644 --- a/packages/ui/vitest.config.ts +++ b/packages/ui/vitest.config.ts @@ -1,9 +1,12 @@ import { fileURLToPath } from 'node:url'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; import { defineConfig } from 'vitest/config'; // Store/router unit tests only — no SvelteKit plugin, no Tauri runtime. The -// $lib alias mirrors svelte.config.js so tests resolve it the same way. +// $lib alias mirrors svelte.config.js so tests resolve it the same way. The Svelte +// plugin compiles `.svelte.ts` modules, whose runes ($state…) need the compiler. export default defineConfig({ + plugins: [svelte()], resolve: { alias: { $lib: fileURLToPath(new URL('./src/lib', import.meta.url)) } },