Mutter Desktop
The web client and its bridge in one window. The main process runs web/bridge/server.mjs,
and the window loads the shared web/app. The shell adds desktop capture, global push to
talk, and protected password storage.
Why it exists. A managed browser carries its employer's policies onto every network, and a
common one disables WebRTC over UDP — voice keeps working because the bridge sends that from
Node, but a screen share from the browser process can never connect. Electron ships its own
Chromium and reads no policy from the registry, so the same code connects. It also gives a real
screen picker with system audio on Windows (loopback capture, which getDisplayMedia in a
browser tab does not offer). When Windows has no playback device, capture sends video alone
instead of failing. The app also has one taskbar entry with our icon and an install that asks for no
admin.
Run it
Same three shapes as VS Code. Grab one from the releases:
| File | Like VS Code's… | What happens |
|---|---|---|
Mutter-x.y.z-win-setup.exe |
User Setup | Installs to %LOCALAPPDATA%\Programs\Mutter, Start menu and desktop shortcut, no admin prompt. Settings in %APPDATA%\Mutter. |
Mutter-x.y.z-win.zip |
.zip download | Unzip anywhere and run Mutter.exe. Create a data folder beside it to keep server details and settings with the app. |
Mutter-x.y.z-win-portable.exe |
Portable Mode | One file. Keeps a data folder beside itself, so a USB stick carries the app and its servers together. |
.dmg / .AppImage |
— | macOS and Linux. The data-folder rule works for the AppImage too. |
They are unsigned, so Windows SmartScreen will say "unrecognised app" the first time: More info → Run anyway.
Saved passwords and TURN secrets use the operating system’s credential protection. Moving a portable profile to another computer or OS account may require entering passwords again. If secure storage is unavailable, passwords stay in memory for the current session; Mutter does not fall back to a plaintext file. Existing localStorage passwords migrate on launch.
On a managed work laptop, don't
If the machine runs an EDR such as CrowdStrike, or AppLocker/WDAC, an unsigned executable it has
never seen is likely to be blocked — and may raise a detection with your name on it. Nothing here
is worth that. The browser-and-bridge setup in web/ already
gives you "install and it works" on such a machine without introducing a single new Windows
binary: install Mutter as an app from the browser's address bar, and have the bridge start with
Windows. Use these builds on machines you own, and hand them to friends.
Build it
Releases are built by the workflow on GitHub's runners —
push a tag like v0.1.0 and the three platforms build and attach themselves to a release, so no
toolchain is needed anywhere near the laptop.
To build locally you need Node 22+:
cd desktop
npm install
npm start # run from source, with the bridge on localhost:8789
npm run test:persistence # two Electron launches with an isolated profile (needs a GUI session)
npm run test:share # actual desktop picker/capture → independent Chromium viewer
npm run dist:win # portable + per-user installer into desktop/dist (run this on Windows)
Windows targets can only be built on Windows without wine; macOS targets only on macOS.
The capture test needs Chromium (CHROME can select its executable). On Linux X11, use a
private, local-only display without an Xauthority file:
Xvfb :198 -screen 0 1280x800x24 -extension MIT-SHM -nolisten tcp -noreset -ac > /tmp/mutter-xvfb.log 2>&1 &
mutter_xvfb_pid=$!
trap 'kill "$mutter_xvfb_pid" 2>/dev/null || true' EXIT
export DISPLAY=:198
unset XAUTHORITY
npm run test:share
It opens the real picker, checks cancellation followed by a fresh attempt, verifies changing pixel colors in a separate viewer process, and checks that stopping releases capture and removes the remote stream. CI runs this test on Linux X11 and Windows for desktop and shared web-client changes.
On macOS 15+, run the test in an unlocked desktop session and select only the Mutter window
in the native sharing picker. Accept macOS capture permission when requested. For Linux
Wayland, MUTTER_TEST_OZONE=wayland selects the Wayland backend; the test session needs
PipeWire and a working screen-cast portal. Select its isolated monitor in the system picker.
Mutter uses the source selected there directly instead of opening a second picker.
How it fits together
BrowserWindow ──http/ws──▶ bridge (in the main process) ──TLS + UDP──▶ murmur
│
└── getDisplayMedia() ──▶ setDisplayMediaRequestHandler ──▶ our picker (web/app/picker.html)
└──▶ audio: 'loopback' on Windows
- The desktop bridge uses loopback port 8789. Keeping the origin stable preserves saved
servers, settings, and certificate pins between launches. The browser bridge uses 8788.
PORTcan select another fixed port; changing it selects a different browser storage origin. Port 0 is refused, and an occupied port produces an error instead of silently switching to a fresh settings store. The bridge'sreadypromise resolves with the URL. web/and the fonts are shipped as extra resources next to the app, not inside the archive, so they can be inspected and edited in place. The bridge finds them throughprocess.resourcesPath.- Packaged builds flip Electron's fuses:
ELECTRON_RUN_AS_NODE,NODE_OPTIONSand--inspectare ignored, the app loads only fromapp.asar, and macOS and Windows builds check that archive's integrity. The web client in the extra resources is outside that check. - Chromium starts with
WebRtcHideLocalIpsWithMdnsdisabled, so screen and camera sharing offer real local network addresses instead of random.localnames. Two computers that both hide their addresses can't connect on a LAN that blocks multicast DNS; phones never hide theirs, which is why a phone could watch when a second computer couldn't. The addresses only go to the people you share with or watch, through the Mumble server's plugin messages. - The renderer runs sandboxed with context isolation. Its preload exposes only credential
reads and writes; the main process checks the requesting window, frame, and URL. The picker
has a separate preload exposing its selection controls. The picker loads the shared
web assets and receives the main window’s selected theme and appearance before it is shown. The main process talks to
the page only through
executeJavaScriptagainstwindow.mutter, to read the push-to-talk binding and to press or release it.
Push to talk from any window
The browser only hears your push-to-talk key while its tab is focused. The desktop app keeps hearing it: while the Mutter window is in the background it watches for the one key or mouse button you bound under Settings, and holds the mic open for exactly as long as you hold it. The key still reaches whatever you are doing, so a game bound to the same key keeps working.
This uses a system-wide input hook (uiohook-napi), which is the same mechanism Discord uses. It compares every key event against your one binding and does nothing else with them; nothing is stored or sent anywhere, and it is inactive while the Mutter window is focused because the page handles the key itself there.
Platform notes:
- Windows: works out of the box.
- macOS: the first press prompts for the Input Monitoring permission in System Settings → Privacy & Security. Until it is granted, push to talk only works with Mutter focused.
- Linux: works on X11 and for XWayland apps. Native Wayland apps do not expose key events to
other programs, so there push to talk only works with Mutter focused. A Wayland session
without an X11
DISPLAYskips the native hook, which otherwise can terminate the app.
If the hook cannot load, the app logs one line and carries on without it.
Signing, later
Unsigned is fine for a handful of friends. If SmartScreen becomes a nuisance, an Authenticode
certificate plus CSC_LINK/CSC_KEY_PASSWORD secrets in the workflow is all electron-builder
needs; the config already leaves room for it.