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