Skip to content

clintcan/macrdp

Repository files navigation

macrdp

Latest release License

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.

Status

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.

Quick start

cargo build --release
codesign -s - --force target/release/macrdp   # ad-hoc sign so TCC grants persist
./target/release/macrdp

First run will prompt for:

  1. Screen Recording permission (System Settings → Privacy & Security → Screen Recording → enable macrdp → restart it).
  2. Accessibility permission (same path, "Accessibility" — required to forward keyboard and mouse).
  3. 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.

Auto-start at login (launchd)

dist/install.sh

Builds + 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 / uninstall

Building the full app

dist/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.sh

Smart-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.sh

See 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).

Release artifacts

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.

CLI

--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.

Headless mode

--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 via CGSConfigureDisplayEnabled. 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 exclusive CGDisplayCapture of 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.

Examples

# 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-paste

Display resolution (--hidpi)

By 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.

Aspect ratio (auto-size path)

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".

Video (H.264)

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 mstsc and on FreeRDP built with H.264 (e.g. the Thincast client).
  • Bitrate. --bitrate N sets the target encoder bitrate in megabits/sec (default 6, 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 — 6 is a good balance; try 812 if 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-h264 defaults 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 --fps explicitly 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-interval seconds (default 2) 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, default 4). 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 set 0 to disable.

Known limitations

  • Reconnecting mstsc to 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.

Color conversion: scalar vs vImage

(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).

Audio

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.

File copy

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.

Known limitations

  • Ctrl-C on a folder in Windows Explorer doesn't reach the Mac. Explorer puts only the Shell IDList format on the clipboard and delay-renders FileGroupDescriptorW, which mstsc doesn't request — so nothing is forwarded over the RDP clipboard channel and you'll hear a beep on Cmd-V. Windows + mstsc behavior, not fixable server-side. Workaround: open the folder in Explorer, Ctrl-A to select its contents, then Ctrl-C — that path uses FileGroupDescriptorW directly 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, .tar and intercept Explorer's clipboard so Ctrl-C either sends no FileGroupDescriptorW to mstsc or sends none at all. The Mac side detects the clipboard transition and clears the pasteboard, so Cmd-V in 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.

Drive redirection

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_nfsno 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. /Volumes isn'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).

Smart-card redirection

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                          # remove

Run 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 explicitly

Then 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 so slotd loads 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. slotd keeps the loaded handler in memory for its whole lifetime and ignores SIGTERM, so simply replacing the bundle on disk does nothing — a rebuilt or upgraded handler isn't picked up until slotd is killed with SIGKILL and the trigger device is replugged. Just re-run the installer: it restarts slotd correctly (sudo pkill -9 -f com.apple.ifdreader) and verifies the new bundle landed. Then unplug/replug the trigger so the fresh slotd loads the new driver. (If you ever do it by hand, note that killall com.apple.ifdreader won't match — the process name is truncated past 15 chars; use pkill -9 -f.)

Reason why this was made

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.

License

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.

About

A native RDP server for macOS, written in Rust on top of IronRDP.

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks

Packages

 
 
 

Contributors