diff --git a/.gitignore b/.gitignore index 939a02f..645cd80 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ Thumbs.db .env *.key *.pem + +# Local reference clone of PowerToys (MWB protocol source) β€” never commit +/PowerToys/ diff --git a/Makefile b/Makefile index 7f22c43..3b7abb7 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,21 @@ -.PHONY: build install uninstall clean test fmt lint check bump +.PHONY: build build-wayland install install-wayland install-only uninstall clean test fmt lint check bump SYSTEMD_USER_DIR := $(HOME)/.config/systemd/user build: go build -o mwb ./cmd/mwb -install: build +# Wayland bidirectional support is opt-in: it needs cgo + libei +# (Arch: libei, Debian: libei-dev). Builds the same binary plus the portal driver. +build-wayland: + CGO_ENABLED=1 go build -tags wayland -o mwb ./cmd/mwb + +# install β€” X11 build, then install the per-user service +# install-wayland β€” Wayland build, then install (does NOT rebuild as X11) +install: build install-only +install-wayland: build-wayland install-only + +install-only: install -D mwb $(HOME)/go/bin/mwb install -d $(SYSTEMD_USER_DIR) install -m 644 mwb.service $(SYSTEMD_USER_DIR)/mwb.service diff --git a/README.md b/README.md index f508c72..c441559 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@

Features • + What's New • Installation • Quick Start • How It Works • @@ -24,105 +25,146 @@ --- +> ### 🍴 This is a fork of [lucky-verma/mwb-linux](https://github.com/lucky-verma/mwb-linux) +> +> It adds **native Wayland bidirectional support**, a **configuration GUI**, **file copy/paste**, **switch-from-any-edge** with a modifier "easy mouse" mode, smoother input, and more. See [What's new in this fork](#whats-new-in-this-fork) for the full list. All credit for the original Linux client and the MWB protocol implementation goes to the upstream project. + ## What is this? -MWB Linux is a native Linux client that connects to **Microsoft PowerToys Mouse Without Borders** on Windows. Move your mouse to the edge of the screen, and it seamlessly jumps to the other machine β€” along with your keyboard and clipboard. +MWB Linux is a native Linux client that connects to **Microsoft PowerToys Mouse Without Borders** on Windows. Move your mouse to the edge of the screen and it seamlessly jumps to the other machine β€” along with your keyboard and clipboard. **Bidirectional control now works natively on Wayland** (KDE Plasma 6 / GNOME 46+), not just X11. ```mermaid flowchart LR - A["🐧 Linux PC
Mouse Β· Keyboard"] <-->|" πŸ–±οΈ Mouse Β· ⌨️ Keyboard Β· πŸ“‹ Clipboard "| B["πŸͺŸ Windows PC
Mouse · Keyboard"] + A["🐧 Linux PC
Mouse Β· Keyboard Β· Files"] <-->|" πŸ–±οΈ Mouse Β· ⌨️ Keyboard Β· πŸ“‹ Clipboard Β· πŸ“ Files "| B["πŸͺŸ Windows PC
Mouse Β· Keyboard Β· Files"] ``` -> Move your mouse to the screen edge β€” the cursor seamlessly jumps to the other machine. - No extra software needed on Windows beyond PowerToys, which bundles Mouse Without Borders. ## Features - **Bidirectional mouse sharing** β€” Control both machines from either keyboard/mouse -- **Seamless edge switching** β€” Move cursor to screen edge, it appears on the other machine -- **Clipboard sync** β€” Copy text or images on one machine, paste on the other +- **Native Wayland support** β€” Bidirectional input via the InputCapture portal + libei (no `xdotool`/`xinput`, no root); X11 still supported +- **Switch from any edge** β€” Cross left/right/top/bottom; choose which edges, or require a held modifier (Shift/Ctrl/Alt) β€” PowerToys-style "Easy Mouse" +- **Configuration GUI** β€” `mwb gui` opens a local web UI to edit settings and control the service +- **Clipboard & file sync** β€” Copy text, images, **or files** on one machine and paste on the other - **Keyboard forwarding** β€” Type on your Linux keyboard, text appears on Windows -- **Full mouse support** β€” Scroll wheel, horizontal scroll, and side buttons (back/forward) +- **Full mouse support** β€” Scroll wheel, touchpad two-finger scroll, horizontal scroll, middle click, and side buttons (back/forward) - **Encrypted** β€” AES-256-CBC encryption with PBKDF2 key derivation -- **Device isolation** β€” When controlling Windows, your Linux cursor doesn't move -- **Dual-mode connection** β€” Acts as both server and client for fast reconnection -- **Zero config on Windows** β€” Works with existing PowerToys MWB setup -- **Lightweight** β€” Single binary, ~5MB, no GUI dependencies +- **Remembered permission** β€” The Wayland capture prompt only appears once (persists across reboots) +- **Lightweight** β€” Single Go binary, minimal dependencies + +## What's new in this fork + +Everything below is added on top of [lucky-verma/mwb-linux](https://github.com/lucky-verma/mwb-linux): + +**Wayland** +- Native **bidirectional** input on Wayland via the `org.freedesktop.portal.InputCapture` portal + libei β€” the upstream project is X11-only for the capture direction. Compositor-native edge barriers and input suppression replace `xdotool`/`xinput`, and no root is needed. +- The capture **permission dialog is remembered** (`persist_mode` + `restore_token`), so it only prompts once. +- The binary **auto-selects** the Wayland driver on a Wayland session and the X11 path otherwise. + +**Switching** +- **Any-edge switching** (left/right/top/bottom) instead of a single configured edge. +- **Selectable edges** (`edges`) and a full **disable** option. +- **Modifier-gated switching** (`switch_modifier`) β€” only cross while holding Shift/Ctrl/Alt, like PowerToys' Easy Mouse. + +**Clipboard / files** +- **File copy/paste** in both directions over MWB's separate file-transfer channel (received files land in `~/Downloads/MouseWithoutBorders/` and on your clipboard). + +**Input quality** +- Fixed **scroll direction**, added **touchpad two-finger scroll** and **horizontal scroll**. +- Added **middle-click** and **side-button** (back/forward) forwarding. +- Smoother cursor motion (single combined packet + sub-pixel accumulation). +- **Resolution-free** coordinate mapping β€” no need to configure the remote screen size. + +**Configuration** +- A built-in **web GUI** (`mwb gui`) to edit all settings, toggle bidirectional/edges/modifier, and start/stop/enable the systemd service. +- `bidirectional` config flag so the daemon can be fully config-driven (no CLI flags needed). ## Demo | Direction | What happens | |-----------|-------------| -| Mouse hits left edge on Linux | Cursor appears on Windows, Linux input disabled | -| Mouse hits right edge on Windows | Cursor returns to Linux, input restored | -| Ctrl+C on Windows | Text/image available on Linux clipboard | -| Ctrl+C on Linux | Text/image available on Windows clipboard | +| Mouse hits an enabled edge on Linux | Cursor crosses to Windows | +| Mouse hits the return edge on Windows | Cursor returns to Linux | +| Ctrl+C on Windows | Text / image / **file** available on Linux | +| Ctrl+C on Linux | Text / image / **file** available on Windows | | Type on Linux keyboard | Text appears in focused Windows app | ## Installation -### One-Line Install (Ubuntu/Debian) +Install **from source** β€” this fork isn't published as a prebuilt package. The +whole thing is a single Go binary; the steps below are complete for a fresh +machine. -```bash -curl -fsSL https://raw.githubusercontent.com/lucky-verma/mwb-linux/main/scripts/install.sh | sudo bash -``` +> Replace `` with your GitHub user (or use +> `lucky-verma/mwb-linux` for the upstream X11-only version). -### From .deb Package +### 1. Install dependencies -Download the versioned `.deb` for your architecture from -[Releases](https://github.com/lucky-verma/mwb-linux/releases), then install it: +**Wayland (KDE Plasma 6+ / GNOME 46+) β€” recommended:** -```bash -sudo dpkg -i mwb-linux_*_amd64.deb +| Distro | Command | +|--------|---------| +| Arch / CachyOS | `sudo pacman -S go libei wl-clipboard` | +| Debian / Ubuntu | `sudo apt install golang libei-dev wl-clipboard` | +| Fedora | `sudo dnf install golang libei-devel wl-clipboard` | -# Add yourself to the input group -sudo usermod -aG input $USER -``` +**X11:** -### From Binary +| Distro | Command | +|--------|---------| +| Arch / CachyOS | `sudo pacman -S go xdotool xorg-xinput xclip` | +| Debian / Ubuntu | `sudo apt install golang xdotool xinput xclip x11-xserver-utils` | +| Fedora | `sudo dnf install golang xdotool xinput xclip` | -```bash -# Download binary -wget https://github.com/lucky-verma/mwb-linux/releases/latest/download/mwb-linux-amd64 -chmod +x mwb-linux-amd64 -sudo mv mwb-linux-amd64 /usr/local/bin/mwb +You need **Go 1.25+**. On Wayland, `libei`/`libei-dev` is a *build* dependency +(cgo); `wl-clipboard` is a *runtime* dependency for file/clipboard sharing. + +### 2. Set up uinput permissions -# Install dependencies -sudo apt install xdotool xinput xclip +Input **injection** always goes through `/dev/uinput`, and on **X11** input +**capture** reads `/dev/input/event*`. Both need the `input` group (this avoids +running as root): -# Setup permissions -sudo bash -c 'modprobe uinput && echo uinput > /etc/modules-load.d/uinput.conf' +```bash +sudo modprobe uinput +echo uinput | sudo tee /etc/modules-load.d/uinput.conf echo 'KERNEL=="uinput", GROUP="input", MODE="0660"' | sudo tee /etc/udev/rules.d/99-mwb-uinput.rules -sudo udevadm control --reload-rules -sudo usermod -aG input $USER +sudo udevadm control --reload-rules && sudo udevadm trigger /dev/uinput +sudo usermod -aG input "$USER" ``` -### From Source +> **Log out and back in** afterwards so the group change takes effect. +> On Wayland the InputCapture portal handles capture, so no root or +> `/dev/input` access is needed for the capture direction β€” only `uinput` for +> injection. + +### 3. Build and install ```bash -git clone https://github.com/lucky-verma/mwb-linux.git +git clone https://github.com//mwb-linux.git cd mwb-linux -make build -make install # no sudo β€” installs a per-user service -systemctl --user enable --now mwb + +# Wayland (cgo + libei): +make install-wayland + +# …or X11: +make install ``` -`make install` is a per-user install: the binary goes to `~/go/bin/mwb` and the -service to `~/.config/systemd/user/`. Do **not** run it with `sudo` β€” that -installs under `root` and the `--user` service then can't find the binary. +This installs a **per-user** systemd service: the binary goes to `~/go/bin/mwb` +and the unit to `~/.config/systemd/user/`. Don't run `make install` with `sudo` +β€” that installs under `root` and the `--user` service can't find the binary. -It does not set up system dependencies. If this is a fresh machine, run the -dependency and permission steps from [From Binary](#from-binary) first -(`xdotool`/`xinput`/`xclip`, the `uinput` module, the udev rule, and the -`input` group). +Now jump to [Quick Start](#quick-start) to configure the security key, then: -> **Note:** Log out and back in after installation for group changes to take effect. -> -> **One installer at a time.** The one-line/`.deb`/binary methods install a -> system service that runs `/usr/local/bin/mwb`. `make install` installs a -> per-user service that runs `~/go/bin/mwb`. If you switch methods, stop and -> disable the old service first so you aren't running a stale binary. +```bash +systemctl --user enable --now mwb +journalctl --user -u mwb -f # follow logs +``` + +To uninstall: `make uninstall`. To run in the foreground for testing instead of +as a service: `mwb -bidi -debug`. ## Quick Start @@ -149,13 +191,39 @@ EOF mwb # Bidirectional (Linux also controls Windows) -sudo mwb -bidi -edge left +mwb -bidi # Wayland: no root needed (the portal handles capture) +sudo mwb -bidi # X11: needs root/input-group to read /dev/input ``` +You can also set `bidirectional = true` in `config.toml` (or toggle it in the +GUI) so the systemd service starts in bidirectional mode without any flags. + ### 4. Add your Linux machine on Windows In PowerToys MWB, enter the security key and device name to connect. +## Configuration GUI + +Prefer a UI to the TOML file? Launch the built-in web GUI: + +```bash +mwb gui +``` + +It opens a local page (`http://127.0.0.1:15199`) where you can edit every +setting, toggle bidirectional mode, and start/stop/enable the systemd user +service. Saved settings are written to `config.toml`; restart the service from +the GUI to apply them. The server binds to localhost only. + +## File copy/paste + +Copying a **file** on one machine and pasting it on the other works in both +directions (text and images too). Files transfer over MWB's separate clipboard +channel (the base port, `15100`); received files are saved to +`~/Downloads/MouseWithoutBorders/` and placed on the local clipboard so you can +paste them in your file manager. Requires `wl-clipboard` on Wayland (`wl-copy`/ +`wl-paste`) or `xclip` on X11. Single files only (zip a folder first); 100 MB cap. + ## How It Works MWB Linux implements the full Mouse Without Borders protocol: @@ -180,9 +248,10 @@ For detailed protocol documentation, see [docs/ARCHITECTURE.md](docs/ARCHITECTUR | `key` | (required) | MWB security key (from PowerToys) | | `name` | hostname | This machine's display name | | `port` | 15100 | Base port (message port = 15101) | -| `remote_width` | 1920 | Remote screen width in pixels | -| `remote_height` | 1080 | Remote screen height in pixels | -| `edge` | left | Screen edge for switching: `left` or `right` | +| `edge` | left | **X11 only.** Screen edge for switching: `left` or `right` | +| `edges` | all four | **Wayland only.** Which edges switch to the remote, e.g. `["left","right"]`. Unset = all four; `["none"]` disables edge switching entirely | +| `switch_modifier` | (none) | **Wayland only.** Require a held key to cross edges (PowerToys "Easy Mouse"): `shift`, `ctrl`, or `alt`. Empty = cross freely | +| `bidirectional` | false | Enable bidirectional mode from config (same as `-bidi`). Lets the GUI and systemd service turn it on without editing flags | | `clipboard` | true | Clipboard sync: set `false` to disable text/image sharing | | `accel_multiplier` | 2.0 | Cursor speed when controlling Windows. Lower it (e.g. `1.0`, `0.5`) if the Windows cursor feels too fast | | `inbound_multiplier` | 1.0 | Cursor speed when Windows controls Linux. `1.0` mirrors Windows exactly; raise it for faster inbound movement | @@ -213,7 +282,7 @@ For detailed protocol documentation, see [docs/ARCHITECTURE.md](docs/ARCHITECTUR Run the setup permissions commands above, then log out and back in. ### Clipboard not syncing -Ensure `xclip` is installed: `sudo apt install xclip` +Ensure the clipboard tool is installed: `wl-clipboard` on Wayland, `xclip` on X11. ### Disable clipboard sharing Set `clipboard = false` in `config.toml`, or run with `-no-clipboard`. The Linux @@ -221,7 +290,8 @@ client then never reads or writes the local clipboard, so it won't override what you copied on Windows. ### Mouse controls both screens simultaneously -Run with `-bidi` flag and `sudo` for device isolation via xinput. +On Wayland the portal suppresses local input automatically. On X11 you need +`-bidi` with `sudo` (or the `input` group) so device isolation via `xinput` can run. ### Connection refused - Check Windows firewall allows port 15100-15101 @@ -234,18 +304,21 @@ Set "Move mouse relatively" to OFF in PowerToys MWB settings. ## Project Structure ``` -cmd/mwb/ CLI entry point +cmd/mwb/ + main.go CLI entry point + driver selection + gui.go, web/ Local web configuration GUI (mwb gui) internal/ - capture/ Edge detection, evdev capture, xinput device isolation - clipboard/ Bidirectional clipboard sync (text + images) + capture/ Edge detection + input forwarding + capture_linux.go X11 path (xdotool/xinput/evdev) + portal_wayland.go Wayland InputCapture portal driver (build tag: wayland) + ei_cgo.go libei receiver (cgo, build tag: wayland) + clipboard/ Clipboard sync (text/image in-band) + file transfer (:15100) config/ TOML configuration input/ Virtual mouse/keyboard via uinput network/ TCP connection, encryption, packet send/receive protocol/ MWB packet types, serialization, AES-256-CBC docs/ ARCHITECTURE.md Detailed protocol and architecture documentation -scripts/ - install.sh Installation helper script ``` ## Known Limitations @@ -253,7 +326,8 @@ scripts/ - **Keyboard on Windows lock screen** β€” Keyboard input may not work on the Windows lock screen (Winlogon desktop security restriction) - **Middle mouse button auto-scroll** β€” Middle-click auto-scroll (scroll lock mode) does not work in browsers; normal middle-click works - **First connection** β€” Initial handshake takes ~3-16s depending on Windows MWB state; subsequent reconnects are instant -- **Bidirectional mode requires X11** β€” Edge detection and device isolation use `xdotool`/`xinput`. Receive-only mode works on Wayland (XWayland session). Native Wayland bidirectional support requires compositor extensions and is not yet implemented. +- **Bidirectional mode on X11** β€” The default build uses `xdotool`/`xinput` for edge detection and device isolation, so bidirectional mode needs an X11 (or XWayland) session. Receive-only mode works everywhere. +- **Bidirectional mode on Wayland (opt-in build)** β€” A native Wayland driver uses the `org.freedesktop.portal.InputCapture` portal + libei (compositor-native edge barriers and input suppression, no `xdotool`/`xinput`, no root). It is opt-in at build time because it needs cgo + libei: `make build-wayland` (Arch: `libei`; Debian: `libei-dev`). The binary auto-selects the portal driver on Wayland and the X11 path otherwise. Requires a compositor with the InputCapture portal (GNOME 46+, KDE Plasma 6+). On Wayland the cursor can switch from **any** edge (the `edge` setting is X11-only), and no remote-resolution config is needed β€” coordinates are normalized like MWB itself. - **Keyboard layout metadata** β€” PowerToys MWB keyboard packets carry Windows virtual-key codes and flags, but not hardware scan codes or Unicode text. MWB Linux uses `keyboard_layout` profiles for common layouts; unsupported profiles fall back to the original US-compatible mapping. Fully zero-config global layout support requires sender-side scan code or Unicode metadata. - **Brief screen stall on return with many input devices** β€” Device isolation re-enables every matched device via `xinput` when control returns to Linux. On setups with many input devices (e.g. several gaming peripherals exposing 15+ `xinput` sub-devices) the compositor can stall for ~1-2s on return (the cursor keeps moving, the screen briefly freezes). An EVIOCGRAB-based isolation was tried to avoid this but introduced a worse cursor regression and was reverted; a proper fix (EVIOCGRAB done right, or libei) is tracked for a future release. - **Cursor speed / drift** β€” Remote cursor movement scales raw evdev deltas by `accel_multiplier` (default 2Γ—); lower it if the Windows cursor feels too fast (the Windows side adds no acceleration of its own). Tracking is open-loop, so the virtual cursor may still drift from the actual position over long sessions. @@ -265,17 +339,18 @@ Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for gui ### Building ```bash -make build # Build binary -make test # Run tests -make lint # Run linter -make check # All of the above +make build # Build binary (X11 bidirectional) +make build-wayland # Build with Wayland bidirectional (cgo + libei required) +make test # Run tests +make lint # Run linter +make check # All of the above ``` ## Acknowledgments -- [Microsoft PowerToys](https://github.com/microsoft/PowerToys) β€” Mouse Without Borders is part of PowerToys (MIT License). This project implements the MWB network protocol for Linux. -- [bketelsen/mwb](https://github.com/bketelsen/mwb) β€” Initial Go implementation of the MWB receive-only client that this project builds upon. -- The MWB protocol specification was derived from the open-source PowerToys codebase. +- [lucky-verma/mwb-linux](https://github.com/lucky-verma/mwb-linux) β€” **The project this is forked from.** All of the original Linux client, the MWB protocol implementation, and the X11 bidirectional support come from there. +- [Microsoft PowerToys](https://github.com/microsoft/PowerToys) β€” Mouse Without Borders is part of PowerToys (MIT License). This project implements the MWB network protocol for Linux; the file-transfer and clipboard wire formats were derived from the open-source PowerToys codebase. +- [bketelsen/mwb](https://github.com/bketelsen/mwb) β€” Initial Go implementation of the MWB receive-only client that upstream builds upon. ## License diff --git a/cmd/mwb/gui.go b/cmd/mwb/gui.go new file mode 100644 index 0000000..4f96d1b --- /dev/null +++ b/cmd/mwb/gui.go @@ -0,0 +1,133 @@ +package main + +import ( + "embed" + "encoding/json" + "flag" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + + "github.com/BurntSushi/toml" + "github.com/lucky-verma/mwb-linux/internal/config" +) + +//go:embed web/index.html +var webFS embed.FS + +// runGUI serves the local configuration UI on 127.0.0.1 and opens a browser. +// ponytail: localhost-only is the trust boundary β€” single-user local tool, the +// handlers shell `systemctl --user` as the invoking user, so no auth is added. +func runGUI(args []string) { + fs := flag.NewFlagSet("gui", flag.ExitOnError) + addr := fs.String("addr", "127.0.0.1:15199", "address to serve the GUI on") + cfgPath := fs.String("config", defaultConfigPath(), "path to config.toml") + open := fs.Bool("open", true, "open the GUI in a browser") + _ = fs.Parse(args) + + mux := http.NewServeMux() + + index, _ := webFS.ReadFile("web/index.html") + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(index) + }) + + mux.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + // Read without validation so a missing/incomplete config still + // renders the form (config.Load would reject a blank host/key). + var cfg config.Config + if _, err := toml.DecodeFile(*cfgPath, &cfg); err != nil && !os.IsNotExist(err) { + writeJSON(w, http.StatusOK, &cfg) // best effort β€” show what we can + return + } + writeJSON(w, http.StatusOK, &cfg) + case http.MethodPost: + var cfg config.Config + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest) + return + } + if cfg.Host == "" || cfg.Key == "" { + http.Error(w, "host and key are required", http.StatusBadRequest) + return + } + if err := config.Save(*cfgPath, &cfg); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, map[string]string{"saved": *cfgPath}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + + mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { + state, _ := exec.Command("systemctl", "--user", "is-active", "mwb").Output() + log, _ := exec.Command("journalctl", "--user", "-u", "mwb", "-n", "20", "--no-pager", "-o", "cat").Output() + st := string(trimNL(state)) + writeJSON(w, http.StatusOK, map[string]any{ + "active": st == "active", + "state": st, + "log": string(log), + }) + }) + + mux.HandleFunc("/api/service", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Action string `json:"action"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + switch req.Action { + case "start", "stop", "restart", "enable", "disable": + default: + http.Error(w, "invalid action", http.StatusBadRequest) + return + } + out, err := exec.Command("systemctl", "--user", req.Action, "mwb").CombinedOutput() + if err != nil { + http.Error(w, string(out)+err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, map[string]string{"ok": req.Action}) + }) + + url := "http://" + *addr + fmt.Printf("mwb GUI serving at %s (config: %s)\n", url, *cfgPath) + if *open { + go func() { _ = exec.Command("xdg-open", url).Start() }() + } + if err := http.ListenAndServe(*addr, mux); err != nil { + fmt.Fprintf(os.Stderr, "gui server error: %v\n", err) + os.Exit(1) + } +} + +func defaultConfigPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "mwb", "config.toml") +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func trimNL(b []byte) []byte { + for len(b) > 0 && (b[len(b)-1] == '\n' || b[len(b)-1] == '\r') { + b = b[:len(b)-1] + } + return b +} diff --git a/cmd/mwb/main.go b/cmd/mwb/main.go index c32ddaa..802fa20 100644 --- a/cmd/mwb/main.go +++ b/cmd/mwb/main.go @@ -21,6 +21,12 @@ import ( ) func main() { + // `mwb gui` launches the local web configuration UI instead of the daemon. + if len(os.Args) > 1 && os.Args[1] == "gui" { + runGUI(os.Args[2:]) + return + } + configPath := flag.String("config", "", "path to config.toml") debug := flag.Bool("debug", false, "enable debug logging") edgeSide := flag.String("edge", "", "screen edge to switch: left or right (overrides config)") @@ -56,13 +62,17 @@ func main() { *edgeSide = "right" // final fallback } + // Bidirectional turns on if either the flag or config requests it, so the + // GUI can toggle it via config without editing the systemd unit. + bidi := *bidirectional || cfg.Bidirectional + // Clipboard runs by default. Either config (clipboard = false) or the // --no-clipboard flag disables it; the flag wins over config. clipboardEnabled := cfg.ClipboardEnabled() && !*noClipboard keyboardLayout := input.ResolveKeyboardLayout(cfg.KeyboardLayout) slog.Debug("debug logging enabled") - slog.Info("mwb starting", "host", cfg.Host, "port", cfg.MessagePort(), "name", cfg.Name, "bidirectional", *bidirectional, "edge", *edgeSide, "clipboard", clipboardEnabled, "keyboard_layout", keyboardLayout) + slog.Info("mwb starting", "host", cfg.Host, "port", cfg.MessagePort(), "name", cfg.Name, "bidirectional", bidi, "edge", *edgeSide, "clipboard", clipboardEnabled, "keyboard_layout", keyboardLayout) mouse, err := input.CreateVirtualMouse("mwb-mouse") if err != nil { @@ -140,22 +150,29 @@ func main() { // Start clipboard sharing on the auto-detected display unless disabled. var clipMgr *clipboard.Manager if clipboardEnabled { - clipMgr = clipboard.NewManager(conn, capture.DetectDisplay()) + clipMgr = clipboard.NewManager(conn, capture.DetectDisplay(), cfg.Key, cfg.Host, cfg.Port) handler.Clipboard = clipMgr clipMgr.Start() } - // Start bidirectional capture if enabled + // Start bidirectional capture if enabled. On Wayland the X11 + // xdotool/xinput path can't work, so use the InputCapture portal driver. var cap *capture.Capturer - if *bidirectional { + var capStop func() + if bidi && isWayland() { + edges := cfg.EnabledEdges() + if len(edges) == 0 { + slog.Info("edge switching disabled (edges = none); not capturing") + } else if c, stop, err := capture.RunWayland(conn, handler, *edgeSide, edges, cfg.SwitchModifier, cfg.AccelMultiplier); err != nil { + slog.Error("wayland capture start failed", "err", err) + } else { + cap, capStop = c, stop + } + } else if bidi { screen := capture.GetScreenSizeXrandr() slog.Info("screen detected", "width", screen.Width, "height", screen.Height) cap = capture.New(conn, screen, *edgeSide) - // Wire remote screen dimensions from config so virtual cursor - // coordinate mapping is correct for non-1080p Windows displays. - cap.SetRemoteScreen(int32(cfg.RemoteWidth), int32(cfg.RemoteHeight)) - slog.Info("remote screen configured", "width", cfg.RemoteWidth, "height", cfg.RemoteHeight) // Cursor speed: the only acceleration knob lives here (Windows // applies none of its own), so honor the configured multiplier. cap.SetAccelMultiplier(cfg.AccelMultiplier) @@ -201,7 +218,9 @@ func main() { } // Stop capture first β€” prevents in-flight SendPacket after conn.Close() - if cap != nil { + if capStop != nil { + capStop() // Wayland: tears down the portal + libei + } else if cap != nil { cap.Stop() } @@ -217,3 +236,9 @@ func main() { sig := <-sigCh slog.Info("shutting down", "signal", sig) } + +// isWayland reports whether we're running under a Wayland session, in which +// case the X11 xdotool/xinput bidi path won't work and we use the portal driver. +func isWayland() bool { + return os.Getenv("XDG_SESSION_TYPE") == "wayland" || os.Getenv("WAYLAND_DISPLAY") != "" +} diff --git a/cmd/mwb/web/index.html b/cmd/mwb/web/index.html new file mode 100644 index 0000000..9589455 --- /dev/null +++ b/cmd/mwb/web/index.html @@ -0,0 +1,159 @@ + + + + + +Mouse Without Borders β€” Linux + + + +

Mouse Without Borders β€” Linux

+ +
+
+ Connection + + + + +
+
+
+
+
+ +
+ Screen & cursor + +
+ + + + +
+ + + + +
+
+
+
+
+ +
+ Behavior +
+
+ + +
+ +
+ + +
+
+ +
+ Service +
…
+
+ + + + + +
+
loading…
+
+ + + + diff --git a/debian/control b/debian/control index 04d072e..b90c90c 100644 --- a/debian/control +++ b/debian/control @@ -7,7 +7,8 @@ Build-Depends: dh-golang, golang-any (>= 2:1.22~), golang-github-burntsushi-toml-dev (>= 1.3.2), - golang-golang-x-crypto-dev (>= 1:0.19.0) + golang-golang-x-crypto-dev (>= 1:0.19.0), + golang-github-godbus-dbus-dev Standards-Version: 4.7.4 Rules-Requires-Root: no Homepage: https://github.com/lucky-verma/mwb-linux diff --git a/debian/mwb.service b/debian/mwb.service index 778755a..4e40856 100644 --- a/debian/mwb.service +++ b/debian/mwb.service @@ -5,7 +5,7 @@ Wants=network-online.target [Service] Type=simple -ExecStart=/usr/bin/mwb -bidi -edge left +ExecStart=/usr/bin/mwb Restart=on-failure RestartSec=5 # DISPLAY and XAUTHORITY are auto-detected by the mwb binary. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f5d0894..6d6fce5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -326,9 +326,13 @@ all writes. ## Future Work -- [ ] File drag-and-drop +- [x] Wayland native bidirectional input (InputCapture portal + libei; `-tags wayland`) +- [x] File copy/paste over the separate clipboard channel (base port 15100): + AES + IV handshake, 64-byte DATA header, then a 1024-byte UTF-16LE + `"*"` header followed by the raw file bytes. +- [ ] File drag-and-drop (the interactive drag path, distinct from copy/paste) - [ ] Multi-monitor support -- [ ] Wayland native support (replace xdotool/xinput with compositor protocol) +- [ ] Native multi-machine layout grid (PowerToys-style) - [ ] Replace xdotool polling with XInput2 RawMotion events (100 forks/sec β†’ 0) - [ ] Replace xinput name-matching with EVIOCGRAB (vendor-agnostic isolation) - [ ] Virtual cursor drift correction (wire UpdateRemoteScreen to incoming abs coords) diff --git a/go.mod b/go.mod index f4720ba..ace614c 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,8 @@ go 1.25.0 require ( github.com/BurntSushi/toml v1.3.2 + github.com/godbus/dbus/v5 v5.2.2 golang.org/x/crypto v0.53.0 ) + +require golang.org/x/sys v0.46.0 // indirect diff --git a/go.sum b/go.sum index 7635227..b6ce5ca 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,8 @@ github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/capture/capture_linux.go b/internal/capture/capture_linux.go index bdde89f..a94dc4a 100644 --- a/internal/capture/capture_linux.go +++ b/internal/capture/capture_linux.go @@ -9,6 +9,7 @@ import ( "encoding/binary" "fmt" "log/slog" + "math" "os" "os/exec" "path/filepath" @@ -26,18 +27,26 @@ const ( evKey = 0x01 evRel = 0x02 - relX = 0x00 - relY = 0x01 - relWheel = 0x08 + relX = 0x00 + relY = 0x01 + relHWheel = 0x06 + relWheel = 0x08 inputEventSize = 24 - // Default remote screen dimensions β€” auto-detected from incoming packets - defaultRemoteWidth = 1920 - defaultRemoteHeight = 1080 + // The MWB wire protocol carries absolute cursor coordinates on a normalized + // 0..normMax grid, so the receiver maps them to its own resolution. We track + // the virtual remote cursor in that same normalized space β€” that's why no + // remote screen resolution is needed (the Windows side auto-maps it). + normMax = 65535 - // Default scaling applied to raw evdev deltas. Approximates libinput's flat - // profile (speed 0.766 β†’ ~1.766, rounded to 2). Overridable via config. + // switchMargin is how far (in normalized units) inside the remote we enter + // from, and how far you must move back before a return is armed. ~10% of the + // range, matching the old 200px-of-1920 debounce. + switchMargin = normMax / 10 + + // Default speed multiplier on top of proportional (1:1 screen) mapping. + // 2.0 β‰ˆ traverse the remote in half a local-screen sweep, matching the old feel. defaultAccelMultiplier = 2.0 ) @@ -68,17 +77,23 @@ type Capturer struct { lastSwitch time.Time // debounce outgoing switches switchSent time.Time // when we last sent switch packets lastActivated time.Time // when cursor last arrived on this machine - remoteX int32 // virtual cursor position on remote (pixels) - remoteY int32 // virtual cursor position on remote (pixels) - remoteW int32 // detected remote screen width - remoteH int32 // detected remote screen height - accelMult float64 // scaling applied to raw evdev deltas (config: accel_multiplier) + remoteX int32 // virtual cursor position on remote (normalized 0..normMax) + remoteY int32 // virtual cursor position on remote (normalized 0..normMax) + accX float64 // sub-pixel motion carry + accY float64 // sub-pixel motion carry + activeEdge string // edge we crossed into the remote on: left/right/top/bottom + accelMult float64 // speed multiplier on the normalized mapping (config: accel_multiplier) edgeY int32 // Y position where cursor left local screen canSwitch bool // true once cursor has been away from edge since activation canReturn bool // true once cursor has moved away from the remote return edge hotkeyCtrl bool // tracks Ctrl key state for hotkey detection hotkeyAlt bool // tracks Alt key state for hotkey detection disabledXinputIDs []int // device IDs we disabled β€” re-enable same set to avoid TOCTOU + + // returnLocal, when set, replaces the X11 xdotool+xinput "return to local" + // action. The Wayland portal driver sets this to call portal Release (which + // both stops capture and warps the local cursor). nil on X11. + returnLocal func(x, y int32) } // New creates a new input capturer. @@ -87,15 +102,14 @@ type Capturer struct { // here can corrupt the attachment state of floating slave devices. func New(conn *network.Conn, screen ScreenInfo, edgeSide string) *Capturer { return &Capturer{ - conn: conn, - screen: screen, - active: true, - edgeSide: edgeSide, - stopCh: make(chan struct{}), - remoteW: defaultRemoteWidth, - remoteH: defaultRemoteHeight, - accelMult: defaultAccelMultiplier, - canSwitch: true, // allow first switch immediately + conn: conn, + screen: screen, + active: true, + edgeSide: edgeSide, + activeEdge: edgeSide, // X11 default; Wayland overrides per crossing + stopCh: make(chan struct{}), + accelMult: defaultAccelMultiplier, + canSwitch: true, // allow first switch immediately } } @@ -122,6 +136,79 @@ func (c *Capturer) SetActive(active bool) { } } +// EnterRemoteEdge sets up the virtual-cursor state when control crosses to the +// remote host across the given local edge. perpFrac is the cursor position along +// that edge as a fraction (0..1) β€” Y for left/right, X for top/bottom. We enter +// switchMargin inside from the return edge so momentum doesn't bounce straight +// back; the perpendicular axis carries the crossing point. +func (c *Capturer) EnterRemoteEdge(edge string, perpFrac float64) { + if perpFrac < 0 { + perpFrac = 0 + } else if perpFrac > 1 { + perpFrac = 1 + } + perp := int32(perpFrac * normMax) + c.mu.Lock() + defer c.mu.Unlock() + c.active = false + c.switchSent = time.Now() + c.activeEdge = edge + switch edge { + case "right": + c.remoteX, c.remoteY = switchMargin, perp + case "top": + c.remoteX, c.remoteY = perp, normMax-switchMargin + case "bottom": + c.remoteX, c.remoteY = perp, switchMargin + default: // left + c.remoteX, c.remoteY = normMax-switchMargin, perp + } + c.accX, c.accY = 0, 0 + c.canReturn = false +} + +// EdgeReentry returns a local pixel position just inside the given edge, +// carrying the perpendicular fraction. Used to park the cursor when a modifier- +// gated switch is declined, so it stays on the local screen near where it hit. +func (c *Capturer) EdgeReentry(edge string, perpFrac float64) (x, y int32) { + const inset = 12 + px := int32(perpFrac * float64(c.screen.Width)) + py := int32(perpFrac * float64(c.screen.Height)) + switch edge { + case "right": + return c.screen.Width - inset, py + case "top": + return px, inset + case "bottom": + return px, c.screen.Height - inset + default: // left + return inset, py + } +} + +// NotifyRemoteSwitch tells the remote host it now owns the cursor (the MWB +// MachineSwitched handoff). Besides the formal switch, this is what makes the +// remote pull our clipboard when we've just copied a file/large data (it checks +// for a recent clipboard beat on MachineSwitched). Call it right after we cross +// to the remote. +func (c *Capturer) NotifyRemoteSwitch() { + pkt := &protocol.Packet{ + Type: protocol.MachineSwitched, + Src: c.conn.MachineID, + Des: c.conn.RemoteID, + } + if err := c.conn.SendPacket(pkt); err != nil { + slog.Debug("send MachineSwitched failed", "err", err) + } +} + +// FeedEvent injects a normalized input event into the shared forwarding path. +// Used by the Wayland portal driver; the X11 path calls handleEvent directly +// from its evdev readers. Both funnel into the same handleRel/handleKey logic. +func (c *Capturer) FeedEvent(typ, code uint16, value int32) { + c.handleEvent(inputEvent{Type: typ, Code: code, Value: value}) +} + // IsActive returns true if cursor is on this machine. func (c *Capturer) IsActive() bool { c.mu.Lock() @@ -146,25 +233,6 @@ func (c *Capturer) SafeEntryPosition() (x, y int32) { return x, y } -// UpdateRemoteScreen detects remote screen dimensions from incoming Mouse packets. -// Called by the handler when we receive absolute mouse coordinates from the server. -func (c *Capturer) UpdateRemoteScreen(absX, absY int32) { - // MWB absolute coords are 0-65535. We can't directly detect resolution from them. - // But we can detect it from the Matrix/HeartbeatEx packets or config. - // For now, this is a placeholder β€” resolution comes from config or is auto-detected. -} - -// SetRemoteScreen sets the remote screen dimensions. -func (c *Capturer) SetRemoteScreen(w, h int32) { - c.mu.Lock() - defer c.mu.Unlock() - if w > 0 && h > 0 && (w != c.remoteW || h != c.remoteH) { - c.remoteW = w - c.remoteH = h - slog.Info("remote screen dimensions updated", "width", w, "height", h) - } -} - // SetAccelMultiplier sets the scaling applied to raw evdev deltas before they // move the remote cursor. Non-positive values are ignored so the default is // never clobbered by an unset/invalid config value. @@ -318,20 +386,25 @@ func (c *Capturer) pollCursorEdge() { c.active = false c.switchSent = time.Now() c.edgeY = y - // Set virtual cursor offset from the return edge to prevent jitter bounce. - // Entry is 200px from the return edge β€” gives room for mouse momentum. + c.activeEdge = c.edgeSide + c.accX, c.accY = 0, 0 + // Virtual cursor offset switchMargin from the return edge (normalized + // 0..normMax space) to give mouse momentum room before a return arms. if c.edgeSide == "left" { - c.remoteX = c.remoteW - 200 + c.remoteX = normMax - switchMargin } else { - c.remoteX = 200 + c.remoteX = switchMargin } - c.remoteY = int32(float64(y) / float64(c.screen.Height) * float64(c.remoteH)) + c.remoteY = int32(float64(y) / float64(c.screen.Height) * normMax) c.canReturn = false // must move away from return edge first c.mu.Unlock() // Disable local input in X11 (synchronous β€” only takes ~2ms) c.disableXinput() + // Formal handoff β€” also triggers the remote to pull our clipboard. + c.NotifyRemoteSwitch() + // Send mouse burst to the entry position on remote // Multiple packets help Windows MWB register the switch reliably conn := c.conn @@ -662,119 +735,200 @@ func (c *Capturer) handleEvent(ev inputEvent) { } } -// applyAcceleration scales raw evdev deltas by mult. The Windows side does no -// acceleration of its own (absolute positioning), so mult is the only -// cursor-speed control. Sub-pixel results are clamped to Β±1 so slow movements -// still register and the cursor can always reach the return edge. -func applyAcceleration(delta int32, mult float64) int32 { - scaled := float64(delta) * mult - if scaled > 0 && scaled < 1 { - return 1 +// pick returns down if cond else up β€” for choosing button DOWN/UP flags. +func pick(cond bool, down, up int32) int32 { + if cond { + return down + } + return up +} + +func (c *Capturer) clampRemoteLocked() { + if c.remoteX < 0 { + c.remoteX = 0 + } + if c.remoteX > normMax { + c.remoteX = normMax + } + if c.remoteY < 0 { + c.remoteY = 0 } - if scaled < 0 && scaled > -1 { - return -1 + if c.remoteY > normMax { + c.remoteY = normMax } - return int32(scaled) +} + +// normPerPx returns how many normalized units one local pixel maps to on each +// axis. Moving the full local screen width thus sweeps the full remote range +// (times accel_multiplier). Guards against a zero screen size. +func (c *Capturer) normPerPx() (x, y float64) { + w, h := c.screen.Width, c.screen.Height + if w <= 0 { + w = 1 + } + if h <= 0 { + h = 1 + } + return float64(normMax) / float64(w), float64(normMax) / float64(h) +} + +// addMotionLocked accumulates already-scaled normalized deltas with sub-pixel +// carry and applies them to the virtual cursor. Shared by handleRel (X11, +// per-axis) and FeedMotion (Wayland, combined). +func (c *Capturer) addMotionLocked(dxNorm, dyNorm float64) { + c.accX += dxNorm + c.accY += dyNorm + ix := math.Trunc(c.accX) + iy := math.Trunc(c.accY) + c.accX -= ix + c.accY -= iy + c.remoteX += int32(ix) + c.remoteY += int32(iy) + c.clampRemoteLocked() } func (c *Capturer) handleRel(ev inputEvent) { c.mu.Lock() defer c.mu.Unlock() + nppx, nppy := c.normPerPx() switch ev.Code { case relX: - c.remoteX += applyAcceleration(ev.Value, c.accelMult) - if c.remoteX < 0 { - c.remoteX = 0 - } - if c.remoteX > c.remoteW { - c.remoteX = c.remoteW - } + c.addMotionLocked(float64(ev.Value)*c.accelMult*nppx, 0) case relY: - c.remoteY += applyAcceleration(ev.Value, c.accelMult) - if c.remoteY < 0 { - c.remoteY = 0 - } - if c.remoteY > c.remoteH { - c.remoteY = c.remoteH - } + c.addMotionLocked(0, float64(ev.Value)*c.accelMult*nppy) case relWheel: - c.sendMouseLocked(0, 0, ev.Value*120, protocol.WM_MOUSEWHEEL) + // evdev REL_WHEEL is +1 per notch up; Windows WHEEL_DELTA is +120 up β€” same + // direction. Send at the current cursor position so it scrolls the right window. + c.sendMouseLocked(c.remoteX, c.remoteY, ev.Value*120, protocol.WM_MOUSEWHEEL) + return + case relHWheel: + c.sendMouseLocked(c.remoteX, c.remoteY, ev.Value*120, protocol.WM_MOUSEHWHEEL) return default: return } + c.afterMotionLocked() +} - // canReturn gate: must move 200px away from return edge before allowing return. - // This prevents jitter/momentum from the initial switch from bouncing back. - returnZone := int32(200) - switch c.edgeSide { - case "left": - if c.remoteX < c.remoteW-returnZone { +// FeedWheel forwards a scroll to the remote at the current cursor position. +// Units are Windows WHEEL_DELTA (Β±120 per notch): vertical>0 scrolls up, +// horizontal>0 scrolls right. Used by the Wayland driver for wheel and touchpad. +func (c *Capturer) FeedWheel(vertical, horizontal int32) { + if c.IsActive() { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if vertical != 0 { + c.sendMouseLocked(c.remoteX, c.remoteY, vertical, protocol.WM_MOUSEWHEEL) + } + if horizontal != 0 { + c.sendMouseLocked(c.remoteX, c.remoteY, horizontal, protocol.WM_MOUSEHWHEEL) + } +} + +// FeedMotion applies a combined relative motion (both axes at once) and sends a +// single mouse packet. The EI/Wayland source delivers dx and dy together, so +// feeding them as one update avoids the per-axis double-send that stair-steps +// diagonal motion. Sub-pixel deltas are accumulated so slow movement stays smooth. +func (c *Capturer) FeedMotion(dx, dy float64) { + if c.IsActive() { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if !c.switchSent.IsZero() && time.Since(c.switchSent) < 100*time.Millisecond { + return + } + nppx, nppy := c.normPerPx() + c.addMotionLocked(dx*c.accelMult*nppx, dy*c.accelMult*nppy) + c.afterMotionLocked() +} + +// afterMotionLocked evaluates the return-edge gate and forwards the new cursor +// position. Caller must hold c.mu. Shared by handleRel (per-axis, X11) and +// FeedMotion (combined, Wayland). +func (c *Capturer) afterMotionLocked() { + // canReturn gate: must move switchMargin away from the entry edge before a + // return is armed β€” prevents the initial momentum from bouncing straight back. + // switchBack fires when the virtual cursor crosses back over that same edge. + switchBack := false + switch c.activeEdge { + case "right": + if c.remoteX > switchMargin { c.canReturn = true } - case "right": - if c.remoteX > returnZone { + switchBack = c.canReturn && c.remoteX <= 0 + case "top": + if c.remoteY < normMax-switchMargin { c.canReturn = true } - } - - // Check if virtual cursor hit the return edge (opposite of edgeSide) - switchBack := false - if c.canReturn { - switch c.edgeSide { - case "left": - // We switched to remote via left edge, return via right edge of remote - if c.remoteX >= c.remoteW-1 { - switchBack = true - } - case "right": - if c.remoteX <= 0 { - switchBack = true - } + switchBack = c.canReturn && c.remoteY >= normMax-1 + case "bottom": + if c.remoteY > switchMargin { + c.canReturn = true } + switchBack = c.canReturn && c.remoteY <= 0 + default: // left + if c.remoteX < normMax-switchMargin { + c.canReturn = true + } + switchBack = c.canReturn && c.remoteX >= normMax-1 } // Log virtual position periodically for debugging - if c.remoteX%200 == 0 || switchBack { - slog.Debug("virtual cursor", "x", c.remoteX, "y", c.remoteY, "switchBack", switchBack) + if c.remoteX%2000 == 0 || switchBack { + slog.Debug("virtual cursor", "x", c.remoteX, "y", c.remoteY, "edge", c.activeEdge, "switchBack", switchBack) } if switchBack { - remY := c.remoteY - remH := c.remoteH - slog.Info("remote edge hit β€” switching back to Ubuntu", "remoteX", c.remoteX, "remoteY", remY) + slog.Info("remote edge hit β€” switching back to local", "edge", c.activeEdge, "remoteX", c.remoteX, "remoteY", c.remoteY) + entryX, entryY := c.localReentryLocked() c.active = true c.switchSent = time.Time{} c.lastActivated = time.Now() c.canSwitch = false // block re-trigger until cursor moves away from edge c.mu.Unlock() - // Move cursor away from edge SYNCHRONOUSLY before enabling xinput - var entryX int32 - if c.edgeSide == "left" { - entryX = 100 + if c.returnLocal != nil { + // Wayland: portal Release stops capture and warps the cursor. + c.returnLocal(entryX, entryY) } else { - entryX = c.screen.Width - 100 + // X11: warp the cursor away from the edge before re-enabling devices. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + cmd := exec.CommandContext(ctx, "xdotool", "mousemove", "--", + fmt.Sprintf("%d", entryX), + fmt.Sprintf("%d", entryY)) + cmd.Env = append(os.Environ(), "DISPLAY="+getDisplay()) + _ = cmd.Run() + cancel() + c.enableXinput() } - entryY := int32(float64(remY) / float64(remH) * float64(c.screen.Height)) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - cmd := exec.CommandContext(ctx, "xdotool", "mousemove", "--", - fmt.Sprintf("%d", entryX), - fmt.Sprintf("%d", entryY)) - cmd.Env = append(os.Environ(), "DISPLAY="+getDisplay()) - _ = cmd.Run() - cancel() - - c.enableXinput() c.mu.Lock() return } - // Send absolute mouse position to remote - absX := int32(float64(c.remoteX) / float64(c.remoteW) * 65535) - absY := int32(float64(c.remoteY) / float64(c.remoteH) * 65535) - c.sendMouseLocked(absX, absY, 0, protocol.WM_MOUSEMOVE) + // remoteX/remoteY are already in the wire's 0..normMax space β€” send directly. + c.sendMouseLocked(c.remoteX, c.remoteY, 0, protocol.WM_MOUSEMOVE) +} + +// localReentryLocked maps the virtual cursor back to a local pixel position just +// inside the edge we originally left, carrying the perpendicular coordinate. +func (c *Capturer) localReentryLocked() (x, y int32) { + const inset = 100 + px := int32(float64(c.remoteX) / normMax * float64(c.screen.Width)) + py := int32(float64(c.remoteY) / normMax * float64(c.screen.Height)) + switch c.activeEdge { + case "right": + return c.screen.Width - inset, py + case "top": + return px, inset + case "bottom": + return px, c.screen.Height - inset + default: // left + return inset, py + } } func (c *Capturer) handleKey(ev inputEvent) { @@ -795,46 +949,37 @@ func (c *Capturer) handleKey(ev inputEvent) { } } - // Mouse buttons - if ev.Code >= 0x110 && ev.Code <= 0x112 { + // Mouse buttons (left/right/middle + side buttons X1/X2 for back/forward). + if ev.Code >= input.BTN_LEFT && ev.Code <= input.BTN_EXTRA { if !c.IsActive() { - var flags int32 + if ev.Value != 0 && ev.Value != 1 { + return // ignore auto-repeat on buttons + } + down := ev.Value == 1 + var flags, wheel int32 switch ev.Code { case input.BTN_LEFT: - switch ev.Value { - case 1: - flags = protocol.WM_LBUTTONDOWN - case 0: - flags = protocol.WM_LBUTTONUP - default: - return - } + flags = pick(down, protocol.WM_LBUTTONDOWN, protocol.WM_LBUTTONUP) case input.BTN_RIGHT: - switch ev.Value { - case 1: - flags = protocol.WM_RBUTTONDOWN - case 0: - flags = protocol.WM_RBUTTONUP - default: - return - } + flags = pick(down, protocol.WM_RBUTTONDOWN, protocol.WM_RBUTTONUP) case input.BTN_MIDDLE: - switch ev.Value { - case 1: - flags = protocol.WM_MBUTTONDOWN - case 0: - flags = protocol.WM_MBUTTONUP - default: - return - } + flags = pick(down, protocol.WM_MBUTTONDOWN, protocol.WM_MBUTTONUP) + case input.BTN_SIDE: // X1 (back) + flags = pick(down, protocol.WM_XBUTTONDOWN, protocol.WM_XBUTTONUP) + wheel = 0x0001 << 16 // XBUTTON1 in the mouseData high word + case input.BTN_EXTRA: // X2 (forward) + flags = pick(down, protocol.WM_XBUTTONDOWN, protocol.WM_XBUTTONUP) + wheel = 0x0002 << 16 // XBUTTON2 in the mouseData high word + default: + return } - // Use current virtual cursor position so clicks register at the - // correct location on Windows, not always at top-left (0,0). + // Send at the current virtual cursor position so the click lands where + // the cursor is, not at (0,0). remoteX/remoteY are already in wire space. c.mu.Lock() - absX := int32(float64(c.remoteX) / float64(c.remoteW) * 65535) - absY := int32(float64(c.remoteY) / float64(c.remoteH) * 65535) + absX, absY := c.remoteX, c.remoteY c.mu.Unlock() - c.sendMouse(absX, absY, 0, flags) + slog.Debug("forwarding mouse button", "code", ev.Code, "down", down, "flags", flags) + c.sendMouse(absX, absY, wheel, flags) } return } diff --git a/internal/capture/capture_linux_test.go b/internal/capture/capture_linux_test.go index a4ff238..31c6e86 100644 --- a/internal/capture/capture_linux_test.go +++ b/internal/capture/capture_linux_test.go @@ -84,49 +84,39 @@ func TestParseXinputIDs_NoRazerWooting(t *testing.T) { } } -// --- applyAcceleration --- - -func TestApplyAcceleration_ZeroDelta(t *testing.T) { - if got := applyAcceleration(0, defaultAccelMultiplier); got != 0 { - t.Errorf("applyAcceleration(0) = %d, want 0", got) - } -} - -func TestApplyAcceleration_SmallPositive(t *testing.T) { - // Values < 1 after scaling should be clamped to 1 - if got := applyAcceleration(1, defaultAccelMultiplier); got < 1 { - t.Errorf("applyAcceleration(1) = %d, should be >= 1", got) - } -} - -func TestApplyAcceleration_SmallNegative(t *testing.T) { - if got := applyAcceleration(-1, defaultAccelMultiplier); got > -1 { - t.Errorf("applyAcceleration(-1) = %d, should be <= -1", got) - } -} - -func TestApplyAcceleration_Symmetry(t *testing.T) { - for _, delta := range []int32{1, 5, 10, 100} { - pos := applyAcceleration(delta, defaultAccelMultiplier) - neg := applyAcceleration(-delta, defaultAccelMultiplier) - if pos != -neg { - t.Errorf("acceleration not symmetric: applyAcceleration(%d)=%d, applyAcceleration(%d)=%d", - delta, pos, -delta, neg) +// --- normalized motion model --- + +func TestEnterRemoteEdge_Positions(t *testing.T) { + cases := []struct { + edge string + wantX, wantY int32 + }{ + {"left", normMax - switchMargin, normMax / 2}, + {"right", switchMargin, normMax / 2}, + {"top", normMax / 2, normMax - switchMargin}, + {"bottom", normMax / 2, switchMargin}, + } + for _, tc := range cases { + c := &Capturer{} + c.EnterRemoteEdge(tc.edge, 0.5) + if c.remoteX != tc.wantX || c.remoteY != tc.wantY { + t.Errorf("edge %s: got (%d,%d), want (%d,%d)", tc.edge, c.remoteX, c.remoteY, tc.wantX, tc.wantY) + } + if c.active { + t.Errorf("edge %s: active should be false after crossing to remote", tc.edge) } } } -func TestApplyAcceleration_Multiplier(t *testing.T) { - // Multiplier scales linearly above the sub-pixel clamp. - if got := applyAcceleration(10, 1.0); got != 10 { - t.Errorf("applyAcceleration(10, 1.0) = %d, want 10", got) - } - if got := applyAcceleration(10, 3.0); got != 30 { - t.Errorf("applyAcceleration(10, 3.0) = %d, want 30", got) +func TestAddMotion_SubPixelAccumulation(t *testing.T) { + c := &Capturer{} + c.addMotionLocked(0.5, 0) // truncates to 0, carries 0.5 + if c.remoteX != 0 { + t.Errorf("after first 0.5: remoteX=%d, want 0 (sub-pixel held, not rounded up)", c.remoteX) } - // A fractional multiplier still moves at least 1px for a unit delta. - if got := applyAcceleration(1, 0.5); got != 1 { - t.Errorf("applyAcceleration(1, 0.5) = %d, want 1 (sub-pixel clamp)", got) + c.addMotionLocked(0.5, 0) // 0.5 + 0.5 = 1.0 β†’ advances exactly 1 + if c.remoteX != 1 { + t.Errorf("after second 0.5: remoteX=%d, want 1 (carry applied)", c.remoteX) } } @@ -166,8 +156,6 @@ func TestSetActive_NoDeadlockOnActivate(t *testing.T) { c := &Capturer{ active: false, stopCh: make(chan struct{}), - remoteW: 1920, - remoteH: 1080, edgeSide: "left", } c.screen = ScreenInfo{Width: 1920, Height: 1080} @@ -192,8 +180,6 @@ func TestSetActive_ResetsGatesOnActivate(t *testing.T) { canSwitch: true, canReturn: true, stopCh: make(chan struct{}), - remoteW: 1920, - remoteH: 1080, } c.SetActive(true) @@ -214,10 +200,8 @@ func TestSetActive_ResetsGatesOnActivate(t *testing.T) { func TestSetActive_NoOpWhenAlreadyActive(t *testing.T) { c := &Capturer{ - active: true, - stopCh: make(chan struct{}), - remoteW: 1920, - remoteH: 1080, + active: true, + stopCh: make(chan struct{}), } // Should not deadlock, should not panic done := make(chan struct{}) diff --git a/internal/capture/ei_cgo.go b/internal/capture/ei_cgo.go new file mode 100644 index 0000000..abb7c5f --- /dev/null +++ b/internal/capture/ei_cgo.go @@ -0,0 +1,154 @@ +//go:build linux && wayland + +// Package capture β€” libei receiver wrapper (cgo). +// +// ponytail: cgo + libei is the lazy path within the chosen portal approach. +// A pure-Go EI wire-protocol implementation is the upgrade if dropping the C +// dependency ever matters; it's a large lift and not worth it today. +package capture + +/* +#cgo pkg-config: libei-1.0 +#include +#include + +// ei_seat_bind_capabilities is variadic with a NULL sentinel, which is fiddly to +// call from cgo. Wrap the fixed capability set we want as a receiver: pointer +// motion, buttons, scroll, and keyboard. Binding is what makes the EIS server +// create devices and start delivering events. +static void mwb_bind_caps(struct ei_seat *seat) { + ei_seat_bind_capabilities(seat, + EI_DEVICE_CAP_POINTER, + EI_DEVICE_CAP_BUTTON, + EI_DEVICE_CAP_SCROLL, + EI_DEVICE_CAP_KEYBOARD, + NULL); +} +*/ +import "C" + +import ( + "fmt" + "unsafe" +) + +// eiEventKind is the subset of EI events we forward to the remote. +type eiEventKind int + +const ( + eiNone eiEventKind = iota + eiMotion + eiButton + eiScroll + eiKey + eiModifiers + eiDisconnect +) + +// eiEvent is a normalized libei event. dx/dy are relative motion (motion), +// code is the evdev button/key code (button/key), value is wheel steps (scroll), +// press distinguishes down/up (button/key). +type eiEvent struct { + kind eiEventKind + dx, dy float64 // relative motion (sub-pixel preserved), or scroll dx/dy + code uint32 + press bool + discrete bool // scroll: true = wheel notches (120-units), false = touchpad pixels +} + +// eiConn is a libei receiver bound to the EIS fd handed back by the portal. +type eiConn struct { + ctx *C.struct_ei +} + +// newEIReceiver creates a receiver context on fd. libei takes ownership of fd. +func newEIReceiver(fd int, name string) (*eiConn, error) { + ctx := C.ei_new_receiver(nil) + if ctx == nil { + return nil, fmt.Errorf("ei_new_receiver returned nil") + } + cName := C.CString(name) + C.ei_configure_name(ctx, cName) + C.free(unsafe.Pointer(cName)) + + if rc := C.ei_setup_backend_fd(ctx, C.int(fd)); rc != 0 { + C.ei_unref(ctx) + return nil, fmt.Errorf("ei_setup_backend_fd failed: %d", int(rc)) + } + return &eiConn{ctx: ctx}, nil +} + +// pollFD returns the libei fd to wait on for readiness. +func (e *eiConn) pollFD() int { return int(C.ei_get_fd(e.ctx)) } + +// dispatch reads any pending data off the fd into libei's internal queue. +func (e *eiConn) dispatch() { C.ei_dispatch(e.ctx) } + +// next drains one event. ok=false means the queue is empty. Non-input events +// (connect, device added/resumed, frame, etc.) are reported as eiNone so the +// caller can ignore them without special-casing every type. +func (e *eiConn) next() (ev eiEvent, ok bool) { + cev := C.ei_get_event(e.ctx) + if cev == nil { + return eiEvent{}, false + } + defer C.ei_event_unref(cev) + + switch C.ei_event_get_type(cev) { + case C.EI_EVENT_SEAT_ADDED: + // Bind the capabilities we want so the EIS server creates devices and + // starts delivering events. Without this no input ever flows. + C.mwb_bind_caps(C.ei_event_get_seat(cev)) + ev.kind = eiNone + case C.EI_EVENT_POINTER_MOTION: + ev.kind = eiMotion + ev.dx = float64(C.ei_event_pointer_get_dx(cev)) + ev.dy = float64(C.ei_event_pointer_get_dy(cev)) + case C.EI_EVENT_BUTTON_BUTTON: + ev.kind = eiButton + ev.code = uint32(C.ei_event_button_get_button(cev)) + ev.press = bool(C.ei_event_button_get_is_press(cev)) + case C.EI_EVENT_SCROLL_DISCRETE: + // Mouse wheel notches. libei reports these already in 120-unit steps, + // matching Windows WHEEL_DELTA, so pass them straight through. + ev.kind = eiScroll + ev.discrete = true + ev.dx = float64(C.ei_event_scroll_get_discrete_dx(cev)) + ev.dy = float64(C.ei_event_scroll_get_discrete_dy(cev)) + if ev.dx == 0 && ev.dy == 0 { + return eiEvent{kind: eiNone}, true + } + case C.EI_EVENT_SCROLL_DELTA: + // Touchpad two-finger scroll: smooth pixel deltas. Accumulated into wheel + // notches downstream. + ev.kind = eiScroll + ev.discrete = false + ev.dx = float64(C.ei_event_scroll_get_dx(cev)) + ev.dy = float64(C.ei_event_scroll_get_dy(cev)) + if ev.dx == 0 && ev.dy == 0 { + return eiEvent{kind: eiNone}, true + } + case C.EI_EVENT_KEYBOARD_KEY: + ev.kind = eiKey + ev.code = uint32(C.ei_event_keyboard_get_key(cev)) + ev.press = bool(C.ei_event_keyboard_get_key_is_press(cev)) + case C.EI_EVENT_KEYBOARD_MODIFIERS: + // Currently-held modifier mask (xkb real-mod bits: Shift=1, Control=4, + // Mod1/Alt=8). Sent at capture start, so it reflects keys held before the + // edge was crossed β€” used to gate switching on a modifier. + ev.kind = eiModifiers + ev.code = uint32(C.ei_event_keyboard_get_xkb_mods_depressed(cev)) + case C.EI_EVENT_DISCONNECT: + ev.kind = eiDisconnect + default: + ev.kind = eiNone + } + return ev, true +} + +func (e *eiConn) close() { + if e.ctx != nil { + C.ei_unref(e.ctx) + e.ctx = nil + } +} diff --git a/internal/capture/portal_wayland.go b/internal/capture/portal_wayland.go new file mode 100644 index 0000000..a52651c --- /dev/null +++ b/internal/capture/portal_wayland.go @@ -0,0 +1,677 @@ +//go:build linux && wayland + +// Package capture β€” Wayland bidirectional driver via the +// org.freedesktop.portal.InputCapture portal + libei. +// +// The portal replaces the three X11-only pieces of the bidi path: +// - pointer barriers at the screen edge β†’ the Activated signal (edge detection) +// - the portal suppresses local input while captured (xinput isolation) +// - Release(session, cursor_position) stops capture and warps the cursor (xdotool) +// +// Input events arrive over the EI socket the portal hands back; we reuse the +// existing handleRel/handleKey forwarding logic via Capturer.FeedEvent. +package capture + +import ( + "fmt" + "log/slog" + "math" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/godbus/dbus/v5" + "github.com/lucky-verma/mwb-linux/internal/network" +) + +const ( + portalDest = "org.freedesktop.portal.Desktop" + portalPath = "/org/freedesktop/portal/desktop" + icIface = "org.freedesktop.portal.InputCapture" + + // InputCapture capability bitmask. + capKeyboard = 1 + capPointer = 2 + + portalRequestTimeout = 60 * time.Second // first call may show a permission dialog + + // touchpadPxPerNotch converts smooth touchpad scroll pixels into wheel + // notches (one WHEEL_DELTA per this many pixels of accumulated scroll). + touchpadPxPerNotch = 30.0 +) + +// barrierPos is the (iiii) position struct the portal expects per barrier. +type barrierPos struct{ X1, Y1, X2, Y2 int32 } + +// cursorPos is the (dd) tuple the portal expects for cursor_position. +type cursorPos struct{ X, Y float64 } + +type portal struct { + conn *dbus.Conn + obj dbus.BusObject + unique string // our unique bus name, dot/colon-sanitized for request paths + tokenSeq atomic.Uint64 + session dbus.ObjectPath + zoneSet uint32 + screenW int32 + screenH int32 + zoneX int32 // zone origin β€” barriers and cursor coords are in this space + zoneY int32 + activeID atomic.Uint32 // last activation_id from the portal + cap *Capturer + ei *eiConn + scrAccX float64 // touchpad scroll pixel accumulators β†’ wheel notches + scrAccY float64 + wantMod uint32 // required modifier mask to allow switching (0 = none) + curMods atomic.Uint32 // latest held-modifier mask from libei + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +// RunWayland sets up the InputCapture portal + libei receiver and returns a +// running Capturer plus a stop function. The returned Capturer reuses the same +// forwarding logic as the X11 path; only the event source and switch triggers +// differ. +func RunWayland(conn *network.Conn, handler *network.Handler, edgeSide string, edges []string, switchModifier string, accel float64) (*Capturer, func(), error) { + bus, err := dbus.ConnectSessionBus() + if err != nil { + return nil, nil, fmt.Errorf("connect session bus: %w", err) + } + p := &portal{ + conn: bus, + obj: bus.Object(portalDest, portalPath), + unique: sanitizeName(bus.Names()[0]), + wantMod: modMask(switchModifier), + stopCh: make(chan struct{}), + } + + if err := p.createSession(); err != nil { + _ = bus.Close() + return nil, nil, err + } + if err := p.start(); err != nil { + _ = bus.Close() + return nil, nil, err + } + if err := p.getZones(); err != nil { + _ = bus.Close() + return nil, nil, err + } + + p.cap = New(conn, ScreenInfo{Width: p.screenW, Height: p.screenH}, edgeSide) + p.cap.SetAccelMultiplier(accel) + // Wire the return-to-local action to portal Release (replaces xdotool+xinput). + p.cap.returnLocal = func(x, y int32) { p.release(x, y) } + + if err := p.setBarriers(edges); err != nil { + _ = bus.Close() + return nil, nil, err + } + fd, err := p.connectToEIS() + if err != nil { + _ = bus.Close() + return nil, nil, err + } + ei, err := newEIReceiver(fd, "mwb") + if err != nil { + _ = syscall.Close(fd) + _ = bus.Close() + return nil, nil, fmt.Errorf("libei receiver: %w", err) + } + p.ei = ei + + if err := p.subscribeSignals(); err != nil { + ei.close() + _ = bus.Close() + return nil, nil, err + } + if err := p.enable(); err != nil { + ei.close() + _ = bus.Close() + return nil, nil, err + } + + // Server-driven returns (Windows bounced the cursor back to us) must release + // the portal so the local cursor is freed β€” the Wayland equivalent of the + // X11 xdotool recenter + xinput enable. + if handler != nil { + handler.OnActivated = p.ReleaseRemote + handler.OnReclaimed = p.ReleaseRemote + } + + p.wg.Add(1) + go p.eiLoop() + + slog.Info("Wayland InputCapture bidirectional enabled", "edge", edgeSide, "screen", fmt.Sprintf("%dx%d", p.screenW, p.screenH)) + + stop := func() { p.stop() } + return p.cap, stop, nil +} + +func (p *portal) stop() { + p.stopOnce.Do(func() { + close(p.stopCh) + _ = p.obj.Call(icIface+".Disable", 0, p.session, map[string]dbus.Variant{}).Err + p.wg.Wait() + if p.ei != nil { + p.ei.close() + } + _ = p.conn.Close() + }) +} + +// --- portal request/response plumbing --- + +func sanitizeName(name string) string { + name = strings.TrimPrefix(name, ":") + return strings.ReplaceAll(name, ".", "_") +} + +func (p *portal) nextToken() string { + return fmt.Sprintf("mwb%d", p.tokenSeq.Add(1)) +} + +// requestPath is the predictable Request object path the portal will emit the +// Response signal on (per the portal spec, derived from our name + token). +func (p *portal) requestPath(token string) dbus.ObjectPath { + return dbus.ObjectPath("/org/freedesktop/portal/desktop/request/" + p.unique + "/" + token) +} + +// doRequest subscribes to the predicted Response path, runs call (which invokes +// the portal method passing the handle_token), and waits for the Response. +func (p *portal) doRequest(call func(token string) error) (map[string]dbus.Variant, error) { + token := p.nextToken() + reqPath := p.requestPath(token) + + if err := p.conn.AddMatchSignal( + dbus.WithMatchObjectPath(reqPath), + dbus.WithMatchInterface("org.freedesktop.portal.Request"), + dbus.WithMatchMember("Response"), + ); err != nil { + return nil, fmt.Errorf("add response match: %w", err) + } + defer func() { + _ = p.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(reqPath), + dbus.WithMatchInterface("org.freedesktop.portal.Request"), + dbus.WithMatchMember("Response"), + ) + }() + + ch := make(chan *dbus.Signal, 8) + p.conn.Signal(ch) + defer p.conn.RemoveSignal(ch) + + if err := call(token); err != nil { + return nil, err + } + + for { + select { + case sig := <-ch: + if sig.Path != reqPath || sig.Name != "org.freedesktop.portal.Request.Response" { + continue + } + var code uint32 + var results map[string]dbus.Variant + if err := dbus.Store(sig.Body, &code, &results); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + if code != 0 { + return nil, fmt.Errorf("portal request rejected (code %d)", code) + } + return results, nil + case <-time.After(portalRequestTimeout): + return nil, fmt.Errorf("portal request timed out") + case <-p.stopCh: + return nil, fmt.Errorf("stopped") + } + } +} + +// createSession uses CreateSession2 β€” the current (non-deprecated) call, which +// returns its results synchronously (a{sv} β†’ a{sv}) instead of via a Response +// signal. Capabilities are negotiated later in Start, not here. +func (p *portal) createSession() error { + opts := map[string]dbus.Variant{ + "session_handle_token": dbus.MakeVariant(p.nextToken()), + } + var results map[string]dbus.Variant + if err := p.obj.Call(icIface+".CreateSession2", 0, opts).Store(&results); err != nil { + return fmt.Errorf("CreateSession2: %w", err) + } + v, ok := results["session_handle"] + if !ok { + return fmt.Errorf("CreateSession2: no session_handle in result") + } + // session_handle may arrive as a string or object path; methods take it as 'o'. + p.session = dbus.ObjectPath(fmt.Sprint(v.Value())) + return nil +} + +// start activates the session and negotiates capabilities. parent_window is +// empty (no parent toplevel). persist_mode=2 + a saved restore_token let the +// compositor remember the grant so the permission dialog only appears once +// (and survives reboots). This is the call that may show that dialog. +func (p *portal) start() error { + prev := loadRestoreToken() + results, err := p.doRequest(func(token string) error { + opts := map[string]dbus.Variant{ + "handle_token": dbus.MakeVariant(token), + "capabilities": dbus.MakeVariant(uint32(capPointer | capKeyboard)), + "persist_mode": dbus.MakeVariant(uint32(2)), // persist until the user revokes + } + if prev != "" { + opts["restore_token"] = dbus.MakeVariant(prev) + } + var handle dbus.ObjectPath + return p.obj.Call(icIface+".Start", 0, p.session, "", opts).Store(&handle) + }) + if err != nil { + return fmt.Errorf("Start: %w", err) + } + // Persist the (possibly refreshed) token so the next run restores silently. + if rt, ok := results["restore_token"]; ok { + if s, ok := rt.Value().(string); ok && s != "" && s != prev { + saveRestoreToken(s) + } + } + return nil +} + +func restoreTokenPath() string { + dir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(dir, "mwb", "inputcapture.token") +} + +func loadRestoreToken() string { + p := restoreTokenPath() + if p == "" { + return "" + } + b, _ := os.ReadFile(p) + return strings.TrimSpace(string(b)) +} + +func saveRestoreToken(tok string) { + p := restoreTokenPath() + if p == "" { + return + } + _ = os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, []byte(tok), 0o600); err != nil { + slog.Warn("could not save InputCapture restore token", "err", err) + } +} + +func (p *portal) getZones() error { + results, err := p.doRequest(func(token string) error { + opts := map[string]dbus.Variant{"handle_token": dbus.MakeVariant(token)} + var handle dbus.ObjectPath + return p.obj.Call(icIface+".GetZones", 0, p.session, opts).Store(&handle) + }) + if err != nil { + return fmt.Errorf("GetZones: %w", err) + } + // zones: a(uuii) = (width, height, x, y). Use the first zone. dbus.Store + // converts the nested struct array straight into typed Go structs. + zv, ok := results["zones"] + if !ok { + return fmt.Errorf("GetZones: no zones") + } + var zones []struct { + W, H uint32 + X, Y int32 + } + if err := dbus.Store([]interface{}{zv.Value()}, &zones); err != nil { + return fmt.Errorf("GetZones: decode zones (%T): %w", zv.Value(), err) + } + if len(zones) == 0 { + return fmt.Errorf("GetZones: empty zones") + } + p.screenW = int32(zones[0].W) + p.screenH = int32(zones[0].H) + p.zoneX = zones[0].X + p.zoneY = zones[0].Y + if zsv, ok := results["zone_set"]; ok { + p.zoneSet = toU32(zsv.Value()) + } + if p.screenW == 0 || p.screenH == 0 { + return fmt.Errorf("GetZones: zero-size zone %dx%d", p.screenW, p.screenH) + } + return nil +} + +// barrier IDs β†’ which local edge the cursor crossed. Used to set up the remote +// entry direction in EnterRemoteEdge. +const ( + barrierLeft = 1 + barrierRight = 2 + barrierTop = 3 + barrierBottom = 4 +) + +func barrierEdge(id uint32) string { + switch id { + case barrierRight: + return "right" + case barrierTop: + return "top" + case barrierBottom: + return "bottom" + default: + return "left" + } +} + +// setBarriers puts a barrier on each enabled zone edge so the cursor can cross +// there (matching Windows MWB's "switch from any side"). Coordinates are in the +// zone's space and pixel-inclusive: a line spans first..last pixel (using the +// full width/height would overshoot the zone and the compositor rejects it). +func (p *portal) setBarriers(edges []string) error { + xL := p.zoneX + xR := p.zoneX + p.screenW + yT := p.zoneY + yB := p.zoneY + p.screenH + byEdge := map[string]map[string]dbus.Variant{ + "left": mkBarrier(barrierLeft, barrierPos{xL, yT, xL, yB - 1}), + "right": mkBarrier(barrierRight, barrierPos{xR, yT, xR, yB - 1}), + "top": mkBarrier(barrierTop, barrierPos{xL, yT, xR - 1, yT}), + "bottom": mkBarrier(barrierBottom, barrierPos{xL, yB, xR - 1, yB}), + } + var barriers []map[string]dbus.Variant + for _, e := range edges { + if b, ok := byEdge[e]; ok { + barriers = append(barriers, b) + } + } + if len(barriers) == 0 { + return fmt.Errorf("no valid edges to set barriers on") + } + results, err := p.doRequest(func(token string) error { + opts := map[string]dbus.Variant{"handle_token": dbus.MakeVariant(token)} + var handle dbus.ObjectPath + return p.obj.Call(icIface+".SetPointerBarriers", 0, + p.session, opts, barriers, p.zoneSet).Store(&handle) + }) + if err != nil { + return fmt.Errorf("SetPointerBarriers: %w", err) + } + if fv, ok := results["failed_barriers"]; ok { + if failed, ok := fv.Value().([]uint32); ok && len(failed) == len(barriers) { + return fmt.Errorf("all barriers rejected by compositor: %v", failed) + } else if len(failed) > 0 { + slog.Warn("some pointer barriers rejected", "failed", failed) + } + } + return nil +} + +func mkBarrier(id uint32, pos barrierPos) map[string]dbus.Variant { + return map[string]dbus.Variant{ + "barrier_id": dbus.MakeVariant(id), + "position": dbus.MakeVariant(pos), + } +} + +func (p *portal) connectToEIS() (int, error) { + var fd dbus.UnixFD + err := p.obj.Call(icIface+".ConnectToEIS", 0, p.session, map[string]dbus.Variant{}).Store(&fd) + if err != nil { + return -1, fmt.Errorf("ConnectToEIS: %w", err) + } + return int(fd), nil +} + +func (p *portal) enable() error { + c := p.obj.Call(icIface+".Enable", 0, p.session, map[string]dbus.Variant{}) + if c.Err != nil { + return fmt.Errorf("Enable: %w", c.Err) + } + return nil +} + +// release stops capture and asks the portal to place the local cursor at (x,y). +func (p *portal) release(x, y int32) { + opts := map[string]dbus.Variant{ + "activation_id": dbus.MakeVariant(p.activeID.Load()), + "cursor_position": dbus.MakeVariant(cursorPos{X: float64(x), Y: float64(y)}), + } + if c := p.obj.Call(icIface+".Release", 0, p.session, opts); c.Err != nil { + slog.Warn("portal Release failed", "err", c.Err) + } +} + +// ReleaseRemote forces a release if we currently hold the remote (used when the +// server bounces the cursor back to us via MachineSwitched/NextMachine). +func (p *portal) ReleaseRemote() { + if !p.cap.IsActive() { + x, y := p.cap.SafeEntryPosition() + p.cap.SetActive(true) + p.release(x, y) + } +} + +// --- signals --- + +func (p *portal) subscribeSignals() error { + for _, member := range []string{"Activated", "Deactivated", "Disabled"} { + if err := p.conn.AddMatchSignal( + dbus.WithMatchObjectPath(portalPath), + dbus.WithMatchInterface(icIface), + dbus.WithMatchMember(member), + ); err != nil { + return fmt.Errorf("subscribe %s: %w", member, err) + } + } + ch := make(chan *dbus.Signal, 16) + p.conn.Signal(ch) + p.wg.Add(1) + go p.signalLoop(ch) + return nil +} + +func (p *portal) signalLoop(ch chan *dbus.Signal) { + defer p.wg.Done() + for { + select { + case <-p.stopCh: + return + case sig := <-ch: + if sig == nil || !strings.HasPrefix(sig.Name, icIface+".") { + continue + } + switch sig.Name { + case icIface + ".Activated": + p.onActivated(sig.Body) + case icIface + ".Deactivated": + // Portal stopped capturing; ensure we consider ourselves local. + p.cap.SetActive(true) + case icIface + ".Disabled": + slog.Info("portal disabled the capture session") + } + } + } +} + +func (p *portal) onActivated(body []interface{}) { + // body: (o session_handle, a{sv} options) with activation_id (u) and + // cursor_position ((dd)). + if len(body) < 2 { + return + } + opts, ok := body[1].(map[string]dbus.Variant) + if !ok { + return + } + if av, ok := opts["activation_id"]; ok { + p.activeID.Store(toU32(av.Value())) + } + edge := "left" + if bv, ok := opts["barrier_id"]; ok { + edge = barrierEdge(toU32(bv.Value())) + } + // perpFrac is the position along the crossed edge: Y for vertical (left/right) + // edges, X for horizontal (top/bottom) edges. + var px, py float64 = 0.5, 0.5 + if cv, ok := opts["cursor_position"]; ok { + if xy, ok := cv.Value().([]interface{}); ok && len(xy) >= 2 { + if x, ok := xy[0].(float64); ok && p.screenW > 0 { + px = (x - float64(p.zoneX)) / float64(p.screenW) + } + if y, ok := xy[1].(float64); ok && p.screenH > 0 { + py = (y - float64(p.zoneY)) / float64(p.screenH) + } + } + } + perpFrac := py + if edge == "top" || edge == "bottom" { + perpFrac = px + } + + if p.wantMod == 0 { + p.cap.EnterRemoteEdge(edge, perpFrac) + p.cap.NotifyRemoteSwitch() // formal handoff + triggers remote clipboard pull + slog.Info("portal Activated β€” control crossed to remote", "edge", edge, "perpFrac", perpFrac) + return + } + // Modifier-gated: the cursor is captured now, but only commit the switch if the + // required modifier is held. libei reports the held-modifier state shortly after + // capture starts, so reset, wait briefly, then commit or release back to local. + p.curMods.Store(0) + go p.gatedEnter(edge, perpFrac) +} + +// gatedEnter commits or declines a modifier-gated edge crossing once libei has +// had a moment to report the held-modifier state. +func (p *portal) gatedEnter(edge string, perpFrac float64) { + select { + case <-p.stopCh: + return + case <-time.After(80 * time.Millisecond): + } + mods := p.curMods.Load() + if mods&p.wantMod != 0 { + p.cap.EnterRemoteEdge(edge, perpFrac) + p.cap.NotifyRemoteSwitch() + slog.Info("modifier held β€” control crossed to remote", "edge", edge, "mods", mods) + return + } + // Modifier not held: stay local. Release the grab and park the cursor just + // inside the edge where it hit. + x, y := p.cap.EdgeReentry(edge, perpFrac) + p.release(x, y) + slog.Debug("edge crossed without modifier β€” staying local", "edge", edge, "mods", mods, "want", p.wantMod) +} + +// modMask maps a modifier name to its xkb real-modifier mask bit. +func modMask(name string) uint32 { + switch strings.ToLower(strings.TrimSpace(name)) { + case "shift": + return 1 // ShiftMask + case "ctrl", "control": + return 4 // ControlMask + case "alt": + return 8 // Mod1Mask + default: + return 0 + } +} + +// --- libei receive loop --- + +func (p *portal) eiLoop() { + defer p.wg.Done() + // ponytail: 2ms poll of the ei fd. ei_dispatch is a cheap no-op when idle; + // 2ms is invisible for input. Upgrade path: block on the fd via unix.Poll + // if the busy-ish tick ever shows up in power profiling. + ticker := time.NewTicker(2 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + p.ei.dispatch() + for { + ev, ok := p.ei.next() + if !ok { + break + } + p.feed(ev) + } + } + } +} + +func (p *portal) feed(ev eiEvent) { + switch ev.kind { + case eiMotion: + // One combined update + one packet β€” avoids per-axis stair-stepping. + p.cap.FeedMotion(ev.dx, ev.dy) + case eiButton: + p.cap.FeedEvent(evKey, uint16(ev.code), boolToVal(ev.press)) + case eiKey: + p.cap.FeedEvent(evKey, uint16(ev.code), boolToVal(ev.press)) + case eiScroll: + // Vertical is negated: EI scroll-down is positive, Windows WHEEL_DELTA-down + // is negative. Horizontal passes through (both positive = right). + if ev.discrete { + p.cap.FeedWheel(-int32(ev.dy), int32(ev.dx)) + } else { + // Touchpad pixels β†’ accumulate into 120-unit notches. + p.scrAccY += ev.dy + p.scrAccX += ev.dx + v := notches(&p.scrAccY) + h := notches(&p.scrAccX) + if v != 0 || h != 0 { + p.cap.FeedWheel(-v, h) + } + } + case eiModifiers: + p.curMods.Store(ev.code) + case eiDisconnect: + slog.Warn("libei disconnected") + p.stop() + } +} + +// notches drains an accumulated pixel scroll into whole WHEEL_DELTA notches, +// leaving the sub-notch remainder in *acc for the next event. +func notches(acc *float64) int32 { + n := math.Trunc(*acc / touchpadPxPerNotch) + *acc -= n * touchpadPxPerNotch + return int32(n) * 120 +} + +func boolToVal(b bool) int32 { + if b { + return 1 + } + return 0 +} + +func toU32(v interface{}) uint32 { + switch n := v.(type) { + case uint32: + return n + case int32: + return uint32(n) + case uint64: + return uint32(n) + case int64: + return uint32(n) + case int: + return uint32(n) + default: + return 0 + } +} diff --git a/internal/capture/wayland_stub.go b/internal/capture/wayland_stub.go new file mode 100644 index 0000000..d1d4fbe --- /dev/null +++ b/internal/capture/wayland_stub.go @@ -0,0 +1,15 @@ +//go:build linux && !wayland + +package capture + +import ( + "fmt" + + "github.com/lucky-verma/mwb-linux/internal/network" +) + +// RunWayland is a stub for builds without the `wayland` tag. The Wayland +// bidirectional driver depends on cgo + libei, so it is opt-in at build time. +func RunWayland(conn *network.Conn, handler *network.Handler, edgeSide string, edges []string, switchModifier string, accel float64) (*Capturer, func(), error) { + return nil, nil, fmt.Errorf("this mwb build has no Wayland support; rebuild with: go build -tags wayland") +} diff --git a/internal/clipboard/clipboard_linux.go b/internal/clipboard/clipboard_linux.go index a05531d..8412139 100644 --- a/internal/clipboard/clipboard_linux.go +++ b/internal/clipboard/clipboard_linux.go @@ -10,11 +10,13 @@ import ( "compress/flate" "context" "encoding/binary" + "encoding/hex" "fmt" "io" "log/slog" "os" "os/exec" + "path/filepath" "strings" "sync" "time" @@ -36,7 +38,11 @@ const ( type Manager struct { conn *network.Conn display string + key string // security key β€” for the separate file-transfer channel + host string // remote host β€” to pull files from + basePort int // file-transfer port (MessagePort-1) lastHash string // hash of last clipboard content we sent + localFile string // local file currently on the clipboard, offered to peers mu sync.Mutex recvBuf bytes.Buffer // accumulates incoming clipboard chunks receiving bool @@ -46,12 +52,16 @@ type Manager struct { wg sync.WaitGroup // tracks pollClipboard goroutine for clean shutdown } -// NewManager creates a clipboard manager. -func NewManager(conn *network.Conn, display string) *Manager { +// NewManager creates a clipboard manager. key/host/basePort enable file transfer +// over the separate MWB clipboard channel; pass an empty key to disable it. +func NewManager(conn *network.Conn, display, key, host string, basePort int) *Manager { return &Manager{ - conn: conn, - display: display, - stopCh: make(chan struct{}), + conn: conn, + display: display, + key: key, + host: host, + basePort: basePort, + stopCh: make(chan struct{}), } } @@ -62,6 +72,9 @@ func (m *Manager) Start() { defer m.wg.Done() m.pollClipboard() }() + if m.key != "" { + go m.serveFiles() // serve files to peers that pull from us + } slog.Info("clipboard sharing enabled") } @@ -79,12 +92,37 @@ func (m *Manager) HandlePacket(pkt *protocol.Packet) { case protocol.ClipboardDataEnd: m.handleEnd(pkt) case protocol.Clipboard: - slog.Debug("clipboard beat received from remote") + slog.Debug("clipboard beat received from remote", "src", pkt.Src, "len", len(pkt.Raw)) + // MWB is a pull model: the beat says "my clipboard changed". Pull it over + // the file-transfer channel β€” files are saved + put on our clipboard; + // text/image come in-band so the pull skips them. Skip if we just set the + // clipboard ourselves, to avoid a loop. + m.mu.Lock() + recentlySet := time.Since(m.justSet) < 3*time.Second + m.mu.Unlock() + if !recentlySet && m.key != "" { + go m.pullRemoteFile() + } case protocol.ClipboardAsk: - slog.Debug("clipboard ask received β€” sending current clipboard") - go m.sendClipboard() + // The remote wants our clipboard. For a file, push it to the asker's + // file-transfer port (MWB's owner-pushes-on-ask model). Otherwise fall + // back to the in-band text path. + slog.Debug("clipboard ask received", "src", pkt.Src, "des", pkt.Des) + if pkt.Des != m.conn.MachineID { + break // ask addressed to a different machine + } + m.mu.Lock() + path := m.localFile + m.mu.Unlock() + if path != "" && m.key != "" && m.host != "" { + go m.pushFile(m.host, path) + } else { + go m.sendClipboard() + } + case protocol.ClipboardPush: + slog.Debug("clipboard push received", "len", len(pkt.Raw), "hex", hex.EncodeToString(pkt.Raw)) default: - slog.Debug("unhandled clipboard packet", "type", pkt.Type) + slog.Debug("unhandled clipboard packet", "type", pkt.Type, "len", len(pkt.Raw), "hex", hex.EncodeToString(pkt.Raw)) } } @@ -106,6 +144,26 @@ func (m *Manager) pollClipboard() { continue } + // Check for a file on the clipboard first β€” beat so peers can pull it + // over the file-transfer channel (file bytes don't go in-band). + if m.key != "" { + if path := m.getLocalClipboardFile(); path != "" { + hash := "file:" + path + m.mu.Lock() + changed := hash != m.lastHash + if changed { + m.lastHash = hash + m.localFile = path + } + m.mu.Unlock() + if changed { + slog.Info("file on clipboard, notifying remote", "path", path) + m.sendBeat() + } + continue + } + } + // Check for image clipboard first imgData := m.getLocalImageClipboard() if imgData != nil { @@ -152,6 +210,44 @@ func (m *Manager) pollClipboard() { } } +// pullRemoteFile pulls the remote clipboard over the file-transfer channel and, +// if it's a file, saves it and puts it on the local clipboard. +func (m *Manager) pullRemoteFile() { + if m.host == "" { + return + } + path, err := pullFile(m.host, m.basePort, m.key, m.conn.MachineID, m.conn.LocalName, fileSaveDir()) + if err != nil { + slog.Debug("clipboard file pull failed", "err", err) + return + } + if path == "" { + return // text/image β€” handled in-band + } + m.mu.Lock() + m.justSet = time.Now() + m.localFile = path + m.lastHash = "file:" + path // don't re-beat the file we just received + m.mu.Unlock() + m.setLocalFileClipboard(path) +} + +// sendBeat notifies peers that our clipboard changed (pull model). Peers then +// pull the data from our file-transfer server. +func (m *Manager) sendBeat() { + pkt := &protocol.Packet{Type: protocol.Clipboard, Src: m.conn.MachineID, Des: protocol.IDAll} + pkt.SetMachineName(m.conn.LocalName) + if err := m.conn.SendPacket(pkt); err != nil { + slog.Debug("send clipboard beat failed", "err", err) + } +} + +// fileSaveDir is where pulled files are written. +func fileSaveDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, "Downloads", "MouseWithoutBorders") +} + // sendClipboard sends the current clipboard to the remote. func (m *Manager) sendClipboard() { text := m.getLocalClipboard() @@ -338,21 +434,26 @@ func (m *Manager) handleImageClipboard(data []byte) { return } - // Set image clipboard via xclip + // Set image clipboard: wl-copy on Wayland, xclip on X11. + var setCmd []string + if onWayland() { + setCmd = []string{"wl-copy", "--type", mimeType} + } else { + setCmd = []string{"xclip", "-selection", "clipboard", "-t", mimeType, "-i", tmpFile} + } ctx, cancel := context.WithTimeout(context.Background(), execTimeout) - cmd := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-t", mimeType, "-i", tmpFile) - cmd.Env = append(os.Environ(), "DISPLAY="+m.display) + cmd := exec.CommandContext(ctx, setCmd[0], setCmd[1:]...) + if onWayland() { + f, _ := os.Open(tmpFile) + defer f.Close() //nolint:errcheck + cmd.Stdin = f + } else { + cmd.Env = append(os.Environ(), "DISPLAY="+m.display) + } err := cmd.Run() cancel() if err != nil { - slog.Error("set image clipboard via xclip failed", "err", err, "mime", mimeType) - ctx2, cancel2 := context.WithTimeout(context.Background(), execTimeout) - cmd2 := exec.CommandContext(ctx2, "xclip", "-selection", "clipboard", "-t", "image/png", "-i", tmpFile) - cmd2.Env = append(os.Environ(), "DISPLAY="+m.display) - if err2 := cmd2.Run(); err2 != nil { - slog.Error("set image clipboard fallback also failed", "err", err2) - } - cancel2() + slog.Error("set image clipboard failed", "err", err, "mime", mimeType, "wayland", onWayland()) return } @@ -366,18 +467,18 @@ func (m *Manager) handleImageClipboard(data []byte) { } // getLocalClipboard reads the current clipboard text. +// On Wayland, xclip reads a stale XWayland clipboard, so use wl-paste there. // Times out after execTimeout to prevent blocking the poll goroutine indefinitely. func (m *Manager) getLocalClipboard() string { - for _, args := range [][]string{ + cmds := [][]string{ {"xclip", "-selection", "clipboard", "-o"}, {"xsel", "--clipboard", "--output"}, - } { - ctx, cancel := context.WithTimeout(context.Background(), execTimeout) - cmd := exec.CommandContext(ctx, args[0], args[1:]...) - cmd.Env = append(os.Environ(), "DISPLAY="+m.display) - out, err := cmd.Output() - cancel() - if err == nil { + } + if onWayland() { + cmds = [][]string{{"wl-paste", "--no-newline"}} + } + for _, args := range cmds { + if out, err := m.runClip(args); err == nil { return string(out) } } @@ -387,13 +488,19 @@ func (m *Manager) getLocalClipboard() string { // setLocalClipboard sets the clipboard text. // Times out after execTimeout to prevent blocking on a hung xclip/xsel. func (m *Manager) setLocalClipboard(text string) { - for _, args := range [][]string{ + cmds := [][]string{ {"xclip", "-selection", "clipboard"}, {"xsel", "--clipboard", "--input"}, - } { + } + if onWayland() { + cmds = [][]string{{"wl-copy"}} + } + for _, args := range cmds { ctx, cancel := context.WithTimeout(context.Background(), execTimeout) cmd := exec.CommandContext(ctx, args[0], args[1:]...) - cmd.Env = append(os.Environ(), "DISPLAY="+m.display) + if !onWayland() { + cmd.Env = append(os.Environ(), "DISPLAY="+m.display) + } cmd.Stdin = strings.NewReader(text) err := cmd.Run() cancel() @@ -401,25 +508,22 @@ func (m *Manager) setLocalClipboard(text string) { return } } - slog.Error("set clipboard failed β€” both xclip and xsel failed") + slog.Error("set clipboard failed", "wayland", onWayland()) } // getLocalImageClipboard checks if clipboard contains an image and returns it. func (m *Manager) getLocalImageClipboard() []byte { - ctx, cancel := context.WithTimeout(context.Background(), execTimeout) - cmd := exec.CommandContext(ctx, "xclip", "-selection", "clipboard", "-t", "TARGETS", "-o") - cmd.Env = append(os.Environ(), "DISPLAY="+m.display) - out, err := cmd.Output() - cancel() + listCmd := []string{"xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"} + getCmd := []string{"xclip", "-selection", "clipboard", "-t", "image/png", "-o"} + if onWayland() { + listCmd = []string{"wl-paste", "--list-types"} + getCmd = []string{"wl-paste", "--type", "image/png"} + } + out, err := m.runClip(listCmd) if err != nil || !strings.Contains(string(out), "image/png") { return nil } - - ctx2, cancel2 := context.WithTimeout(context.Background(), execTimeout) - cmd2 := exec.CommandContext(ctx2, "xclip", "-selection", "clipboard", "-t", "image/png", "-o") - cmd2.Env = append(os.Environ(), "DISPLAY="+m.display) - imgData, err := cmd2.Output() - cancel2() + imgData, err := m.runClip(getCmd) if err != nil || len(imgData) == 0 { return nil } diff --git a/internal/clipboard/filetransfer_linux.go b/internal/clipboard/filetransfer_linux.go new file mode 100644 index 0000000..edd745b --- /dev/null +++ b/internal/clipboard/filetransfer_linux.go @@ -0,0 +1,361 @@ +//go:build linux + +package clipboard + +import ( + "context" + "crypto/rand" + "encoding/binary" + "fmt" + "io" + "log/slog" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/lucky-verma/mwb-linux/internal/protocol" +) + +// MWB transfers files (and clipboard data > 1 MB) over a SEPARATE TCP connection +// on the base port (TcpPort = MessagePort-1), distinct from the input/control +// channel. The flow (reverse-engineered from PowerToys SocketStuff.cs / +// Clipboard.cs): +// +// 1. AES + IV-block exchange (identical to the control channel). +// 2. A 64-byte DATA header exchange (Type=Clipboard/ClipboardPush, Src, Des, +// PostAction, MachineName). No magic/checksum stamp β€” sent raw-encrypted. +// 3. A 1024-byte header: the UTF-16LE string "*". +// 4. dataSize raw bytes of file content. +// +// The machine that copied broadcasts a Clipboard beat; the machine that wants +// the data connects to the copier's base port and pulls it. + +const ( + clipHeaderSize = 1024 + maxFileSize = 100 * 1024 * 1024 // match MWB's 100 MB cap + clipDialQuiet = 10 * time.Second + clipXferQuiet = 5 * time.Minute +) + +// clipHandshake runs the clipboard-channel handshake on conn and returns the +// encrypted writer / decrypted reader for the transfer. push selects our header +// type: true=ClipboardPush (we will send), false=Clipboard (we will receive). +func clipHandshake(conn net.Conn, key string, machineID uint32, name string, push bool) (*protocol.EncryptWriter, *protocol.DecryptReader, error) { + aesKey := protocol.DeriveKey(key) + iv := protocol.FixedIV() + enc, err := protocol.NewEncryptWriter(conn, aesKey, iv) + if err != nil { + return nil, nil, err + } + dec, err := protocol.NewDecryptReader(conn, aesKey, iv) + if err != nil { + return nil, nil, err + } + + // 1. send 16-byte random IV block (primes the CBC chain, like the control conn) + ran := make([]byte, 16) + if _, err := rand.Read(ran); err != nil { + return nil, nil, err + } + if _, err := enc.Write(ran); err != nil { + return nil, nil, fmt.Errorf("send IV block: %w", err) + } + + // 2. send the 64-byte DATA header (raw, no magic stamp) + hdr := make([]byte, 64) + if push { + hdr[0] = byte(protocol.ClipboardPush) + } else { + hdr[0] = byte(protocol.Clipboard) + } + binary.LittleEndian.PutUint32(hdr[8:12], machineID) // Src + binary.LittleEndian.PutUint32(hdr[12:16], protocol.IDAll) // Des + binary.LittleEndian.PutUint32(hdr[16:20], 1) // PostAction = Desktop + for i := 32; i < 64; i++ { + hdr[i] = ' ' + } + copy(hdr[32:64], []byte(name)) // ASCII machine name, space-padded + if _, err := enc.Write(hdr); err != nil { + return nil, nil, fmt.Errorf("send header: %w", err) + } + + // 3. read peer IV block, 4. read peer 64-byte header + if _, err := io.ReadFull(dec, make([]byte, 16)); err != nil { + return nil, nil, fmt.Errorf("read IV block: %w", err) + } + peer := make([]byte, 64) + if _, err := io.ReadFull(dec, peer); err != nil { + return nil, nil, fmt.Errorf("read peer header: %w", err) + } + if pt := protocol.PackageType(peer[0]); pt != protocol.Clipboard && pt != protocol.ClipboardPush { + return nil, nil, fmt.Errorf("unexpected peer header type %d", pt) + } + return enc, dec, nil +} + +// pullFile connects to the remote's clipboard port and receives a file, saving +// it under saveDir. Returns "" (no error) when the remote's clipboard is text or +// an image β€” those are delivered in-band on the control channel instead. +func pullFile(host string, basePort int, key string, machineID uint32, name, saveDir string) (string, error) { + addr := net.JoinHostPort(host, strconv.Itoa(basePort)) + conn, err := net.DialTimeout("tcp", addr, clipDialQuiet) + if err != nil { + return "", fmt.Errorf("dial %s: %w", addr, err) + } + defer conn.Close() //nolint:errcheck + _ = conn.SetDeadline(time.Now().Add(clipXferQuiet)) + + _, dec, err := clipHandshake(conn, key, machineID, name, false) + if err != nil { + return "", fmt.Errorf("handshake: %w", err) + } + + hdr := make([]byte, clipHeaderSize) + if _, err := io.ReadFull(dec, hdr); err != nil { + return "", fmt.Errorf("read file header: %w", err) + } + headerStr := strings.TrimRight(decodeUTF16LE(hdr), "\x00") + slog.Debug("clipboard file header", "header", headerStr) + parts := strings.SplitN(headerStr, "*", 2) + if len(parts) < 2 { + return "", fmt.Errorf("bad header %q", headerStr) + } + size, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return "", fmt.Errorf("bad size %q", parts[0]) + } + name0 := strings.ToLower(parts[1]) + if strings.HasPrefix(name0, "text") || strings.HasPrefix(name0, "image") { + return "", nil // handled in-band + } + if size <= 0 || size > maxFileSize { + return "", fmt.Errorf("file size out of range: %d", size) + } + + if err := os.MkdirAll(saveDir, 0o755); err != nil { + return "", err + } + dest := filepath.Join(saveDir, winBase(parts[1])) + f, err := os.Create(dest) + if err != nil { + return "", err + } + defer f.Close() //nolint:errcheck + if _, err := io.CopyN(f, dec, size); err != nil { + return "", fmt.Errorf("receive file body: %w", err) + } + slog.Info("received file from remote", "path", dest, "size", size) + return dest, nil +} + +// serveFiles listens on the base port and streams the current local file to any +// peer that connects to pull it (the copier-serves, paster-pulls model). +func (m *Manager) serveFiles() { + addr := fmt.Sprintf(":%d", m.basePort) + ln, err := net.Listen("tcp", addr) + if err != nil { + slog.Error("clipboard file server listen failed", "port", m.basePort, "err", err) + return + } + slog.Info("clipboard file server listening", "port", m.basePort) + go func() { + <-m.stopCh + _ = ln.Close() + }() + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-m.stopCh: + return + default: + continue + } + } + go m.serveFileConn(conn) + } +} + +func (m *Manager) serveFileConn(conn net.Conn) { + defer conn.Close() //nolint:errcheck + _ = conn.SetDeadline(time.Now().Add(clipXferQuiet)) + + // A peer pulled from us (PowerToys' server side sends push=true). + enc, _, err := clipHandshake(conn, m.key, m.conn.MachineID, m.conn.LocalName, true) + if err != nil { + slog.Debug("clipboard serve handshake failed", "err", err) + return + } + m.mu.Lock() + path := m.localFile + m.mu.Unlock() + if err := sendFileStream(enc, path); err != nil { + slog.Debug("clipboard serve failed", "err", err) + } +} + +// pushFile connects to the asker's clipboard port and pushes the local file. +// This is MWB's response to a ClipboardAsk: the data owner pushes to the asker. +func (m *Manager) pushFile(host, path string) { + addr := net.JoinHostPort(host, strconv.Itoa(m.basePort)) + conn, err := net.DialTimeout("tcp", addr, clipDialQuiet) + if err != nil { + slog.Debug("clipboard push dial failed", "addr", addr, "err", err) + return + } + defer conn.Close() //nolint:errcheck + _ = conn.SetDeadline(time.Now().Add(clipXferQuiet)) + enc, _, err := clipHandshake(conn, m.key, m.conn.MachineID, m.conn.LocalName, true) + if err != nil { + slog.Debug("clipboard push handshake failed", "err", err) + return + } + if err := sendFileStream(enc, path); err != nil { + slog.Debug("clipboard push failed", "err", err) + } +} + +// sendFileStream writes the 1024-byte "*" header followed by the +// raw file bytes over the (encrypted) stream. +func sendFileStream(enc io.Writer, path string) error { + if path == "" { + return fmt.Errorf("no local file to send") + } + fi, err := os.Stat(path) + if err != nil || fi.IsDir() { + return fmt.Errorf("file unavailable: %v", err) + } + header := make([]byte, clipHeaderSize) + copy(header, encodeUTF16LE(fmt.Sprintf("%d*%s", fi.Size(), path))) + if _, err := enc.Write(header); err != nil { + return fmt.Errorf("write header: %w", err) + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() //nolint:errcheck + + // The AES-CBC stream requires 16-byte-aligned writes. File sizes rarely align, + // so zero-pad the final block; the receiver reads exactly dataSize bytes from + // the header and ignores the padding. + const bufSize = 64 * 1024 // multiple of 16 + buf := make([]byte, bufSize) + remaining := fi.Size() + for remaining > 0 { + toRead := int64(bufSize) + if toRead > remaining { + toRead = remaining + } + n, err := io.ReadFull(f, buf[:toRead]) + if err != nil && err != io.ErrUnexpectedEOF { + return fmt.Errorf("read file: %w", err) + } + remaining -= int64(n) + w := n + if remaining == 0 && w%16 != 0 { // final block β€” pad up to 16 + pad := 16 - w%16 + for i := 0; i < pad; i++ { + buf[w+i] = 0 + } + w += pad + } + if _, err := enc.Write(buf[:w]); err != nil { + return fmt.Errorf("send body: %w", err) + } + } + slog.Info("sent file to remote", "path", path, "size", fi.Size()) + return nil +} + +// onWayland reports whether to use wl-clipboard instead of xclip. +func onWayland() bool { return os.Getenv("WAYLAND_DISPLAY") != "" } + +// getLocalClipboardFile returns the path of a single local file currently on the +// clipboard (as a text/uri-list file:// entry), or "" if there isn't one. +func (m *Manager) getLocalClipboardFile() string { + var listCmd, getCmd []string + if onWayland() { + listCmd = []string{"wl-paste", "--list-types"} + getCmd = []string{"wl-paste", "--no-newline", "--type", "text/uri-list"} + } else { + listCmd = []string{"xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"} + getCmd = []string{"xclip", "-selection", "clipboard", "-t", "text/uri-list", "-o"} + } + types, err := m.runClip(listCmd) + if err != nil || !strings.Contains(string(types), "text/uri-list") { + return "" + } + out, err := m.runClip(getCmd) + if err != nil { + return "" + } + for _, line := range strings.FieldsFunc(string(out), func(r rune) bool { return r == '\n' || r == '\r' }) { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "file://") { + continue + } + p := uriToPath(line) + if fi, err := os.Stat(p); err == nil && !fi.IsDir() { + return p + } + } + return "" +} + +// setLocalFileClipboard puts a single file on the local clipboard as a +// text/uri-list entry so file managers can paste it. +func (m *Manager) setLocalFileClipboard(path string) { + uri := "file://" + pathToURI(path) + var args []string + if onWayland() { + args = []string{"wl-copy", "--type", "text/uri-list"} + } else { + args = []string{"xclip", "-selection", "clipboard", "-t", "text/uri-list"} + } + ctx, cancel := context.WithTimeout(context.Background(), execTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + if !onWayland() { + cmd.Env = append(os.Environ(), "DISPLAY="+m.display) + } + cmd.Stdin = strings.NewReader(uri + "\n") + if err := cmd.Run(); err != nil { + slog.Warn("set file clipboard failed", "err", err) + return + } + slog.Info("file placed on local clipboard", "path", path) +} + +func (m *Manager) runClip(args []string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), execTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + if !onWayland() { + cmd.Env = append(os.Environ(), "DISPLAY="+m.display) + } + return cmd.Output() +} + +// uriToPath converts a file:// URI to a local path (decoding %20 etc.). +func uriToPath(uri string) string { + p := strings.TrimPrefix(uri, "file://") + p = strings.ReplaceAll(p, "%20", " ") + return p +} + +// pathToURI percent-encodes spaces in a path for a file:// URI. +func pathToURI(path string) string { + return strings.ReplaceAll(path, " ", "%20") +} + +// winBase returns the final path element, splitting on both Windows and Unix +// separators (the remote name is typically a full Windows path). +func winBase(p string) string { + p = strings.ReplaceAll(p, "\\", "/") + return filepath.Base(p) +} diff --git a/internal/clipboard/filetransfer_test.go b/internal/clipboard/filetransfer_test.go new file mode 100644 index 0000000..928a12b --- /dev/null +++ b/internal/clipboard/filetransfer_test.go @@ -0,0 +1,78 @@ +//go:build linux + +package clipboard + +import ( + "bytes" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// sendFileStream must 16-byte-align the body (AES-CBC requirement) by zero- +// padding the final block, while the header reports the true size. +func TestSendFileStreamPadsToBlock(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "f.bin") + const size = 5014 // not a multiple of 16 + content := bytes.Repeat([]byte{0xAB}, size) + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + if err := sendFileStream(&buf, path); err != nil { + t.Fatal(err) + } + + body := buf.Bytes()[clipHeaderSize:] // skip the 1024-byte header + if len(body)%16 != 0 { + t.Errorf("body length %d not 16-aligned", len(body)) + } + if len(body) != size+(16-size%16) { + t.Errorf("body length %d, want %d", len(body), size+(16-size%16)) + } + if !bytes.Equal(body[:size], content) { + t.Error("first dataSize bytes do not match the file content") + } + header := decodeUTF16LE(buf.Bytes()[:clipHeaderSize]) + if !strings.HasPrefix(header, strconv.Itoa(size)+"*") { + t.Errorf("header %q does not report true size", header) + } +} + +func TestWinBase(t *testing.T) { + cases := map[string]string{ + `C:\Users\me\Desktop\report.pdf`: "report.pdf", + "/home/me/file.txt": "file.txt", + `file.txt`: "file.txt", + } + for in, want := range cases { + if got := winBase(in); got != want { + t.Errorf("winBase(%q) = %q, want %q", in, got, want) + } + } +} + +// The 1024-byte file header is the UTF-16LE string "*", null-padded. +// Verify our encode + the shared decode round-trips and parses. +func TestFileHeaderRoundTrip(t *testing.T) { + name := `C:\Users\me\report.pdf` + const size int64 = 4096 + header := make([]byte, clipHeaderSize) + copy(header, encodeUTF16LE(strconv.FormatInt(size, 10)+"*"+name)) + + got := decodeUTF16LE(header) // stops at the null padding + parts := strings.SplitN(got, "*", 2) + if len(parts) != 2 { + t.Fatalf("header did not split: %q", got) + } + if parts[0] != "4096" || parts[1] != name { + t.Errorf("parsed (%q,%q), want (4096,%q)", parts[0], parts[1], name) + } + if winBase(parts[1]) != "report.pdf" { + t.Errorf("winBase = %q", winBase(parts[1])) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index cd088b8..cdd1537 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,19 +4,32 @@ package config import ( "fmt" "os" + "path/filepath" "github.com/BurntSushi/toml" ) type Config struct { - Host string `toml:"host"` - Key string `toml:"key"` - Name string `toml:"name"` - Port int `toml:"port"` - RemoteWidth int `toml:"remote_width"` - RemoteHeight int `toml:"remote_height"` - Edge string `toml:"edge"` - Clipboard *bool `toml:"clipboard"` // nil = unset, treated as enabled + Host string `toml:"host"` + Key string `toml:"key"` + Name string `toml:"name"` + Port int `toml:"port"` + Edge string `toml:"edge"` // X11 single switch edge: left or right + Clipboard *bool `toml:"clipboard"` // nil = unset, treated as enabled + + // Edges selects which screen edges switch to the remote (Wayland only). + // Unset = all four. ["left","right"] = only those. ["none"] = disable + // edge switching entirely. + Edges []string `toml:"edges"` + + // SwitchModifier gates edge switching on a held modifier (Wayland only), + // like PowerToys' Easy Mouse. "" = switch freely; "shift"/"ctrl"/"alt" = + // only cross while that key is held. + SwitchModifier string `toml:"switch_modifier"` + + // Bidirectional enables this machine to also control the remote host. + // Equivalent to the -bidi flag; the flag OR-s with this so either turns it on. + Bidirectional bool `toml:"bidirectional"` // KeyboardLayout controls inbound Windows->Linux keyboard mapping. "auto" // detects the local Linux layout when possible; unsupported layouts fall back @@ -58,12 +71,6 @@ func Load(path string) (*Config, error) { if len(cfg.Name) > 15 { cfg.Name = cfg.Name[:15] } - if cfg.RemoteWidth == 0 { - cfg.RemoteWidth = 1920 - } - if cfg.RemoteHeight == 0 { - cfg.RemoteHeight = 1080 - } if cfg.AccelMultiplier <= 0 { cfg.AccelMultiplier = 2.0 } @@ -79,6 +86,45 @@ func Load(path string) (*Config, error) { return &cfg, nil } +// Save writes the config back to path as TOML, creating parent dirs as needed. +// Used by the GUI to persist edited settings. +func Save(path string, cfg *Config) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create config: %w", err) + } + if err := toml.NewEncoder(f).Encode(cfg); err != nil { + f.Close() //nolint:errcheck + return fmt.Errorf("encode config: %w", err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("close config: %w", err) + } + return nil +} + +// EnabledEdges resolves which edges switch to the remote (Wayland). Unset +// defaults to all four; an explicit "none"/"off" disables switching entirely; +// otherwise the listed valid edges are used. +func (c *Config) EnabledEdges() []string { + if len(c.Edges) == 0 { + return []string{"left", "right", "top", "bottom"} + } + var out []string + for _, e := range c.Edges { + switch e { + case "left", "right", "top", "bottom": + out = append(out, e) + case "none", "off": + return nil + } + } + return out +} + func (c *Config) MessagePort() int { return c.Port + 1 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7771e44..72a56cf 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -182,3 +182,19 @@ func TestLoadConfigValidation(t *testing.T) { t.Error("should fail without key") } } + +func TestSaveRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sub", "config.toml") // sub/ exercises MkdirAll + in := &Config{Host: "10.0.0.2", Key: "k", Name: "box", Edge: "right", Bidirectional: true} + if err := Save(path, in); err != nil { + t.Fatal(err) + } + out, err := Load(path) + if err != nil { + t.Fatal(err) + } + if out.Host != "10.0.0.2" || out.Edge != "right" || !out.Bidirectional { + t.Errorf("round-trip mismatch: %+v", out) + } +} diff --git a/internal/network/client.go b/internal/network/client.go index 6949ca1..134a0fe 100644 --- a/internal/network/client.go +++ b/internal/network/client.go @@ -307,10 +307,18 @@ func (c *Conn) RecvPacket() (*protocol.Packet, error) { full := make([]byte, protocol.PacketSizeEx) copy(full, buf) copy(full[protocol.PacketSize:], ext) - return protocol.UnmarshalPacket(full) + pkt, err := protocol.UnmarshalPacket(full) + if err == nil { + pkt.Raw = full + } + return pkt, err } - return protocol.UnmarshalPacket(buf) + pkt, err := protocol.UnmarshalPacket(buf) + if err == nil { + pkt.Raw = buf + } + return pkt, err } // Close sends a ByeBye packet (like OG MWB) then closes the connection. diff --git a/internal/network/handler.go b/internal/network/handler.go index 44b569a..7b4f398 100644 --- a/internal/network/handler.go +++ b/internal/network/handler.go @@ -2,6 +2,7 @@ package network import ( + "encoding/hex" "log/slog" "time" @@ -79,13 +80,25 @@ func (h *Handler) HandlePacket(pkt *protocol.Packet) { if h.Clipboard != nil { h.Clipboard.HandlePacket(pkt) } + case protocol.ClipboardDragDrop, protocol.ClipboardDragDropEnd, protocol.ExplorerDragDrop, + protocol.ClipboardCapture, protocol.ClipboardDragDropOp: + // File-transfer / drag-drop family β€” not implemented. Trace the decrypted + // bytes so the wire format can be reverse-engineered from real traffic. + tracePacket("file/drag packet", pkt) case protocol.Hello, protocol.Awake, protocol.HandshakeAck: // expected control packets β€” ignore silently default: - slog.Debug("unhandled packet type", "type", pkt.Type) + tracePacket("unhandled packet", pkt) } } +// tracePacket logs a packet's type and decrypted bytes (hex) at debug level β€” +// used to reverse-engineer unimplemented packet types like file transfer. +func tracePacket(msg string, pkt *protocol.Packet) { + slog.Debug(msg, "type", pkt.Type, "src", pkt.Src, "des", pkt.Des, + "len", len(pkt.Raw), "hex", hex.EncodeToString(pkt.Raw)) +} + func (h *Handler) handleMouse(pkt *protocol.Packet) { md := pkt.Mouse var err error diff --git a/internal/protocol/packet.go b/internal/protocol/packet.go index 9066c75..2e411c8 100644 --- a/internal/protocol/packet.go +++ b/internal/protocol/packet.go @@ -48,6 +48,10 @@ type Packet struct { // Extended area (bytes 32-63, only for big packets) machineName [32]byte + + // Raw holds the decrypted wire bytes, populated by RecvPacket for diagnostics + // (so unknown/file packets can be dumped in the clear). nil for sent packets. + Raw []byte } // SetMachineName sets the machine name (ASCII, max 32 chars, space-padded). diff --git a/mwb.service b/mwb.service index b6d6699..d1bb8c9 100644 --- a/mwb.service +++ b/mwb.service @@ -12,7 +12,10 @@ Type=simple # %h is the home of the user running this --user unit, matching where # `make install` places the binary ($HOME/go/bin/mwb). The system-wide # installer (scripts/install.sh) ships its own unit pointing at /usr/local/bin. -ExecStart=%h/go/bin/mwb -bidi -edge left +# All settings (edge, bidirectional, etc.) come from config.toml so the GUI can +# change them without editing this unit. Set `bidirectional = true` in config +# to enable bidirectional mode. +ExecStart=%h/go/bin/mwb Restart=on-failure RestartSec=5 diff --git a/packaging/mwb.service b/packaging/mwb.service index 7f0de00..1e04019 100644 --- a/packaging/mwb.service +++ b/packaging/mwb.service @@ -9,7 +9,7 @@ Type=simple # Override here only if auto-detection fails for your setup. # Environment=DISPLAY=:1 # Environment=XAUTHORITY=/run/user/1000/gdg/Xauthority -ExecStart=/usr/local/bin/mwb -bidi -edge left +ExecStart=/usr/local/bin/mwb Restart=on-failure RestartSec=5 diff --git a/scripts/install.sh b/scripts/install.sh index 042a98b..7e0f735 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -105,7 +105,7 @@ Wants=network-online.target [Service] Type=simple -ExecStart=/usr/local/bin/mwb -bidi -edge left +ExecStart=/usr/local/bin/mwb Restart=on-failure RestartSec=5 # DISPLAY and XAUTHORITY are auto-detected by the mwb binary.