From 6a4c1d69a142da928e76a3cd94878c8e594daf88 Mon Sep 17 00:00:00 2001 From: daredoole Date: Mon, 27 Apr 2026 23:10:42 -0400 Subject: [PATCH 1/6] feat(ui,clipboard): consolidate UI/UX and implement HTML/Image sync - Replaced fragmented Zenity dialogs with professional Python/GTK3 forms. - Decluttered Tray menu with new 'Advanced' submenu and improved status labels. - Implemented full HTML and Image clipboard synchronization via MIME-aware backends. - Updated README.md with high-fidelity setup instructions and feature details. - Refactored core network and runtime layers to support structured clipboard payloads. --- README.md | 46 ++- mwb-desktop-ui.sh | 356 +++++++---------------- src/ClientRuntime.cpp | 45 +-- src/ClientRuntime.h | 2 +- src/ClipboardManager.cpp | 240 +++++++++++---- src/ClipboardManager.h | 22 +- src/ConfigDialog.py | 115 ++++++++ src/NetworkManager.cpp | 264 ++++++++++++----- src/NetworkManager.h | 23 +- src/TrayController.cpp | 28 +- tests/test_clipboard_socket_security.cpp | 12 +- 11 files changed, 722 insertions(+), 431 deletions(-) create mode 100755 src/ConfigDialog.py diff --git a/README.md b/README.md index 43af9c3..71627e7 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ What is working well in current testing: - Windows-to-Linux keyboard input - Windows-to-Linux pointer movement and clicks -- text clipboard sync +- Text, HTML, and image clipboard sync - `systemd --user` service management - Windows pairing-helper export for first-time setup and recovery @@ -50,26 +50,40 @@ What still needs caution: 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: +1. **Prerequisites:** Build the project and install a clipboard helper such as `wl-clipboard` (Wayland) or `xclip` (X11). Ensure `python3-gi` and GTK3 are installed for the configuration dialogs. +2. **Setup UI:** Launch the easy setup menu with: + `./mwb-desktop-ui.sh menu` +3. **Configure & Pair:** + - Choose **Settings** to enter your Windows Host IP and Security Key. + - Or, choose **Peers (Discovery & Known)** to automatically find your Windows machine on the network. +4. **Export Helper:** Once configured, the client can export a PowerShell helper to Windows with: + `./build/mwb_client export-windows-pair --config ~/.config/mwb-client/config.ini --position top-left` +5. **Windows Sync:** Run the exported `.ps1` script on Windows to synchronize the Linux machine's identity with PowerToys Mouse Without Borders. +6. **Start Service:** Choose **Start Service** from the `./mwb-desktop-ui.sh menu` to begin sharing. + +### Advanced CLI Setup + +For power users who prefer manual configuration: + +1. Generate a config: `./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: +2. Store the shared key in the desktop keyring: `printf '%s' 'MySecurityKey123' | ./build/mwb_client secret-store --config ~/.config/mwb-client/config.ini --secret-id desktop-default --stdin` -4. Export the Windows helper: +3. 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: +4. 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: +5. 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`. +6. 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 +- Text, HTML, and Image 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 @@ -106,13 +120,15 @@ mwb-client-linux/ ### 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 ``` ### Prerequisites (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 ``` ### Compile @@ -291,6 +307,10 @@ Recommended interval test: 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`. +If the tray or controller prints `libayatana-appindicator is deprecated`, it is a known +compatibility warning when building against older indicator libraries. The application +remains functional. + 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 @@ -577,15 +597,13 @@ The machine name sent by this client must match the name configured in Windows's - 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. +- Clipboard sync preserves CF_HTML metadata and supports raw image transfers alongside plain text, but drag/drop file transfer is not yet 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: diff --git a/mwb-desktop-ui.sh b/mwb-desktop-ui.sh index f1da83e..2b4e25d 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 - host="$(zenity --entry --title="$host_dialog_title" --text="$host_dialog_text" --entry-text="$host_entry_text" || true)" - [[ -n "$host" ]] || 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" + # 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/ClientRuntime.cpp b/src/ClientRuntime.cpp index b750d8e..49817ce 100644 --- a/src/ClientRuntime.cpp +++ b/src/ClientRuntime.cpp @@ -307,24 +307,28 @@ 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_network->SetOnClipboardCallback([this](const ClipboardPayload& 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; + 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)" << std::endl; + } else if (payload.plainText) { + std::cout << "[CLIPBOARD] Received text update (" << payload.plainText->size() << " bytes)" << std::endl; + } }); } else if (!m_options.clipboardEnabled) { std::cerr << "WARN: Clipboard sync disabled by configuration." << std::endl; @@ -385,23 +389,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 +426,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..3a950bb 100644 --- a/src/ClipboardManager.cpp +++ b/src/ClipboardManager.cpp @@ -33,28 +33,37 @@ 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; @@ -119,7 +128,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 +180,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 +244,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 +368,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 +390,6 @@ bool runWatchCommand( while (waitpid(pid, &status, 0) < 0 && errno == EINTR) { } - if (!buffered.empty()) { - onTextChange(buffered); - } - return !exitedUnexpectedly; } @@ -335,8 +398,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 +479,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 +502,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,7 +1001,10 @@ 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, @@ -946,22 +1014,31 @@ std::unique_ptr createBackend() { 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 +1047,89 @@ 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); + ClipboardPayload payload; + + 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; + } + + if (hasImage) { + auto bytes = runReadBytesCommand(m_readImageCommand); + if (bytes.has_value()) { + payload.image = ClipboardImagePayload{"image/png", std::move(*bytes), std::nullopt, std::nullopt}; + } } - while (!output->empty() && (output->back() == '\n' || output->back() == '\r')) { - output->pop_back(); + if (hasHtml) { + auto html = runReadCommand(m_readHtmlCommand); + if (html.has_value()) { + payload.html = parseHtmlClipboardValue(*html); + } } - return output; + + payload.plainText = runReadCommand(m_readTextCommand); + + 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) { + if (payload.image) { + CommandSpec cmd = m_writeCommand; + if (m_name.find("wl-clipboard") != std::string::npos) { + cmd.argv.push_back("--type"); + 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 +1148,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 +1189,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 +1224,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..1211af2 --- /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..5e6d755 100644 --- a/src/NetworkManager.cpp +++ b/src/NetworkManager.cpp @@ -689,8 +689,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 +888,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 +909,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() { @@ -1130,9 +1123,9 @@ void NetworkManager::FinalizeInlineClipboardTransfer() { } if (type == static_cast(PackageType::ClipboardText)) { - const auto decoded = ClipboardManager::DecodeTextPayload(payload); + const auto decoded = ClipboardManager::DecodePayload(payload); if (decoded.has_value()) { - DeliverClipboardText(*decoded); + DeliverClipboardPayload(*decoded); } else { std::cerr << "WARN: Failed to decode inline clipboard text payload." << std::endl; } @@ -1140,43 +1133,63 @@ void NetworkManager::FinalizeInlineClipboardTransfer() { } if (type == static_cast(PackageType::ClipboardImage)) { - std::cerr << "WARN: Clipboard image payload received, but image sync is not implemented yet." << std::endl; + auto imageBytes = ClipboardManager::DecodeImagePayload(payload); + if (imageBytes.has_value()) { + DeliverClipboardPayload(ClipboardManager::MakeImagePayload("image/png", std::move(*imageBytes))); + } else { + std::cerr << "WARN: Failed to decode inline clipboard image payload." << std::endl; + } } } -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 +1197,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 +1315,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 +1365,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()) { @@ -1426,10 +1516,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 +1527,13 @@ 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()) { + std::cout << "[CLIPBOARD] Pulled image payload (" << payloadSize << " bytes)" << 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 +1546,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; } @@ -1484,8 +1593,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; } @@ -2262,16 +2371,33 @@ 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()) { + 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/TrayController.cpp b/src/TrayController.cpp index 8a031b6..2e37c54 100644 --- a/src/TrayController.cpp +++ b/src/TrayController.cpp @@ -384,7 +384,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); @@ -544,20 +543,31 @@ 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()); + + // Advanced Submenu + GtkWidget* advancedItem = gtk_menu_item_new_with_label("Advanced"); + GtkWidget* advancedMenu = gtk_menu_new(); + gtk_menu_item_set_submenu(GTK_MENU_ITEM(advancedItem), advancedMenu); + + context.showStatusItem = AddMenuItem(advancedMenu, "Show Service Details", G_CALLBACK(OnShowStatus), &context); + context.installDesktopEntriesItem = AddMenuItem(advancedMenu, "Install Desktop Entries", G_CALLBACK(OnInstallDesktopEntries), &context); + context.trayHelpItem = AddMenuItem(advancedMenu, "Tray Visibility Help", G_CALLBACK(OnShowTrayHelp), &context); + + gtk_menu_shell_append(GTK_MENU_SHELL(menu), advancedItem); + 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,7 +585,7 @@ 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); diff --git a/tests/test_clipboard_socket_security.cpp b/tests/test_clipboard_socket_security.cpp index fa546c7..38a1637 100644 --- a/tests/test_clipboard_socket_security.cpp +++ b/tests/test_clipboard_socket_security.cpp @@ -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(); }); From 0de5f3c746d48e339e05a4d923e362d824b1b80f Mon Sep 17 00:00:00 2001 From: daredoole Date: Tue, 28 Apr 2026 00:09:51 -0400 Subject: [PATCH 2/6] fix(net,protocol): zero-magic handshake, Heartbeat_v2, label constants, trailing whitespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Accept clipboard handshake packets with zero magic (PowerToys MWB compat) - Add desId param to exchangeClipboardHandshake; use kBroadcastMachineId fallback - Force magic=0 on RequestRemoteClipboard secondary socket - Add Heartbeat_v2 (type 80) to PackageType and PackageTypeName - Rename clipboard socket labels: "text"→"TXT", "image"→"IMG" - Add debug logging throughout handshake flow - Strip trailing whitespace (CI static check) Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- mwb-desktop-ui.sh | 2 +- src/ClipboardManager.cpp | 2 +- src/ConfigDialog.py | 4 +- src/NetworkManager.cpp | 89 ++++++++++++++++++++++++++++++---------- src/Protocol.h | 5 ++- 6 files changed, 76 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 71627e7..f390723 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Recommended first-run flow: 1. **Prerequisites:** Build the project and install a clipboard helper such as `wl-clipboard` (Wayland) or `xclip` (X11). Ensure `python3-gi` and GTK3 are installed for the configuration dialogs. 2. **Setup UI:** Launch the easy setup menu with: `./mwb-desktop-ui.sh menu` -3. **Configure & Pair:** +3. **Configure & Pair:** - Choose **Settings** to enter your Windows Host IP and Security Key. - Or, choose **Peers (Discovery & Known)** to automatically find your Windows machine on the network. 4. **Export Helper:** Once configured, the client can export a PowerShell helper to Windows with: diff --git a/mwb-desktop-ui.sh b/mwb-desktop-ui.sh index 2b4e25d..7f013c4 100755 --- a/mwb-desktop-ui.sh +++ b/mwb-desktop-ui.sh @@ -834,7 +834,7 @@ edit_settings() { 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 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)" diff --git a/src/ClipboardManager.cpp b/src/ClipboardManager.cpp index 3a950bb..2008de7 100644 --- a/src/ClipboardManager.cpp +++ b/src/ClipboardManager.cpp @@ -1073,7 +1073,7 @@ std::optional ExternalCommandClipboardBackend::ReadPayload() { } payload.plainText = runReadCommand(m_readTextCommand); - + if (!payload.plainText && !payload.html && !payload.image) { return std::nullopt; } diff --git a/src/ConfigDialog.py b/src/ConfigDialog.py index 1211af2..c150021 100755 --- a/src/ConfigDialog.py +++ b/src/ConfigDialog.py @@ -23,7 +23,7 @@ def __init__(self, title, fields, current_values): 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": @@ -44,7 +44,7 @@ def __init__(self, title, fields, current_values): 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:]): diff --git a/src/NetworkManager.cpp b/src/NetworkManager.cpp index 5e6d755..9a1940d 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; } @@ -1202,7 +1249,7 @@ std::optional> NetworkManager::SnapshotClipboardPayload(boo // 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; @@ -1483,12 +1530,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; @@ -1572,12 +1619,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; @@ -2345,12 +2392,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; } diff --git a/src/Protocol.h b/src/Protocol.h index 0356776..8b956ab 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, @@ -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; From 5faaaa13ddd823a896a372a36f776c58befaec29 Mon Sep 17 00:00:00 2001 From: daredoole Date: Tue, 28 Apr 2026 00:25:40 -0400 Subject: [PATCH 3/6] fix(clipboard): fix image sync protocol types and trimming - Corrected PackageType values for clipboard (9->69, etc) to match MWB protocol. - Implemented trailing null-byte trimming for received image payloads. - Increased inline and socket clipboard max sizes to 16MB. - Use zero magic for clipboard socket handshake per observed PowerToys behavior. - Switched wl-clipboard-klipper to wl-paste --watch for reliable detection. --- src/ClientRuntime.cpp | 6 +++++- src/ClipboardManager.cpp | 6 ++---- src/NetworkManager.cpp | 15 ++++++++++++++- src/Protocol.h | 4 ++-- tests/verify_image_backend.cpp | 32 ++++++++++++++++++++++++++++++++ verify_image | Bin 0 -> 335824 bytes 6 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 tests/verify_image_backend.cpp create mode 100755 verify_image diff --git a/src/ClientRuntime.cpp b/src/ClientRuntime.cpp index 49817ce..a043892 100644 --- a/src/ClientRuntime.cpp +++ b/src/ClientRuntime.cpp @@ -325,7 +325,11 @@ int ClientRuntime::Run() { } if (payload.image) { - std::cout << "[CLIPBOARD] Received image update (" << payload.image->bytes.size() << " bytes)" << std::endl; + 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; } diff --git a/src/ClipboardManager.cpp b/src/ClipboardManager.cpp index 2008de7..36efd18 100644 --- a/src/ClipboardManager.cpp +++ b/src/ClipboardManager.cpp @@ -1006,9 +1006,7 @@ std::unique_ptr createBackend() { 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) { @@ -1084,7 +1082,7 @@ bool ExternalCommandClipboardBackend::WritePayload(const ClipboardPayload& paylo if (payload.image) { CommandSpec cmd = m_writeCommand; if (m_name.find("wl-clipboard") != std::string::npos) { - cmd.argv.push_back("--type"); + cmd.argv.push_back("-t"); cmd.argv.push_back(payload.image->mimeType); } else if (m_name == "xclip") { cmd.argv.push_back("-t"); diff --git a/src/NetworkManager.cpp b/src/NetworkManager.cpp index 9a1940d..7b41189 100644 --- a/src/NetworkManager.cpp +++ b/src/NetworkManager.cpp @@ -1182,6 +1182,13 @@ void NetworkManager::FinalizeInlineClipboardTransfer() { 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. + // Truncate to the actual image size if it's a known format like PNG. + // (89 50 4E 47 ... 49 45 4E 44 AE 42 60 82 for PNG) + // For now, let's just trim all trailing nulls. + 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; @@ -1576,7 +1583,10 @@ void NetworkManager::RequestRemoteClipboard(uint32_t expectedRemoteMachineId) { if (kind == kClipboardSocketImageLabel) { auto imageBytes = ClipboardManager::DecodeImagePayload(payload); if (imageBytes.has_value()) { - std::cout << "[CLIPBOARD] Pulled image payload (" << payloadSize << " bytes)" << std::endl; + 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; @@ -2425,6 +2435,9 @@ void NetworkManager::HandleClipboardConnection(int fd) { } else if (kind == kClipboardSocketImageLabel) { 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))); } } diff --git a/src/Protocol.h b/src/Protocol.h index 8b956ab..3b677b8 100644 --- a/src/Protocol.h +++ b/src/Protocol.h @@ -37,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; 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 0000000000000000000000000000000000000000..44864c9331af4d3fbd7eaa5002b99b516b0f1b43 GIT binary patch literal 335824 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 lWdTFM-Bl-7dHY;=X?{lGXjyGrQUt`hz6(#f& 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 Date: Tue, 28 Apr 2026 00:36:44 -0400 Subject: [PATCH 4/6] test(clipboard): use magic=0 in PushClipboardText to match server expectation Clipboard socket incoming handler uses magic=0 (PowerToys compat). Test was still using crypto.Get24BitHash() for the response packet, causing receivePacketOnSocket to reject it and crash the test with an unhandled std::runtime_error. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_clipboard_socket_security.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_clipboard_socket_security.cpp b/tests/test_clipboard_socket_security.cpp index 38a1637..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); From 2b8f3208ea441c1d4ba99da057243fcf998ee4ba Mon Sep 17 00:00:00 2001 From: daredoole Date: Tue, 28 Apr 2026 12:33:22 -0400 Subject: [PATCH 5/6] fix(tray,net): stabilize UI, fix reconnect loop, and revamp README --- README.md | 643 ++++----------------------------------- src/AppConfig.h | 2 +- src/ClientRuntime.cpp | 36 +-- src/ClipboardManager.cpp | 34 ++- src/NetworkManager.cpp | 67 ++-- src/TrayController.cpp | 26 +- 6 files changed, 171 insertions(+), 637 deletions(-) diff --git a/README.md b/README.md index f390723..e8e731b 100644 --- a/README.md +++ b/README.md @@ -3,640 +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, HTML, and image 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. **Prerequisites:** Build the project and install a clipboard helper such as `wl-clipboard` (Wayland) or `xclip` (X11). Ensure `python3-gi` and GTK3 are installed for the configuration dialogs. -2. **Setup UI:** Launch the easy setup menu with: - `./mwb-desktop-ui.sh menu` -3. **Configure & Pair:** - - Choose **Settings** to enter your Windows Host IP and Security Key. - - Or, choose **Peers (Discovery & Known)** to automatically find your Windows machine on the network. -4. **Export Helper:** Once configured, the client can export a PowerShell helper to Windows with: - `./build/mwb_client export-windows-pair --config ~/.config/mwb-client/config.ini --position top-left` -5. **Windows Sync:** Run the exported `.ps1` script on Windows to synchronize the Linux machine's identity with PowerToys Mouse Without Borders. -6. **Start Service:** Choose **Start Service** from the `./mwb-desktop-ui.sh menu` to begin sharing. - -### Advanced CLI Setup - -For power users who prefer manual configuration: - -1. Generate a config: - `./build/mwb_client init-config --config ~/.config/mwb-client/config.ini --host 192.0.2.10 --name fedora` -2. Store the shared key in the desktop keyring: - `printf '%s' 'MySecurityKey123' | ./build/mwb_client secret-store --config ~/.config/mwb-client/config.ini --secret-id desktop-default --stdin` -3. Export the Windows helper: - `./build/mwb_client export-windows-pair --config ~/.config/mwb-client/config.ini --position top-left` -4. Run the exported PowerShell helper on Windows to seed PowerToys MWB state: - `powershell -ExecutionPolicy Bypass -File .\\inputflow-windows-pair-fedora.ps1 -ClosePowerToys` -5. 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` -6. 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, HTML, and Image 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 \ - python3-gi gir1.2-gtk-3.0 + 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 \ - python3-gobject gtk3 + 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`. - -If the tray or controller prints `libayatana-appindicator is deprecated`, it is a known -compatibility warning when building against older indicator libraries. The application -remains functional. - -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/`: - -- `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 - -If you attach a trace publicly, redact: - -- shared security keys -- private hostnames -- private IP addresses -- exported helper scripts with baked-in secrets - -## Docker - -```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 -``` - -## 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 +See the full [documentation section](#detailed-documentation) for environment variables and protocol details. -The PowerToys MWB protocol uses: + +## Detailed Documentation -- **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 +### 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. -The machine name sent by this client must match the name configured in Windows's MWB machine list. +### Configuration (`config.ini`) +Supports `key_file`, `key_secret_id` (keyring), `screen_width/height` overrides, and more. Default path: `~/.config/mwb-client/config.ini`. -## Known Limitations +### 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 -- 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 preserves CF_HTML metadata and supports raw image transfers alongside plain text, but drag/drop file transfer is not yet 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. +### Network & Protocol +- **Port:** 15101 (Input), 15100 (Clipboard). +- **Encryption:** AES-256-CBC (PowerToys compatible). -## Roadmap - -Short term: - -- 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/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 a043892..18ebbad 100644 --- a/src/ClientRuntime.cpp +++ b/src/ClientRuntime.cpp @@ -313,26 +313,28 @@ int ClientRuntime::Run() { } m_network->SetClipboardProvider(m_clipboard->MakeProvider()); m_network->SetOnClipboardCallback([this](const ClipboardPayload& payload) { - if (!m_clipboard->SetPayload(payload)) { - std::cerr << "WARN: Failed to write incoming clipboard payload through backend '" - << m_clipboard->BackendName() << "'." << std::endl; - return; - } + 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_lastClipboardPayload = payload; - } + { + std::lock_guard lock(m_clipboardStateMutex); + m_lastClipboardPayload = payload; + } - 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]); + 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; } - 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; diff --git a/src/ClipboardManager.cpp b/src/ClipboardManager.cpp index 36efd18..69825cd 100644 --- a/src/ClipboardManager.cpp +++ b/src/ClipboardManager.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -68,6 +69,11 @@ class ExternalCommandClipboardBackend final : public ClipboardBackend { 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) { @@ -1046,8 +1052,24 @@ std::unique_ptr createBackend() { } // namespace std::optional ExternalCommandClipboardBackend::ReadPayload() { + static int callCount = 0; + if (++callCount % 10 == 0) { + std::cout << "[DEBUG] Clipboard ReadPayload called " << callCount << " times" << std::endl; + } 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; + } + ClipboardPayload payload; + payload.plainText = plainText; bool hasHtml = false; bool hasImage = false; @@ -1070,7 +1092,10 @@ std::optional ExternalCommandClipboardBackend::ReadPayload() { } } - payload.plainText = runReadCommand(m_readTextCommand); + { + std::lock_guard lock(m_cacheMutex); + m_cachedPayload = payload; + } if (!payload.plainText && !payload.html && !payload.image) { return std::nullopt; @@ -1079,6 +1104,13 @@ std::optional ExternalCommandClipboardBackend::ReadPayload() { } 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) { diff --git a/src/NetworkManager.cpp b/src/NetworkManager.cpp index 7b41189..544c916 100644 --- a/src/NetworkManager.cpp +++ b/src/NetworkManager.cpp @@ -1169,31 +1169,30 @@ void NetworkManager::FinalizeInlineClipboardTransfer() { return; } - 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; + 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)) { - auto imageBytes = ClipboardManager::DecodeImagePayload(payload); - if (imageBytes.has_value()) { - // PowerToys often pads images with null bytes up to the next block size. - // Truncate to the actual image size if it's a known format like PNG. - // (89 50 4E 47 ... 49 45 4E 44 AE 42 60 82 for PNG) - // For now, let's just trim all trailing nulls. - while (!imageBytes->empty() && imageBytes->back() == 0) { - imageBytes->pop_back(); + 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; } - DeliverClipboardPayload(ClipboardManager::MakeImagePayload("image/png", std::move(*imageBytes))); - } else { - std::cerr << "WARN: Failed to decode inline clipboard image payload." << std::endl; } - } + }).detach(); } void NetworkManager::DeliverClipboardPayload(const ClipboardPayload& payload) { @@ -1702,7 +1701,7 @@ void NetworkManager::RunLoop() { } continue; } - reconnectState = ResetReconnectAfterSuccess(reconnectPolicy); + // Do NOT reset reconnectState here; wait for handshake success } uint8_t serverNoise[16]; @@ -1711,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; } @@ -1719,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; } @@ -1821,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; } diff --git a/src/TrayController.cpp b/src/TrayController.cpp index 2e37c54..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); @@ -393,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 { @@ -557,16 +563,9 @@ int main(int argc, char** argv) { gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); - // Advanced Submenu - GtkWidget* advancedItem = gtk_menu_item_new_with_label("Advanced"); - GtkWidget* advancedMenu = gtk_menu_new(); - gtk_menu_item_set_submenu(GTK_MENU_ITEM(advancedItem), advancedMenu); - - context.showStatusItem = AddMenuItem(advancedMenu, "Show Service Details", G_CALLBACK(OnShowStatus), &context); - context.installDesktopEntriesItem = AddMenuItem(advancedMenu, "Install Desktop Entries", G_CALLBACK(OnInstallDesktopEntries), &context); - context.trayHelpItem = AddMenuItem(advancedMenu, "Tray Visibility Help", G_CALLBACK(OnShowTrayHelp), &context); - - gtk_menu_shell_append(GTK_MENU_SHELL(menu), advancedItem); + 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); @@ -589,8 +588,7 @@ int main(int argc, char** argv) { 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) { From fbb597477e8efdb9b664c5a2869e2274914fc4f9 Mon Sep 17 00:00:00 2001 From: daredoole Date: Tue, 28 Apr 2026 12:35:54 -0400 Subject: [PATCH 6/6] fix(ci): remove trailing whitespace and cleanup debug logs --- README.md | 2 +- src/ClipboardManager.cpp | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/README.md b/README.md index e8e731b..c5939bd 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Recommended first-run flow for most users: - **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:** +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. diff --git a/src/ClipboardManager.cpp b/src/ClipboardManager.cpp index 69825cd..3db92c7 100644 --- a/src/ClipboardManager.cpp +++ b/src/ClipboardManager.cpp @@ -12,7 +12,6 @@ #include #include #include -#include #include #include #include @@ -1052,10 +1051,6 @@ std::unique_ptr createBackend() { } // namespace std::optional ExternalCommandClipboardBackend::ReadPayload() { - static int callCount = 0; - if (++callCount % 10 == 0) { - std::cout << "[DEBUG] Clipboard ReadPayload called " << callCount << " times" << std::endl; - } auto types = runReadCommand(m_listTypesCommand); auto plainText = runReadCommand(m_readTextCommand);