diff --git a/README.md b/README.md index 43af9c3..c5939bd 100644 --- a/README.md +++ b/README.md @@ -3,622 +3,119 @@ ![Status: Public Beta](https://img.shields.io/badge/status-public%20beta-e0a100) ![License: GPLv3](https://img.shields.io/badge/license-GPLv3-blue) -> **⚠️ Public Beta** -> -> InputFlow is usable today for Windows-to-Linux keyboard, pointer, and text clipboard sharing, but it is still stabilizing around reconnection behavior, PowerToys peer registration, and desktop-environment edge cases. Expect rough edges, keep logs when something looks wrong, and prefer the pairing-helper flow described below. +InputFlow is a native C++17 Linux companion for [Microsoft PowerToys "Mouse Without Borders"](https://learn.microsoft.com/en-us/windows/powertoys/mouse-without-borders), enabling seamless cursor and keyboard sharing between Linux and Windows. -InputFlow is a native C++17 Linux companion for [Microsoft PowerToys "Mouse Without Borders"](https://learn.microsoft.com/en-us/windows/powertoys/mouse-without-borders), enabling seamless cursor and keyboard sharing between Linux and Windows while keeping the Linux-side product identity distinct. +## 🚀 Quick Start (Tray & UI Setup) -Works on X11 and is being hardened on Wayland via Linux's `uinput` kernel interface. Pointer injection now uses an absolute `EV_ABS` virtual mouse so protocol coordinates map directly into the detected local screen range. Runtime screen sizing prefers KDE's Wayland logical geometry when available, then falls back to `/sys/class/drm`; it does not require `xrandr`. +Recommended first-run flow for most users: -The command, service, and config paths still use `mwb` / `mwb-client` names for protocol compatibility and upgrade continuity. They will be migrated to `inputflow` aliases in a dedicated compatibility pass. +1. **Install Prerequisites:** + - **Fedora:** `sudo dnf install python3-gobject gtk3 libayatana-appindicator3` + - **Ubuntu/Debian:** `sudo apt install python3-gi gir1.2-gtk-3.0 libayatana-appindicator3-0.1` +2. **Launch Setup UI:** Run `./mwb-desktop-ui.sh menu` +3. **Configure:** + - Go to **Settings** -> Enter your Windows Host IP and Security Key. +4. **Pair with Windows:** + - In the same UI, use the **Export Helper** option. + - Run the exported `.ps1` script on your Windows machine to register the Linux peer. +5. **Start:** Choose **Start Service** or launch the tray with `./build/mwb_tray`. -For contribution and disclosure policy, see [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md). +--- -## Attribution +## 🛠️ Build & Installation -This repository started as a fork of [chrischip/mwb-client-linux](https://github.com/chrischip/mwb-client-linux). - -Since then it has been substantially expanded and reworked with: - -- service and config management -- clipboard sync hardening -- controller and tray flows -- packaging and CI -- protocol debugging and recovery tooling -- public beta documentation and support scripts - -The upstream project deserves credit for proving out the original Linux-side interoperability work. - -## Public Beta Status - -What is working well in current testing: - -- Windows-to-Linux keyboard input -- Windows-to-Linux pointer movement and clicks -- text clipboard sync -- `systemd --user` service management -- Windows pairing-helper export for first-time setup and recovery - -What still needs caution: - -- some PowerToys builds do not learn the Linux peer automatically from a blank state -- reconnect behavior is improved but still under active hardening -- Wayland behavior depends on compositor support for `uinput` devices and clipboard helpers - -## Quick Start - -Recommended first-run flow: - -1. Build the project and install a clipboard helper such as `wl-clipboard` on Wayland or `xclip` on X11. -2. Generate a Linux config with the Windows host and Linux machine name: - `./build/mwb_client init-config --config ~/.config/mwb-client/config.ini --host 192.0.2.10 --name fedora` -3. Store the shared key in the desktop keyring instead of leaving `key=` inline: - `printf '%s' 'MySecurityKey123' | ./build/mwb_client secret-store --config ~/.config/mwb-client/config.ini --secret-id desktop-default --stdin` -4. Export the Windows helper: - `./build/mwb_client export-windows-pair --config ~/.config/mwb-client/config.ini --position top-left` -5. Run the exported PowerShell helper on Windows to seed PowerToys MWB state: - `powershell -ExecutionPolicy Bypass -File .\\inputflow-windows-pair-fedora.ps1 -ClosePowerToys` -6. Install and start the Linux service: - `./build/mwb_client install-user-service --config ~/.config/mwb-client/config.ini` - `systemctl --user daemon-reload && systemctl --user enable --now mwb-client.service` -7. Verify the service with `./build/mwb_client doctor --config ~/.config/mwb-client/config.ini`. - -## Features - -- Absolute cursor movement and click injection (left, right, middle buttons, scroll wheel) -- Keyboard injection via Virtual Key Code translation to Linux `EV_KEY` codes -- Optional MPRIS media-key dispatch through `playerctl` for play/pause, next, previous, and stop -- Text clipboard sync using PowerToys MWB's inline and clipboard-socket flows, with structured payload parsing that preserves CF_HTML metadata while keeping plain-text fallback behavior -- Automatic reconnect with backoff and idle retry when the Windows host is offline -- Bidirectional TCP connection (connects out to Windows and accepts Windows's inbound connection) -- Windows pairing-helper export that seeds PowerToys peer state when current builds do not learn the Linux peer automatically -- Safer key sourcing via `key_file=` / `--key-file` or `key_secret_id=` / `--key-secret-id` -- Lightweight desktop controller and optional tray controller for the `systemd --user` service -- PowerToys-compatible AES-256-CBC transport and packet framing - -## Project Structure - -``` -mwb-client-linux/ -├── CMakeLists.txt -├── Dockerfile -├── README.md -├── docs/screenshots/ -├── packaging/ -├── tests/ -├── tools/ -└── src/ - ├── AppConfig.* / AppState.* - ├── ClientRuntime.* / ClipboardManager.* - ├── CryptoHelper.* / Discovery.* - ├── InputDispatcher.* / InputManager.* - ├── MediaKeyBridge.* - ├── NetworkManager.* - ├── PeerRecovery.* / SecretStore.* - ├── Protocol.h / ReconnectPolicy.h / ScreenGeometry.h - ├── TrayController.cpp - └── main.cpp -``` - -## Build - -### Prerequisites (Ubuntu / Debian) +### 1. Prerequisites +**Ubuntu / Debian:** ```bash -sudo apt-get install -y build-essential cmake pkg-config libssl-dev zlib1g-dev +sudo apt-get install -y build-essential cmake pkg-config libssl-dev zlib1g-dev \ + python3-gi gir1.2-gtk-3.0 libayatana-appindicator3-dev ``` -### Prerequisites (Fedora) - +**Fedora:** ```bash -sudo dnf install -y gcc-c++ cmake make pkgconf-pkg-config openssl-devel zlib-devel +sudo dnf install -y gcc-c++ cmake make pkgconf-pkg-config openssl-devel zlib-devel \ + python3-gobject gtk3 libayatana-appindicator3-devel ``` -### Compile +### 2. Compile ```bash -cmake -S . -B build +cmake -S . -B build -DMWB_BUILD_TRAY=ON cmake --build build -j$(nproc) -ctest --test-dir build --output-on-failure -``` - -Optional desktop/runtime helpers: - -- `zenity` for the desktop controller -- `wl-clipboard`, `xclip`, or `xsel` for clipboard sync -- `playerctl` for MPRIS media-key dispatch -- GTK 3 plus Ayatana AppIndicator development packages if you want the optional `mwb_tray` binary - -### Sanitizer debug build - -```bash -cmake -S . -B build-sanitize -DCMAKE_BUILD_TYPE=Debug -DMWB_ENABLE_SANITIZERS=ON -cmake --build build-sanitize -j$(nproc) -ctest --test-dir build-sanitize --output-on-failure ``` -## Runtime - -### `/dev/uinput` access - -Load the kernel module once: +### 3. Setup `/dev/uinput` (Crucial for Mouse/Keyboard) ```bash sudo modprobe uinput -``` - -Persist it across reboots: - -```bash -echo uinput | sudo tee /etc/modules-load.d/uinput.conf -``` - -Allow non-root access with a dedicated group and udev rule: - -```bash sudo groupadd -r inputflow sudo usermod -aG inputflow $USER echo 'KERNEL=="uinput", GROUP="inputflow", MODE="0660", OPTIONS+="static_node=uinput"' | sudo tee /etc/udev/rules.d/99-inputflow-uinput.rules sudo udevadm control --reload-rules && sudo udevadm trigger ``` +*Logout and back in for group changes to take effect.* -Log out and back in for group membership to take effect. Avoid adding desktop users to a broad `input` group unless your distribution explicitly requires it, because that can grant access to physical input event devices too. +--- -If `/dev/uinput` is unavailable, the client still connects for protocol testing, but local input injection stays disabled until the device node is accessible. +## ✨ Features -Reusable distro packaging snippets for this setup and the user service live under `packaging/`. -The Fedora/RPM skeleton is `packaging/rpm/inputflow.spec`; it packages the -existing `mwb_client` command and `mwb-client.service` compatibility names. +- **Absolute Cursor Movement:** Precise pointer control across screens. +- **Keyboard Sync:** Full keyboard sharing with media key support. +- **Rich Clipboard:** Text, HTML, and **Image** synchronization. +- **Systemd Integration:** Runs as a lightweight user service. +- **Tray Tool:** Quick access to settings and connection status. +- **Auto-Reconnect:** Smart backoff logic when Windows goes offline. -### Screen sizing +--- -The client uses this order: +## ⚠️ Public Beta Status -1. `screen_width` and `screen_height` from config, or `--screen-width` and `--screen-height` -2. `MWB_SCREEN_WIDTH` and `MWB_SCREEN_HEIGHT`, if both are set -3. KDE logical geometry from `kscreen-doctor -o`, when available -4. Enabled connector modes from `/sys/class/drm` -5. A 1920×1080 fallback +InputFlow is usable today but is still stabilizing. Expect rough edges around reconnection and desktop-environment edge cases. -CLI overrides win over environment variables, and environment variables win over values loaded from `config.ini`. -Explicit overrides are still recommended on stacked or mixed-DPI multi-monitor desktops, and inside containers where automatic detection may not reflect the host desktop accurately. +**What is working well:** +- Windows-to-Linux keyboard/mouse input. +- Full Clipboard sync (Text/HTML/Images). +- `systemd` service management. +- Windows pairing-helper for easy setup. -Example: +--- -```bash -MWB_SCREEN_WIDTH=2560 MWB_SCREEN_HEIGHT=1600 ./build/mwb_client 192.0.2.10 MySecurityKey123 -``` +## 📖 Advanced Usage & CLI -Example with explicit `run` overrides: - -```bash -./build/mwb_client run --config ~/.config/mwb-client/config.ini --screen-width 2560 --screen-height 1600 -``` - -### Clipboard helper tools - -Text clipboard sync depends on a userspace clipboard command on Linux. Install one of: - -- Wayland: `wl-clipboard` -- X11: `xclip` or `xsel` - -The current Linux clipboard backend still publishes text to local applications. -The protocol layer now keeps a structured clipboard payload model so explicit -`TXT` entries, CF_HTML raw data, CF_HTML offsets/fragments, normalized plain-text -fallback, and future image payloads can be handled without overloading the -text-only API surface. - -Runtime tuning: - -- `MWB_CLIPBOARD_POLL_MS=1000` adjusts how often the client polls the local clipboard for changes. -- `MWB_CLIPBOARD_RECEIVE_ONLY=1` keeps incoming clipboard sync enabled while disabling local clipboard watches. -- `MWB_CLIPBOARD_FORCE_POLL=1` re-enables polling on Wayland compositors where `wl-paste --watch` is unsupported. -- `MWB_DISABLE_CLIPBOARD=1` disables clipboard sync entirely for input-latency troubleshooting. -- `MWB_KEY_FILE=/path/to/security-key` loads the security key from a file instead of `key=`. -- `MWB_KEY_SECRET_ID=desktop-default` loads the security key from the desktop keyring via Secret Service. -- `MWB_DEBUG_NETWORK=1` enables verbose packet and heartbeat logging. -- `MWB_KEY_REPEAT_DELAY_MS=250` and `MWB_KEY_REPEAT_PERIOD_MS=33` override the virtual keyboard repeat settings when a desktop session does not apply its own defaults. -- `MWB_MPRIS_PLAYER=spotify` targets a specific MPRIS player for media keys when `playerctl` is installed. -- `MWB_DISABLE_MPRIS_MEDIA_KEYS=1` disables MPRIS media-key dispatch and leaves media keys to the virtual keyboard fallback. -- `MWB_LATENCY_REPORT=1` prints input queue and injection timing when the client shuts down. - -Cross-host latency probe: - -```bash -# On the Linux client. -python3 tools/latency_probe.py server --bind 0.0.0.0 --port 15111 - -# On the Windows host, from a checkout/copy of this repository. -py tools\latency_probe.py client --port 15111 --count 1000 --warmup 50 --interval-ms 1 --color always -``` - -The probe reports round-trip latency, estimated one-way latency, responder ACK processing time, jitter, drops, CPU usage, and resident memory in milliseconds. It does not require synchronized clocks because the client measures round-trip time locally and the responder only reports its own internal ACK processing duration. This cross-host probe is the useful measurement for Windows-to-Linux service/network latency; the CTest input-latency test is only a deterministic local collector check. - -Probe methodology: - -- Start `server` on the machine being measured as the responder, usually the Linux client. -- Run `client` from the other machine, usually the Windows host. -- The server stays running and accepts repeated client runs. Add `--once` if you want it to exit after one run. -- Each sample sends one numbered TCP probe packet, receives an ACK, and records round-trip time on the client clock. -- `estimated one-way` is `round trip / 2`; use it as an approximation, not a synchronized-clock truth. -- `server ACK process` is measured only on the server clock and shows how long the responder took to receive and ACK the packet. -- `warmup` samples are discarded so connection setup, CPU wakeup, and first-use effects do not skew the table. -- `jitter` is sample standard deviation; high jitter means the input path is inconsistent even when average latency looks acceptable. -- The probe sets `TCP_NODELAY` so results are less affected by TCP batching. - -Probe flags: - -- `server --bind ADDR` chooses the local address to listen on. Use `0.0.0.0` to accept LAN clients or `127.0.0.1` for local-only testing. -- `server --port PORT` chooses the TCP port. The default is `15111`. -- `server --once` exits after one client run. Without it, the server keeps accepting more tests. -- `client HOST` is the responder IP or host name. -- `client --port PORT` must match the server port. -- `client --count N` is the number of measured samples after warmup. -- `client --warmup N` is the number of initial samples to discard. -- `client --interval-ms MS` waits between probes. Use `1` to approximate a 1000 Hz input cadence, and `0` to stress the network path without pacing. -- `client --timeout-ms MS` controls per-packet timeout. -- `client --color auto|always|never` controls terminal color. - -Copyable probe commands: - -```bash -# Linux responder, all network interfaces. -python3 tools/latency_probe.py server --bind 0.0.0.0 --port 15111 - -# Linux responder, local-only smoke test. -python3 tools/latency_probe.py server --bind 127.0.0.1 --port 15111 -``` - -```powershell -# Windows host, 1000 Hz-style paced test. Replace . -python .\latency_probe.py client --port 15111 --count 1000 --warmup 50 --interval-ms 1 --color always - -# Windows host, unpaced burst test. Replace . -python .\latency_probe.py client --port 15111 --count 1000 --warmup 50 --interval-ms 0 --color always - -# Windows host, longer stability run. Replace . -python .\latency_probe.py client --port 15111 --count 10000 --warmup 100 --interval-ms 1 --timeout-ms 2000 --color always -``` - -Recommended interval test: - -1. Run the paced `--interval-ms 1` command and save the output. -2. Restart the server, then run the unpaced `--interval-ms 0` command and save the output. -3. If unpaced latency is much lower, scheduler/timer pacing or power management is contributing. -4. If both runs have high p95/p99 latency, investigate Wi-Fi quality, VPNs, Windows power mode, CPU load, and LAN path. -5. Prefer p95/p99 and max over average when judging input feel. - -If the client prints `socket.timeout`, the responder is not reachable. Confirm the Linux server is still running, the IP address is correct, the port matches, and the firewall allows TCP port `15111`. - -On Wayland, the client prefers `wl-paste --watch` for near-immediate clipboard updates when the compositor supports the wlroots data-control protocol. If watch mode is unavailable, local clipboard polling is disabled by default to avoid disrupting launcher shortcuts on GNOME-style sessions. Incoming clipboard writes from Windows still work, and you can opt into an explicit receive-only mode with `MWB_CLIPBOARD_RECEIVE_ONLY=1` or force poll fallback with `MWB_CLIPBOARD_FORCE_POLL=1`. - -### Security notes - -- The Linux listeners only accept inbound control and clipboard connections from the configured Windows peer IP. -- Remote clipboard payloads are size-limited, and unsupported image clipboard transfers are rejected instead of being buffered indefinitely. -- Clipboard socket trust remains bound to an active authenticated control session before any decoded clipboard text is delivered. -- The client now validates the legacy MWB packet magic/checksum on receive, but the upstream PowerToys protocol itself does not provide modern authenticated encryption. Full MAC/AEAD protection would require a protocol change on both ends. -- Prefer `--key-file`, `--key-secret-id`, or `--stdin` over putting the security key directly in shell history. - -## Usage - -```bash -./build/mwb_client [PORT] -``` - -Or use the subcommands: +For power users who prefer manual control: ```bash ./build/mwb_client run --config ~/.config/mwb-client/config.ini -./build/mwb_client run --host 192.0.2.10 --key-file ~/.config/mwb-client/security-key -./build/mwb_client run --host 192.0.2.10 --key-secret-id desktop-default ./build/mwb_client discover ./build/mwb_client doctor --config ~/.config/mwb-client/config.ini -./build/mwb_client init-config --config ~/.config/mwb-client/config.ini -printf '%s' 'MySecurityKey123' | ./build/mwb_client secret-store --config ~/.config/mwb-client/config.ini --secret-id desktop-default --stdin -./build/mwb_client install-user-service -``` - -`doctor` is read-only. It reports config validity, key source, `/dev/uinput` access, session type, clipboard helpers, XDG portal reachability, and user-service state without starting the network client. - -Useful `run` options: - -```bash -./build/mwb_client run --host 192.0.2.10 --key MySecurityKey123 --name fedora -./build/mwb_client run --host 192.0.2.10 --key-file ~/.config/mwb-client/security-key --name fedora -./build/mwb_client run --host 192.0.2.10 --key-secret-id desktop-default --name fedora -./build/mwb_client run --config ~/.config/mwb-client/config.ini --screen-width 2560 --screen-height 1600 -./build/mwb_client run --config ~/.config/mwb-client/config.ini --clipboard-receive-only -./build/mwb_client run --config ~/.config/mwb-client/config.ini --manual-only -./build/mwb_client run --config ~/.config/mwb-client/config.ini --mpris-player spotify -``` - -- `WINDOWS_IP` — IP address of the Windows machine running PowerToys MWB -- `SECURITY_KEY` — The security key shown in **PowerToys → Mouse Without Borders → Security key** -- `PORT` — Optional, defaults to `15101` (keyboard/mouse channel). The clipboard socket uses `PORT - 1`, so the PowerToys defaults remain `15101` for input and `15100` for clipboard. - -Example: - -```bash -./build/mwb_client 192.0.2.10 MySecurityKey123 -``` - -Example with explicit screen sizing: - -```bash -MWB_SCREEN_WIDTH=2560 MWB_SCREEN_HEIGHT=1600 ./build/mwb_client 192.0.2.10 MySecurityKey123 -``` - -### Discovery - -`discover` scans directly connected private IPv4 networks for reachable listeners on TCP port `15101`. It does not use the security key, does not trust peers automatically, and does not enable input by itself. Host names are best-effort through Avahi/mDNS and Windows NetBIOS node-status lookup. - -```bash -./build/mwb_client discover --port 15101 --timeout-ms 200 --max-hosts 256 -``` - -Use a discovered IP with the existing key-based flow: - -```bash -./build/mwb_client run --host 192.0.2.10 --key MySecurityKey123 -./build/mwb_client run --host 192.0.2.10 --key-file ~/.config/mwb-client/security-key -./build/mwb_client run --host 192.0.2.10 --key-secret-id desktop-default -``` - -### Config and user service - -Generate a config template: - -```bash -./build/mwb_client init-config --config ~/.config/mwb-client/config.ini -``` - -Export a ready-to-run Windows PowerShell helper from the current Linux config: - -```bash -./build/mwb_client export-windows-pair --config ~/.config/mwb-client/config.ini -./build/mwb_client export-windows-pair --config ~/.config/mwb-client/config.ini --position top-left -``` - -By default this writes `./inputflow-windows-pair-.ps1` with the Linux machine name, -shared security key, and detected Linux IPv4 baked in. Copy that script to the Windows -machine and run: - -```powershell -powershell -ExecutionPolicy Bypass -File .\inputflow-windows-pair-fedora.ps1 -ClosePowerToys -``` - -The helper synchronizes PowerToys `SecurityKey`, `MachineMatrixString`, `MachinePool`, and -`Name2IP` so the Linux peer appears reliably even when current PowerToys builds do not -persist it after the first TCP handshake. Pass `--linux-ip` if auto-detection picks the -wrong local address, `--position` to pick `top-left`, `top-right`, `bottom-left`, or -`bottom-right`, and `--force` to overwrite an existing exported helper. - -Install a `systemd --user` service instead of backgrounding the process manually: - -```bash -./build/mwb_client install-user-service -systemctl --user daemon-reload -systemctl --user enable --now mwb-client.service -``` - -The generated service runs the client in the foreground with: - -```bash -./build/mwb_client run --config ~/.config/mwb-client/config.ini -``` - -`config.ini` now supports: - -- `key_file=` to load the security key from a separate file -- `key_secret_id=` to load the security key from the desktop keyring through Secret Service -- `machine_name=` to override the hostname sent to PowerToys -- `clipboard_send_enabled=` for explicit receive-only clipboard mode -- `auto_connect_enabled=` to keep the service connected automatically or leave it idle until re-enabled -- `reconnect_initial_backoff_ms=`, `reconnect_max_backoff_ms=`, and `reconnect_idle_retry_ms=` to tune offline retry behavior; runtime delays are capped at 30000 ms so a recovered peer does not wait many minutes to reconnect -- `screen_width=` and `screen_height=` to override automatic screen-size detection -- `mpris_media_keys_enabled=` and `mpris_player=` to control MPRIS media-key dispatch -- `latency_report=` to print input queue and injection timing when the service stops -- `MWB_MOUSE_TRACE=N` to keep and dump the last N mouse packets on shutdown for input debugging; values above 1024 are capped - -Example: - -```ini -host=192.0.2.10 -key= -key_file=security-key -key_secret_id= -machine_name=fedora -port=15101 -clipboard_enabled=true -clipboard_send_enabled=true -clipboard_force_poll=false -clipboard_poll_ms=1000 -auto_connect_enabled=true -reconnect_initial_backoff_ms=1000 -reconnect_max_backoff_ms=30000 -reconnect_idle_retry_ms=30000 -screen_width= -screen_height= -mpris_media_keys_enabled=true -mpris_player= -latency_report=false -``` - -When `key_file` is relative, it is resolved relative to the directory containing `config.ini`. - -To migrate an existing inline key or key file into the desktop keyring and rewrite the config in one step: - -```bash -printf '%s' 'MySecurityKey123' | ./build/mwb_client secret-store --config ~/.config/mwb-client/config.ini --secret-id desktop-default --stdin -``` - -To remove a stored desktop-keyring entry: - -```bash -./build/mwb_client secret-clear --config ~/.config/mwb-client/config.ini --secret-id desktop-default -``` - -Runtime state is stored separately under XDG state, for example `~/.local/state/mwb-client/state.ini`. It records a stable local machine ID plus discovered/approved peers without changing the security-key trust model. -The saved peer state also tracks whether a peer is connected right now so the desktop controller can surface live connection status without guessing from historical timestamps. - -### Desktop Controller - -For Fedora/KDE/GNOME, the repo includes a lightweight Zenity desktop controller: - -```bash -./mwb-desktop-ui.sh -``` - -It edits config, runs discovery, shows known peers, starts/stops/restarts the `systemd --user` service, and can install desktop entries for itself and the tray controller. -The settings flow supports an inline security key, a separate key-file path, or a Secret Service-backed desktop-keyring entry, plus clipboard, screen-size override, MPRIS media-key, and input-latency diagnostic options. -The dedicated Connection Behavior screen lets you switch between auto-connect and manual mode and tune the reconnect timing without editing `config.ini` by hand. -The discovery and known-peer views show whether a peer is already paired, whether it is connected now, and whether it is the configured host. -Known peers are actionable from the controller: you can promote a saved peer to the configured Windows host or forget it from saved peer state without editing files by hand. -The settings flow also distinguishes between editing the current configured host and replacing it with a newly discovered peer. - -### Tray Controller - -When `gtk+-3.0` and `ayatana-appindicator3` development packages are available at build time, CMake also builds an optional tray controller: - -```bash -./build/mwb_tray -``` - -The InputFlow tray menu can: - -- open the Zenity controller -- open the dedicated Connection Behavior editor -- show the InputFlow name and service state in tray hover text when the desktop indicator host supports it -- keep only one tray instance running per user session -- jump directly to settings, peer discovery, known peers, and service details -- start, stop, or restart `mwb-client.service` -- show current service status -- show tray visibility help and install desktop entries - -On GNOME/Wayland, tray visibility still depends on AppIndicator support in the shell environment, so the Zenity controller remains the primary fallback. -When the tray is available, the controller and tray share the same peer-management flow, including discovery, known-peer actions, and service control. - -### Setup in PowerToys - -1. Open **PowerToys → Mouse Without Borders** on Windows. -2. Enable the feature and note your **Security key**. -3. Prefer the exported `inputflow-windows-pair-.ps1` helper from Linux to seed the shared key, `MachineMatrixString`, `MachinePool`, and `Name2IP`. -4. Reopen PowerToys and confirm the Linux peer appears in the layout. -5. Place the Linux machine in the desired screen slot. -6. Start the Linux service and verify the connection from the tray or desktop controller. - -### Troubleshooting and Diagnostics - -Start with the built-in doctor: - -```bash -./build/mwb_client doctor --config ~/.config/mwb-client/config.ini ``` -Useful Linux-side checks: - -- `systemctl --user status mwb-client.service` -- `journalctl --user -u mwb-client.service` -- `./build/mwb_client discover --port 15101 --timeout-ms 200 --max-hosts 256` - -Windows-side support helpers in `tools/`: +See the full [documentation section](#detailed-documentation) for environment variables and protocol details. -- `windows_mwb_collect.ps1` gathers PowerToys process, listener, event-log, and settings state -- `windows_mwb_lock_inspect.ps1` samples transient lockers for `settings.json` -- `windows_mwb_socket_trace.ps1` captures MWB-specific process, service, event-log, file, and socket traces -- `windows_mwb_seed_peer.ps1` seeds PowerToys peer state directly when recovery is needed + +## Detailed Documentation -If you attach a trace publicly, redact: +### Attribution +This repository started as a fork of [chrischip/mwb-client-linux](https://github.com/chrischip/mwb-client-linux) and has been substantially expanded with service management, rich clipboard support, and recovery tooling. -- shared security keys -- private hostnames -- private IP addresses -- exported helper scripts with baked-in secrets +### Configuration (`config.ini`) +Supports `key_file`, `key_secret_id` (keyring), `screen_width/height` overrides, and more. Default path: `~/.config/mwb-client/config.ini`. -## Docker +### Screen Sizing +The client detects screen size in this order: +1. Config/CLI overrides +2. KDE logical geometry (`kscreen-doctor`) +3. DRM connector modes (`/sys/class/drm`) +4. 1920x1080 fallback -```bash -docker build -t mwb-linux . -docker run --rm -it --network host --device /dev/uinput:/dev/uinput \ - -e MWB_SCREEN_WIDTH=2560 -e MWB_SCREEN_HEIGHT=1600 \ - mwb-linux -``` +### Network & Protocol +- **Port:** 15101 (Input), 15100 (Clipboard). +- **Encryption:** AES-256-CBC (PowerToys compatible). -## Podman - -```bash -podman build -t mwb-linux . -podman run --rm -it --network host --device /dev/uinput:/dev/uinput \ - --security-opt label=disable --group-add keep-groups \ - -e MWB_SCREEN_WIDTH=2560 -e MWB_SCREEN_HEIGHT=1600 \ - localhost/mwb-linux -``` - -`--security-opt label=disable` is commonly needed on SELinux-enforcing Fedora hosts for direct `/dev/uinput` access. Rootless Podman may also need `--group-add keep-groups` so the container keeps the host user's supplemental groups. - -## Protocol Notes - -The PowerToys MWB protocol uses: - -- **Transport:** TCP on port 15101 (mouse/keyboard) and 15100 (clipboard) -- **Encryption:** AES-256-CBC, no padding, streaming mode -- **Key derivation:** PBKDF2-HMAC-SHA512, 50 000 iterations, fixed salt derived from `ulong.MaxValue` encoded as UTF-16LE -- **IV:** Fixed string `"1844674407370955"` (ASCII, 16 bytes) -- **Magic number:** 24-bit hash of the security key via 50 000 rounds of SHA-512 -- **Packet sizes:** 32 bytes (small: mouse, keyboard, small heartbeat) or 64 bytes (big: handshake, identity, matrix) -- **Handshake:** Both sides exchange type-126 challenge packets and respond with type-127 acknowledgements carrying the bitwise-NOT of the received 16-byte challenge payload -- **Post-handshake control:** `Hello` (3), `Heartbeat` (20), `Awake` (21), and `Heartbeat_ex` / `Heartbeat_ex_l2` / `Heartbeat_ex_l3` (51/52/53) are used for identity, keepalive, and peer-registration flow - -The machine name sent by this client must match the name configured in Windows's MWB machine list. - -## Known Limitations - -- Outside KDE Wayland sessions, automatic screen sizing from `/sys/class/drm` assumes enabled outputs form one horizontal desktop. Use `screen_width`/`screen_height` or `MWB_SCREEN_WIDTH`/`MWB_SCREEN_HEIGHT` for stacked displays, mixed-DPI layouts, or containerized runs. Incorrect geometry means incorrect absolute pointer scaling. -- Some PowerToys builds still do not persist a blank-state Linux peer automatically. In that case, use the exported Windows pairing helper first. -- Clipboard sync currently writes text to the local Linux clipboard. The protocol parser preserves CF_HTML metadata internally for richer future backends, but local multi-MIME HTML ownership, image clipboard data, and drag/drop file transfer are not implemented. -- Wayland compositor handling of synthetic absolute `uinput` pointer devices varies. If cursor reachability breaks, run once with `MWB_MOUSE_TRACE=200`, reproduce the issue, then stop the service and inspect the dumped packet trace. - -## Roadmap - -Short term: - -- Wire the structured clipboard payload model into richer local backends that can publish HTML plus plain-text fallback where the desktop protocol supports it. -- Confirm the PowerToys MWB image wire payload format before enabling image clipboard receive/write support. -- Add explicit file send/receive into a configured folder. - -Longer term: - -- Add a Wayland-native input backend using libei / XDG desktop portals alongside the current `uinput` backend. -- Add screenshot handoff, command palette actions, and "open on other machine" workflows. -- Add a custom Windows companion only when features require protocol extensions, such as bidirectional lock sync, telemetry, richer file drag/drop, or audio routing. - -Future platform track: - -- Split shared protocol, crypto, network, and config code into a platform-neutral core. -- Prototype a macOS receiver using CoreGraphics event posting, `NSPasteboard`, a `LaunchAgent`, and an AppKit menu bar controller. -- Consider bidirectional macOS support only after receiver mode works, because global edge capture and keyboard monitoring require macOS Input Monitoring / Accessibility permission flows. - -## Test Coverage - -The current automated checks cover: - -- config, state, and discovery unit tests -- KDE Wayland logical screen-geometry parser tests -- peer-recovery/reconnect candidate selection tests -- reconnect-policy and persisted session-state transition tests -- input-mapping regression tests for adaptive coordinate scaling and edge clamping -- input-latency summary tests -- MPRIS media-key bridge mapping tests -- protocol parsing, structured clipboard payload and CF_HTML preservation tests, session binding, and clipboard socket security regression tests -- `mwb_client --help` smoke validation -- `mwb_client doctor` smoke validation and health-report category assertions +--- ## License - GNU GPL v3.0 — see [LICENSE](LICENSE). -InputFlow is independent and is not affiliated with or endorsed by Microsoft. It interoperates with the PowerToys Mouse Without Borders protocol by studying the published open-source implementation at [microsoft/PowerToys](https://github.com/microsoft/PowerToys). - -Microsoft and Mouse Without Borders are trademarks of the Microsoft group of companies. +InputFlow is independent and not affiliated with Microsoft. Interoperability is based on the open-source [microsoft/PowerToys](https://github.com/microsoft/PowerToys) implementation. diff --git a/mwb-desktop-ui.sh b/mwb-desktop-ui.sh index f1da83e..7f013c4 100755 --- a/mwb-desktop-ui.sh +++ b/mwb-desktop-ui.sh @@ -80,6 +80,10 @@ require_ui() { printf 'zenity is required for %s desktop UI.\n' "$APP_NAME" >&2 exit 1 fi + if ! python3 -c "import gi; gi.require_version('Gtk', '3.0')" >/dev/null 2>&1; then + printf 'python3-gi and GTK3 are required for %s desktop UI.\n' "$APP_NAME" >&2 + exit 1 + fi } require_client_binary() { @@ -98,6 +102,12 @@ require_tray_binary() { start_tray() { require_tray_binary || return 1 + if pgrep -x "mwb_tray" >/dev/null; then + printf 'Restarting existing InputFlow tray...\n' + pkill -x "mwb_tray" || true + # Give the lock file a moment to be released + sleep 0.5 + fi exec "$TRAY_BIN" } @@ -649,31 +659,22 @@ service_state_label() { } menu_summary_text() { - local state host machine_name port key key_file secret_id auth_label auto_connect_enabled reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms + local state host auth_label auto_connect_enabled reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms state="$(service_state)" host="$(read_config_value host)" - machine_name="$(read_config_value machine_name)" - port="$(read_config_value port)" key="$(read_config_value key)" key_file="$(read_config_value key_file)" secret_id="$(read_secret_id_value)" IFS=$'\t' read -r auto_connect_enabled reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms < <(read_connection_behavior_values) auth_label="$(configured_auth_label "$key" "$key_file" "$secret_id")" - [[ -n "$host" ]] || host="not configured" - [[ -n "$machine_name" ]] || machine_name="not set" - [[ -n "$port" ]] || port="15101" + [[ -n "$host" ]] || host="None" - printf 'Service: %s\nConfigured host: %s\nAuthentication: %s\nMachine name: %s\nPort: %s\nConnection: %s (%s-%s ms, idle %s ms)' \ + printf 'Status: %s\nHost: %s\nAuth: %s\nReconnect: %s' \ "$(service_state_label "$state")" \ "$host" \ "$auth_label" \ - "$machine_name" \ - "$port" \ - "$(connection_behavior_mode_label "$auto_connect_enabled")" \ - "$reconnect_initial_backoff_ms" \ - "$reconnect_max_backoff_ms" \ - "$reconnect_idle_retry_ms" + "$( [[ "$auto_connect_enabled" == "true" ]] && printf 'Auto' || printf 'Manual' )" } show_status() { @@ -731,6 +732,7 @@ show_peers() { --text="Peer: ${selected_name:-unknown} ($selected_host:$selected_port)" \ --column="Use" --column="Action" \ TRUE "Use as configured Windows host" \ + FALSE "Edit settings for this peer" \ FALSE "Forget this peer" || true)" [[ -n "$selected_action" ]] || return 0 @@ -738,6 +740,9 @@ show_peers() { "Use as configured Windows host") set_configured_host "$selected_host" ;; + "Edit settings for this peer") + edit_settings "$selected_host" + ;; "Forget this peer") if zenity --question --title="$APP_NAME peer actions" --width=480 \ --text="Forget peer ${selected_name:-unknown} at $selected_host:$selected_port?\n\nThis removes it from saved peer state only."; then @@ -813,27 +818,8 @@ discover_and_save_peer() { return 1 fi - host="$(read_config_value host)" - key="$(read_config_value key)" - key_file="$(read_config_value key_file)" - secret_id="$(read_secret_id_value)" - auth_count="$(configured_auth_source_count "$key" "$key_file" "$secret_id")" - - if (( auth_count == 1 )); then - action="$(zenity --list --radiolist --title="$APP_NAME discovered peer" --width=560 --height=260 \ - --text="Discovered Windows host: $selected\n\nChoose how to use it." \ - --column="Use" --column="Action" \ - TRUE "Use as configured Windows host now" \ - FALSE "Open full settings for this peer" || true)" - [[ -n "$action" ]] || return 1 - if [[ "$action" == "Use as configured Windows host now" ]]; then - set_configured_host "$selected" - else - edit_settings "$selected" || return 1 - fi - else - edit_settings "$selected" || return 1 - fi + # Always prompt for settings to ensure key is entered/verified + edit_settings "$selected" || return 1 if ! service_active; then if zenity --question --title="$APP_NAME" --width=480 \ @@ -846,194 +832,92 @@ discover_and_save_peer() { edit_settings() { local preset_host="${1:-}" local host key key_file secret_id secret_key_name machine_name port screen_width screen_height auto_connect_enabled reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms clipboard_enabled clipboard_force_poll clipboard_poll_ms - local clipboard_send_enabled current_auth_mode auth_action key_mode cleanup_secret_id saved_message host_dialog_title host_dialog_text host_entry_text - local mpris_media_keys_enabled mpris_player latency_report + local clipboard_send_enabled current_auth_mode auth_action key_mode cleanup_secret_id saved_message + local mpris_media_keys_enabled mpris_player latency_report gui_output + host="$(read_config_value host)" key="$(read_config_value key)" key_file="$(read_config_value key_file)" secret_id="$(read_secret_id_value)" secret_key_name="$(detect_secret_id_key_name)" machine_name="$(read_config_value machine_name)" - port="$(read_config_value port)" + port="$(read_config_value port)"; [[ -n "$port" ]] || port="15101" screen_width="$(read_config_value screen_width)" screen_height="$(read_config_value screen_height)" IFS=$'\t' read -r auto_connect_enabled reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms < <(read_connection_behavior_values) - clipboard_enabled="$(read_config_value clipboard_enabled)" - clipboard_send_enabled="$(read_config_value clipboard_send_enabled)" - clipboard_force_poll="$(read_config_value clipboard_force_poll)" - clipboard_poll_ms="$(read_config_value clipboard_poll_ms)" - mpris_media_keys_enabled="$(read_config_value "$MPRIS_MEDIA_KEYS_CONFIG_KEY")" + clipboard_enabled="$(read_config_value clipboard_enabled)"; [[ -n "$clipboard_enabled" ]] || clipboard_enabled="true" + clipboard_send_enabled="$(read_config_value clipboard_send_enabled)"; [[ -n "$clipboard_send_enabled" ]] || clipboard_send_enabled="true" + clipboard_force_poll="$(read_config_value clipboard_force_poll)"; [[ -n "$clipboard_force_poll" ]] || clipboard_force_poll="false" + clipboard_poll_ms="$(read_config_value clipboard_poll_ms)"; [[ -n "$clipboard_poll_ms" ]] || clipboard_poll_ms="1000" + mpris_media_keys_enabled="$(read_config_value "$MPRIS_MEDIA_KEYS_CONFIG_KEY")"; [[ -n "$mpris_media_keys_enabled" ]] || mpris_media_keys_enabled="true" mpris_player="$(read_config_value "$MPRIS_PLAYER_CONFIG_KEY")" - latency_report="$(read_config_value "$LATENCY_REPORT_CONFIG_KEY")" + latency_report="$(read_config_value "$LATENCY_REPORT_CONFIG_KEY")"; [[ -n "$latency_report" ]] || latency_report="false" - [[ -n "$port" ]] || port="15101" - [[ -n "$clipboard_enabled" ]] || clipboard_enabled="true" - [[ -n "$clipboard_send_enabled" ]] || clipboard_send_enabled="true" - [[ -n "$clipboard_force_poll" ]] || clipboard_force_poll="false" - [[ -n "$clipboard_poll_ms" ]] || clipboard_poll_ms="1000" - [[ -n "$mpris_media_keys_enabled" ]] || mpris_media_keys_enabled="true" - [[ -n "$latency_report" ]] || latency_report="false" current_auth_mode="$(configured_auth_mode "$key" "$key_file" "$secret_id")" - if (( $(configured_auth_source_count "$key" "$key_file" "$secret_id") > 1 )); then - zenity --warning --text="Multiple authentication sources are configured. Saving settings will keep only the method selected in the next step." - fi + local fields="host:Windows Host:entry||machine_name:Local Machine Name:entry||port:Network Port:entry||screen_width:Screen Width:entry||screen_height:Screen Height:entry||clipboard_poll_ms:Clipboard Poll (ms):entry||mpris_player:MPRIS Player:entry||clipboard_enabled:Sync Clipboard:switch||clipboard_send_enabled:Send Local Clipboard:switch||clipboard_force_poll:Force Wayland Polling:switch||mpris_media_keys_enabled:Enable Media Keys:switch||latency_report:Print Latency Report:switch" + local values="${preset_host:-$host}|$machine_name|$port|$screen_width|$screen_height|$clipboard_poll_ms|$mpris_player|$clipboard_enabled|$clipboard_send_enabled|$clipboard_force_poll|$mpris_media_keys_enabled|$latency_report" - if [[ -n "$preset_host" ]]; then - host_dialog_title="$APP_NAME add discovered peer" - if [[ -n "$host" && "$host" != "$preset_host" ]]; then - host_dialog_text="Discovered Windows host. Saving this replaces the currently configured host.\n\nCurrent host: $host\nDiscovered host: $preset_host" - else - host_dialog_text="Discovered Windows host. Saving this sets the configured host used by InputFlow." - fi - host_entry_text="$preset_host" - else - host_dialog_title="$APP_NAME settings" - host_dialog_text="Configured Windows host.\n\nEdit the current host entry used by InputFlow." - host_entry_text="$host" - fi + gui_output="$(python3 "$SCRIPT_DIR/src/ConfigDialog.py" "$APP_NAME Settings" "$fields" "$values" || true)" + [[ -n "$gui_output" ]] || return 1 + + IFS='|' read -r host machine_name port screen_width screen_height clipboard_poll_ms mpris_player clipboard_enabled clipboard_send_enabled clipboard_force_poll mpris_media_keys_enabled latency_report <<< "$gui_output" - host="$(zenity --entry --title="$host_dialog_title" --text="$host_dialog_text" --entry-text="$host_entry_text" || true)" - [[ -n "$host" ]] || return 1 + # Validation + if ! is_integer_in_range "$port" 1 65535; then zenity --error --text="Port must be 1-65535."; return 1; fi + # Authentication (Keep Zenity for secret-tool branching) while true; do - auth_action="$(zenity --list --radiolist --title="$APP_NAME authentication" --width=520 --height=220 \ - --text="Current authentication: $current_auth_mode" \ + auth_action="$(zenity --list --radiolist --title="$APP_NAME Auth" --width=500 --height=220 \ + --text="Method: $current_auth_mode" \ --column="Use" --column="Action" \ - TRUE "Edit authentication settings" \ - FALSE "Reveal current key" || true)" + TRUE "Change method" \ + FALSE "Reveal key" \ + FALSE "Continue" || true)" [[ -n "$auth_action" ]] || return 1 - if [[ "$auth_action" != "Reveal current key" ]]; then - break + [[ "$auth_action" == "Continue" ]] && break + if [[ "$auth_action" == "Reveal key" ]]; then + show_current_security_key "$key" "$key_file" "$secret_id" "$current_auth_mode" || true + continue fi - show_current_security_key "$key" "$key_file" "$secret_id" "$current_auth_mode" || true - done - key_mode="$(zenity --list --radiolist --title="$APP_NAME authentication" --width=500 --height=220 \ - --column="Use" --column="Method" \ - $([[ "$current_auth_mode" == "Inline security key" ]] && printf 'TRUE' || printf 'FALSE') "Inline security key" \ - $([[ "$current_auth_mode" == "Key file path" ]] && printf 'TRUE' || printf 'FALSE') "Key file path" \ - $([[ "$current_auth_mode" == "Secret Service entry" ]] && printf 'TRUE' || printf 'FALSE') "Secret Service entry" || true)" - [[ -n "$key_mode" ]] || return 1 - - if [[ "$key_mode" == "Inline security key" ]]; then - local entered_key - entered_key="$(zenity --password --title="$APP_NAME settings" --text="Security key$([[ "$current_auth_mode" == "Inline security key" && -n "$key" ]] && printf ' (leave blank to keep current key)')" || true)" - if [[ -n "$entered_key" ]]; then - key="$entered_key" - elif [[ -z "$key" || "$current_auth_mode" != "Inline security key" ]]; then - zenity --error --text="Enter a security key to use inline." - return 1 - fi - key_file="" - cleanup_secret_id="$(choose_secret_cleanup_target "$secret_id" "$key_mode" "" || true)" - secret_id="" - else - key="" - if [[ "$key_mode" == "Key file path" ]]; then - local entered_key_file key_file_dialog_path - key_file_dialog_path="${key_file:-$HOME/}" - if [[ -n "$key_file" ]]; then - key_file_dialog_path="$(resolve_config_relative_path "$key_file")" - fi - entered_key_file="$(zenity --file-selection --title="$APP_NAME settings" --filename="$key_file_dialog_path" || true)" + key_mode="$(zenity --list --radiolist --title="$APP_NAME Method" --width=500 --height=220 \ + --column="Use" --column="Method" \ + $([[ "$current_auth_mode" == "Inline security key" ]] && printf 'TRUE' || printf 'FALSE') "Inline security key" \ + $([[ "$current_auth_mode" == "Key file path" ]] && printf 'TRUE' || printf 'FALSE') "Key file path" \ + $([[ "$current_auth_mode" == "Secret Service entry" ]] && printf 'TRUE' || printf 'FALSE') "Secret Service entry" || true)" + [[ -n "$key_mode" ]] || return 1 + + if [[ "$key_mode" == "Inline security key" ]]; then + local entered_key + entered_key="$(zenity --password --title="$APP_NAME Key" --text="Security key" || true)" + if [[ -n "$entered_key" ]]; then key="$entered_key"; fi + key_file=""; cleanup_secret_id="$(choose_secret_cleanup_target "$secret_id" "$key_mode" "" || true)"; secret_id="" + elif [[ "$key_mode" == "Key file path" ]]; then + local entered_key_file + entered_key_file="$(zenity --file-selection --title="$APP_NAME Key File" || true)" [[ -n "$entered_key_file" ]] || return 1 - key_file="$entered_key_file" - cleanup_secret_id="$(choose_secret_cleanup_target "$secret_id" "$key_mode" "" || true)" - secret_id="" + key_file="$entered_key_file"; key=""; cleanup_secret_id="$(choose_secret_cleanup_target "$secret_id" "$key_mode" "" || true)"; secret_id="" else local entered_secret_id entered_secret_key - entered_secret_id="$(zenity --entry --title="$APP_NAME settings" --text="Secret Service identifier" --entry-text="$secret_id" || true)" + entered_secret_id="$(zenity --entry --title="$APP_NAME Secret ID" --text="Identifier" --entry-text="$secret_id" || true)" [[ -n "$entered_secret_id" ]] || return 1 - entered_secret_key="$(zenity --password --title="$APP_NAME settings" --text="Security key to store in Secret Service$([[ "$current_auth_mode" == "Secret Service entry" && "$entered_secret_id" == "$secret_id" ]] && printf ' (leave blank to keep the stored key)')" || true)" - if [[ -n "$entered_secret_key" ]]; then - store_secret_service_key "$entered_secret_id" "$entered_secret_key" || return 1 - elif [[ "$current_auth_mode" != "Secret Service entry" || "$entered_secret_id" != "$secret_id" ]]; then - zenity --error --text="Enter a security key to store for the selected Secret Service identifier." - return 1 - fi - unset entered_secret_key - key_file="" - cleanup_secret_id="$(choose_secret_cleanup_target "$secret_id" "$key_mode" "$entered_secret_id" || true)" - secret_id="$entered_secret_id" + entered_secret_key="$(zenity --password --title="$APP_NAME Secret Key" --text="Key to store" || true)" + if [[ -n "$entered_secret_key" ]]; then store_secret_service_key "$entered_secret_id" "$entered_secret_key" || return 1; fi + key_file=""; key=""; cleanup_secret_id="$(choose_secret_cleanup_target "$secret_id" "$key_mode" "$entered_secret_id" || true)"; secret_id="$entered_secret_id" fi - fi - - machine_name="$(zenity --entry --title="$APP_NAME settings" --text="Machine name" --entry-text="$machine_name" || true)" - [[ -n "$machine_name" ]] || machine_name="" - port="$(zenity --entry --title="$APP_NAME settings" --text="Port" --entry-text="$port" || true)" - [[ -n "$port" ]] || return 1 - if ! is_integer_in_range "$port" 1 65535; then - zenity --error --text="Port must be an integer between 1 and 65535." - return 1 - fi - screen_width="$(zenity --entry --title="$APP_NAME settings" --text="Screen width override (blank for automatic)" --entry-text="$screen_width" || true)" - if [[ -n "$screen_width" ]] && ! is_integer_in_range "$screen_width" 1 2147483647; then - zenity --error --text="Screen width must be blank or a positive integer." - return 1 - fi - screen_height="$(zenity --entry --title="$APP_NAME settings" --text="Screen height override (blank for automatic)" --entry-text="$screen_height" || true)" - if [[ -n "$screen_height" ]] && ! is_integer_in_range "$screen_height" 1 2147483647; then - zenity --error --text="Screen height must be blank or a positive integer." - return 1 - fi - if { [[ -n "$screen_width" && -z "$screen_height" ]] || [[ -z "$screen_width" && -n "$screen_height" ]]; }; then - zenity --error --text="Set both screen width and screen height, or leave both blank for automatic sizing." - return 1 - fi - clipboard_poll_ms="$(zenity --entry --title="$APP_NAME settings" --text="Clipboard poll interval (ms)" --entry-text="$clipboard_poll_ms" || true)" - [[ -n "$clipboard_poll_ms" ]] || return 1 - if ! is_integer_in_range "$clipboard_poll_ms" 1 2147483647; then - zenity --error --text="Clipboard poll interval must be a positive integer." - return 1 - fi - - local toggles - toggles="$(zenity --list --checklist --title="$APP_NAME clipboard options" --width=500 --height=250 \ - --column="Enabled" --column="Option" \ - $([[ "$clipboard_enabled" == "true" ]] && printf 'TRUE' || printf 'FALSE') "Enable clipboard sync" \ - $([[ "$clipboard_send_enabled" == "true" ]] && printf 'TRUE' || printf 'FALSE') "Send local clipboard changes" \ - $([[ "$clipboard_force_poll" == "true" ]] && printf 'TRUE' || printf 'FALSE') "Force Wayland polling fallback" \ - --separator='|' || true)" - - clipboard_enabled="false" - clipboard_send_enabled="false" - clipboard_force_poll="false" - [[ "$toggles" == *"Enable clipboard sync"* ]] && clipboard_enabled="true" - [[ "$toggles" == *"Send local clipboard changes"* ]] && clipboard_send_enabled="true" - [[ "$toggles" == *"Force Wayland polling fallback"* ]] && clipboard_force_poll="true" - - mpris_player="$(zenity --entry --title="$APP_NAME media keys" --text="MPRIS player name (blank for active player)" --entry-text="$mpris_player" || true)" - toggles="$(zenity --list --checklist --title="$APP_NAME media keys" --width=500 --height=180 \ - --column="Enabled" --column="Option" \ - $([[ "$mpris_media_keys_enabled" == "true" ]] && printf 'TRUE' || printf 'FALSE') "Dispatch media keys through MPRIS/playerctl" \ - $([[ "$latency_report" == "true" ]] && printf 'TRUE' || printf 'FALSE') "Print input latency report when service stops" \ - --separator='|' || true)" - mpris_media_keys_enabled="false" - latency_report="false" - [[ "$toggles" == *"Dispatch media keys through MPRIS/playerctl"* ]] && mpris_media_keys_enabled="true" - [[ "$toggles" == *"Print input latency report when service stops"* ]] && latency_report="true" + current_auth_mode="$key_mode" + break + done write_config "$host" "$key" "$key_file" "$secret_id" "$machine_name" "$port" "$auto_connect_enabled" "$reconnect_initial_backoff_ms" "$reconnect_max_backoff_ms" "$reconnect_idle_retry_ms" "$clipboard_enabled" "$clipboard_send_enabled" "$clipboard_force_poll" "$clipboard_poll_ms" "$screen_width" "$screen_height" "$mpris_media_keys_enabled" "$mpris_player" "$latency_report" "$secret_key_name" - if [[ -n "$preset_host" ]]; then - saved_message="Saved $host as the configured Windows host in $CONFIG_PATH" - else - saved_message="Saved settings to $CONFIG_PATH" - fi - if [[ -n "$cleanup_secret_id" ]]; then - if clear_secret_service_key "$cleanup_secret_id"; then - saved_message+=$'\nCleared the previous Secret Service entry.' - else - saved_message+=$'\nThe previous Secret Service entry could not be cleared.' - fi - fi - zenity --info --text="$saved_message" - offer_service_restart_if_active "Settings were updated while the background service is running." + zenity --info --text="Settings saved." + offer_service_restart_if_active "Settings updated." } edit_connection_behavior() { local host key key_file secret_id secret_key_name machine_name port screen_width screen_height auto_connect_enabled reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms clipboard_enabled clipboard_send_enabled clipboard_force_poll clipboard_poll_ms mpris_media_keys_enabled mpris_player latency_report - local toggles summary_text + local gui_output host="$(read_config_value host)" key="$(read_config_value key)" @@ -1053,47 +937,21 @@ edit_connection_behavior() { mpris_player="$(read_config_value "$MPRIS_PLAYER_CONFIG_KEY")" latency_report="$(read_config_value "$LATENCY_REPORT_CONFIG_KEY")"; [[ -n "$latency_report" ]] || latency_report="false" - summary_text="$(connection_behavior_summary "$auto_connect_enabled" "$reconnect_initial_backoff_ms" "$reconnect_max_backoff_ms" "$reconnect_idle_retry_ms")" - toggles="$(zenity --list --checklist --title="$APP_NAME connection behavior" --width=560 --height=220 \ - --text="$summary_text" \ - --column="Enabled" --column="Option" \ - $([[ "$auto_connect_enabled" == "true" ]] && printf 'TRUE' || printf 'FALSE') "Automatically reconnect to the configured Windows host" \ - --separator='|' || true)" + local fields="mode:Connection Mode|Auto-reconnect to host|Manual start only:combo||initial:Initial Retry Delay (ms):entry||max:Maximum Retry Delay (ms):entry||idle:Idle Retry Interval (ms):entry" + local mode_val="$( [[ "$auto_connect_enabled" == "true" ]] && printf 'Auto-reconnect to host' || printf 'Manual start only' )" + local values="$mode_val|$reconnect_initial_backoff_ms|$reconnect_max_backoff_ms|$reconnect_idle_retry_ms" - auto_connect_enabled="false" - [[ "$toggles" == *"Automatically reconnect to the configured Windows host"* ]] && auto_connect_enabled="true" + gui_output="$(python3 "$SCRIPT_DIR/src/ConfigDialog.py" "$APP_NAME Connection" "$fields" "$values" || true)" + [[ -n "$gui_output" ]] || return 1 - reconnect_initial_backoff_ms="$(zenity --entry --title="$APP_NAME connection behavior" --text="Initial retry delay in milliseconds" --entry-text="$reconnect_initial_backoff_ms" || true)" - [[ -n "$reconnect_initial_backoff_ms" ]] || return 1 - reconnect_max_backoff_ms="$(zenity --entry --title="$APP_NAME connection behavior" --text="Maximum retry delay in milliseconds" --entry-text="$reconnect_max_backoff_ms" || true)" - [[ -n "$reconnect_max_backoff_ms" ]] || return 1 - reconnect_idle_retry_ms="$(zenity --entry --title="$APP_NAME connection behavior" --text="Idle retry interval in milliseconds once the peer stays offline" --entry-text="$reconnect_idle_retry_ms" || true)" - [[ -n "$reconnect_idle_retry_ms" ]] || return 1 + IFS='|' read -r mode_label reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms <<< "$gui_output" - if ! is_integer_in_range "$reconnect_initial_backoff_ms" 1 2147483647; then - zenity --error --text="Initial retry delay must be a positive integer." - return 1 - fi - if ! is_integer_in_range "$reconnect_max_backoff_ms" 1 2147483647; then - zenity --error --text="Maximum retry delay must be a positive integer." - return 1 - fi - if ! is_integer_in_range "$reconnect_idle_retry_ms" 1 2147483647; then - zenity --error --text="Idle retry interval must be a positive integer." - return 1 - fi - if (( reconnect_initial_backoff_ms > reconnect_max_backoff_ms )); then - zenity --error --text="Initial retry delay cannot exceed the maximum retry delay." - return 1 - fi - if (( reconnect_idle_retry_ms < reconnect_max_backoff_ms )); then - zenity --error --text="Idle retry interval should be greater than or equal to the maximum retry delay." - return 1 - fi + auto_connect_enabled="false" + [[ "$mode_label" == "Auto-reconnect to host" ]] && auto_connect_enabled="true" write_config "$host" "$key" "$key_file" "$secret_id" "$machine_name" "$port" "$auto_connect_enabled" "$reconnect_initial_backoff_ms" "$reconnect_max_backoff_ms" "$reconnect_idle_retry_ms" "$clipboard_enabled" "$clipboard_send_enabled" "$clipboard_force_poll" "$clipboard_poll_ms" "$screen_width" "$screen_height" "$mpris_media_keys_enabled" "$mpris_player" "$latency_report" "$secret_key_name" - zenity --info --text="Saved connection behavior to $CONFIG_PATH" - offer_service_restart_if_active "Connection behavior was updated while the background service is running." + zenity --info --text="Connection behavior saved." + offer_service_restart_if_active "Connection behavior updated." } show_tray_visibility_help() { @@ -1242,31 +1100,35 @@ EOF main_menu() { while true; do local choice - choice="$(zenity --list --title="$APP_NAME" --text="$(menu_summary_text)" --width=540 --height=380 \ + choice="$(zenity --list --title="$APP_NAME" --text="$(menu_summary_text)" --width=540 --height=400 \ --column="Action" \ - "Open settings" \ - "Connection behavior" \ - "Discover peers" \ - "Start background service" \ - "Restart background service" \ - "Stop background service" \ - "Show known peers" \ - "Tray visibility help" \ - "Show service details" \ - "Install desktop entries" \ + "Settings" \ + "Peers (Discovery & Known)" \ + "Connection Behavior" \ + "Start Service" \ + "Stop Service" \ + "Restart Service" \ + "Show Service Details" \ + "Install Desktop Entries" \ + "Tray Visibility Help" \ "Quit" || true)" case "$choice" in - "Open settings") edit_settings ;; - "Connection behavior") edit_connection_behavior ;; - "Discover peers") discover_and_save_peer ;; - "Start background service") start_session ;; - "Restart background service") restart_session ;; - "Stop background service") stop_session ;; - "Show known peers") show_peers ;; - "Tray visibility help") show_tray_visibility_help ;; - "Show service details") show_status ;; - "Install desktop entries") install_desktop_entry ;; + "Settings") edit_settings ;; + "Peers (Discovery & Known)") + local peer_choice + peer_choice="$(zenity --list --title="$APP_NAME Peers" --text="Manage peers" --width=400 --height=250 \ + --column="Action" "Discover Peers" "Known Peers" "Back" || true)" + [[ "$peer_choice" == "Discover Peers" ]] && discover_and_save_peer + [[ "$peer_choice" == "Known Peers" ]] && show_peers + ;; + "Connection Behavior") edit_connection_behavior ;; + "Start Service") start_session ;; + "Stop Service") stop_session ;; + "Restart Service") restart_session ;; + "Show Service Details") show_status ;; + "Install Desktop Entries") install_desktop_entry ;; + "Tray Visibility Help") show_tray_visibility_help ;; ""|"Quit") exit 0 ;; esac done diff --git a/src/AppConfig.h b/src/AppConfig.h index 463db7f..9c8f716 100644 --- a/src/AppConfig.h +++ b/src/AppConfig.h @@ -17,7 +17,7 @@ struct AppConfig { bool clipboardEnabled{true}; bool clipboardSendEnabled{true}; bool clipboardForcePoll{false}; - int clipboardPollMs{1000}; + int clipboardPollMs{5000}; bool autoConnectEnabled{true}; int reconnectInitialBackoffMs{1000}; int reconnectMaxBackoffMs{30000}; diff --git a/src/ClientRuntime.cpp b/src/ClientRuntime.cpp index b750d8e..18ebbad 100644 --- a/src/ClientRuntime.cpp +++ b/src/ClientRuntime.cpp @@ -307,24 +307,34 @@ int ClientRuntime::Run() { if (m_clipboard) { m_clipboardBackendName = m_clipboard->BackendName(); std::cout << "[INFO] Clipboard backend: " << m_clipboardBackendName << std::endl; - m_lastClipboardText = m_clipboard->GetText(); - if (m_lastClipboardText.has_value() && !m_lastClipboardText->empty()) { - m_network->PrimeLocalClipboardText(*m_lastClipboardText); + m_lastClipboardPayload = m_clipboard->GetPayload(); + if (m_lastClipboardPayload.has_value()) { + m_network->PrimeLocalClipboardPayload(*m_lastClipboardPayload); } m_network->SetClipboardProvider(m_clipboard->MakeProvider()); - m_network->SetOnClipboardCallback([this](const std::string& text) { - if (!m_clipboard->SetText(text)) { - std::cerr << "WARN: Failed to write incoming clipboard text through backend '" - << m_clipboard->BackendName() << "'." << std::endl; - return; - } + m_network->SetOnClipboardCallback([this](const ClipboardPayload& payload) { + std::thread([this, payload]() { + if (!m_clipboard->SetPayload(payload)) { + std::cerr << "WARN: Failed to write incoming clipboard payload through backend '" + << m_clipboard->BackendName() << "'." << std::endl; + return; + } - { - std::lock_guard lock(m_clipboardStateMutex); - m_lastClipboardText = text; - } + { + std::lock_guard lock(m_clipboardStateMutex); + m_lastClipboardPayload = payload; + } - std::cout << "[CLIPBOARD] Received text update (" << text.size() << " bytes)" << std::endl; + if (payload.image) { + std::cout << "[CLIPBOARD] Received image update (" << payload.image->bytes.size() << " bytes). Header: "; + for (std::size_t i = 0; i < std::min(payload.image->bytes.size(), static_cast(8)); ++i) { + printf("%02x ", payload.image->bytes[i]); + } + std::cout << std::endl; + } else if (payload.plainText) { + std::cout << "[CLIPBOARD] Received text update (" << payload.plainText->size() << " bytes)" << std::endl; + } + }).detach(); }); } else if (!m_options.clipboardEnabled) { std::cerr << "WARN: Clipboard sync disabled by configuration." << std::endl; @@ -385,23 +395,30 @@ void ClientRuntime::StartClipboardWatcher() { m_clipboardWatcherRunning = true; m_clipboardWatcher = std::thread([this]() { - const auto handleClipboardText = [this](const std::string& text) { + const auto handleClipboardPayload = [this](const ClipboardPayload& payload) { bool changed = false; { std::lock_guard lock(m_clipboardStateMutex); - if (!m_lastClipboardText || *m_lastClipboardText != text) { - m_lastClipboardText = text; + if (!m_lastClipboardPayload || + m_lastClipboardPayload->plainText != payload.plainText || + (m_lastClipboardPayload->image.has_value() != payload.image.has_value()) || + (payload.image && m_lastClipboardPayload->image && payload.image->bytes != m_lastClipboardPayload->image->bytes)) { + m_lastClipboardPayload = payload; changed = true; } } if (changed && m_network) { - std::cout << "[CLIPBOARD] Local text changed (" << text.size() << " bytes)" << std::endl; - m_network->NotifyLocalClipboardChanged(text); + if (payload.image) { + std::cout << "[CLIPBOARD] Local image changed (" << payload.image->bytes.size() << " bytes)" << std::endl; + } else if (payload.plainText) { + std::cout << "[CLIPBOARD] Local text changed (" << payload.plainText->size() << " bytes)" << std::endl; + } + m_network->NotifyLocalClipboardChanged(payload); } }; - if (m_clipboard->WatchTextChanges(m_clipboardWatcherRunning, handleClipboardText)) { + if (m_clipboard->WatchPayloadChanges(m_clipboardWatcherRunning, handleClipboardPayload)) { return; } @@ -415,9 +432,9 @@ void ClientRuntime::StartClipboardWatcher() { } while (m_clipboardWatcherRunning) { - const auto currentText = m_clipboard->GetText(); - if (currentText.has_value()) { - handleClipboardText(*currentText); + const auto currentPayload = m_clipboard->GetPayload(); + if (currentPayload.has_value()) { + handleClipboardPayload(*currentPayload); } std::this_thread::sleep_for(std::chrono::milliseconds(m_options.clipboardPollMs)); diff --git a/src/ClientRuntime.h b/src/ClientRuntime.h index c721ea5..0b5ac76 100644 --- a/src/ClientRuntime.h +++ b/src/ClientRuntime.h @@ -78,7 +78,7 @@ class ClientRuntime { std::atomic m_clipboardWatcherRunning{false}; std::thread m_clipboardWatcher; std::mutex m_clipboardStateMutex; - std::optional m_lastClipboardText; + std::optional m_lastClipboardPayload; std::string m_clipboardBackendName; }; diff --git a/src/ClipboardManager.cpp b/src/ClipboardManager.cpp index 796b38f..3db92c7 100644 --- a/src/ClipboardManager.cpp +++ b/src/ClipboardManager.cpp @@ -33,32 +33,46 @@ class ExternalCommandClipboardBackend final : public ClipboardBackend { public: ExternalCommandClipboardBackend( std::string name, - CommandSpec readCommand, + CommandSpec listTypesCommand, + CommandSpec readTextCommand, + CommandSpec readHtmlCommand, + CommandSpec readImageCommand, CommandSpec writeCommand, CommandSpec watchCommand = {}, CommandSpec watchReadCommand = {}, std::string watchSignalNeedle = {}) : m_name(std::move(name)), - m_readCommand(std::move(readCommand)), + m_listTypesCommand(std::move(listTypesCommand)), + m_readTextCommand(std::move(readTextCommand)), + m_readHtmlCommand(std::move(readHtmlCommand)), + m_readImageCommand(std::move(readImageCommand)), m_writeCommand(std::move(writeCommand)), m_watchCommand(std::move(watchCommand)), m_watchReadCommand(std::move(watchReadCommand)), m_watchSignalNeedle(std::move(watchSignalNeedle)) {} - std::optional ReadText() override; - bool WriteText(const std::string& text) override; + std::optional ReadPayload() override; + bool WritePayload(const ClipboardPayload& payload) override; std::string Name() const override { return m_name; } - bool WatchTextChanges( + bool WatchPayloadChanges( const std::atomic& running, - const std::function& onTextChange) override; + const std::function& onPayloadChange) override; private: std::string m_name; - CommandSpec m_readCommand; + CommandSpec m_listTypesCommand; + CommandSpec m_readTextCommand; + CommandSpec m_readHtmlCommand; + CommandSpec m_readImageCommand; CommandSpec m_writeCommand; CommandSpec m_watchCommand; CommandSpec m_watchReadCommand; std::string m_watchSignalNeedle; + + std::mutex m_cacheMutex; + std::string m_lastTypes; + std::optional m_lastPlainText; + std::optional m_cachedPayload; }; bool writeAll(int fd, const uint8_t* data, std::size_t length) { @@ -119,7 +133,7 @@ std::optional findExecutable(const std::string& executable) { return std::nullopt; } -std::optional runReadCommand(const CommandSpec& command) { +std::optional> runReadBytesCommand(const CommandSpec& command) { if (command.argv.empty()) { return std::nullopt; } @@ -171,7 +185,15 @@ std::optional runReadCommand(const CommandSpec& command) { return std::nullopt; } - return std::string(output.begin(), output.end()); + return output; +} + +std::optional runReadCommand(const CommandSpec& command) { + auto bytes = runReadBytesCommand(command); + if (!bytes.has_value()) { + return std::nullopt; + } + return std::string(bytes->begin(), bytes->end()); } void trimTrailingNewlines(std::string& text) { @@ -227,10 +249,58 @@ bool runWriteCommand(const CommandSpec& command, const std::string& input) { return wroteOk && WIFEXITED(status) && WEXITSTATUS(status) == 0; } +bool runWriteBytesCommand(const CommandSpec& command, const std::vector& input) { + if (command.argv.empty()) { + return false; + } + + int stdinPipe[2]; + if (pipe(stdinPipe) != 0) { + return false; + } + + const pid_t pid = fork(); + if (pid < 0) { + close(stdinPipe[0]); + close(stdinPipe[1]); + return false; + } + + if (pid == 0) { + dup2(stdinPipe[0], STDIN_FILENO); + close(stdinPipe[0]); + close(stdinPipe[1]); + + std::vector argv; + argv.reserve(command.argv.size() + 1); + for (const std::string& arg : command.argv) { + argv.push_back(const_cast(arg.c_str())); + } + argv.push_back(nullptr); + + execv(argv[0], argv.data()); + _exit(127); + } + + close(stdinPipe[0]); + const bool wroteOk = writeAll( + stdinPipe[1], + input.data(), + input.size()); + close(stdinPipe[1]); + + int status = 0; + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) { + } + + return wroteOk && WIFEXITED(status) && WEXITSTATUS(status) == 0; +} + bool runWatchCommand( const CommandSpec& command, const std::atomic& running, - const std::function& onTextChange) { + const std::function& onPayloadChange, + const std::function()>& readPayload) { if (command.argv.empty()) { return false; } @@ -303,9 +373,11 @@ bool runWatchCommand( buffered.append(chunk.data(), static_cast(count)); std::size_t separator = 0; while ((separator = buffered.find('\0')) != std::string::npos) { - const std::string text = buffered.substr(0, separator); - onTextChange(text); buffered.erase(0, separator + 1); + auto payload = readPayload(); + if (payload.has_value()) { + onPayloadChange(*payload); + } } } @@ -323,10 +395,6 @@ bool runWatchCommand( while (waitpid(pid, &status, 0) < 0 && errno == EINTR) { } - if (!buffered.empty()) { - onTextChange(buffered); - } - return !exitedUnexpectedly; } @@ -335,8 +403,10 @@ bool runSignalWatchCommand( const CommandSpec& readCommand, const std::string& signalNeedle, const std::atomic& running, - const std::function& onTextChange) { - if (watchCommand.argv.empty() || readCommand.argv.empty() || signalNeedle.empty()) { + const std::function& onPayloadChange, + const std::function()>& readPayload) { + (void)readCommand; // Not needed if we use readPayload instead + if (watchCommand.argv.empty() || signalNeedle.empty()) { return false; } @@ -414,10 +484,9 @@ bool runSignalWatchCommand( continue; } - auto text = runReadCommand(readCommand); - if (text.has_value()) { - trimTrailingNewlines(*text); - onTextChange(*text); + auto payload = readPayload(); + if (payload.has_value()) { + onPayloadChange(*payload); } } } @@ -438,6 +507,7 @@ bool runSignalWatchCommand( return !exitedUnexpectedly; } + std::u16string utf8ToUtf16(std::string_view text) { std::wstring_convert, char16_t> converter; return converter.from_bytes(text.data(), text.data() + text.size()); @@ -936,32 +1006,42 @@ std::unique_ptr createBackend() { const CommandSpec readCommand{{*wlPaste, "--no-newline"}}; return std::make_unique( "wl-clipboard-klipper", + CommandSpec{{*wlPaste, "--list-types"}}, readCommand, + CommandSpec{{*wlPaste, "--no-newline", "--type", "text/html"}}, + CommandSpec{{*wlPaste, "--no-newline", "--type", "image/png"}}, CommandSpec{{*wlCopy}}, - CommandSpec{{*gdbus, "monitor", "--session", "--dest", "org.kde.klipper", "--object-path", "/klipper"}}, - readCommand, - "clipboardChanged"); + CommandSpec{{*wlPaste, "--no-newline", "--watch", *shell, "-c", "printf '\\0'"}} ); } if (hasWayland && shell && wlPaste && wlCopy) { return std::make_unique( "wl-clipboard", + CommandSpec{{*wlPaste, "--list-types"}}, CommandSpec{{*wlPaste, "--no-newline"}}, + CommandSpec{{*wlPaste, "--no-newline", "--type", "text/html"}}, + CommandSpec{{*wlPaste, "--no-newline", "--type", "image/png"}}, CommandSpec{{*wlCopy}}, - CommandSpec{{*wlPaste, "--no-newline", "--watch", *shell, "-c", "cat; printf '\\0'"}} ); + CommandSpec{{*wlPaste, "--no-newline", "--watch", *shell, "-c", "printf '\\0'"}} ); } if (hasDisplay && xclip) { return std::make_unique( "xclip", - CommandSpec{{*xclip, "-selection", "clipboard", "-out"}}, - CommandSpec{{*xclip, "-selection", "clipboard", "-in"}}); + CommandSpec{{*xclip, "-selection", "clipboard", "-t", "TARGETS", "-o"}}, + CommandSpec{{*xclip, "-selection", "clipboard", "-o"}}, + CommandSpec{{*xclip, "-selection", "clipboard", "-t", "text/html", "-o"}}, + CommandSpec{{*xclip, "-selection", "clipboard", "-t", "image/png", "-o"}}, + CommandSpec{{*xclip, "-selection", "clipboard", "-i"}}); } if (hasDisplay && xsel) { return std::make_unique( "xsel", + CommandSpec{}, // xsel doesn't easily list types CommandSpec{{*xsel, "--clipboard", "--output"}}, + CommandSpec{}, + CommandSpec{}, CommandSpec{{*xsel, "--clipboard", "--input"}}); } @@ -970,35 +1050,111 @@ std::unique_ptr createBackend() { } // namespace -std::optional ExternalCommandClipboardBackend::ReadText() { - auto output = runReadCommand(m_readCommand); - if (!output.has_value()) { - return std::nullopt; +std::optional ExternalCommandClipboardBackend::ReadPayload() { + auto types = runReadCommand(m_listTypesCommand); + auto plainText = runReadCommand(m_readTextCommand); + + { + std::lock_guard lock(m_cacheMutex); + if (m_cachedPayload && types == m_lastTypes && plainText == m_lastPlainText) { + return m_cachedPayload; + } + m_lastTypes = types.value_or(""); + m_lastPlainText = plainText; } - while (!output->empty() && (output->back() == '\n' || output->back() == '\r')) { - output->pop_back(); + ClipboardPayload payload; + payload.plainText = plainText; + + bool hasHtml = false; + bool hasImage = false; + if (types.has_value()) { + hasHtml = types->find("text/html") != std::string::npos; + hasImage = types->find("image/png") != std::string::npos || types->find("image/jpeg") != std::string::npos; } - return output; + + if (hasImage) { + auto bytes = runReadBytesCommand(m_readImageCommand); + if (bytes.has_value()) { + payload.image = ClipboardImagePayload{"image/png", std::move(*bytes), std::nullopt, std::nullopt}; + } + } + + if (hasHtml) { + auto html = runReadCommand(m_readHtmlCommand); + if (html.has_value()) { + payload.html = parseHtmlClipboardValue(*html); + } + } + + { + std::lock_guard lock(m_cacheMutex); + m_cachedPayload = payload; + } + + if (!payload.plainText && !payload.html && !payload.image) { + return std::nullopt; + } + return payload; } -bool ExternalCommandClipboardBackend::WriteText(const std::string& text) { - return runWriteCommand(m_writeCommand, text); +bool ExternalCommandClipboardBackend::WritePayload(const ClipboardPayload& payload) { + { + std::lock_guard lock(m_cacheMutex); + m_cachedPayload = payload; + m_lastTypes.clear(); // Force re-list on next poll + m_lastPlainText = payload.plainText; + } + + if (payload.image) { + CommandSpec cmd = m_writeCommand; + if (m_name.find("wl-clipboard") != std::string::npos) { + cmd.argv.push_back("-t"); + cmd.argv.push_back(payload.image->mimeType); + } else if (m_name == "xclip") { + cmd.argv.push_back("-t"); + cmd.argv.push_back(payload.image->mimeType); + } + return runWriteBytesCommand(cmd, payload.image->bytes); + } + + if (payload.html && payload.html->fragment && !payload.html->fragment->empty()) { + if (m_name.find("wl-clipboard") != std::string::npos) { + CommandSpec htmlCmd = m_writeCommand; + htmlCmd.argv.push_back("--type"); + htmlCmd.argv.push_back("text/html"); + return runWriteCommand(htmlCmd, *payload.html->fragment); + } else if (m_name == "xclip") { + CommandSpec htmlCmd = m_writeCommand; + htmlCmd.argv.push_back("-t"); + htmlCmd.argv.push_back("text/html"); + return runWriteCommand(htmlCmd, *payload.html->fragment); + } + } + + if (payload.plainText) { + return runWriteCommand(m_writeCommand, *payload.plainText); + } + + return false; } -bool ExternalCommandClipboardBackend::WatchTextChanges( +bool ExternalCommandClipboardBackend::WatchPayloadChanges( const std::atomic& running, - const std::function& onTextChange) { + const std::function& onPayloadChange) { + const auto readFn = [this]() { return ReadPayload(); }; + if (!m_watchReadCommand.argv.empty() && !m_watchSignalNeedle.empty()) { return runSignalWatchCommand( m_watchCommand, m_watchReadCommand, m_watchSignalNeedle, running, - onTextChange); + onPayloadChange, + readFn); } - return runWatchCommand(m_watchCommand, running, onTextChange); + return runWatchCommand(m_watchCommand, running, onPayloadChange, readFn); } ClipboardManager::ClipboardManager(std::unique_ptr backend) @@ -1017,38 +1173,38 @@ std::string ClipboardManager::BackendName() { return m_backend ? m_backend->Name() : "unavailable"; } -std::optional ClipboardManager::GetText() { +std::optional ClipboardManager::GetPayload() { std::lock_guard lock(m_backendMutex); if (!m_backend) { return std::nullopt; } - return m_backend->ReadText(); + return m_backend->ReadPayload(); } -bool ClipboardManager::SetText(const std::string& text) { +bool ClipboardManager::SetPayload(const ClipboardPayload& payload) { std::lock_guard lock(m_backendMutex); - return m_backend && m_backend->WriteText(text); + return m_backend && m_backend->WritePayload(payload); } -bool ClipboardManager::WatchTextChanges( +bool ClipboardManager::WatchPayloadChanges( const std::atomic& running, - const std::function& onTextChange) { + const std::function& onPayloadChange) { ClipboardBackend* backend = nullptr; { std::lock_guard lock(m_backendMutex); backend = m_backend.get(); } - return backend && backend->WatchTextChanges(running, onTextChange); + return backend && backend->WatchPayloadChanges(running, onPayloadChange); } -std::function()> ClipboardManager::MakeProvider() { - return [this]() { return GetText(); }; +std::function()> ClipboardManager::MakeProvider() { + return [this]() { return GetPayload(); }; } -std::function ClipboardManager::MakeConsumer() { - return [this](const std::string& text) { - (void)SetText(text); +std::function ClipboardManager::MakeConsumer() { + return [this](const ClipboardPayload& payload) { + (void)SetPayload(payload); }; } @@ -1058,6 +1214,10 @@ std::vector ClipboardManager::EncodeTextPayload(const std::string& text return deflateRaw(encodeUtf16Le(taggedText)); } +std::vector ClipboardManager::EncodeImagePayload(const std::vector& bytes) { + return bytes; // MWB images are often sent uncompressed or with their own compression over the socket +} + ClipboardPayload ClipboardManager::MakeTextPayload(const std::string& text) { ClipboardPayload payload; payload.plainText = text; @@ -1089,6 +1249,11 @@ std::optional ClipboardManager::DecodeTextPayload(const std::vector return decoded->plainText; } +std::optional> ClipboardManager::DecodeImagePayload(const std::vector& payload) { + return payload; // Identity for now +} + + std::vector ClipboardManager::EncodeSocketHeader(std::size_t payloadSize, const std::string& kind) { const std::u16string header = utf8ToUtf16(std::to_string(payloadSize) + "*" + kind); std::vector encoded = encodeUtf16Le(header); diff --git a/src/ClipboardManager.h b/src/ClipboardManager.h index b8642ba..385ca24 100644 --- a/src/ClipboardManager.h +++ b/src/ClipboardManager.h @@ -38,12 +38,12 @@ class ClipboardBackend { public: virtual ~ClipboardBackend() = default; - virtual std::optional ReadText() = 0; - virtual bool WriteText(const std::string& text) = 0; + virtual std::optional ReadPayload() = 0; + virtual bool WritePayload(const ClipboardPayload& payload) = 0; virtual std::string Name() const = 0; - virtual bool WatchTextChanges( + virtual bool WatchPayloadChanges( const std::atomic& running, - const std::function& onTextChange) { + const std::function& onPayloadChange) { return false; } }; @@ -55,20 +55,22 @@ class ClipboardManager { static std::unique_ptr CreateDefault(); std::string BackendName(); - std::optional GetText(); - bool SetText(const std::string& text); - bool WatchTextChanges( + std::optional GetPayload(); + bool SetPayload(const ClipboardPayload& payload); + bool WatchPayloadChanges( const std::atomic& running, - const std::function& onTextChange); + const std::function& onPayloadChange); - std::function()> MakeProvider(); - std::function MakeConsumer(); + std::function()> MakeProvider(); + std::function MakeConsumer(); static std::vector EncodeTextPayload(const std::string& text); + static std::vector EncodeImagePayload(const std::vector& bytes); static ClipboardPayload MakeTextPayload(const std::string& text); static ClipboardPayload MakeImagePayload(std::string mimeType, std::vector bytes); static std::optional DecodePayload(const std::vector& payload); static std::optional DecodeTextPayload(const std::vector& payload); + static std::optional> DecodeImagePayload(const std::vector& payload); static std::vector EncodeSocketHeader(std::size_t payloadSize, const std::string& kind); static bool DecodeSocketHeader( diff --git a/src/ConfigDialog.py b/src/ConfigDialog.py new file mode 100755 index 0000000..c150021 --- /dev/null +++ b/src/ConfigDialog.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +import sys +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +class ConfigDialog(Gtk.Window): + def __init__(self, title, fields, current_values): + super().__init__(title=title) + self.set_border_width(10) + self.set_default_size(450, -1) + self.set_resizable(False) + self.result = None + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self.add(vbox) + + grid = Gtk.Grid(column_spacing=10, row_spacing=10) + vbox.pack_start(grid, True, True, 0) + + self.widgets = {} + for i, (key, label, type) in enumerate(fields): + lbl = Gtk.Label(label=label, xalign=0) + grid.attach(lbl, 0, i, 1, 1) + + value = current_values.get(key, "") + + if type == "entry": + widget = Gtk.Entry() + widget.set_text(str(value)) + grid.attach(widget, 1, i, 1, 1) + self.widgets[key] = (widget, "entry") + elif type == "switch": + widget = Gtk.Switch() + widget.set_active(str(value).lower() == "true") + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + hbox.pack_start(widget, False, False, 0) + grid.attach(hbox, 1, i, 1, 1) + self.widgets[key] = (widget, "switch") + elif type == "combo": + options = label.split("|") # Hack to pass options + lbl.set_text(options[0]) + widget = Gtk.ComboBoxText() + for opt in options[1:]: + widget.append_text(opt) + + # Find current index + active_idx = 0 + for idx, opt in enumerate(options[1:]): + if opt == value: + active_idx = idx + break + widget.set_active(active_idx) + grid.attach(widget, 1, i, 1, 1) + self.widgets[key] = (widget, "combo") + + hbuttonbox = Gtk.ButtonBox(spacing=10, layout_style=Gtk.ButtonBoxStyle.END) + vbox.pack_start(hbuttonbox, False, False, 0) + + btn_cancel = Gtk.Button(label="Cancel") + btn_cancel.connect("clicked", self.on_cancel) + hbuttonbox.add(btn_cancel) + + btn_save = Gtk.Button(label="Save") + btn_save.connect("clicked", self.on_save) + btn_save.get_style_context().add_class("suggested-action") + hbuttonbox.add(btn_save) + + self.connect("destroy", Gtk.main_quit) + + def on_save(self, btn): + res = [] + for key, (widget, type) in self.widgets.items(): + if type == "entry": + res.append(widget.get_text()) + elif type == "switch": + res.append("true" if widget.get_active() else "false") + elif type == "combo": + res.append(widget.get_active_text()) + self.result = "|".join(res) + self.destroy() + + def on_cancel(self, btn): + self.destroy() + +def main(): + if len(sys.argv) < 4: + print("Usage: settings_gui.py TITLE FIELDS VALUES") + sys.exit(1) + + title = sys.argv[1] + raw_fields = sys.argv[2].split("||") + raw_values = sys.argv[3].split("|") + + fields = [] + current_values = {} + for i, field in enumerate(raw_fields): + name, label, type = field.split(":") + fields.append((name, label, type)) + if i < len(raw_values): + current_values[name] = raw_values[i] + + win = ConfigDialog(title, fields, current_values) + win.show_all() + Gtk.main() + + if win.result: + print(win.result) + sys.exit(0) + else: + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/src/NetworkManager.cpp b/src/NetworkManager.cpp index 3270fd3..544c916 100644 --- a/src/NetworkManager.cpp +++ b/src/NetworkManager.cpp @@ -98,6 +98,7 @@ const char* PackageTypeName(uint8_t type) { case PackageType::Heartbeat_ex: return "Heartbeat_ex"; case PackageType::Heartbeat_ex_l2: return "Heartbeat_ex_l2"; case PackageType::Heartbeat_ex_l3: return "Heartbeat_ex_l3"; + case PackageType::Heartbeat_v2: return "Heartbeat_v2"; default: return "Unknown"; } } @@ -471,21 +472,45 @@ std::optional> receivePacketOnSocket( return std::nullopt; } if (!packetHasValidMagicAndChecksum(plain, magic)) { - g_lastReceivePacketStatus = ReceivePacketStatus::Error; - if (DebugNetworkLoggingEnabled()) { - const uint8_t type = plain.empty() ? 0 : plain[0]; - std::cerr << "[RECV] First packet block failed magic/checksum validation." - << " type=" << static_cast(type) - << " magicBytes=0x" << std::hex - << static_cast(plain[2]) << static_cast(plain[3]) - << " expected=0x" << ((magic >> 16) & 0xFF) << ((magic >> 24) & 0xFF) - << std::dec << std::endl; - if (DebugPacketBytesLoggingEnabled()) { - std::cerr << "[RECV][INVALID] bytes=" << HexDump(plain.data(), plain.size()) - << std::endl; + const uint8_t type = plain.empty() ? 0 : plain[0]; + // PowerToys MWB sometimes uses zero magic for clipboard socket handshake packets. + const bool isClipboardSocketHandshake = (type == static_cast(PackageType::Clipboard) || + type == static_cast(PackageType::ClipboardPush) || + type == static_cast(PackageType::ClipboardAsk)); + const bool hasZeroMagic = (plain[2] == 0 && plain[3] == 0); + + if (isClipboardSocketHandshake && hasZeroMagic) { + // Validate checksum at least + uint8_t expectedChecksum = 0; + for (std::size_t index = 2; index < kSmallPacketSize; ++index) { + expectedChecksum = static_cast(expectedChecksum + plain[index]); + } + if (expectedChecksum == plain[1]) { + if (DebugNetworkLoggingEnabled()) { + std::cout << "[RECV] Accepting clipboard handshake packet with zero magic." << std::endl; + } + } else { + g_lastReceivePacketStatus = ReceivePacketStatus::Error; + return std::nullopt; + } + } else { + g_lastReceivePacketStatus = ReceivePacketStatus::Error; + if (DebugNetworkLoggingEnabled()) { + std::cerr << "[RECV] First packet block failed magic/checksum validation." + << " type=" << static_cast(type) + << " magicBytes=0x" << std::hex << std::setw(2) << std::setfill('0') << static_cast(plain[2]) + << std::setw(2) << std::setfill('0') << static_cast(plain[3]) + << " expected=0x" << std::setw(2) << std::setfill('0') << ((magic >> 16) & 0xFF) + << std::setw(2) << std::setfill('0') << ((magic >> 24) & 0xFF) + << " checksum=0x" << std::setw(2) << std::setfill('0') << static_cast(plain[1]) + << std::dec << std::endl; + if (DebugPacketBytesLoggingEnabled()) { + std::cerr << "[RECV][INVALID] bytes=" << HexDump(plain.data(), plain.size()) + << std::endl; + } } + return std::nullopt; } - return std::nullopt; } if (!isBigPackage(plain[0])) { @@ -603,11 +628,15 @@ bool exchangeClipboardHandshake( uint32_t magic, const std::string& machineName, uint32_t machineId, + uint32_t desId, const std::atomic& running, bool advertisePush, MWBPacket& remoteHeader, bool& remoteRequestsPush, std::string& remoteName) { + if (DebugNetworkLoggingEnabled()) { + std::cout << "[CLIPBOARD] Starting handshake, advertisePush=" << advertisePush << std::endl; + } std::vector noise(16); if (!FillRandomBytes(noise.data(), noise.size())) { return false; @@ -616,17 +645,20 @@ bool exchangeClipboardHandshake( std::vector encryptedNoise; if (!crypto.EncryptStream(noise, encryptedNoise) || !writeAll(fd, encryptedNoise.data(), encryptedNoise.size())) { + if (DebugNetworkLoggingEnabled()) std::cerr << "[CLIPBOARD] Handshake: Failed to write noise" << std::endl; return false; } uint8_t peerNoise[16]; if (!readExact(fd, peerNoise, sizeof(peerNoise), running)) { + if (DebugNetworkLoggingEnabled()) std::cerr << "[CLIPBOARD] Handshake: Failed to read noise" << std::endl; return false; } std::vector peerNoiseVec(peerNoise, peerNoise + sizeof(peerNoise)); std::vector ignoredNoise; if (!crypto.DecryptStream(peerNoiseVec, ignoredNoise)) { + if (DebugNetworkLoggingEnabled()) std::cerr << "[CLIPBOARD] Handshake: Failed to decrypt noise" << std::endl; return false; } @@ -635,14 +667,22 @@ bool exchangeClipboardHandshake( header.type = static_cast(advertisePush ? PackageType::ClipboardPush : PackageType::Clipboard); const uint32_t src = htole32(machineId); std::memcpy(&header.src, &src, sizeof(src)); + const uint32_t des = htole32(desId != 0 ? desId : kBroadcastMachineId); + std::memcpy(&header.des, &des, sizeof(des)); std::memset(&header.data[0], 0, sizeof(uint32_t)); // ClipboardPostAction::Other if (!sendPacketOnSocket(fd, crypto, magic, machineName, header, true, 0, 0, 0)) { + if (DebugNetworkLoggingEnabled()) std::cerr << "[CLIPBOARD] Handshake: Failed to send packet" << std::endl; return false; } auto packet = receivePacketOnSocket(fd, crypto, magic, running); - if (!packet || packet->size() < kBigPacketSize) { + if (!packet) { + if (DebugNetworkLoggingEnabled()) std::cerr << "[CLIPBOARD] Handshake: Failed to receive packet" << std::endl; + return false; + } + if (packet->size() < kBigPacketSize) { + if (DebugNetworkLoggingEnabled()) std::cerr << "[CLIPBOARD] Handshake: Received packet too small (" << packet->size() << ")" << std::endl; return false; } @@ -650,12 +690,19 @@ bool exchangeClipboardHandshake( if (!remote || (remote->type != static_cast(PackageType::Clipboard) && remote->type != static_cast(PackageType::ClipboardPush))) { + if (DebugNetworkLoggingEnabled()) { + std::cerr << "[CLIPBOARD] Handshake: Received unexpected packet type " + << (remote ? static_cast(remote->type) : -1) << std::endl; + } return false; } remoteHeader = *remote; remoteRequestsPush = (remote->type == static_cast(PackageType::ClipboardPush)); remoteName = extractMachineName(*remote); + if (DebugNetworkLoggingEnabled()) { + std::cout << "[CLIPBOARD] Handshake success! remoteName=" << remoteName << std::endl; + } return true; } @@ -689,8 +736,7 @@ bool receiveClipboardPayload( return false; } - const std::size_t maxPayloadSize = - (kind == kClipboardSocketTextLabel) ? kClipboardSocketMaxSize : 0; + const std::size_t maxPayloadSize = kClipboardSocketMaxSize; if (payloadSize > maxPayloadSize) { return false; } @@ -889,10 +935,10 @@ void NetworkManager::ShutdownSessionSockets() { void NetworkManager::SetOnMouseCallback(std::function cb) { m_onMouse = std::move(cb); } void NetworkManager::SetOnKeyboardCallback(std::function cb) { m_onKeyboard = std::move(cb); } -void NetworkManager::SetOnClipboardCallback(std::function cb) { m_onClipboard = std::move(cb); } +void NetworkManager::SetOnClipboardCallback(std::function cb) { m_onClipboard = std::move(cb); } void NetworkManager::SetOnSessionEstablished(std::function cb) { m_onSessionEstablished = std::move(cb); } void NetworkManager::SetOnSessionDisconnected(std::function cb) { m_onSessionDisconnected = std::move(cb); } -void NetworkManager::SetClipboardProvider(std::function()> provider) { m_clipboardProvider = std::move(provider); } +void NetworkManager::SetClipboardProvider(std::function()> provider) { m_clipboardProvider = std::move(provider); } void NetworkManager::SetReconnectBackoff(int initialBackoffMs, int maxBackoffMs, int idleRetryMs) { const ReconnectPolicy policy = NormalizeReconnectPolicy(initialBackoffMs, maxBackoffMs, idleRetryMs); @@ -910,14 +956,8 @@ void NetworkManager::SetLocalIdentity(uint32_t machineId, const std::string& mac } } -void NetworkManager::PrimeLocalClipboardText(const std::string& text) { - if (text.empty()) { - return; - } - - std::lock_guard lock(m_clipboardMutex); - m_pendingClipboardText = text; - m_pendingClipboardPayload = ClipboardManager::EncodeTextPayload(text); +void NetworkManager::PrimeLocalClipboardPayload(const ClipboardPayload& payload) { + UpdatePendingClipboardPayload(payload); } bool NetworkManager::Connect() { @@ -1129,54 +1169,80 @@ void NetworkManager::FinalizeInlineClipboardTransfer() { return; } - if (type == static_cast(PackageType::ClipboardText)) { - const auto decoded = ClipboardManager::DecodeTextPayload(payload); - if (decoded.has_value()) { - DeliverClipboardText(*decoded); - } else { - std::cerr << "WARN: Failed to decode inline clipboard text payload." << std::endl; + std::thread([this, payload = std::move(payload), type]() mutable { + if (type == static_cast(PackageType::ClipboardText)) { + const auto decoded = ClipboardManager::DecodePayload(payload); + if (decoded.has_value()) { + DeliverClipboardPayload(*decoded); + } else { + std::cerr << "WARN: Failed to decode inline clipboard text payload." << std::endl; + } + return; } - return; - } - if (type == static_cast(PackageType::ClipboardImage)) { - std::cerr << "WARN: Clipboard image payload received, but image sync is not implemented yet." << std::endl; - } + if (type == static_cast(PackageType::ClipboardImage)) { + auto imageBytes = ClipboardManager::DecodeImagePayload(payload); + if (imageBytes.has_value()) { + // PowerToys often pads images with null bytes up to the next block size. + while (!imageBytes->empty() && imageBytes->back() == 0) { + imageBytes->pop_back(); + } + DeliverClipboardPayload(ClipboardManager::MakeImagePayload("image/png", std::move(*imageBytes))); + } else { + std::cerr << "WARN: Failed to decode inline clipboard image payload." << std::endl; + } + } + }).detach(); } -void NetworkManager::DeliverClipboardText(const std::string& text) { +void NetworkManager::DeliverClipboardPayload(const ClipboardPayload& payload) { { std::lock_guard lock(m_clipboardMutex); - m_suppressedClipboardText = text; - m_pendingClipboardText = text; - m_pendingClipboardPayload = ClipboardManager::EncodeTextPayload(text); + m_suppressedClipboardPayload = payload; + m_pendingClipboardPayloadStruct = payload; + if (payload.image) { + m_pendingClipboardPayload = ClipboardManager::EncodeImagePayload(payload.image->bytes); + } else if (payload.plainText) { + m_pendingClipboardPayload = ClipboardManager::EncodeTextPayload(*payload.plainText); + } } if (m_onClipboard) { - m_onClipboard(text); + m_onClipboard(payload); } } -bool NetworkManager::UpdatePendingClipboardText(const std::string& text) { +bool NetworkManager::UpdatePendingClipboardPayload(const ClipboardPayload& payload) { std::lock_guard lock(m_clipboardMutex); - if (text.empty()) { - m_pendingClipboardText = text; + if (!payload.plainText && !payload.image) { + m_pendingClipboardPayloadStruct.reset(); m_pendingClipboardPayload.clear(); return false; } - if (m_suppressedClipboardText && *m_suppressedClipboardText == text) { - m_suppressedClipboardText.reset(); + auto payloadsEqual = [](const ClipboardPayload& a, const ClipboardPayload& b) { + if (a.plainText != b.plainText) return false; + if (a.image.has_value() != b.image.has_value()) return false; + if (a.image && b.image && a.image->bytes != b.image->bytes) return false; + return true; + }; + + if (m_suppressedClipboardPayload && payloadsEqual(*m_suppressedClipboardPayload, payload)) { + m_suppressedClipboardPayload.reset(); return false; } - if (m_pendingClipboardText && *m_pendingClipboardText == text) { + if (m_pendingClipboardPayloadStruct && payloadsEqual(*m_pendingClipboardPayloadStruct, payload)) { return false; } - m_pendingClipboardText = text; - m_pendingClipboardPayload = ClipboardManager::EncodeTextPayload(text); + m_pendingClipboardPayloadStruct = payload; + if (payload.image) { + m_pendingClipboardPayload = ClipboardManager::EncodeImagePayload(payload.image->bytes); + } else if (payload.plainText) { + m_pendingClipboardPayload = ClipboardManager::EncodeTextPayload(*payload.plainText); + } return true; } @@ -1184,26 +1250,78 @@ std::optional> NetworkManager::SnapshotClipboardPayload(boo std::lock_guard lock(m_clipboardMutex); if (refreshFromProvider && m_clipboardProvider) { - auto text = m_clipboardProvider(); - if (text.has_value() && !text->empty()) { - if (m_suppressedClipboardText && *m_suppressedClipboardText == *text) { - m_suppressedClipboardText.reset(); + auto payload = m_clipboardProvider(); + if (payload.has_value()) { + // Re-using logic from UpdatePendingClipboardPayload essentially + // but we can't call it while holding the lock if it were to use the lock. + // Let's just do it manually here. + + auto payloadsEqual = [](const ClipboardPayload& a, const ClipboardPayload& b) { + if (a.plainText != b.plainText) return false; + if (a.image.has_value() != b.image.has_value()) return false; + if (a.image && b.image && a.image->bytes != b.image->bytes) return false; + return true; + }; + + if (m_suppressedClipboardPayload && payloadsEqual(*m_suppressedClipboardPayload, *payload)) { + m_suppressedClipboardPayload.reset(); return std::nullopt; } - if (!m_pendingClipboardText || *m_pendingClipboardText != *text) { - m_pendingClipboardText = *text; - m_pendingClipboardPayload = ClipboardManager::EncodeTextPayload(*text); + if (!m_pendingClipboardPayloadStruct || !payloadsEqual(*m_pendingClipboardPayloadStruct, *payload)) { + m_pendingClipboardPayloadStruct = payload; + if (payload->image) { + m_pendingClipboardPayload = ClipboardManager::EncodeImagePayload(payload->image->bytes); + } else if (payload->plainText) { + m_pendingClipboardPayload = ClipboardManager::EncodeTextPayload(*payload->plainText); + } } } } - if (m_pendingClipboardPayload.empty()) { + if (m_pendingClipboardPayload.empty() || (m_pendingClipboardPayloadStruct && m_pendingClipboardPayloadStruct->image)) { + // Text/HTML snapshot doesn't return image payload return std::nullopt; } return m_pendingClipboardPayload; } +std::optional> NetworkManager::SnapshotClipboardImagePayload(bool refreshFromProvider) { + std::lock_guard lock(m_clipboardMutex); + + if (refreshFromProvider && m_clipboardProvider) { + auto payload = m_clipboardProvider(); + if (payload.has_value()) { + auto payloadsEqual = [](const ClipboardPayload& a, const ClipboardPayload& b) { + if (a.plainText != b.plainText) return false; + if (a.image.has_value() != b.image.has_value()) return false; + if (a.image && b.image && a.image->bytes != b.image->bytes) return false; + return true; + }; + + if (m_suppressedClipboardPayload && payloadsEqual(*m_suppressedClipboardPayload, *payload)) { + m_suppressedClipboardPayload.reset(); + return std::nullopt; + } + + if (!m_pendingClipboardPayloadStruct || !payloadsEqual(*m_pendingClipboardPayloadStruct, *payload)) { + m_pendingClipboardPayloadStruct = payload; + if (payload->image) { + m_pendingClipboardPayload = ClipboardManager::EncodeImagePayload(payload->image->bytes); + } else if (payload->plainText) { + m_pendingClipboardPayload = ClipboardManager::EncodeTextPayload(*payload->plainText); + } + } + } + } + + if (m_pendingClipboardPayload.empty() || !m_pendingClipboardPayloadStruct || !m_pendingClipboardPayloadStruct->image) { + return std::nullopt; + } + return m_pendingClipboardPayload; +} + + bool NetworkManager::SendClipboardAnnouncement() { MWBPacket packet; std::memset(&packet, 0, sizeof(packet)); @@ -1250,28 +1368,49 @@ bool NetworkManager::SendInlineClipboardText(const std::vector& payload return SendPacket(endPacket, true); } -void NetworkManager::NotifyLocalClipboardChanged() { - if (!m_handshakeDone) { - return; +bool NetworkManager::SendInlineClipboardImage(const std::vector& payload) { + if (!m_handshakeDone || payload.empty()) { + return false; } - const auto payload = SnapshotClipboardPayload(true); - if (!payload.has_value()) { - return; + for (std::size_t index = 0; index < payload.size(); index += kClipboardChunkSize) { + MWBPacket packet; + std::memset(&packet, 0, sizeof(packet)); + packet.type = static_cast(PackageType::ClipboardImage); + + const uint32_t broadcast = htole32(kBroadcastMachineId); + std::memcpy(&packet.des, &broadcast, sizeof(broadcast)); + + const std::size_t chunkSize = std::min(kClipboardChunkSize, payload.size() - index); + std::memcpy(packet.data, payload.data() + index, chunkSize); + if (!SendPacket(packet, true)) { + return false; + } } - if (payload->size() <= kClipboardInlineMaxSize && SendInlineClipboardText(*payload)) { - std::cout << "[CLIPBOARD] Sent inline text payload (" << payload->size() << " bytes compressed)" << std::endl; + MWBPacket endPacket; + std::memset(&endPacket, 0, sizeof(endPacket)); + endPacket.type = static_cast(PackageType::ClipboardDataEnd); + const uint32_t broadcast = htole32(kBroadcastMachineId); + std::memcpy(&endPacket.des, &broadcast, sizeof(broadcast)); + return SendPacket(endPacket, true); +} + +void NetworkManager::NotifyLocalClipboardChanged() { + if (!m_handshakeDone) { return; } - if (SendClipboardAnnouncement()) { - std::cout << "[CLIPBOARD] Advertised clipboard payload for socket transfer" << std::endl; + if (m_clipboardProvider) { + auto payload = m_clipboardProvider(); + if (payload.has_value()) { + NotifyLocalClipboardChanged(*payload); + } } } -void NetworkManager::NotifyLocalClipboardChanged(const std::string& text) { - if (!UpdatePendingClipboardText(text)) { +void NetworkManager::NotifyLocalClipboardChanged(const ClipboardPayload& payload) { + if (!UpdatePendingClipboardPayload(payload)) { return; } @@ -1279,14 +1418,18 @@ void NetworkManager::NotifyLocalClipboardChanged(const std::string& text) { return; } - const auto payload = SnapshotClipboardPayload(false); - if (!payload.has_value()) { - return; - } - - if (payload->size() <= kClipboardInlineMaxSize && SendInlineClipboardText(*payload)) { - std::cout << "[CLIPBOARD] Sent inline text payload (" << payload->size() << " bytes compressed)" << std::endl; - return; + if (payload.image) { + const auto encoded = SnapshotClipboardImagePayload(false); + if (encoded.has_value() && encoded->size() <= kClipboardInlineMaxSize && SendInlineClipboardImage(*encoded)) { + std::cout << "[CLIPBOARD] Sent inline image payload (" << encoded->size() << " bytes)" << std::endl; + return; + } + } else { + const auto encoded = SnapshotClipboardPayload(false); + if (encoded.has_value() && encoded->size() <= kClipboardInlineMaxSize && SendInlineClipboardText(*encoded)) { + std::cout << "[CLIPBOARD] Sent inline text payload (" << encoded->size() << " bytes compressed)" << std::endl; + return; + } } if (SendClipboardAnnouncement()) { @@ -1393,12 +1536,12 @@ void NetworkManager::RequestRemoteClipboard(uint32_t expectedRemoteMachineId) { } CryptoHelper crypto(m_key); - const uint32_t magic = crypto.Get24BitHash(); + const uint32_t magic = 0; // PowerToys often uses zero magic for secondary clipboard socket bool remotePush = false; std::string remoteName; MWBPacket remoteHeader; std::memset(&remoteHeader, 0, sizeof(remoteHeader)); - if (!exchangeClipboardHandshake(fd, crypto, magic, m_myName, m_myId, m_running, false, remoteHeader, remotePush, remoteName)) { + if (!exchangeClipboardHandshake(fd, crypto, magic, m_myName, m_myId, expectedRemoteMachineId, m_running, false, remoteHeader, remotePush, remoteName)) { std::cerr << "WARN: Clipboard pull handshake failed." << std::endl; closeSocket(fd); return; @@ -1426,10 +1569,10 @@ void NetworkManager::RequestRemoteClipboard(uint32_t expectedRemoteMachineId) { closeSocket(fd); if (kind == kClipboardSocketTextLabel) { - const auto text = ClipboardManager::DecodeTextPayload(payload); - if (text.has_value()) { + const auto decoded = ClipboardManager::DecodePayload(payload); + if (decoded.has_value()) { std::cout << "[CLIPBOARD] Pulled text payload (" << payloadSize << " bytes compressed)" << std::endl; - DeliverClipboardText(*text); + DeliverClipboardPayload(*decoded); } else { std::cerr << "WARN: Failed to decode socket clipboard text payload." << std::endl; } @@ -1437,7 +1580,16 @@ void NetworkManager::RequestRemoteClipboard(uint32_t expectedRemoteMachineId) { } if (kind == kClipboardSocketImageLabel) { - std::cerr << "WARN: Clipboard image received over socket, but image sync is not implemented yet." << std::endl; + auto imageBytes = ClipboardManager::DecodeImagePayload(payload); + if (imageBytes.has_value()) { + while (!imageBytes->empty() && imageBytes->back() == 0) { + imageBytes->pop_back(); + } + std::cout << "[CLIPBOARD] Pulled image payload (" << payloadSize << " bytes, trimmed to " << imageBytes->size() << ")" << std::endl; + DeliverClipboardPayload(ClipboardManager::MakeImagePayload("image/png", std::move(*imageBytes))); + } else { + std::cerr << "WARN: Failed to decode socket clipboard image payload." << std::endl; + } return; } @@ -1450,7 +1602,20 @@ void NetworkManager::PushClipboardToRemote(uint32_t expectedRemoteMachineId) { return; } - const auto payload = SnapshotClipboardPayload(true); + std::optional> payload; + std::string kind; + + { + std::lock_guard lock(m_clipboardMutex); + if (m_pendingClipboardPayloadStruct && m_pendingClipboardPayloadStruct->image) { + payload = SnapshotClipboardImagePayload(true); + kind = kClipboardSocketImageLabel; + } else { + payload = SnapshotClipboardPayload(true); + kind = kClipboardSocketTextLabel; + } + } + if (!payload.has_value()) { return; } @@ -1463,12 +1628,12 @@ void NetworkManager::PushClipboardToRemote(uint32_t expectedRemoteMachineId) { } CryptoHelper crypto(m_key); - const uint32_t magic = crypto.Get24BitHash(); + const uint32_t magic = 0; // PowerToys often uses zero magic for secondary clipboard socket bool remotePush = false; std::string remoteName; MWBPacket remoteHeader; std::memset(&remoteHeader, 0, sizeof(remoteHeader)); - if (!exchangeClipboardHandshake(fd, crypto, magic, m_myName, m_myId, m_running, true, remoteHeader, remotePush, remoteName)) { + if (!exchangeClipboardHandshake(fd, crypto, magic, m_myName, m_myId, expectedRemoteMachineId, m_running, true, remoteHeader, remotePush, remoteName)) { std::cerr << "WARN: Clipboard push handshake failed." << std::endl; closeSocket(fd); return; @@ -1484,8 +1649,8 @@ void NetworkManager::PushClipboardToRemote(uint32_t expectedRemoteMachineId) { return; } - if (sendClipboardPayload(fd, crypto, *payload, kClipboardSocketTextLabel)) { - std::cout << "[CLIPBOARD] Pushed text payload over clipboard socket (" << payload->size() << " bytes compressed)" << std::endl; + if (sendClipboardPayload(fd, crypto, *payload, kind)) { + std::cout << "[CLIPBOARD] Pushed " << kind << " payload over clipboard socket (" << payload->size() << " bytes)" << std::endl; } else { std::cerr << "WARN: Failed to send clipboard payload over clipboard socket." << std::endl; } @@ -1536,7 +1701,7 @@ void NetworkManager::RunLoop() { } continue; } - reconnectState = ResetReconnectAfterSuccess(reconnectPolicy); + // Do NOT reset reconnectState here; wait for handshake success } uint8_t serverNoise[16]; @@ -1545,6 +1710,14 @@ void NetworkManager::RunLoop() { fprintf(stderr, "[OUTBOUND] Failed to receive server noise\n"); } closeSocket(m_socket); + + const int scheduledBackoffMs = ScheduledReconnectDelayMs(reconnectPolicy, reconnectState); + const int delayMs = AddReconnectJitter(scheduledBackoffMs); + std::cout << "[RECONNECT] Protocol error (noise). Retrying in " << delayMs << " ms." << std::endl; + reconnectState = AdvanceReconnectAfterFailure(reconnectPolicy, reconnectState); + if (!SleepWithStop(m_running, delayMs)) { + break; + } continue; } @@ -1553,6 +1726,14 @@ void NetworkManager::RunLoop() { if (!m_crypto.DecryptStream(encryptedNoise, ignoredNoise)) { fprintf(stderr, "[OUTBOUND] Failed to decrypt server noise\n"); closeSocket(m_socket); + + const int scheduledBackoffMs = ScheduledReconnectDelayMs(reconnectPolicy, reconnectState); + const int delayMs = AddReconnectJitter(scheduledBackoffMs); + std::cout << "[RECONNECT] Crypto error (noise). Retrying in " << delayMs << " ms." << std::endl; + reconnectState = AdvanceReconnectAfterFailure(reconnectPolicy, reconnectState); + if (!SleepWithStop(m_running, delayMs)) { + break; + } continue; } @@ -1655,6 +1836,14 @@ void NetworkManager::RunLoop() { if (!m_handshakeDone) { fprintf(stderr, "[OUTBOUND] Handshake failed, will reconnect\n"); closeSocket(m_socket); + + const int scheduledBackoffMs = ScheduledReconnectDelayMs(reconnectPolicy, reconnectState); + const int delayMs = AddReconnectJitter(scheduledBackoffMs); + std::cout << "[RECONNECT] Handshake failed. Retrying in " << delayMs << " ms." << std::endl; + reconnectState = AdvanceReconnectAfterFailure(reconnectPolicy, reconnectState); + if (!SleepWithStop(m_running, delayMs)) { + break; + } continue; } @@ -2236,12 +2425,12 @@ void NetworkManager::HandleClipboardConnection(int fd) { TrackSessionSocket(fd); CryptoHelper crypto(m_key); - const uint32_t magic = crypto.Get24BitHash(); + const uint32_t magic = 0; // PowerToys often uses zero magic for secondary clipboard socket bool remoteRequestsPush = false; std::string remoteName; MWBPacket remoteHeader; std::memset(&remoteHeader, 0, sizeof(remoteHeader)); - if (!exchangeClipboardHandshake(fd, crypto, magic, m_myName, m_myId, m_running, true, remoteHeader, remoteRequestsPush, remoteName)) { + if (!exchangeClipboardHandshake(fd, crypto, magic, m_myName, m_myId, 0, m_running, true, remoteHeader, remoteRequestsPush, remoteName)) { closeSocket(fd); return; } @@ -2262,16 +2451,36 @@ void NetworkManager::HandleClipboardConnection(int fd) { std::vector payload; if (receiveClipboardPayload(fd, crypto, m_running, payloadSize, kind, payload)) { if (kind == kClipboardSocketTextLabel) { - const auto text = ClipboardManager::DecodeTextPayload(payload); - if (text.has_value()) { - DeliverClipboardText(*text); + const auto decoded = ClipboardManager::DecodePayload(payload); + if (decoded.has_value()) { + DeliverClipboardPayload(*decoded); } } else if (kind == kClipboardSocketImageLabel) { - std::cerr << "WARN: Clipboard image received over socket, but image sync is not implemented yet." << std::endl; + auto imageBytes = ClipboardManager::DecodeImagePayload(payload); + if (imageBytes.has_value()) { + while (!imageBytes->empty() && imageBytes->back() == 0) { + imageBytes->pop_back(); + } + DeliverClipboardPayload(ClipboardManager::MakeImagePayload("image/png", std::move(*imageBytes))); + } } } - } else if (const auto payload = SnapshotClipboardPayload(false); payload.has_value()) { - sendClipboardPayload(fd, crypto, *payload, kClipboardSocketTextLabel); + } else { + std::optional> payload; + std::string kind; + { + std::lock_guard lock(m_clipboardMutex); + if (m_pendingClipboardPayloadStruct && m_pendingClipboardPayloadStruct->image) { + payload = SnapshotClipboardImagePayload(false); + kind = kClipboardSocketImageLabel; + } else { + payload = SnapshotClipboardPayload(false); + kind = kClipboardSocketTextLabel; + } + } + if (payload.has_value()) { + sendClipboardPayload(fd, crypto, *payload, kind); + } } closeSocket(fd); diff --git a/src/NetworkManager.h b/src/NetworkManager.h index 5f3a169..f8ed7fc 100644 --- a/src/NetworkManager.h +++ b/src/NetworkManager.h @@ -13,6 +13,7 @@ #include "CryptoHelper.h" #include "Protocol.h" +#include "ClipboardManager.h" namespace mwb { @@ -23,14 +24,14 @@ class NetworkManager { void SetOnMouseCallback(std::function cb); void SetOnKeyboardCallback(std::function cb); - void SetOnClipboardCallback(std::function cb); + void SetOnClipboardCallback(std::function cb); void SetOnSessionEstablished(std::function cb); void SetOnSessionDisconnected(std::function cb); - void SetClipboardProvider(std::function()> provider); + void SetClipboardProvider(std::function()> provider); void SetLocalIdentity(uint32_t machineId, const std::string& machineName = {}); - void PrimeLocalClipboardText(const std::string& text); + void PrimeLocalClipboardPayload(const ClipboardPayload& payload); void NotifyLocalClipboardChanged(); - void NotifyLocalClipboardChanged(const std::string& text); + void NotifyLocalClipboardChanged(const ClipboardPayload& payload); void SetAutoConnectEnabled(bool enabled) { m_autoConnectEnabled = enabled; } void SetReconnectBackoff(int initialBackoffMs, int maxBackoffMs, int idleRetryMs); @@ -81,16 +82,16 @@ class NetworkManager { std::function m_onMouse; std::function m_onKeyboard; - std::function m_onClipboard; + std::function m_onClipboard; std::function m_onSessionEstablished; std::function m_onSessionDisconnected; - std::function()> m_clipboardProvider; - std::optional m_pendingClipboardText; + std::function()> m_clipboardProvider; + std::optional m_pendingClipboardPayloadStruct; std::vector m_pendingClipboardPayload; std::vector m_inlineClipboardBuffer; uint8_t m_inlineClipboardType{0}; bool m_discardInlineClipboard{false}; - std::optional m_suppressedClipboardText; + std::optional m_suppressedClipboardPayload; std::string m_remoteName; std::unordered_map m_activeSessionPeers; std::unordered_set m_sessionSockets; @@ -125,15 +126,17 @@ class NetworkManager { void HandleClipboardControlPacket(const MWBPacket& packet, uint32_t remoteMachineId); void FinalizeInlineClipboardTransfer(); - void DeliverClipboardText(const std::string& text); + void DeliverClipboardPayload(const ClipboardPayload& payload); void RequestRemoteClipboard(uint32_t expectedRemoteMachineId); void PushClipboardToRemote(uint32_t expectedRemoteMachineId); - bool UpdatePendingClipboardText(const std::string& text); + bool UpdatePendingClipboardPayload(const ClipboardPayload& payload); std::optional> SnapshotClipboardPayload(bool refreshFromProvider); + std::optional> SnapshotClipboardImagePayload(bool refreshFromProvider); bool SendClipboardAnnouncement(); bool SendClipboardAsk(); bool SendInlineClipboardText(const std::vector& payload); + bool SendInlineClipboardImage(const std::vector& payload); }; } // namespace mwb diff --git a/src/Protocol.h b/src/Protocol.h index 0356776..3b677b8 100644 --- a/src/Protocol.h +++ b/src/Protocol.h @@ -22,6 +22,7 @@ enum class PackageType : uint8_t { ClipboardDataEnd = 76, ClipboardAsk = 78, ClipboardPush = 79, + Heartbeat_v2 = 80, Keyboard = 122, Mouse = 123, ClipboardText = 124, @@ -36,8 +37,8 @@ constexpr std::size_t kBigPacketSize = 64; constexpr std::size_t kHandshakeChallengeSize = 16; constexpr std::size_t kClipboardChunkSize = 48; constexpr std::size_t kClipboardSocketHeaderSize = 1024; -constexpr std::size_t kClipboardInlineMaxSize = 1024 * 1024; -constexpr std::size_t kClipboardSocketMaxSize = 8 * 1024 * 1024; +constexpr std::size_t kClipboardInlineMaxSize = 16 * 1024 * 1024; +constexpr std::size_t kClipboardSocketMaxSize = 16 * 1024 * 1024; constexpr std::size_t kClipboardTextInflatedMaxSize = 16 * 1024 * 1024; constexpr int kClipboardPortOffset = -1; constexpr uint32_t kBroadcastMachineId = 0x000000ff; @@ -45,8 +46,8 @@ constexpr const char* kClipboardTextPrefix = "TXT"; constexpr const char* kClipboardHtmlPrefix = "HTM"; constexpr const char* kClipboardRtfPrefix = "RTF"; constexpr const char* kClipboardTextSeparator = "{4CFF57F7-BEDD-43d5-AE8F-27A61E886F2F}"; -constexpr const char* kClipboardSocketTextLabel = "text"; -constexpr const char* kClipboardSocketImageLabel = "image"; +constexpr const char* kClipboardSocketTextLabel = "TXT"; +constexpr const char* kClipboardSocketImageLabel = "IMG"; constexpr uint32_t kLlkhfExtended = 0x01; constexpr uint32_t kLlkhfInjected = 0x10; constexpr uint32_t kLlkhfAltDown = 0x20; diff --git a/src/TrayController.cpp b/src/TrayController.cpp index 8a031b6..4334e23 100644 --- a/src/TrayController.cpp +++ b/src/TrayController.cpp @@ -41,6 +41,7 @@ struct TrayContext { GtkWidget* restartItem{nullptr}; std::string controllerPath; std::string iconThemePath; + std::string lastState; }; std::optional RunCommandCapture(const std::string& command) { @@ -370,6 +371,11 @@ AppIndicatorStatus IndicatorStatus(const std::string& state) { } void UpdateIndicatorVisuals(TrayContext* context, const std::string& state) { + if (context->lastState == state) { + return; + } + context->lastState = state; + const std::string displayState = DescribeState(state); const std::string statusText = "Service: " + displayState; const std::string indicatorDescription = IndicatorDescription(displayState); @@ -384,7 +390,6 @@ void UpdateIndicatorVisuals(TrayContext* context, const std::string& state) { gtk_widget_set_sensitive(context->restartItem, active); const bool controllerAvailable = !context->controllerPath.empty(); - gtk_widget_set_sensitive(context->openControllerItem, controllerAvailable); gtk_widget_set_sensitive(context->editSettingsItem, controllerAvailable); gtk_widget_set_sensitive(context->editConnectionItem, controllerAvailable); gtk_widget_set_sensitive(context->discoverPeersItem, controllerAvailable); @@ -394,12 +399,12 @@ void UpdateIndicatorVisuals(TrayContext* context, const std::string& state) { gtk_widget_set_sensitive(context->showStatusItem, controllerAvailable); const std::string shortLabel = IndicatorLabel(state); + const std::string accessibleLabel = IndicatorAccessibleLabel(state); app_indicator_set_status(context->indicator, IndicatorStatus(state)); app_indicator_set_title(context->indicator, kAppName); - app_indicator_set_label(context->indicator, shortLabel.c_str(), IndicatorAccessibleLabel(state).c_str()); + app_indicator_set_label(context->indicator, shortLabel.c_str(), accessibleLabel.c_str()); if (!context->iconThemePath.empty()) { - app_indicator_set_icon_theme_path(context->indicator, context->iconThemePath.c_str()); app_indicator_set_attention_icon_full(context->indicator, "inputflow-tray-attention", "InputFlow needs attention"); app_indicator_set_icon_full(context->indicator, BundledIndicatorIconName(state), indicatorDescription.c_str()); } else { @@ -544,20 +549,24 @@ int main(int argc, char** argv) { context.statusItem = gtk_menu_item_new_with_label("Service: Checking..."); gtk_widget_set_sensitive(context.statusItem, FALSE); gtk_menu_shell_append(GTK_MENU_SHELL(menu), context.statusItem); - gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); - context.openControllerItem = AddMenuItem(menu, "Open Controller", G_CALLBACK(OnOpenController), &context); - context.editSettingsItem = AddMenuItem(menu, "Edit Settings", G_CALLBACK(OnEditSettings), &context); + + context.editSettingsItem = AddMenuItem(menu, "Settings", G_CALLBACK(OnEditSettings), &context); context.editConnectionItem = AddMenuItem(menu, "Connection Behavior", G_CALLBACK(OnEditConnectionBehavior), &context); context.discoverPeersItem = AddMenuItem(menu, "Discover Peers", G_CALLBACK(OnDiscoverPeers), &context); - context.showPeersItem = AddMenuItem(menu, "Show Known Peers", G_CALLBACK(OnShowPeers), &context); - context.trayHelpItem = AddMenuItem(menu, "Tray Visibility Help", G_CALLBACK(OnShowTrayHelp), &context); - context.installDesktopEntriesItem = AddMenuItem(menu, "Install Desktop Entries", G_CALLBACK(OnInstallDesktopEntries), &context); - context.showStatusItem = AddMenuItem(menu, "Show Service Details", G_CALLBACK(OnShowStatus), &context); + context.showPeersItem = AddMenuItem(menu, "Known Peers", G_CALLBACK(OnShowPeers), &context); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); context.startItem = AddMenuItem(menu, "Start Service", G_CALLBACK(OnStartService), &context); context.stopItem = AddMenuItem(menu, "Stop Service", G_CALLBACK(OnStopService), &context); context.restartItem = AddMenuItem(menu, "Restart Service", G_CALLBACK(OnRestartService), &context); + + gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); + + context.showStatusItem = AddMenuItem(menu, "Show Service Details", G_CALLBACK(OnShowStatus), &context); + context.installDesktopEntriesItem = AddMenuItem(menu, "Install Desktop Entries", G_CALLBACK(OnInstallDesktopEntries), &context); + context.trayHelpItem = AddMenuItem(menu, "Tray Visibility Help", G_CALLBACK(OnShowTrayHelp), &context); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); AddMenuItem(menu, "Quit", G_CALLBACK(OnQuit), &context); gtk_widget_show_all(menu); @@ -575,12 +584,11 @@ int main(int argc, char** argv) { app_indicator_set_title(context.indicator, kAppName); app_indicator_set_label(context.indicator, "IF", "IF"); app_indicator_set_menu(context.indicator, GTK_MENU(menu)); - app_indicator_set_secondary_activate_target(context.indicator, context.openControllerItem); + app_indicator_set_secondary_activate_target(context.indicator, context.editSettingsItem); UpdateIndicatorVisuals(&context, QueryServiceState()); app_indicator_set_status(context.indicator, APP_INDICATOR_STATUS_ACTIVE); - MaybeShowStartupHint(context); - g_timeout_add_seconds(2, RefreshStatus, &context); + g_timeout_add_seconds(30, RefreshStatus, &context); gtk_main(); if (instanceLockFd >= 0) { diff --git a/tests/test_clipboard_socket_security.cpp b/tests/test_clipboard_socket_security.cpp index fa546c7..13af488 100644 --- a/tests/test_clipboard_socket_security.cpp +++ b/tests/test_clipboard_socket_security.cpp @@ -295,7 +295,7 @@ void SendHeartbeatEx(MainSession& session, uint32_t remoteMachineId, uint16_t wi void PushClipboardText(int port, const std::string& key, uint32_t remoteMachineId, const std::string& text) { const int fd = ConnectLocal(port); mwb::CryptoHelper crypto(key); - const uint32_t magic = crypto.Get24BitHash(); + const uint32_t magic = 0; // Clipboard socket uses zero magic (PowerToys compat) try { ReadAndDecrypt(fd, crypto, 16); @@ -362,10 +362,12 @@ void TestClipboardSocketRequiresActiveSession() { std::condition_variable clipboardChanged; std::optional clipboardText; - manager.SetOnClipboardCallback([&](const std::string& text) { + manager.SetOnClipboardCallback([&](const mwb::ClipboardPayload& payload) { { std::lock_guard lock(clipboardMutex); - clipboardText = text; + if (payload.plainText) { + clipboardText = *payload.plainText; + } } clipboardChanged.notify_all(); }); @@ -428,10 +430,12 @@ void TestClipboardTrustRevokedAfterControlDisconnect() { std::mutex clipboardMutex; std::condition_variable clipboardChanged; std::optional clipboardText; - manager.SetOnClipboardCallback([&](const std::string& text) { + manager.SetOnClipboardCallback([&](const mwb::ClipboardPayload& payload) { { std::lock_guard lock(clipboardMutex); - clipboardText = text; + if (payload.plainText) { + clipboardText = *payload.plainText; + } } clipboardChanged.notify_all(); }); diff --git a/tests/verify_image_backend.cpp b/tests/verify_image_backend.cpp new file mode 100644 index 0000000..8f895f5 --- /dev/null +++ b/tests/verify_image_backend.cpp @@ -0,0 +1,32 @@ +#include "ClipboardManager.h" +#include +#include + +int main() { + auto manager = mwb::ClipboardManager::CreateDefault(); + if (!manager) { + std::cerr << "Failed to create clipboard manager" << std::endl; + return 1; + } + + std::cout << "Using backend: " << manager->BackendName() << std::endl; + + auto payload = manager->GetPayload(); + if (!payload) { + std::cout << "Clipboard is empty" << std::endl; + return 0; + } + + if (payload->image) { + std::cout << "SUCCESS: Detected image payload!" << std::endl; + std::cout << "MIME type: " << payload->image->mimeType << std::endl; + std::cout << "Size: " << payload->image->bytes.size() << " bytes" << std::endl; + } else if (payload->plainText) { + std::cout << "Detected text: " << *payload->plainText << std::endl; + std::cout << "No image found in clipboard." << std::endl; + } else { + std::cout << "Detected unknown payload type." << std::endl; + } + + return 0; +} diff --git a/verify_image b/verify_image new file mode 100755 index 0000000..44864c9 Binary files /dev/null and b/verify_image differ