diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59ba067..605cf4a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,34 @@ jobs: ./build-freerdp-static.sh make rdp-sidecar-static + # Windows ships the same sidecar, built static via vcpkg so it carries no + # loose DLLs. Cache the vcpkg-built FreeRDP so it isn't recompiled every run. + - name: Cache vcpkg FreeRDP (Windows) + if: runner.os == 'Windows' + uses: actions/cache@v4 + with: + path: | + C:/vcpkg/installed + C:/vcpkg/packages + # The vcpkg install depends only on the triplet, not on our CMakeLists, + # so key on a manual marker (bump to force a rebuild) rather than file + # hashes — otherwise every CMake tweak recompiles FreeRDP (~9 min). + key: vcpkg-freerdp-x64-windows-static-v1 + + - name: Build RDP sidecar (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + & "$env:VCPKG_INSTALLATION_ROOT\vcpkg.exe" install freerdp:x64-windows-static + cmake -S native/rdp-spike -B native/rdp-spike/build ` + -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_INSTALLATION_ROOT\scripts\buildsystems\vcpkg.cmake" ` + -DVCPKG_TARGET_TRIPLET=x64-windows-static ` + -DCMAKE_BUILD_TYPE=Release + cmake --build native/rdp-spike/build --config Release + $exe = Get-ChildItem -Recurse -Filter rdp-sidecar.exe native/rdp-spike/build | Select-Object -First 1 + if (-not $exe) { throw "rdp-sidecar.exe not produced by the build" } + Copy-Item $exe.FullName native/rdp-spike/rdp-sidecar.exe -Force + # Notarization uses an App Store Connect API key. electron-builder reads # APPLE_API_KEY as a path to the .p8, so materialize it from the secret. - name: Prepare App Store Connect API key @@ -151,7 +179,27 @@ jobs: Pop-Location } + # Always expose the built installers on the run itself so branch/dispatch + # builds are downloadable for testing without cutting a release. Grab them + # from the run's "Artifacts" section (Actions tab → this run). + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: noxed-${{ matrix.os }} + path: | + dist/*.dmg + dist/*.zip + dist/*.exe + dist/*.AppImage + dist/*.deb + native/rdp-spike/rdp-sidecar.exe + if-no-files-found: ignore + retention-days: 7 + + # Only publish on a real tag push. workflow_dispatch on a branch still runs + # the full build (useful for verifying the sidecars) but skips publishing. - name: Attach artifacts to release + if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v2 with: files: | diff --git a/electron-builder.yml b/electron-builder.yml index 1ed20c1..fc68133 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -47,6 +47,12 @@ mac: - zip win: icon: build/icon.png + # Ship the self-contained RDP sidecar (built via native/rdp-spike/CMakeLists.txt + # with vcpkg's x64-windows-static FreeRDP). Lands at resources/rdp-sidecar.exe — + # see sidecarPath() in ipc/rdp.ts and the win32 check in verify-sidecar.js. + extraResources: + - from: native/rdp-spike/rdp-sidecar.exe + to: rdp-sidecar.exe target: - nsis linux: diff --git a/native/rdp-spike/CMakeLists.txt b/native/rdp-spike/CMakeLists.txt new file mode 100644 index 0000000..be9fa68 --- /dev/null +++ b/native/rdp-spike/CMakeLists.txt @@ -0,0 +1,48 @@ +# Windows build for the RDP sidecar. +# +# macOS builds the sidecar via the Makefile + build-freerdp-static.sh (a trimmed +# from-source static FreeRDP). On Windows the easiest self-contained path is +# vcpkg's static triplet, which ships static FreeRDP 3 plus its transitive deps +# (OpenSSL, zlib, ...). Configure with the vcpkg toolchain so find_package +# resolves everything: +# +# vcpkg install freerdp:x64-windows-static +# cmake -S native/rdp-spike -B native/rdp-spike/build \ +# -DCMAKE_TOOLCHAIN_FILE=/scripts/buildsystems/vcpkg.cmake \ +# -DVCPKG_TARGET_TRIPLET=x64-windows-static -DCMAKE_BUILD_TYPE=Release +# cmake --build native/rdp-spike/build --config Release +# +# Produces rdp-sidecar.exe, which electron-builder bundles via win.extraResources. +cmake_minimum_required(VERSION 3.16) +project(rdp_sidecar C) + +set(CMAKE_C_STANDARD 11) + +# Link the CRT statically so the .exe doesn't depend on the VC++ redistributable. +if(MSVC) + set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +endif() + +# FreeRDP 3 from vcpkg. The CONFIG packages export imported targets that carry +# their static transitive dependencies, so we don't enumerate OpenSSL/zlib here. +find_package(FreeRDP CONFIG REQUIRED) +find_package(FreeRDP-Client CONFIG REQUIRED) +find_package(WinPR CONFIG REQUIRED) + +add_executable(rdp-sidecar sidecar.c) +set_target_properties(rdp-sidecar PROPERTIES OUTPUT_NAME rdp-sidecar) + +target_link_libraries(rdp-sidecar PRIVATE freerdp freerdp-client winpr) + +# FreeRDP's vcpkg config pulls in transitive static deps (e.g. cjson) referenced +# by bare library name rather than a full path, so make the vcpkg static lib dir +# searchable for the linker. +if(DEFINED VCPKG_INSTALLED_DIR AND DEFINED VCPKG_TARGET_TRIPLET) + target_link_directories(rdp-sidecar PRIVATE + "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/lib") +endif() + +if(WIN32) + # Sockets + crypto/security backends FreeRDP/WinPR pull in on Windows. + target_link_libraries(rdp-sidecar PRIVATE ws2_32 crypt32 secur32 winmm bcrypt) +endif() diff --git a/native/rdp-spike/sidecar.c b/native/rdp-spike/sidecar.c index 2f87d65..36e5b6c 100644 --- a/native/rdp-spike/sidecar.c +++ b/native/rdp-spike/sidecar.c @@ -32,11 +32,19 @@ #include #include +#ifdef _WIN32 +#include +#include +#include +#endif + #include #include #include #include +#include #include +#include #include typedef struct @@ -46,6 +54,106 @@ typedef struct size_t packedCap; } SidecarContext; +/* + * Input injection. The parent process (src/main/ipc/rdp.ts) writes fixed 8-byte + * little-endian messages to our stdin; a reader thread pushes them onto a small + * ring queue and signals g_inputEvent, which the main event loop waits on + * alongside FreeRDP's own handles. The loop drains the queue and calls the + * FreeRDP input functions on the main thread (the transport isn't thread-safe). + * + * Wire message (8 bytes): + * u8 type 1=mouse, 2=scancode key, 3=unicode key + * u8 _pad + * u16 flags PTR_FLAGS_* (mouse) or KBD_FLAGS_* (keyboard) + * u16 a mouse x, or key code (scancode / unicode unit) + * u16 b mouse y (0 for keys) + * + * Keeping the sidecar generic — it just forwards flags/codes — means all the + * key-mapping lives in the renderer where it's easy to iterate. + */ +typedef struct +{ + UINT8 type; + UINT16 flags; + UINT16 a; + UINT16 b; +} InputMsg; + +#define INPUT_QUEUE_CAP 512 +static CRITICAL_SECTION g_inputLock; +static InputMsg g_inputQueue[INPUT_QUEUE_CAP]; +static int g_inputHead = 0; +static int g_inputTail = 0; +static HANDLE g_inputEvent = NULL; + +static UINT16 read_u16_le(const UINT8* p) +{ + return (UINT16)(p[0] | ((UINT16)p[1] << 8)); +} + +static DWORD WINAPI input_reader_thread(LPVOID arg) +{ + (void)arg; + UINT8 buf[8]; + for (;;) + { + /* Block until a full 8-byte message is available; stop on EOF/short read + * (parent closed stdin, i.e. the session is going away). */ + if (fread(buf, 1, sizeof(buf), stdin) != sizeof(buf)) + break; + + InputMsg msg; + msg.type = buf[0]; + msg.flags = read_u16_le(buf + 2); + msg.a = read_u16_le(buf + 4); + msg.b = read_u16_le(buf + 6); + + EnterCriticalSection(&g_inputLock); + int next = (g_inputTail + 1) % INPUT_QUEUE_CAP; + if (next != g_inputHead) /* drop if full rather than block the reader */ + { + g_inputQueue[g_inputTail] = msg; + g_inputTail = next; + } + LeaveCriticalSection(&g_inputLock); + SetEvent(g_inputEvent); + } + return 0; +} + +static void drain_input(rdpContext* context) +{ + rdpInput* input = context->input; + for (;;) + { + InputMsg msg; + EnterCriticalSection(&g_inputLock); + if (g_inputHead == g_inputTail) + { + LeaveCriticalSection(&g_inputLock); + break; + } + msg = g_inputQueue[g_inputHead]; + g_inputHead = (g_inputHead + 1) % INPUT_QUEUE_CAP; + LeaveCriticalSection(&g_inputLock); + + switch (msg.type) + { + case 1: + freerdp_input_send_mouse_event(input, msg.flags, msg.a, msg.b); + break; + case 2: + freerdp_input_send_keyboard_event(input, msg.flags, (UINT8)msg.a); + break; + case 3: + freerdp_input_send_unicode_keyboard_event(input, msg.flags, msg.a); + break; + default: + break; + } + } +} + static void write_u32_le(BYTE* p, UINT32 v) { p[0] = (BYTE)(v & 0xFF); @@ -201,6 +309,23 @@ static int sidecar_entry(RDP_CLIENT_ENTRY_POINTS* pEntryPoints) int main(int argc, char* argv[]) { +#ifdef _WIN32 + /* Windows opens stdout in text mode, which translates every \n to \r\n and + * would corrupt our binary frame stream (silent pixel desync). Force binary + * before any frame is written; bail out if it fails rather than ship garbage. */ + if (_setmode(_fileno(stdout), _O_BINARY) == -1) + { + fprintf(stderr, "[sidecar] failed to set stdout to binary mode\n"); + return 1; + } + /* stdin carries binary 8-byte input messages; text mode would mangle them. */ + if (_setmode(_fileno(stdin), _O_BINARY) == -1) + { + fprintf(stderr, "[sidecar] failed to set stdin to binary mode\n"); + return 1; + } +#endif + if (argc < 4 || argc > 6) { fprintf(stderr, "usage: %s [width] [height]\n", argv[0]); @@ -220,9 +345,10 @@ int main(int argc, char* argv[]) fprintf(stderr, "[sidecar] failed to read password from stdin\n"); return 2; } - /* Remove trailing newline */ + /* Remove trailing newline (and CR, in case stdin is binary on Windows) */ size_t len = strlen(pass); - if (len > 0 && pass[len - 1] == '\n') pass[len - 1] = '\0'; + while (len > 0 && (pass[len - 1] == '\n' || pass[len - 1] == '\r')) + pass[--len] = '\0'; quiet_wlog_to_stderr(); @@ -236,12 +362,31 @@ int main(int argc, char* argv[]) return 1; } +#ifdef _WIN32 + /* Winsock must be initialized before any name resolution / socket call. + * FreeRDP's own Windows client does this in its global init; without it + * getaddrinfo fails and freerdp_connect reports DNS_NAME_NOT_FOUND even for + * a perfectly valid host. */ + WSADATA wsaData; + if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) + { + fprintf(stderr, "[sidecar] WSAStartup failed\n"); + freerdp_client_context_free(context); + return 1; + } +#endif + rdpSettings* settings = context->settings; freerdp_settings_set_string(settings, FreeRDP_ServerHostname, host); freerdp_settings_set_uint32(settings, FreeRDP_ServerPort, port); freerdp_settings_set_string(settings, FreeRDP_Username, user); freerdp_settings_set_string(settings, FreeRDP_Password, pass); - freerdp_settings_set_bool(settings, FreeRDP_IgnoreCertificate, FALSE); + /* Internal RDP hosts almost always present a self-signed certificate (mstsc + * just prompts the user to trust it). This is a view-only tool, so accept the + * cert automatically rather than failing the TLS handshake with + * ERRCONNECT_TLS_CONNECT_FAILED (0x00020008). */ + freerdp_settings_set_bool(settings, FreeRDP_IgnoreCertificate, TRUE); + freerdp_settings_set_bool(settings, FreeRDP_AutoAcceptCertificate, TRUE); freerdp_settings_set_uint32(settings, FreeRDP_DesktopWidth, width); freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight, height); freerdp_settings_set_uint32(settings, FreeRDP_ColorDepth, 32); @@ -256,10 +401,22 @@ int main(int argc, char* argv[]) goto cleanup; } + /* Start the stdin input reader now that we're connected. The auto-reset + * event wakes the main loop whenever input arrives so it can be flushed to + * the (single-threaded) FreeRDP transport. */ + InitializeCriticalSection(&g_inputLock); + g_inputEvent = CreateEvent(NULL, FALSE, FALSE, NULL); + HANDLE readerThread = NULL; + if (g_inputEvent) + readerThread = CreateThread(NULL, 0, input_reader_thread, NULL, 0, NULL); + else + fprintf(stderr, "[sidecar] input disabled: failed to create event\n"); + while (!freerdp_shall_disconnect_context(context)) { HANDLE handles[64]; - DWORD count = freerdp_get_event_handles(context, handles, 64); + /* Reserve one slot for the input event. */ + DWORD count = freerdp_get_event_handles(context, handles, 63); if (count == 0) { fprintf(stderr, "[sidecar] failed to get event handles\n"); @@ -267,7 +424,11 @@ int main(int argc, char* argv[]) break; } - DWORD status = WaitForMultipleObjects(count, handles, FALSE, INFINITE); + DWORD total = count; + if (g_inputEvent) + handles[total++] = g_inputEvent; + + DWORD status = WaitForMultipleObjects(total, handles, FALSE, INFINITE); if (status == WAIT_FAILED) { fprintf(stderr, "[sidecar] wait failed\n"); @@ -275,13 +436,21 @@ int main(int argc, char* argv[]) break; } + if (g_inputEvent) + drain_input(context); + if (!freerdp_check_event_handles(context)) break; } freerdp_disconnect(instance); + if (readerThread) + CloseHandle(readerThread); cleanup: freerdp_client_context_free(context); +#ifdef _WIN32 + WSACleanup(); +#endif return rc; } diff --git a/scripts/verify-sidecar.js b/scripts/verify-sidecar.js index fa3e269..d339438 100644 --- a/scripts/verify-sidecar.js +++ b/scripts/verify-sidecar.js @@ -35,7 +35,43 @@ exports.default = async function verifySidecar(context) { return } - // Windows/Linux sidecars are a separate build (not yet produced); nothing to - // verify until extraResources ships them for those platforms. + if (platform === 'win32') { + // win.extraResources lands under /resources/. + const sidecar = join(appOutDir, 'resources', 'rdp-sidecar.exe') + if (!existsSync(sidecar)) { + throw new Error( + `[verify-sidecar] RDP sidecar missing from package: ${sidecar}\n` + + `Build it first (vcpkg static FreeRDP): see native/rdp-spike/CMakeLists.txt`, + ) + } + // The vcpkg x64-windows-static build + static CRT should leave no FreeRDP/ + // OpenSSL/vcpkg DLL dependencies — only Windows system DLLs. Best-effort + // check via dumpbin; skip quietly if the VS toolchain isn't on PATH. + try { + const deps = execFileSync('dumpbin', ['/dependents', sidecar], { encoding: 'utf8' }) + const leaked = deps + .split('\n') + .map((l) => l.trim()) + .filter((l) => /\.dll$/i.test(l)) + .filter((l) => /freerdp|winpr|libssl|libcrypto|zlib|vcruntime|msvcp/i.test(l)) + if (leaked.length > 0) { + throw new Error( + `[verify-sidecar] RDP sidecar links non-redistributable DLLs:\n ${leaked.join('\n ')}\n` + + `Rebuild static: vcpkg install freerdp:x64-windows-static + the static CRT (see CMakeLists.txt).`, + ) + } + console.log('[verify-sidecar] Windows RDP sidecar is self-contained ✓') + } catch (err) { + if (err.message && err.message.startsWith('[verify-sidecar]')) throw err + // Only swallow the error when dumpbin genuinely isn't on PATH; any other + // failure (e.g. dumpbin ran and errored) is real and must fail the build. + if (err.code !== 'ENOENT') throw err + console.log('[verify-sidecar] Windows RDP sidecar present (dumpbin unavailable, skipped dep scan) ✓') + } + return + } + + // Linux sidecar is a separate build (not yet produced); nothing to verify + // until extraResources ships it for that platform. console.log(`[verify-sidecar] no sidecar check for ${platform} (not bundled yet)`) } diff --git a/src/main/ipc/rdp.ts b/src/main/ipc/rdp.ts index 5b028a9..7d3b13f 100644 --- a/src/main/ipc/rdp.ts +++ b/src/main/ipc/rdp.ts @@ -12,8 +12,8 @@ import { NotFoundError, OwnershipError, ValidationError, ConnectionError, toMess // canvas. Same shape as localTerminal.ts spawning node-pty, but the payload is // binary pixels instead of terminal text. // -// SPIKE STATUS: output-only (read-only desktop). Input injection and a bundled, -// signed sidecar binary for packaged builds are the next milestones. +// Input (mouse/keyboard) is streamed back to the sidecar over stdin via the +// rdp:input channel. Live resize re-negotiation is still a TODO. interface RdpSession { proc: ChildProcessWithoutNullStreams @@ -172,9 +172,10 @@ export function registerRdpHandlers(): void { { stdio: ['pipe', 'pipe', 'pipe'] }, ) - // Write password to stdin instead of passing as command-line argument + // Write password to stdin instead of passing as command-line argument. + // Keep stdin OPEN afterwards: input events (rdp:input) are streamed to the + // sidecar over the same pipe as fixed 8-byte messages. proc.stdin.write(password + '\n') - proc.stdin.end() const id = randomUUID() const entry: RdpSession = { proc, sender: event.sender, buffer: Buffer.alloc(0), resyncs: 0 } @@ -221,4 +222,36 @@ export function registerRdpHandlers(): void { requireSession(event, rawId) disposeSession(rawId as string) }) + + // Input injection: fire-and-forget for low latency. The renderer sends decoded + // input (mouse PTR_FLAGS / keyboard KBD_FLAGS already computed) and we forward + // it to the sidecar as a fixed 8-byte little-endian message matching InputMsg + // in sidecar.c: u8 type, u8 pad, u16 flags, u16 a, u16 b. + ipcMain.on( + 'rdp:input', + (event, rawId: unknown, type: unknown, flags: unknown, a: unknown, b: unknown) => { + if (typeof rawId !== 'string') return + const entry = sessions.get(rawId) + if (!entry || entry.sender.id !== event.sender.id) return + if ( + typeof type !== 'number' || + typeof flags !== 'number' || + typeof a !== 'number' || + typeof b !== 'number' + ) + return + const stdin = entry.proc.stdin + if (!stdin.writable) return + const msg = Buffer.alloc(8) + msg.writeUInt8(type & 0xff, 0) + msg.writeUInt16LE(flags & 0xffff, 2) + msg.writeUInt16LE(a & 0xffff, 4) + msg.writeUInt16LE(b & 0xffff, 6) + try { + stdin.write(msg) + } catch { + // session tearing down; ignore + } + }, + ) } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 6e014f7..8a49425 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -178,6 +178,7 @@ declare global { rdp: { connect: (config: { host: string; port?: number; username: string; password: string; width?: number; height?: number }) => Promise disconnect: (id: string) => Promise + input: (id: string, type: number, flags: number, a: number, b: number) => void onFrame: (cb: (id: string, width: number, height: number, pixels: Uint8Array) => void) => () => void onClose: (cb: (id: string, error: string | null) => void) => () => void } diff --git a/src/preload/index.ts b/src/preload/index.ts index d9eec60..e2ff2f8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -292,6 +292,9 @@ contextBridge.exposeInMainWorld('api', { connect: (config: { host: string; port?: number; username: string; password: string; width?: number; height?: number }) => ipcRenderer.invoke('rdp:connect', config), disconnect: (id: string) => ipcRenderer.invoke('rdp:disconnect', id), + // Fire-and-forget input. type: 1=mouse, 2=scancode key, 3=unicode key. + input: (id: string, type: number, flags: number, a: number, b: number) => + ipcRenderer.send('rdp:input', id, type, flags, a, b), onFrame: (cb: (id: string, width: number, height: number, pixels: Uint8Array) => void) => { const handler = (_e: any, id: string, width: number, height: number, pixels: Uint8Array) => cb(id, width, height, pixels) diff --git a/src/renderer/src/components/ConnectionManager/AddConnectionModal.tsx b/src/renderer/src/components/ConnectionManager/AddConnectionModal.tsx index 766ffb5..b749d48 100644 --- a/src/renderer/src/components/ConnectionManager/AddConnectionModal.tsx +++ b/src/renderer/src/components/ConnectionManager/AddConnectionModal.tsx @@ -6,6 +6,7 @@ import { } from 'lucide-react' import { useAppStore } from '../../store' import { ipcErrorMessage } from '../../lib/format' +import { rdpSupported } from '../../lib/platform' interface K8sContextEntry { name: string @@ -559,9 +560,9 @@ function TypeSelector({ selected, onSelect }: { selected: ConnectionType onSelect: (t: ConnectionType) => void }) { - // RDP needs the bundled FreeRDP sidecar, which currently ships on macOS only. + // RDP needs the bundled FreeRDP sidecar, which ships on macOS + Windows. // Hide the type elsewhere so we never offer a connection that can't run. - const options = TYPE_OPTIONS.filter(o => o.type !== 'rdp' || window.api.platform === 'darwin') + const options = TYPE_OPTIONS.filter(o => o.type !== 'rdp' || rdpSupported) return (
diff --git a/src/renderer/src/components/Dashboard/Dashboard.tsx b/src/renderer/src/components/Dashboard/Dashboard.tsx index 91d429e..4658c30 100644 --- a/src/renderer/src/components/Dashboard/Dashboard.tsx +++ b/src/renderer/src/components/Dashboard/Dashboard.tsx @@ -5,6 +5,7 @@ import { } from 'lucide-react' import { useAppStore, Session } from '../../store' import { groupColor } from '../../lib/colors' +import { rdpSupported } from '../../lib/platform' import { CompactServerCard, HealthCard, ServerListRow } from './ServerViews' import { ServerContextMenu } from '../ServerContextMenu' @@ -403,7 +404,7 @@ export default function Dashboard() { onOpenDocker={(ctxMenu.session.type ?? 'ssh') === 'ssh' ? () => { openDockerTab(ctxMenu.session); setCtxMenu(null) } : undefined} - onOpenRdp={window.api.platform === 'darwin' && ctxMenu.session?.type === 'rdp' + onOpenRdp={rdpSupported && ctxMenu.session?.type === 'rdp' ? () => { openRdpTab(ctxMenu.session); setCtxMenu(null) } : undefined} onColorChange={c => handleColorChange(ctxMenu.session, c)} diff --git a/src/renderer/src/components/RDP/RdpView.tsx b/src/renderer/src/components/RDP/RdpView.tsx index c80a0f0..d99960b 100644 --- a/src/renderer/src/components/RDP/RdpView.tsx +++ b/src/renderer/src/components/RDP/RdpView.tsx @@ -1,17 +1,50 @@ import React, { useEffect, useRef, useState } from 'react' import type { Tab } from '../../store' import { useAppStore } from '../../store' +import { lookupScancode } from '../../lib/rdpKeymap' -// Read-only RDP desktop: the FreeRDP sidecar streams RGBA frames over IPC and -// we blit each one to a canvas. Input injection is the next milestone. +// RDP desktop: the FreeRDP sidecar streams RGBA frames over IPC and we blit each +// one to a canvas; mouse/keyboard events on the canvas are sent back over the +// same sidecar (window.api.rdp.input). // -// SPIKE STATUS: connects, paints frames, surfaces connect/close errors. No -// reconnect, no resize negotiation, no keyboard/mouse yet. +// STATUS: connects, paints frames, mouse + keyboard input. No live resize +// re-negotiation yet (resizing after connect just scales the canvas). type Status = 'connecting' | 'connected' | 'error' | 'closed' +// Wire message types (match sidecar.c InputMsg.type). +const INPUT_MOUSE = 1 +const INPUT_KEY = 2 + +// FreeRDP pointer flags (see freerdp/input.h). +const PTR_FLAGS_MOVE = 0x0800 +const PTR_FLAGS_DOWN = 0x8000 +const PTR_FLAGS_BUTTON1 = 0x1000 // left +const PTR_FLAGS_BUTTON2 = 0x2000 // right +const PTR_FLAGS_BUTTON3 = 0x4000 // middle +const PTR_FLAGS_WHEEL = 0x0200 +const PTR_FLAGS_WHEEL_NEGATIVE = 0x0100 +const WHEEL_ROTATION_MASK = 0x01ff + +// FreeRDP keyboard flags. +const KBD_FLAGS_EXTENDED = 0x0100 +const KBD_FLAGS_RELEASE = 0x8000 + +function mouseButtonFlag(button: number): number { + if (button === 0) return PTR_FLAGS_BUTTON1 + if (button === 2) return PTR_FLAGS_BUTTON2 + if (button === 1) return PTR_FLAGS_BUTTON3 + return 0 +} + export default function RdpView({ tab }: { tab: Tab }) { const canvasRef = useRef(null) + const containerRef = useRef(null) + // The active sidecar session id, readable from event handlers. + const activeIdRef = useRef(null) + // Coalesce frequent mousemove events to one send per animation frame. + const pendingMoveRef = useRef<{ x: number; y: number } | null>(null) + const moveRafRef = useRef(null) const sessions = useAppStore((s) => s.sessions) const [status, setStatus] = useState('connecting') const [message, setMessage] = useState('') @@ -50,6 +83,7 @@ export default function RdpView({ tab }: { tab: Tab }) { offFns.push( window.api.rdp.onClose((id, error) => { if (id !== rdpId && id !== pendingId) return + activeIdRef.current = null setStatus(error ? 'error' : 'closed') if (error) setMessage(error) }), @@ -59,14 +93,22 @@ export default function RdpView({ tab }: { tab: Tab }) { try { const { password } = await window.api.sessions.getCredentials(session.id) if (disposed) return + // Negotiate the desktop at the current pane size so it fills the window + // instead of rendering at a fixed small resolution. RDP widths/heights + // must be even; clamp to a sane minimum. (No live re-negotiation yet — + // resizing after connect just scales the canvas via object-fit.) + const el = containerRef.current + const even = (n: number) => n - (n % 2) + const width = el && el.clientWidth > 0 ? Math.max(640, even(el.clientWidth)) : 1280 + const height = el && el.clientHeight > 0 ? Math.max(480, even(el.clientHeight)) : 800 const id = await window.api.rdp.connect({ host: session.host, // RDP connections store 3389; fall back for non-RDP hosts opened ad hoc. port: session.port || 3389, username: session.username, password: password ?? '', - width: 1280, - height: 800, + width, + height, }) if (disposed) { // Component was unmounted while connect was in progress @@ -74,6 +116,7 @@ export default function RdpView({ tab }: { tab: Tab }) { } else { rdpId = id pendingId = null + activeIdRef.current = id } } catch (err) { if (!disposed) { @@ -85,14 +128,99 @@ export default function RdpView({ tab }: { tab: Tab }) { return () => { disposed = true + activeIdRef.current = null + if (moveRafRef.current !== null) cancelAnimationFrame(moveRafRef.current) offFns.forEach((off) => off()) if (rdpId) window.api.rdp.disconnect(rdpId).catch(() => {}) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab.sessionId]) + // Map a client (viewport) coordinate to a remote desktop pixel, accounting for + // the object-fit: contain letterboxing. Returns null for points in the + // letterbox margins (outside the actual desktop image). + const toRemote = (clientX: number, clientY: number): { x: number; y: number } | null => { + const canvas = canvasRef.current + if (!canvas) return null + const rect = canvas.getBoundingClientRect() + const imgW = canvas.width + const imgH = canvas.height + if (!imgW || !imgH || !rect.width || !rect.height) return null + const scale = Math.min(rect.width / imgW, rect.height / imgH) + const dispW = imgW * scale + const dispH = imgH * scale + const offX = (rect.width - dispW) / 2 + const offY = (rect.height - dispH) / 2 + const x = Math.round((clientX - rect.left - offX) / scale) + const y = Math.round((clientY - rect.top - offY) / scale) + if (x < 0 || y < 0 || x >= imgW || y >= imgH) return null + return { x, y } + } + + const sendMouse = (flags: number, x: number, y: number): void => { + const id = activeIdRef.current + if (id) window.api.rdp.input(id, INPUT_MOUSE, flags, x, y) + } + + const sendKey = (scancode: number, extended: boolean, down: boolean): void => { + const id = activeIdRef.current + if (!id) return + const flags = (extended ? KBD_FLAGS_EXTENDED : 0) | (down ? 0 : KBD_FLAGS_RELEASE) + window.api.rdp.input(id, INPUT_KEY, flags, scancode, 0) + } + + const onMouseMove = (e: React.MouseEvent): void => { + const p = toRemote(e.clientX, e.clientY) + if (!p) return + pendingMoveRef.current = p + if (moveRafRef.current === null) { + moveRafRef.current = requestAnimationFrame(() => { + moveRafRef.current = null + const move = pendingMoveRef.current + if (move) sendMouse(PTR_FLAGS_MOVE, move.x, move.y) + }) + } + } + + const onMouseDown = (e: React.MouseEvent): void => { + canvasRef.current?.focus() + const p = toRemote(e.clientX, e.clientY) + if (!p) return + e.preventDefault() + // Position the pointer first, then press, so the click lands where expected. + sendMouse(PTR_FLAGS_MOVE, p.x, p.y) + sendMouse(PTR_FLAGS_DOWN | mouseButtonFlag(e.button), p.x, p.y) + } + + const onMouseUp = (e: React.MouseEvent): void => { + const p = toRemote(e.clientX, e.clientY) + if (!p) return + e.preventDefault() + sendMouse(mouseButtonFlag(e.button), p.x, p.y) + } + + const onWheel = (e: React.WheelEvent): void => { + const p = toRemote(e.clientX, e.clientY) + if (!p) return + const up = e.deltaY < 0 + const magnitude = 120 & WHEEL_ROTATION_MASK + const flags = PTR_FLAGS_WHEEL | (up ? 0 : PTR_FLAGS_WHEEL_NEGATIVE) | magnitude + sendMouse(flags, p.x, p.y) + } + + const onKey = (e: React.KeyboardEvent, down: boolean): void => { + const key = lookupScancode(e.code) + if (!key) return + // Swallow the event so browser/app shortcuts don't fire while the desktop + // has focus; the keystroke goes to the remote instead. + e.preventDefault() + e.stopPropagation() + sendKey(key.scancode, key.extended, down) + } + return (
@@ -115,11 +243,20 @@ export default function RdpView({ tab }: { tab: Tab }) { )} e.preventDefault()} + onKeyDown={(e) => onKey(e, true)} + onKeyUp={(e) => onKey(e, false)} style={{ display: status === 'connected' ? 'block' : 'none', - maxWidth: '100%', - maxHeight: '100%', + width: '100%', + height: '100%', objectFit: 'contain', + outline: 'none', }} />
diff --git a/src/renderer/src/components/Sidebar/Sidebar.tsx b/src/renderer/src/components/Sidebar/Sidebar.tsx index 9ab1aac..e19ca8b 100644 --- a/src/renderer/src/components/Sidebar/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar/Sidebar.tsx @@ -7,6 +7,7 @@ import { import { useAppStore, Session, groupColor } from '../../store' import K8sIcon from '../K8sIcon' import { ServerContextMenu, MenuItem, useMenuBehavior, COLORS } from '../ServerContextMenu' +import { rdpSupported } from '../../lib/platform' // Sorts a session list by a saved id order; unknown ids keep their position at the end. function applySavedOrder(list: Session[], order?: string[]): Session[] { @@ -245,7 +246,7 @@ export default function Sidebar() { /> )} - {rdpSessions.length > 0 && window.api.platform === 'darwin' && ( + {rdpSessions.length > 0 && rdpSupported && ( { openDockerTab(ctxMenu.session!); setCtxMenu(null) } : undefined} - onOpenRdp={window.api.platform === 'darwin' && (ctxMenu.session?.type ?? 'ssh') === 'rdp' + onOpenRdp={rdpSupported && (ctxMenu.session?.type ?? 'ssh') === 'rdp' ? () => { openRdpTab(ctxMenu.session!); setCtxMenu(null) } : undefined} onColorChange={c => handleColorChange(ctxMenu.session!, c)} diff --git a/src/renderer/src/lib/platform.ts b/src/renderer/src/lib/platform.ts new file mode 100644 index 0000000..58bc037 --- /dev/null +++ b/src/renderer/src/lib/platform.ts @@ -0,0 +1,7 @@ +// Platforms with a bundled RDP sidecar. The native FreeRDP sidecar is built and +// shipped per-platform (macOS + Windows so far); the RDP UI is gated to these so +// builds without a sidecar don't surface a feature that can't run. Add 'linux' +// here once a Linux sidecar ships. +const RDP_PLATFORMS: ReadonlyArray = ['darwin', 'win32'] + +export const rdpSupported: boolean = RDP_PLATFORMS.includes(window.api.platform) diff --git a/src/renderer/src/lib/rdpKeymap.ts b/src/renderer/src/lib/rdpKeymap.ts new file mode 100644 index 0000000..e5a9d01 --- /dev/null +++ b/src/renderer/src/lib/rdpKeymap.ts @@ -0,0 +1,125 @@ +// Maps a browser KeyboardEvent.code to an RDP (set 1 / "scancode set 1") scan +// code plus whether it's an extended key (sent with KBD_FLAGS_EXTENDED). The +// sidecar forwards these straight to freerdp_input_send_keyboard_event, so all +// the layout knowledge lives here rather than in native code. +// +// Using event.code (physical key, layout-independent) rather than event.key +// keeps this a fixed table; the remote applies its own keyboard layout. + +export interface RdpKey { + scancode: number + extended: boolean +} + +const k = (scancode: number, extended = false): RdpKey => ({ scancode, extended }) + +export const SCANCODES: Record = { + Escape: k(0x01), + Digit1: k(0x02), + Digit2: k(0x03), + Digit3: k(0x04), + Digit4: k(0x05), + Digit5: k(0x06), + Digit6: k(0x07), + Digit7: k(0x08), + Digit8: k(0x09), + Digit9: k(0x0a), + Digit0: k(0x0b), + Minus: k(0x0c), + Equal: k(0x0d), + Backspace: k(0x0e), + Tab: k(0x0f), + KeyQ: k(0x10), + KeyW: k(0x11), + KeyE: k(0x12), + KeyR: k(0x13), + KeyT: k(0x14), + KeyY: k(0x15), + KeyU: k(0x16), + KeyI: k(0x17), + KeyO: k(0x18), + KeyP: k(0x19), + BracketLeft: k(0x1a), + BracketRight: k(0x1b), + Enter: k(0x1c), + ControlLeft: k(0x1d), + KeyA: k(0x1e), + KeyS: k(0x1f), + KeyD: k(0x20), + KeyF: k(0x21), + KeyG: k(0x22), + KeyH: k(0x23), + KeyJ: k(0x24), + KeyK: k(0x25), + KeyL: k(0x26), + Semicolon: k(0x27), + Quote: k(0x28), + Backquote: k(0x29), + ShiftLeft: k(0x2a), + Backslash: k(0x2b), + KeyZ: k(0x2c), + KeyX: k(0x2d), + KeyC: k(0x2e), + KeyV: k(0x2f), + KeyB: k(0x30), + KeyN: k(0x31), + KeyM: k(0x32), + Comma: k(0x33), + Period: k(0x34), + Slash: k(0x35), + ShiftRight: k(0x36), + NumpadMultiply: k(0x37), + AltLeft: k(0x38), + Space: k(0x39), + CapsLock: k(0x3a), + F1: k(0x3b), + F2: k(0x3c), + F3: k(0x3d), + F4: k(0x3e), + F5: k(0x3f), + F6: k(0x40), + F7: k(0x41), + F8: k(0x42), + F9: k(0x43), + F10: k(0x44), + NumLock: k(0x45), + ScrollLock: k(0x46), + Numpad7: k(0x47), + Numpad8: k(0x48), + Numpad9: k(0x49), + NumpadSubtract: k(0x4a), + Numpad4: k(0x4b), + Numpad5: k(0x4c), + Numpad6: k(0x4d), + NumpadAdd: k(0x4e), + Numpad1: k(0x4f), + Numpad2: k(0x50), + Numpad3: k(0x51), + Numpad0: k(0x52), + NumpadDecimal: k(0x53), + F11: k(0x57), + F12: k(0x58), + + // Extended keys (preceded by 0xE0 on the wire → KBD_FLAGS_EXTENDED). + NumpadEnter: k(0x1c, true), + ControlRight: k(0x1d, true), + NumpadDivide: k(0x35, true), + AltRight: k(0x38, true), + Home: k(0x47, true), + ArrowUp: k(0x48, true), + PageUp: k(0x49, true), + ArrowLeft: k(0x4b, true), + ArrowRight: k(0x4d, true), + End: k(0x4f, true), + ArrowDown: k(0x50, true), + PageDown: k(0x51, true), + Insert: k(0x52, true), + Delete: k(0x53, true), + MetaLeft: k(0x5b, true), + MetaRight: k(0x5c, true), + ContextMenu: k(0x5d, true), +} + +export function lookupScancode(code: string): RdpKey | undefined { + return SCANCODES[code] +}