A native RDP server for macOS, written in Rust on top of IronRDP. Connect from mstsc, Microsoft Remote Desktop, or FreeRDP to drive your Mac desktop with keyboard, mouse, real-cursor-shape forwarding, text + image clipboard sync, Mac↔Windows file copy, read-write drive redirection (mount the client's drives in Finder), smart-card redirection (use the client's smart card from macOS apps), system audio forwarding, and optional H.264 video (EGFX/AVC420, hardware-encoded). NLA/CredSSP is supported. Authenticates against your local Mac account via PAM.
This is the macOS equivalent of xrdp. Not a client, not a VNC bridge.
v0 — daily-driver usable on a trusted LAN. Latest release: v0.8.9 — docs/housekeeping: a USB-redirection entitlement-request draft (docs/entitlement-request.md), account-identifier scrub from the docs, and a Linux-stub lint cleanup. The generic-USB-redirection feasibility writeup landed in v0.8.8, the GUI smart-card-installer self-kill fix in v0.8.6, and the visual app-switcher HUD (--app-switcher-hud) in v0.8.3. See CLAUDE.md for what's wired up, what isn't, and known quirks.
cargo build --release
codesign -s - --force target/release/macrdp # ad-hoc sign so TCC grants persist
./target/release/macrdpFirst run will prompt for:
- Screen Recording permission (System Settings → Privacy & Security → Screen Recording → enable
macrdp→ restart it). - Accessibility permission (same path, "Accessibility" — required to forward keyboard and mouse).
- Your Mac password at the terminal — validated against your local account via PAM
checkpw, then used as the RDP credential.
Then connect from a client to <your-mac-ip>:3390 with your Mac username and password. mstsc will prompt for credentials in its own NLA dialog — no need to pre-type the username.
dist/install.shBuilds + signs + installs to ~/.local/bin/macrdp, stores your Mac password in the macOS Keychain under service macrdp, drops a launchd plist at ~/Library/LaunchAgents/com.user.macrdp.plist, and loads it. macrdp will start on every login and restart if it crashes. Re-run the script after cargo build --release to refresh the installed binary.
launchctl print gui/$UID/com.user.macrdp | head # status
launchctl kickstart -k gui/$UID/com.user.macrdp # restart
launchctl bootout gui/$UID/com.user.macrdp # stop / uninstalldist/install.sh above installs a bare binary. If you'd rather have a
proper signed macrdp.app — a stable bundle identity at a fixed path (so
TCC grants survive rebuilds), an LSUIElement background agent, and the
embedded smart-card IFD handler + its installer — build it with packaging/.
(A GitHub release already includes an ad-hoc-signed .app;
build locally when you want a Developer-ID-signed + notarized one, or the
menu-bar controller app, which CI doesn't produce.)
packaging/make-app.sh # build + sign + install to /Applications
security add-generic-password -s macrdp -a "$(id -un)" -w 'YOUR_PASSWORD'
packaging/install-launchagent.sh # load LaunchAgent (label com.clintcan.macrdp)make-app.sh does the whole thing: builds the release binary and the
ifd-handler cdylib, assembles macrdp.app, embeds ifd-macrdp.bundle (the
smart-card IFD handler) plus install-ifd-handler.sh under Contents/Resources/,
co-signs everything, and installs to /Applications.
Signing. By default it ad-hoc signs (local use only — fine for your own Mac). For a build you can distribute, sign with your Developer ID and notarize:
CODESIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \
NOTARIZE=1 NOTARY_PROFILE=macrdp-notary \
packaging/make-app.sh(NOTARY_PROFILE is a notarytool keychain profile you set up once with
xcrun notarytool store-credentials.) Override the bundle identifier with
BUNDLE_PREFIX=com.acme.
Distribution DMG. To wrap the signed app(s) into a styled, signed +
notarized .dmg:
NOTARIZE=1 NOTARY_PROFILE=macrdp-notary packaging/make-dmg.shSmart-card redirection needs one extra privileged step after the app is installed — the IFD handler has to be copied into a root-owned system directory. Run the embedded installer once (one GUI admin prompt):
/Applications/macrdp.app/Contents/Resources/install-ifd-handler.shSee Smart-card redirection for the USB-trigger caveat and verification.
Config. Toggle features (H.264/AAC/HiDPI), bind address, and an EXTRA_FLAGS
escape hatch live in ~/Library/Application Support/macrdp/config.env — outside
the bundle, so edits never disturb the signature or the TCC grants. The two
auto-start paths (LaunchAgent vs the controller app) are mutually exclusive
(both bind :3390 and share the macrdp Keychain entry) — pick one. See
packaging/README.md for the full guide (icons, controller
app, per-script options, TCC notes).
Pushing a v* tag runs the release workflow,
which builds on an Apple-Silicon runner and attaches these to a draft GitHub
Release (Apple Silicon / aarch64-apple-darwin only):
| File | What it is |
|---|---|
macrdp-<ver>-aarch64-apple-darwin.tar.gz |
the bare CLI binary + LICENSE/README |
macrdp-<ver>-aarch64-apple-darwin-app.zip |
the full macrdp.app, with the embedded smart-card IFD handler (ifd-macrdp.bundle) + install-ifd-handler.sh — the only artifact that carries everything --enable-smartcard-redirection needs |
SHA256SUMS |
checksums for both |
Both are ad-hoc signed, not notarized — macOS Gatekeeper shows a "can't
verify developer" prompt, so open the app once via right-click → Open (or
xattr -dr com.apple.quarantine macrdp.app). For a Developer-ID-signed +
notarized build, or the menu-bar controller app (neither is produced in CI),
build locally with packaging/make-app.sh.
--bind 0.0.0.0:3390 Listen address (3390 by default; 3389 needs root)
--username NAME Defaults to $USER
--password PASS Skip the interactive prompt
--skip-auth Bypass PAM (testing only)
--keychain Read password from macOS Keychain (service=macrdp)
-v, --verbose Show all the noisy logs the default filter hides
--allow-sleep Let the Mac sleep / auto-lock normally (default
is to spawn `caffeinate` so an idle Mac doesn't
drop the connection mid-session)
--width / --height Override autodetected display size
--hidpi Capture the primary display at backing (Retina) pixel
resolution instead of logical points (e.g. 3024×1964
vs 1512×982) for crisp native pixels. ~4× the pixels;
best with --enable-h264. Ignored with --width/--height
or --virtual-display. See "Display". macOS-only.
--no-client-resolution Serve the Mac's native size instead of adopting the
resolution the client requests at connect. By default,
when mirroring without --width/--height/--hidpi/
--virtual-display, macrdp serves exactly what the client
asks for (so mstsc presents 1:1, no client-side rescale).
Pass this to serve native and let the client scale.
--stretch On the auto-size path, stretch the Mac screen to fill
the client frame. By default, when the client's
resolution has a different aspect ratio than the Mac,
macrdp preserves the Mac's aspect ratio with black bars
(letterbox/pillarbox) and maps mouse input into the
centered picture. Pass this for the old fill-and-distort
behavior. No effect with --width/--height or at a
matching aspect ratio. See "Display".
--unminimize-on-switch On Cmd+Tab, un-minimize the target app's window (bring
it back from the Dock) instead of just activating the
app. Off by default (matches native macOS, which leaves
a minimized window minimized). macOS-only.
--alt-tab-switch Also accept Option+Tab (Alt+Tab from the client) as an
app-switch trigger, in addition to Cmd+Tab. Off by
default. For clients/configs that forward Alt+Tab but
gate Win+Tab (e.g. mstsc's "Apply Windows key
combinations" when windowed). Option+Shift+Tab cycles
backward. macOS-only.
--app-switcher-hud Show a visual app-switcher overlay (icon row, like
macOS's native Cmd+Tab) on the remote during Cmd+Tab /
Option+Tab. Off by default. macrdp spawns a small helper
that draws a real on-screen panel, which ScreenCaptureKit
captures — so the client sees it. The switch behaves the
same with or without it. macOS-only.
--keyboard-layout SPEC Force a keyboard layout for non-US clients instead of
auto-detecting it from the client. By default the
layout is auto-detected from the client's announced
KLID (US/unknown keep the positional keycode path);
pass a name (`french`, `de`, `azerty`), a Windows KLID
(`0x040C`), or a macOS input-source id to force one, or
`none` to disable translation. Keys are translated via
UCKeyTranslate and posted as Unicode; the Mac's own
input source is untouched. macOS-only.
--fps N Frame rate cap (default 15, or 60 with --enable-h264
— see "Video" for why H.264 wants the higher rate)
--enable-h264 Stream the display as H.264 over EGFX (AVC420),
hardware-encoded via VideoToolbox, instead of legacy
bitmaps. Falls back to legacy automatically for
clients that don't negotiate H.264. See "Video".
--bitrate N Target H.264 bitrate in Mbps (default 6; only with
--enable-h264). Raise it (8–12) for sharper detail if
you have bandwidth headroom.
--keyframe-interval SECS H.264 periodic keyframe (IDR) interval in seconds
(default 2; only with --enable-h264). Safety net for
transient decode glitches; fractional values OK.
--keyframe-on-change Force on-change H.264 keyframes (OFF by default; only
with --enable-h264): an IDR on large changes (window-
to-front, scroll, app launch) and briefly after a click.
The periodic interval + flush-burst already cover this,
so enable it only if big updates lag. See "Video".
--flush-frames N Trailing frames re-sent after each change to drain
mstsc's presentation buffer (default 4; only with
--enable-h264). Stops the last keystroke before a pause
lagging until the next keyframe. 0 disables. See "Video".
--enable-aac Compress system audio as AAC-LC over RDPSND
(WAVE_FORMAT_AAC_MS) instead of raw PCM — ~11x less
audio bandwidth. Clients that don't decode AAC fall
back to PCM automatically. Off by default (adds
~40–50 ms latency). macOS-only. See "Audio".
--aac-bitrate BPS AAC target bitrate in bits/sec (default 128000; only
with --enable-aac). 96000 saves the most bandwidth,
192000 is near-transparent.
--no-lazy-paste Opt out of lazy Windows→Mac file paste (default ON).
With lazy, temp files are pre-sized but empty when the
copy lands and stream bytes only on Cmd-V, with macOS's
native "Preparing to paste" progress dialog. Pass this
to fall back to the eager path (downloads everything
on copy, auto-fires Cmd-V into Finder when done).
See "Windows → Mac file copy" below.
--enable-drive-redirection Let the connecting client redirect its local
drive(s) (mstsc: Local Resources → Drives; FreeRDP:
/drive:NAME,PATH); the Mac mounts each as a real
read-write volume in Finder (in-process NFS + built-in
mount_nfs, no root/kext/FUSE). Off by default. See
"Drive redirection" below. macOS-only.
--enable-smartcard-redirection Let the connecting client redirect its
smart-card reader (mstsc: Local Resources → More →
Smart cards; FreeRDP: /smartcard) so macOS apps can use
the card through it (MS-RDPESC). Off by default.
Requires installing the PC/SC IFD handler once + a USB
trigger device — see "Smart-card redirection" below.
macOS-only.
--no-mute-on-minimize Opt out of muting audio while the client window is
minimized (default ON). When the client sends the
standard `SuppressOutput` PDU on minimize, the server
stops emitting Wave PDUs so the client's audio queue
drains naturally; on refocus, audio resumes in sync
with the freshly IDR'd video. Pass this to keep audio
flowing through a minimize (preserves "minimized
YouTube keeps playing on the Mac speakers") at the
cost of accepting that drift on refocus. See "Audio"
below.
--qoi-force-rgb Force QOI BitmapUpdates to emit `Channels::Rgb` instead
of the natural `Channels::Rgba` mapping from a *A32
capture. Default OFF (matches upstream ironrdp-server).
Only matters if you connect with an IronRDP-based viewer
built against ironrdp-session WITHOUT the RGBA decode
patch (currently every published release, until
Devolutions/IronRDP#1341 lands) — without this flag those
viewers will render blank with one "Unsupported RGBA QOI
data" warning per frame. mstsc / Microsoft Remote Desktop
/ Windows App / FreeRDP don't advertise QOI and are
unaffected either way.
--cert-dir PATH Persisted TLS cert (default ~/Library/Application Support/macrdp)
--virtual-display Serve a headless virtual display at --width × --height
instead of mirroring the primary panel — local screen
stays untouched. Requires --width and --height.
--make-primary Promote the virtual display to system primary (the one
with the menu bar). Only valid with --virtual-display.
--detach-primary While a client is connected, disable every physical
display (backlights off, no menu bar). Restored on
disconnect / exit. Only with --virtual-display.
--capture-primary Alternative to --detach-primary: exclusive
CGDisplayCapture of every physical display, then
gamma-clamp to black. Panels stay backlit but render
solid black. Use when --detach-primary doesn't
actually blank the panel on your hardware. Mutually
exclusive with --detach-primary. Only with
--virtual-display.
RUST_LOG=debug for verbose logging.
--virtual-display --width W --height H allocates a headless display via undocumented CGVirtualDisplay* private API and serves it over RDP instead of mirroring the Mac's panel. Behaves like plugging in an external monitor — the remote session gets its own desktop at the requested resolution, and you keep using the Mac locally as normal. Add --make-primary to give the virtual display the menu bar so new app windows open there.
To go fully headless while a client is connected, pick one:
--detach-primary— turns the backlight off on every built-in / external panel viaCGSConfigureDisplayEnabled. Cleanest visually. On some macOS versions / displays the disable transaction succeeds but the panel keeps showing the desktop; if you hit that, switch to:--capture-primary— takes exclusiveCGDisplayCaptureof every physical display and forces the gamma LUT to map every input to black. Backlight stays on but panels render solid black. Works everywhere capture is allowed; uses only public CG symbols.
Both restore the original layout when the last client disconnects, and both auto-revert on SIGKILL / panic (no logout required). Pick --detach-primary first; fall back to --capture-primary if your hardware doesn't honor the disable.
# Default — loopback only, mirror primary panel, prompt for password.
./macrdp
# Accept LAN connections, force a non-$USER account.
./macrdp --bind 0.0.0.0:3390 --username clint
# Higher frame rate, custom cert dir.
./macrdp --fps 30 --cert-dir ~/.macrdp-certs
# H.264 video over EGFX (much lower bandwidth than legacy bitmaps).
./macrdp --enable-h264
# Verbose logs (DEBUG level).
./macrdp -v
# Headless virtual display at 1440p — local Mac screen stays available.
./macrdp --virtual-display --width 2560 --height 1440
# Same, but the virtual display owns the menu bar (drive it as your main desktop).
./macrdp --virtual-display --width 2560 --height 1440 --make-primary
# Fully headless on connect: physical panels go dark, revived on disconnect.
./macrdp --virtual-display --width 2560 --height 1440 --detach-primary
# Same idea, for hardware where --detach-primary doesn't actually blank the panel.
./macrdp --virtual-display --width 2560 --height 1440 --capture-primary
# Non-interactive launch (used by dist/install.sh): password from Keychain.
./macrdp --keychain
# Quick dev test on loopback — skips PAM, accepts --password verbatim.
./macrdp --skip-auth --password test
# Use the eager Windows→Mac file paste path (default is lazy / on-demand).
./macrdp --no-lazy-pasteBy default macrdp captures and advertises the Mac's logical resolution — the points it reports in System Settings (e.g. 1512×982 on a default-scaled 14" MacBook). On a Retina panel that's half the physical pixels, so any client whose window is larger upscales it and text looks soft.
Pass --hidpi to capture at the display's backing (Retina) pixel resolution instead (e.g. 3024×1964) — clients then render crisp native pixels. It's opt-in because it's ~4× the pixels:
- Pair it with
--enable-h264. H.264 compresses the higher resolution cleanly and the client downscales it sharply — that's the real "Retina remote desktop" experience. On the legacy bitmap path it just means 4× the bandwidth. - mstsc feels laggy at HiDPI. mstsc decodes 4× the pixels every frame and its ~2-frame presentation buffer now holds 4×-bigger frames, so responsiveness drops. Thincast / FreeRDP stay snappy — their H.264 decoders keep up. The server itself isn't the bottleneck (it encodes a 3024×1964 frame in ~10 ms, well inside the 60fps budget); the cost is client-side decode. Prefer a capable client if you want HiDPI.
- Ignored when you pass explicit
--width/--height(you've chosen the size) or with--virtual-display(already an explicit resolution).
Input and cursor are resolution-correct at any setting — clicks land precisely and the pointer stays normal-sized.
By default macrdp serves exactly the resolution the connecting client requests (e.g. mstsc full-screen on a 1920×1080 monitor gets a 1920×1080 session). When that resolution's aspect ratio differs from the Mac's panel (e.g. a 16:9 client against a 16:10 MacBook), macrdp preserves the Mac's aspect ratio and adds black bars (letterbox top/bottom or pillarbox left/right) so the picture isn't distorted, and maps mouse input into the centered picture so clicks stay accurate. Verified: a 1512×982 Mac served to a 1920×1080 client produces a centered 1663×1080 image with 128 px bars each side.
Pass --stretch to instead fill the whole frame (the old behavior) — no bars, but the image is non-uniformly scaled on an aspect mismatch (e.g. ~13.5% vertical compression for 16:10→16:9). --stretch has no effect when the aspect already matches, or with explicit --width/--height (those always stretch). Either way, serving a non-native resolution forces full-frame updates (higher bandwidth) and, on mstsc with --enable-h264, the scaling amplifies its trailing-frame presentation lag — a Mac whose native resolution already matches the client (no scaling) is snappier. See "Video".
By default the display is sent as legacy bitmaps (RemoteFx/QOI to mstsc, NSCodec/raw to others) — works everywhere, but bandwidth-heavy. Pass --enable-h264 to stream the desktop as H.264 over the EGFX virtual channel (MS-RDPEGFX, AVC420), hardware-encoded with VideoToolbox. Far less bandwidth, especially for video/scrolling/photos.
How it behaves:
- Automatic fallback. Clients that don't advertise H.264 (AVC420) decode — e.g. a FreeRDP build without an H.264 decoder — transparently fall back to legacy bitmaps. No need to match the flag to the client. mstsc, FreeRDP-with-H.264, and the macOS Windows App / Microsoft Remote Desktop client all decode the H.264 stream.
- Wire format. The AVC420 payload is Annex-B framed (what Microsoft's decoder expects). The bitstream is verified rendering on
mstscand on FreeRDP built with H.264 (e.g. the Thincast client). - Bitrate.
--bitrate Nsets the target encoder bitrate in megabits/sec (default6, only meaningful with--enable-h264). Raising it sharpens detail but grows each frame, so the big per-frame writes are more likely to fill the socket buffer and delay audio on a constrained link —6is a good balance; try8–12if you have headroom. - Color. The stream is encoded as full-range BT.709. This matters for
mstsc, which reads AVC420 luma as full-range regardless of the bitstream flag — video-range output otherwise renders washed-out / lighter there. FreeRDP honors the flag and is correct either way. To get full range we convert each captured BGRA frame to full-range NV12 ourselves (VideoToolbox would otherwise emit video-range from a BGRA source); that conversion is vImage-accelerated — see Color conversion: scalar vs vImage. - Frame rate.
--enable-h264defaults to 60fps (vs 15 for legacy). mstsc holds a fixed ~2-frame presentation buffer for the H.264 stream, so at 30fps typing lags ~2 keystrokes (~66ms) while at 60fps that buffer is ~33ms and feels immediate. FreeRDP-based clients don't buffer this way and are snappy at any rate. Set--fpsexplicitly to override (lower it to save CPU/bandwidth if your client/link doesn't need 60). - Keyframes. A keyframe (IDR) is forced on the first frame, then periodically every
--keyframe-intervalseconds (default2) as a safety net — some clients (mstsc) only fully recover a transient decode glitch on the next IDR, so a long interval leaves garbled regions (notably text) lingering. Lower it for faster recovery at the cost of bandwidth/quality; raise it for smoother typing. Optionally, pass--keyframe-on-change(off by default) to additionally force an IDR whenever a large area changes at once (window-to-front, scroll, app launch) and briefly after a mouse click, so big updates land immediately instead of waiting for the periodic interval (rising-edge detection keeps sustained churn like video from forcing an IDR every frame). It's off by default because the periodic interval plus the trailing flush-burst (--flush-frames) already drain mstsc's presentation buffer, so the extra forced IDRs mostly just spend bitrate/quality at a fixed bitrate for no typing benefit — enable it only if large updates visibly lag on your client/link. When enabled, the trigger thresholds are tunable:--keyframe-change-pct(default 20, the dirty-area % that fires an IDR),--keyframe-click-pct(default 5, the lowered threshold after a click), and--keyframe-click-window-ms(default 400, how long that lowered threshold lasts). - Flush frames (
--flush-frames, default4). ScreenCaptureKit only delivers a frame when the screen changes, so after the last keystroke before a pause there are no further frames to push it through mstsc's ~2-frame AVC420 presentation buffer — it would strand there until the next change or periodic keyframe (the classic "typing follows the keyframe" lag). After each change the server re-submits the last frame this many times as cheap skip-P-frames, draining the buffer so the change appears within a couple of frame intervals (~33 ms at 60fps), then goes quiet. mstsc needs ≥2; raise if a slight trailing lag remains, or set0to disable.
- Reconnecting
mstscto a still-running macrdp can show a black screen (with a live cursor). This is an mstsc-specific quirk: it retains EGFX surfaces for the lifetime of its process and mis-composites on reconnect. It is not a server bug — FreeRDP reconnects cleanly over the same stream. Reliable workaround: fully close the mstsc window and open a new connection — quitting the client clears its surface cache, so the desktop renders every time, with no Windows reboot needed. (A server-side fresh-surface-id workaround was tried but only mitigated this unreliably on mstsc, so it was dropped in favor of this documented recovery.) - H.264 is macOS-only (VideoToolbox) and still maturing — bitrate and keyframe behavior are tunable (above), but dirty-region encoding is not yet done: every frame is a full encode (dirty rects are used only to time on-demand keyframes, not to encode sub-regions). H.264's own inter-prediction keeps unchanged regions cheap regardless.
(Implementation detail — skip unless you're profiling CPU or porting the encoder.)
VideoToolbox, given a BGRA source, emits video-range YUV (luma 16–235). mstsc reads AVC420 luma as full-range, so that looks washed out (see Color above). The fix is to hand VideoToolbox a YUV buffer that's already full-range, which means doing the BGRA → full-range BT.709 NV12 (420f) color conversion ourselves, once per captured frame, on the capture thread.
That conversion is a real per-frame cost, so it's done with vImage (Apple's Accelerate framework), which runs the RGB→Y'CbCr math on the CPU's vector units (NEON on Apple Silicon). A scalar reference implementation (a plain Rust loop) is kept as well: it's the fallback for any frame vImage declines (e.g. odd dimensions), the oracle the vImage path is unit-tested against, and the baseline below. Both produce identical output (within ±1 rounding).
Single-thread cost per frame, Apple M3 (cargo test --release bench_nv12_full_range -- --ignored --nocapture):
| Resolution | scalar | vImage | speedup |
|---|---|---|---|
| 1470×956 | 3.36 ms | 0.12 ms | ~29× |
| 1920×1080 | 4.98 ms | 0.16 ms | ~32× |
| 2560×1440 | 8.88 ms | 0.33 ms | ~27× |
| 3840×2160 (4K) | 20.0 ms | 0.84 ms | ~24× |
At 60fps the frame budget is 16.67 ms. The scalar path is fine at 1080p (~30% of one core) but exceeds the budget at 4K, where it would cap the achievable frame rate before the encoder even runs; vImage keeps the conversion at ~1% of budget across the board, so it's never the bottleneck. The implementation lives in src/videotoolbox.rs (bgra_to_nv12_full_range_vimage, with bgra_to_nv12_full_range as the scalar reference).
System audio rides over the RDPSND virtual channel as 16-bit stereo PCM at 44.1 kHz. ScreenCaptureKit only supports 8 / 16 / 24 / 48 kHz, so the capture loop captures at 48 kHz and resamples to 44.1 with rubato before sending. 44.1 matches the native rate of most Windows audio endpoints, which avoids the client-side resampling drift that otherwise accumulates into multi-second audio backlogs. A generation counter on the audio factory keeps a client reconnect from leaving a second capture loop feeding the channel. The vendored ironrdp-server carries a single patch that makes dispatch_server_events keep the newest queued waves on per-batch overflow instead of the oldest — without it, a one-off video-encode stall would bake a permanent audio-latency offset into the session.
The capture loop also self-heals a dead SCK audio stream: over a long session ScreenCaptureKit can stop delivering samples or transiently fail to start, which previously left the connection silent for the rest of the session (video is a separate stream and kept running). The loop now rebuilds the audio SCStream with capped exponential backoff (250 ms → 5 s) on both start failures and mid-stream end, resetting the backoff once a sample arrives; the generation guard still retires it on reconnect, so there's no double-capture.
AAC compression (opt-in, --enable-aac). By default audio is uncompressed PCM (~1.4 Mbit/s). Pass --enable-aac to encode it as AAC-LC over RDPSND (WAVE_FORMAT_AAC_MS, ~128 kbps by default — about 11x smaller), which matters over WAN or constrained links. The encoder is AudioToolbox (software AAC-LC); the wire payload is raw AAC access units. The server advertises AAC ahead of PCM, so clients that decode it (mstsc, Microsoft Remote Desktop / Windows App, FreeRDP built with AAC support) negotiate AAC automatically while clients without it fall back to PCM transparently. It's off by default because AAC adds ~40–50 ms of encoder priming latency — on a LAN, PCM's zero added latency is the better default. Tune the bitrate with --aac-bitrate (default 128000; 96000 saves the most bandwidth, 192000 is near-transparent for music).
Mute on minimize (default-on, opt out with --no-mute-on-minimize). When the client minimizes its window it sends the standard SuppressOutput { None } PDU; the server stops emitting both EGFX video frames and RDPSND waves until the client refocuses (RefreshRectangle / SuppressOutput { Some(rect) }). Without this, mstsc accumulates a backlog of video frames + audio waves during a long minimize that has to chew through on refocus, producing several seconds of input lockout, audio drift, and a video catch-up storm. With it, you get a brief audio gap on refocus and audio + video resume in sync. Both gates are debounced (1 s) so transient SuppressOutput flaps mstsc emits under wire pressure (e.g., during a heavy local cargo build) don't oscillate the mute and cause stutter. Pass --no-mute-on-minimize if you specifically want audio to keep playing while the client window is minimized — accepting that audio will drift by however long was spent minimized.
Bidirectional via MS-RDPECLIP. Both directions support single files and folder trees.
Mac → Windows. Cmd-C a file or folder in Finder, Ctrl-V in Windows Explorer. The pasteboard walk recurses into directories (skipping symlinks, capped at 10 000 descriptors per copy) and emits the right relative_path so Explorer reconstructs the tree. Bytes stream on demand via FileContentsRequest chunks (4 MiB per chunk). Windows shows its native "Copying…" progress dialog.
Windows → Mac (lazy, default). Ctrl-C in Explorer, Cmd-V in Finder. The server pre-allocates an empty temp file per leaf at its declared size and registers each one with NSFileCoordinator via NSFilePresenter. Bytes only start streaming when Finder asks for them on Cmd-V, and macOS shows its native "Preparing to paste" progress dialog during the wait. Folder trees and multi-file selections both work. Lower chunk parallelism is used than the eager path so the RDP session stays responsive (mouse / keyboard / video) while a multi-hundred-MB paste is in flight. If you'd rather have files downloaded eagerly the moment Windows announces a copy (and Cmd-V auto-fired into Finder when ready, with an audible Glass-chime cue), pass --no-lazy-paste.
Ctrl-Con a folder in Windows Explorer doesn't reach the Mac. Explorer puts only the Shell IDList format on the clipboard and delay-rendersFileGroupDescriptorW, whichmstscdoesn't request — so nothing is forwarded over the RDP clipboard channel and you'll hear a beep onCmd-V. Windows + mstsc behavior, not fixable server-side. Workaround: open the folder in Explorer,Ctrl-Ato select its contents, thenCtrl-C— that path usesFileGroupDescriptorWdirectly and folder structure is preserved.- Some Windows shell extensions silently swallow specific files from the clipboard. Archive tools (7-Zip, WinRAR, built-in Compressed Folders) commonly hook extensions like
.zip,.gz,.7z,.bz,.bz2,.rar,.tarand intercept Explorer's clipboard soCtrl-Ceither sends noFileGroupDescriptorWto mstsc or sends none at all. The Mac side detects the clipboard transition and clears the pasteboard, soCmd-Vin Finder beeps clearly instead of silently re-pasting the previous file. Workaround: rename the file to a neutral extension (e.g..bin) and Windows will publish it normally.
Opt-in with --enable-drive-redirection (off by default). The connecting
client redirects its local drive(s) and the Mac mounts each as a real
read-write volume in Finder — the inverse of file copy: instead of moving
bytes through the clipboard, you browse the client's filesystem live. Enable it
on the client too (mstsc: Local Resources → More → Drives; FreeRDP:
/drive:NAME,PATH).
Under the hood each redirected drive is served by an in-process NFSv3 server
that translates NFS operations into RDPDR (MS-RDPEFS) requests, mounted via the
built-in mount_nfs — no root, no kext, no FUSE. The kernel drives lazy
lookups as you browse, so full subdirectory navigation works, and reads/writes
reuse a kept-open handle so large sequential transfers don't re-open per chunk.
Reading, editing, creating, mkdir, rename, and delete all work where the
redirected Windows user has permission — e.g. write to Users\<you>\Documents,
not the C:\ root (which an unelevated mstsc session can't write; that surfaces
as a normal "permission denied", not an error). Mounts are torn down when the
client disconnects.
macOS-only. Every redirected filesystem device gets its own volume.
/Volumesisn't writable without root on a stock Mac, so the mountpoint falls back to a per-session folder under$TMPDIR(it still shows as a volume in Finder).
Opt-in with --enable-smartcard-redirection (off by default). The connecting
client redirects its smart-card reader and macOS PC/SC apps can use the card
through it — the standard RDP direction (MS-RDPESC), so the card stays on the
client while the Mac in the session reads it. Enable it on the client too
(mstsc: Local Resources → More → Smart cards; FreeRDP: /smartcard).
On the macOS side macrdp ships its own PC/SC IFD handler — a small reader
driver loaded by com.apple.ifdreader that presents the redirected card as a
real Finder/PC/SC reader and bridges every PC/SC call to the client over
MS-RDPESC. It's written from scratch (MIT/Apache), so there's no GPL vpcd
dependency. The whole chain is verified end-to-end on mstsc against a card,
including a full APDU transceive.
Why a user-space handler and not a kernel driver? Redirection happens at the PC/SC (APDU) layer, not raw USB, and macOS's smart-card stack is user-space by design — the IFD handler is Apple's supported plug-in point, with no entitlements, signing gymnastics, or reboot a kext would demand. See the rationale in docs/known-quirks.md.
In plain terms: why this "reader hook" instead of USB passthrough (à la VirtualHere)?
There are two ways to let a card plugged into the client be used by apps on the Mac:
-
Fake the hardware (the VirtualHere route). Pretend the whole USB card-reader is physically plugged into the Mac. To make macOS believe a USB device is really attached, you write a low-level driver (a DriverKit system extension) — which needs Apple-granted permissions, a user-approved install, and a lot of plumbing to emulate the USB gadget. It's like shipping the physical reader across the network and bolting a fake one onto the Mac's USB port. Powerful and general (works for any USB gadget), but heavy.
-
Use the built-in slot (what macrdp does). macOS already has a smart-card system (PC/SC) with an official plug-in slot for "reader helpers." macrdp drops in a tiny helper that says "I'm a card reader," and whenever an app asks the card a question, the helper forwards it over the network to the real card on the client and relays the answer back. No fake USB device, no driver, no special permissions — it installs as a small file in a folder. Think of it as a receptionist macOS already provides, to whom we just hand a message-forwarder.
Smart cards talk a simple question-and-answer protocol, so we don't need to fake any hardware — just pass the messages along, and macOS gives us the exact spot to plug that in. The USB-passthrough approach is the right tool for sharing arbitrary USB gadgets that have no such slot, but for smart cards it's massive overkill — all that driver/permission friction to end up at the same place the small helper reaches directly. Same result, far less machinery.
One-time setup — the IFD handler installs into a root-owned system directory,
so it can't be done by drag-to-Applications; run the bundled installer once (one
GUI admin prompt, no manual sudo):
# From a checkout, or from an installed app's Resources:
packaging/install-ifd-handler.sh
/Applications/macrdp.app/Contents/Resources/install-ifd-handler.sh # DMG install
packaging/install-ifd-handler.sh --uninstall # removeRun interactively, the installer lists your attached USB devices and lets you pick the one to use as the load trigger (see the caveat below for why a trigger is needed). To bind one non-interactively instead — or just to look up a device's IDs — use the picker directly or pass them yourself:
packaging/select-usb-trigger.sh # list devices, print VID/PID
IFD_VID=0x2174 IFD_PID=0x2100 packaging/install-ifd-handler.sh # bind explicitlyThen verify the reader registered with system_profiler SPSmartCardsDataType.
macOS-only. macOS loads a third-party IFD driver only on a USB hotplug matching the bundle's VID/PID, so a headless server needs a USB device permanently attached (any stick works as the trigger — pick it during install or bind it with
IFD_VID/IFD_PID); after installing, unplug/replug it soslotdloads the driver. The handler talks to macrdp on loopback port 40242 (MACRDP_SCARD_PORT). No physical card needed to try it: create a Windows TPM virtual smart card (tpmvscmgr create …) and redirect that.
Reloading after an upgrade.
slotdkeeps the loaded handler in memory for its whole lifetime and ignoresSIGTERM, so simply replacing the bundle on disk does nothing — a rebuilt or upgraded handler isn't picked up untilslotdis killed withSIGKILLand the trigger device is replugged. Just re-run the installer: it restartsslotdcorrectly (sudo pkill -9 -f com.apple.ifdreader) and verifies the new bundle landed. Then unplug/replug the trigger so the freshslotdloads the new driver. (If you ever do it by hand, note thatkillall com.apple.ifdreaderwon't match — the process name is truncated past 15 chars; usepkill -9 -f.)
This was done to scratch an itch. There are practically no active open source RDP servers for MacOS. The closest project that does this functionality is xrdp; however this program only runs on Linux/Unix machines, and has no homebrew equivalent on Macs. Done in a few hours with the help of Claude and runs pretty well. Multi-monitor support is on the list when I'm bored or need a distraction from real life.
Licensed under either of MIT or Apache-2.0 at your option. Being permissively licensed, a productized/notarized build may be sold commercially with support — that's selling the product, not a license exemption.