+ {/if}
+
+ {#if phase === 'connected' && transfers && (transfers.upload || transfers.remote)}
+
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)) }
},