From dd08a04069a3f6092d184e5b428903b005034376 Mon Sep 17 00:00:00 2001 From: daredoole Date: Tue, 28 Apr 2026 20:31:20 -0400 Subject: [PATCH 1/5] Wire topology preview and layout wizard --- CMakeLists.txt | 10 ++ README.md | 13 +- docs/beta-workflow.md | 22 ++- docs/compatibility.md | 5 +- docs/migration.md | 6 +- docs/topology.md | 122 +++++++++++++ mwb-desktop-ui.sh | 249 ++++++++++++++++++++++++-- src/AppConfig.cpp | 18 ++ src/AppConfig.h | 2 + src/ClientRuntime.cpp | 78 +++++++++ src/ClientRuntime.h | 8 +- src/InputDispatcher.cpp | 32 ++++ src/InputDispatcher.h | 11 ++ src/TopologyModel.cpp | 272 +++++++++++++++++++++++++++++ src/TopologyModel.h | 19 ++ src/main.cpp | 2 + tests/test_main.cpp | 12 ++ tests/test_topology_model.cpp | 60 +++++++ tests/topology_config_docs_test.py | 146 ++++++++++++++++ 19 files changed, 1066 insertions(+), 21 deletions(-) create mode 100644 docs/topology.md create mode 100644 tests/topology_config_docs_test.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 6cd1945..8c595df 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -46,6 +46,7 @@ add_executable(mwb_client src/main.cpp src/PeerRecovery.cpp src/SecretStore.cpp + src/TopologyModel.cpp src/ClipboardManager.cpp src/CryptoHelper.cpp src/InputManager.cpp @@ -85,6 +86,8 @@ include(CTest) find_package(PkgConfig QUIET) if (BUILD_TESTING) + find_program(PYTHON3_EXECUTABLE python3) + add_executable(mwb_client_unit_tests tests/test_main.cpp src/AppConfig.cpp @@ -184,6 +187,13 @@ if (BUILD_TESTING) add_test(NAME mwb_input_device_capability_tests COMMAND mwb_input_device_capability_tests) add_test(NAME mwb_input_latency_tests COMMAND mwb_input_latency_tests) add_test(NAME mwb_topology_model_tests COMMAND mwb_topology_model_tests) + if (PYTHON3_EXECUTABLE) + add_test(NAME mwb_topology_config_docs + COMMAND "${PYTHON3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/topology_config_docs_test.py" + "${CMAKE_CURRENT_SOURCE_DIR}/docs/topology.md" + ) + endif() add_test(NAME mwb_mouse_trace_tests COMMAND mwb_mouse_trace_tests) add_test(NAME mwb_media_key_bridge_tests COMMAND mwb_media_key_bridge_tests) add_test(NAME mwb_protocol_security_tests COMMAND mwb_protocol_security_tests) diff --git a/README.md b/README.md index 3ac7156..27de028 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,13 @@ Recommended first-run flow for most users: 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:** +4. **Choose layout (optional):** + - Open **Topology/Layout Wizard** for side-by-side, stacked, AAB, BAA, ABA, or asymmetric/manual presets. + - Confirm the dry-run preview to write `~/.config/mwb-client/*.topology` and enable `topology_file`/`topology_enabled`. +5. **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`. +6. **Start:** Choose **Start Service** or launch the tray with `./build/mwb_tray`. For the full beta setup, health-check, diagnostics, connection-quality, and packaging-verification workflow, see [docs/beta-workflow.md](docs/beta-workflow.md). @@ -98,9 +101,11 @@ See the full [documentation section](#detailed-documentation) for environment va User-facing beta operations: - [Guided Windows pairing and export helper](docs/beta-workflow.md#guided-pairing-and-export-helper) +- [Topology/layout wizard](docs/beta-workflow.md#topologylayout-wizard) - [Health checks and diagnostics bundle](docs/beta-workflow.md#health-check) - [Connection quality and latency reporting](docs/beta-workflow.md#connection-quality) - [Packaging verification](docs/beta-workflow.md#packaging-verification) +- [Topology config contract and layout wizard dry-run expectations](docs/topology.md) - [Migration from other keyboard/mouse sharing tools](docs/migration.md) - [Compatibility matrix and platform caveats](docs/compatibility.md) @@ -111,7 +116,9 @@ User-facing beta operations: 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. ### Configuration (`config.ini`) -Supports `key_file`, `key_secret_id` (keyring), `screen_width/height` overrides, and more. Default path: `~/.config/mwb-client/config.ini`. +Supports `key_file`, `key_secret_id` (keyring), `screen_width/height` overrides, `topology_enabled`, `topology_file`, and more. Default path: `~/.config/mwb-client/config.ini`. + +Display-level topology is a separate opt-in preview contract. The default runtime remains MWB-compatible machine placement unless topology is explicitly enabled; see [docs/topology.md](docs/topology.md) for examples, wrap policies, and dry-run validation expectations. ### Screen Sizing The client detects screen size in this order: diff --git a/docs/beta-workflow.md b/docs/beta-workflow.md index 47fc90c..c8052ef 100644 --- a/docs/beta-workflow.md +++ b/docs/beta-workflow.md @@ -12,7 +12,8 @@ Use the desktop controller for the guided path: 1. Open **Settings** and enter the Windows host IP, local machine name, port, and exactly one authentication source: inline key, `key_file`, or Secret Service key ID. 2. Use **Connection Behavior** to choose automatic reconnect behavior before starting the service. -3. Export a Windows helper from Linux when the UI exposes the action, or use the CLI fallback: +3. Optionally run **Topology/Layout Wizard** before exporting if you want to draft a side-by-side, stacked, AAB, BAA, ABA, or asymmetric/manual layout. +4. Export a Windows helper from Linux when the UI exposes the action, or use the CLI fallback: ```bash ./build/mwb_client export-windows-pair \ @@ -31,6 +32,25 @@ Keep the exported `.ps1` private because it contains pairing material. Delete it ![Pairing helper walkthrough](screenshots/pairing-helper.svg) +## Topology/Layout Wizard + +Open the wizard from the desktop controller: + +```bash +./mwb-desktop-ui.sh layout-wizard +``` + +The wizard asks for a preset, machine labels, display size, wrap policy, and output file name. It shows a dry-run preview of the exact topology file before making changes. + +Only after confirmation, the wizard writes the topology file under `~/.config/mwb-client/` and updates `config.ini`: + +```ini +topology_enabled=true +topology_file=/home/example/.config/mwb-client/topology-side-by-side.topology +``` + +Current limitation: the topology file is saved for topology-aware runtime builds, but runtime handoff is still resolver/trace-gated. Verify behavior with PowerToys MWB and the exported helper until direct cross-machine handoff enforcement lands. + ## Health Check Run the built-in doctor before filing a beta issue or after changing package/service setup: diff --git a/docs/compatibility.md b/docs/compatibility.md index 3c43421..4ef0812 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -18,6 +18,7 @@ InputFlow is a native Linux peer for Microsoft PowerToys Mouse Without Borders ( | Clipboard receive/send | Supported beta path | Requires local helpers: `wl-clipboard` on Wayland or `xclip`/`xsel` on X11. Availability is reported by `doctor`. | | systemd user service | Opt-in | Packaging includes a user unit, but users should enable/start it only after validating config, key source, and `/dev/uinput` access. | | Network trust model | Trusted LAN/subnet | Use on a trusted local network. Do not expose MWB ports to untrusted networks or the public internet. | +| Display-level topology config | Opt-in preview | The contract is documented in [Topology Config Contract](topology.md), but the default runtime remains MWB-compatible machine placement while handoff behavior matures. | ## Linux Session Details @@ -53,4 +54,6 @@ The systemd user service is a convenience, not a required first step. During mig ## Topology Expectations -Current compatibility is machine-level MWB placement. The roadmap includes separating machines from displays, configurable wrap policies, AAB/BAA/ABA layouts, stacked layouts, asymmetric layouts, and dry-run path previews that show pointer routes before applying a layout. +Current compatibility is machine-level MWB placement. Display-level topology is intentionally gated and opt-in so InputFlow can remain compatible with PowerToys MWB while the runtime handoff behavior matures. + +The topology contract separates machines from displays and supports configurable wrap policies, AAB/BAA/ABA layouts, stacked layouts, asymmetric layouts, and dry-run path previews. See [Topology Config Contract](topology.md) for the preview file format and validation expectations. diff --git a/docs/migration.md b/docs/migration.md index e092783..f11f3ac 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -23,7 +23,7 @@ For the guided pairing flow, see [Public Beta Workflow](beta-workflow.md#guided- | --- | --- | | Server | A peer that currently owns the local pointer and sends input to another peer. This role is situational, not a fixed machine type. | | Client | A peer receiving remote input. This role is also situational. | -| Screen | A machine entry in the current MWB layout. Multi-display topology is tracked separately on the roadmap. | +| Screen | A machine entry in the current MWB layout. Display-level topology is a separate opt-in preview contract. | | Screen name | `machine_name` / MWB peer name. Names must match what the other peer expects. | | Configuration file | `~/.config/mwb-client/config.ini` for InputFlow; PowerToys MWB settings on Windows. | | Shared secret / password | MWB security key. InputFlow can read it from an inline `key`, `key_file`, or Secret Service `key_secret_id`. | @@ -75,6 +75,6 @@ Avoid publishing configs, helper scripts, logs, or screenshots that expose keys, ## Topology Roadmap -InputFlow currently focuses on MWB-compatible machine placement. The topology roadmap includes a cleaner machine/display split, explicit wrap policies, AAB/BAA/ABA layouts, stacked layouts, asymmetric layouts, and dry-run path previews so users can inspect pointer transitions before applying them. +InputFlow currently focuses on MWB-compatible machine placement. The topology preview adds a cleaner machine/display split, explicit wrap policies, AAB/BAA/ABA layouts, stacked layouts, asymmetric layouts, and dry-run path previews so users can inspect pointer transitions before applying them. -Until those features are user-facing, treat topology as machine-level MWB placement and verify changes in PowerToys MWB after exporting. +Until the runtime topology feature gate is enabled and validated for your setup, treat topology as machine-level MWB placement and verify changes in PowerToys MWB after exporting. If you are testing the layout wizard or runtime topology branch, use the [Topology Config Contract](topology.md) and keep dry-run enabled until validation and preview output match the intended handoff behavior. diff --git a/docs/topology.md b/docs/topology.md new file mode 100644 index 0000000..71a03f3 --- /dev/null +++ b/docs/topology.md @@ -0,0 +1,122 @@ +# Topology Files + +InputFlow topology files describe machines, their individual displays, explicit border links, and optional wrap fallback. The current runtime consumes these files only when `topology_enabled=true` and `topology_file=...` are set in `config.ini`. + +This is additive to the beta flow. If topology is disabled, missing, or invalid, InputFlow keeps the existing single-screen runtime behavior. + +## Format + +Topology files are line-based `key=value` files: + +| Key | Format | +| --- | --- | +| `wrap` | `none`, `horizontal`, `vertical`, or `both` | +| `machine` | `MACHINE_ID` | +| `display` | `DISPLAY_ID,MACHINE_ID,X,Y,WIDTH,HEIGHT` | +| `link` | `SOURCE_DISPLAY,EXIT_EDGE,TARGET_DISPLAY,ENTRY_EDGE` | + +Edges are `left`, `right`, `up`, or `down`. Explicit links win over wrap fallback. Display coordinates are per-machine logical geometry, not physical millimeters. + +The layout wizard writes this format after a dry-run preview. Manual files should stay in `~/.config/mwb-client/*.topology`. + +## Examples + +### AAB + +```ini +# topology-example: aab +wrap=none +machine=A +machine=B +display=A1,A,0,0,1920,1080 +display=A2,A,1920,0,1920,1080 +display=B1,B,3840,0,1920,1080 +link=A1,right,A2,left +link=A2,left,A1,right +link=A2,right,B1,left +link=B1,left,A2,right +``` + +### BAA + +```ini +# topology-example: baa +wrap=none +machine=A +machine=B +display=B1,B,0,0,1920,1080 +display=A1,A,1920,0,1920,1080 +display=A2,A,3840,0,1920,1080 +link=B1,right,A1,left +link=A1,left,B1,right +link=A1,right,A2,left +link=A2,left,A1,right +``` + +### ABA + +```ini +# topology-example: aba +wrap=none +machine=A +machine=B +display=A1,A,0,0,1920,1080 +display=B1,B,1920,0,1920,1080 +display=A2,A,3840,0,1920,1080 +link=A1,right,B1,left +link=B1,left,A1,right +link=B1,right,A2,left +link=A2,left,B1,right +``` + +### Stacked + +```ini +# topology-example: stacked +wrap=none +machine=A +machine=B +display=A1,A,0,0,1920,1080 +display=B1,B,0,1080,1920,1080 +link=A1,down,B1,up +link=B1,up,A1,down +``` + +### Asymmetric + +```ini +# topology-example: asymmetric +wrap=none +machine=A +machine=B +display=A1,A,0,0,3840,2160 +display=B1,B,3840,540,1920,1080 +link=A1,right,B1,left +link=B1,left,A1,right +``` + +### Horizontal Wrap + +```ini +# topology-example: wrap-horizontal +wrap=horizontal +machine=A +machine=B +display=A1,A,0,0,1920,1080 +display=A2,A,1920,0,1920,1080 +display=B1,B,3840,0,1920,1080 +link=A2,right,B1,left +link=B1,left,A2,right +``` + +## Runtime Contract + +`topology_enabled=false` is the default. Enabling topology loads and validates the topology file during startup. Invalid topology logs a warning and falls back to the existing behavior instead of blocking startup. + +The current runtime uses topology to resolve and log edge transitions for dry-run verification. It does not yet replace the protocol handoff path. This lets beta users validate AAB, BAA, ABA, stacked, asymmetric, and wrap layouts without changing remote-control behavior by default. + +## Troubleshooting + +Use the diagnostics bundle when reporting topology bugs. It records `topology_enabled`, `topology_file`, load/validation status, display geometry, session type, and recent runtime logs. + +If movement is unexpected, set `wrap=none` and add explicit `link=` lines for each intended transition. If the wizard output is wrong, edit the `.topology` file directly and restart the user service. diff --git a/mwb-desktop-ui.sh b/mwb-desktop-ui.sh index ac0c58d..b3dcf49 100755 --- a/mwb-desktop-ui.sh +++ b/mwb-desktop-ui.sh @@ -13,6 +13,8 @@ RECONNECT_IDLE_CONFIG_KEY="${MWB_RECONNECT_IDLE_CONFIG_KEY:-reconnect_idle_retry MPRIS_MEDIA_KEYS_CONFIG_KEY="${MWB_MPRIS_MEDIA_KEYS_CONFIG_KEY:-mpris_media_keys_enabled}" MPRIS_PLAYER_CONFIG_KEY="${MWB_MPRIS_PLAYER_CONFIG_KEY:-mpris_player}" LATENCY_REPORT_CONFIG_KEY="${MWB_LATENCY_REPORT_CONFIG_KEY:-latency_report}" +TOPOLOGY_ENABLED_CONFIG_KEY="${MWB_TOPOLOGY_ENABLED_CONFIG_KEY:-topology_enabled}" +TOPOLOGY_FILE_CONFIG_KEY="${MWB_TOPOLOGY_FILE_CONFIG_KEY:-topology_file}" DIAGNOSTICS_BUNDLE_SCRIPT="$SCRIPT_DIR/scripts/inputflow-diagnostics-bundle.sh" DEFAULT_AUTO_CONNECT_ENABLED="${MWB_DEFAULT_AUTO_CONNECT_ENABLED:-true}" DEFAULT_RECONNECT_INITIAL_MS="${MWB_DEFAULT_RECONNECT_INITIAL_MS:-1000}" @@ -233,6 +235,40 @@ canonical_managed_key() { return 1 } +write_topology_config_keys() { + local topology_file="$1" + local tmp_path line line_key + local saw_enabled=false saw_file=false + + mkdir -p "$(dirname "$CONFIG_PATH")" + tmp_path="$(mktemp "${CONFIG_PATH}.tmp.XXXXXX")" + + if [[ -f "$CONFIG_PATH" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" =~ ^[[:space:]]*([A-Za-z0-9_.-]+)[[:space:]]*= ]]; then + line_key="${BASH_REMATCH[1]}" + case "$line_key" in + "$TOPOLOGY_ENABLED_CONFIG_KEY") + printf '%s=true\n' "$TOPOLOGY_ENABLED_CONFIG_KEY" >>"$tmp_path" + saw_enabled=true + continue + ;; + "$TOPOLOGY_FILE_CONFIG_KEY") + printf '%s=%s\n' "$TOPOLOGY_FILE_CONFIG_KEY" "$topology_file" >>"$tmp_path" + saw_file=true + continue + ;; + esac + fi + printf '%s\n' "$line" >>"$tmp_path" + done <"$CONFIG_PATH" + fi + + [[ "$saw_enabled" == true ]] || printf '%s=true\n' "$TOPOLOGY_ENABLED_CONFIG_KEY" >>"$tmp_path" + [[ "$saw_file" == true ]] || printf '%s=%s\n' "$TOPOLOGY_FILE_CONFIG_KEY" "$topology_file" >>"$tmp_path" + mv "$tmp_path" "$CONFIG_PATH" +} + write_config() { local host="$1" key="$2" key_file="$3" secret_id="$4" machine_name="$5" port="$6" auto_connect_enabled="$7" reconnect_initial_backoff_ms="$8" reconnect_max_backoff_ms="$9" reconnect_idle_retry_ms="${10}" clipboard_enabled="${11}" clipboard_send_enabled="${12}" clipboard_force_poll="${13}" clipboard_poll_ms="${14}" screen_width="${15}" screen_height="${16}" mpris_media_keys_enabled="${17}" mpris_player="${18}" latency_report="${19}" local secret_key_name="${20:-$(detect_secret_id_key_name)}" @@ -660,7 +696,7 @@ service_state_label() { } menu_summary_text() { - local state host auth_label auto_connect_enabled reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms + local state host key key_file secret_id auth_label auto_connect_enabled reconnect_initial_backoff_ms reconnect_max_backoff_ms reconnect_idle_retry_ms topology_enabled topology_file topology_label state="$(service_state)" host="$(read_config_value host)" key="$(read_config_value key)" @@ -668,14 +704,22 @@ menu_summary_text() { 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")" + topology_enabled="$(read_config_value "$TOPOLOGY_ENABLED_CONFIG_KEY")" + topology_file="$(read_config_value "$TOPOLOGY_FILE_CONFIG_KEY")" [[ -n "$host" ]] || host="None" + if [[ "$topology_enabled" == "true" && -n "$topology_file" ]]; then + topology_label="$(basename "$topology_file")" + else + topology_label="Disabled" + fi - printf 'Status: %s\nHost: %s\nAuth: %s\nReconnect: %s' \ + printf 'Status: %s\nHost: %s\nAuth: %s\nReconnect: %s\nTopology: %s' \ "$(service_state_label "$state")" \ "$host" \ "$auth_label" \ - "$( [[ "$auto_connect_enabled" == "true" ]] && printf 'Auto' || printf 'Manual' )" + "$( [[ "$auto_connect_enabled" == "true" ]] && printf 'Auto' || printf 'Manual' )" \ + "$topology_label" } show_status() { @@ -958,24 +1002,202 @@ Next steps: 4. Return here and run Health Check." } +sanitize_topology_name() { + local value="$1" + value="$(printf '%s' "$value" | tr -cs 'A-Za-z0-9_.-' '_' | sed 's/^_*//;s/_*$//')" + [[ -n "$value" ]] || value="machine" + printf '%s\n' "$value" +} + +topology_default_machine_a() { + local machine_name + machine_name="$(read_config_value machine_name)" + [[ -n "$machine_name" ]] || machine_name="$(hostname -s 2>/dev/null || printf 'linux')" + sanitize_topology_name "$machine_name" +} + +topology_default_machine_b() { + local host + host="$(read_config_value host)" + [[ -n "$host" ]] || host="windows" + sanitize_topology_name "$host" +} + +topology_append_display() { + local id="$1" machine="$2" x="$3" y="$4" width="$5" height="$6" + printf 'display=%s,%s,%s,%s,%s,%s\n' "$id" "$machine" "$x" "$y" "$width" "$height" +} + +topology_append_link() { + local source="$1" exit_edge="$2" target="$3" entry_edge="$4" + printf 'link=%s,%s,%s,%s\n' "$source" "$exit_edge" "$target" "$entry_edge" +} + +generate_topology_content() { + local preset="$1" machine_a="$2" machine_b="$3" width="$4" height="$5" wrap_policy="$6" manual_content="${7:-}" + local a1="${machine_a}-1" a2="${machine_a}-2" b1="${machine_b}-1" + local x1=0 x2="$width" x3 y2="$height" + + if [[ "$preset" == "manual" ]]; then + printf '%s\n' "$manual_content" + return 0 + fi + + x3=$((width * 2)) + + cat <"$preview_path" + topology_content="$(zenity --text-info --editable --title="$APP_NAME manual topology" --width=760 --height=520 \ + --filename="$preview_path" || true)" + rm -f "$preview_path" + [[ -n "$topology_content" ]] || return 1 + else + fields="machine_a:Machine A (Linux/current):entry||machine_b:Machine B (Windows/peer):entry||display_width:Display Width:entry||display_height:Display Height:entry||wrap_policy:Wrap Policy|none|horizontal|vertical|both:combo||file_name:Topology File Name:entry" + values="$machine_a|$machine_b|$display_width|$display_height|$wrap_policy|$file_name" + gui_output="$(python3 "$SCRIPT_DIR/src/ConfigDialog.py" "$APP_NAME topology/layout wizard" "$fields" "$values" || true)" + [[ -n "$gui_output" ]] || return 1 + IFS='|' read -r machine_a machine_b display_width display_height wrap_policy file_name <<< "$gui_output" + + machine_a="$(sanitize_topology_name "$machine_a")" + machine_b="$(sanitize_topology_name "$machine_b")" + if ! is_integer_in_range "$display_width" 1 100000; then zenity --error --text="Display width must be a positive integer."; return 1; fi + if ! is_integer_in_range "$display_height" 1 100000; then zenity --error --text="Display height must be a positive integer."; return 1; fi + topology_content="$(generate_topology_content "$preset" "$machine_a" "$machine_b" "$display_width" "$display_height" "$wrap_policy")" + fi + + file_name="$(basename "${file_name:-topology-${preset}.topology}")" + [[ "$file_name" == *.topology ]] || file_name="${file_name}.topology" + topology_dir="$(dirname "$CONFIG_PATH")" + topology_path="$topology_dir/$file_name" + + preview_path="$(mktemp)" + printf '%s\n' "$topology_content" >"$preview_path" + if ! zenity --text-info --title="$APP_NAME topology dry-run preview" --width=820 --height=560 \ + --filename="$preview_path" --ok-label="Continue" --cancel-label="Back"; then + rm -f "$preview_path" + return 1 + fi + rm -f "$preview_path" + + if ! zenity --question --title="$APP_NAME topology/layout wizard" --width=620 \ + --text="Apply this topology?\n\nWill write:\n$topology_path\n\nWill set:\n$TOPOLOGY_ENABLED_CONFIG_KEY=true\n$TOPOLOGY_FILE_CONFIG_KEY=$topology_path\n\nCurrent limitation: runtime handoff is resolver/trace-gated until direct cross-machine handoff enforcement lands."; then + return 1 + fi + + mkdir -p "$topology_dir" + printf '%s\n' "$topology_content" >"$topology_path" + write_topology_config_keys "$topology_path" + zenity --info --width=620 --text="Topology saved.\n\nFile: $topology_path\nConfig: $CONFIG_PATH" + offer_service_restart_if_active "Topology settings updated." +} + guided_pairing() { while true; do local choice - choice="$(zenity --list --title="$APP_NAME guided pairing" --width=620 --height=360 \ + choice="$(zenity --list --title="$APP_NAME guided pairing" --width=620 --height=390 \ --text="Use this flow to discover Windows, save Linux settings, export the Windows helper, then verify the setup." \ --column="Step" \ "1. Discover Windows peer and save settings" \ "2. Edit settings manually" \ - "3. Export Windows helper" \ - "4. Start service" \ - "5. Run health check" \ + "3. Topology/layout wizard" \ + "4. Export Windows helper" \ + "5. Start service" \ + "6. Run health check" \ "Back" || true)" case "$choice" in "1. Discover Windows peer and save settings") discover_and_save_peer ;; "2. Edit settings manually") edit_settings ;; - "3. Export Windows helper") export_windows_helper ;; - "4. Start service") start_session ;; - "5. Run health check") health_check ;; + "3. Topology/layout wizard") layout_wizard ;; + "4. Export Windows helper") export_windows_helper ;; + "5. Start service") start_session ;; + "6. Run health check") health_check ;; ""|"Back") return 0 ;; esac done @@ -1268,9 +1490,10 @@ EOF main_menu() { while true; do local choice - choice="$(zenity --list --title="$APP_NAME" --text="$(menu_summary_text)" --width=540 --height=400 \ + choice="$(zenity --list --title="$APP_NAME" --text="$(menu_summary_text)" --width=540 --height=430 \ --column="Action" \ "Guided Pairing" \ + "Topology/Layout Wizard" \ "Health Check" \ "Diagnostics Bundle" \ "Connection Quality" \ @@ -1287,6 +1510,7 @@ main_menu() { case "$choice" in "Guided Pairing") guided_pairing ;; + "Topology/Layout Wizard") layout_wizard ;; "Health Check") health_check ;; "Diagnostics Bundle") diagnostics_bundle ;; "Connection Quality") connection_quality ;; @@ -1315,6 +1539,7 @@ require_ui case "${1:-menu}" in ""|menu) main_menu ;; guided-pairing|pairing|export-helper) guided_pairing ;; + layout-wizard|topology-wizard|topology|layout) layout_wizard ;; health-check|doctor) health_check ;; diagnostics-bundle|diagnostics) diagnostics_bundle ;; connection-quality|quality) connection_quality ;; @@ -1330,7 +1555,7 @@ case "${1:-menu}" in tray) start_tray ;; install-desktop-entry|install-desktop-entries) install_desktop_entry ;; help|-h|--help) - printf 'Usage: %s [menu|guided-pairing|health-check|diagnostics-bundle|connection-quality|settings|connection|discover|peers|tray-help|status|start|restart|stop|tray|install-desktop-entry]\n' "$(basename "${BASH_SOURCE[0]}")" + printf 'Usage: %s [menu|guided-pairing|layout-wizard|health-check|diagnostics-bundle|connection-quality|settings|connection|discover|peers|tray-help|status|start|restart|stop|tray|install-desktop-entry]\n' "$(basename "${BASH_SOURCE[0]}")" ;; *) zenity --error --text="Unknown action: $1" diff --git a/src/AppConfig.cpp b/src/AppConfig.cpp index d990cd6..d089b2a 100644 --- a/src/AppConfig.cpp +++ b/src/AppConfig.cpp @@ -352,6 +352,21 @@ bool ParseAppConfig(std::string_view text, AppConfig& outConfig, std::string* er continue; } + if (key == "topology_enabled" || key == "topology_runtime_enabled") { + const auto parsed = ParseConfigBool(value); + if (!parsed.has_value()) { + SetError(errorMessage, "Config key 'topology_enabled' expects true/false."); + return false; + } + outConfig.topologyRuntimeEnabled = *parsed; + continue; + } + + if (key == "topology_file") { + outConfig.topologyFile = std::string(value); + continue; + } + SetError(errorMessage, "Unknown config key '" + std::string(key) + "' on line " + std::to_string(lineNumber) + "."); return false; } @@ -416,6 +431,8 @@ std::string RenderAppConfig(const AppConfig& config) { out << "mpris_media_keys_enabled=" << RenderBool(config.mprisMediaKeysEnabled) << '\n'; out << "mpris_player=" << config.mprisPlayer << '\n'; out << "latency_report=" << RenderBool(config.latencyReport) << '\n'; + out << "topology_enabled=" << RenderBool(config.topologyRuntimeEnabled) << '\n'; + out << "topology_file=" << config.topologyFile << '\n'; return out.str(); } @@ -434,6 +451,7 @@ std::string RenderSampleAppConfig() { out << "# Relative key_file paths resolve against the directory containing config.ini.\n"; out << "# Set auto_connect_enabled=false to keep the service idle until you re-enable it.\n"; out << "# Set screen_width and screen_height to your local desktop size when needed.\n"; + out << "# Set topology_enabled=true and topology_file=... to preview runtime topology transitions.\n"; out << RenderAppConfig(sample); return out.str(); } diff --git a/src/AppConfig.h b/src/AppConfig.h index 9c8f716..6ce4f34 100644 --- a/src/AppConfig.h +++ b/src/AppConfig.h @@ -27,6 +27,8 @@ struct AppConfig { bool mprisMediaKeysEnabled{true}; std::string mprisPlayer; bool latencyReport{false}; + bool topologyRuntimeEnabled{false}; + std::string topologyFile; }; AppConfig LoadDefaultAppConfig(); diff --git a/src/ClientRuntime.cpp b/src/ClientRuntime.cpp index 18ebbad..590f6e2 100644 --- a/src/ClientRuntime.cpp +++ b/src/ClientRuntime.cpp @@ -52,6 +52,35 @@ std::optional> ParseMode(const std::string& mode) { return std::pair{*width, *height}; } +std::string SelectTopologySourceDisplay(const TopologyModel& topology, + const std::string& localMachineName, + const ClientRuntime::ScreenSize& screenSize) { + const Display* firstLocal = nullptr; + const Display* firstAny = nullptr; + + for (const auto& display : topology.displays()) { + if (firstAny == nullptr) { + firstAny = &display; + } + if (!localMachineName.empty() && display.machineId == localMachineName) { + if (display.width == screenSize.width && display.height == screenSize.height) { + return display.id; + } + if (firstLocal == nullptr) { + firstLocal = &display; + } + } + } + + if (firstLocal != nullptr) { + return firstLocal->id; + } + if (localMachineName.empty() && firstAny != nullptr) { + return firstAny->id; + } + return {}; +} + std::optional ReadScreenSizeFromDrm() { namespace fs = std::filesystem; @@ -249,6 +278,54 @@ ClientRuntime::ScreenSize ClientRuntime::DetectScreenSize() const { return ScreenSize{kFallbackScreenWidth, kFallbackScreenHeight, ScreenSize::Source::Fallback}; } +void ClientRuntime::ConfigureTopologyPreview(const ScreenSize& screenSize) { + m_dispatcher.SetTopologyPreview(nullptr, {}, false); + m_topology.reset(); + + if (!m_options.topologyRuntimeEnabled) { + return; + } + + if (m_options.topologyFilePath.empty()) { + std::cerr << "WARN: Topology runtime enabled but topology_file is empty; using default pointer behavior." << std::endl; + return; + } + + TopologyModel loaded; + std::string error; + if (!LoadTopologyConfig(m_options.topologyFilePath, loaded, &error)) { + std::cerr << "WARN: Failed to load topology config '" << m_options.topologyFilePath.string() + << "': " << error << "; using default pointer behavior." << std::endl; + return; + } + + const auto issues = loaded.validate(); + if (!issues.empty()) { + std::cerr << "WARN: Invalid topology config '" << m_options.topologyFilePath.string() + << "': " << topologyIssueCodeName(issues.front().code) + << ": " << issues.front().message + << "; using default pointer behavior." << std::endl; + return; + } + + const std::string sourceDisplayId = SelectTopologySourceDisplay( + loaded, + m_options.localMachineName, + screenSize); + if (sourceDisplayId.empty()) { + std::cerr << "WARN: Topology config '" << m_options.topologyFilePath.string() + << "' has no display for local machine '" << m_options.localMachineName + << "'; using default pointer behavior." << std::endl; + return; + } + + m_topology = std::make_shared(std::move(loaded)); + m_dispatcher.SetTopologyPreview(m_topology, sourceDisplayId, true); + std::cout << "[TOPOLOGY] Loaded dry-run topology preview from " + << m_options.topologyFilePath.string() + << " using source display " << sourceDisplayId << "." << std::endl; +} + int ClientRuntime::Run() { const ScreenSize screenSize = DetectScreenSize(); if (screenSize.source == ScreenSize::Source::Fallback) { @@ -278,6 +355,7 @@ int ClientRuntime::Run() { if (!m_input.Initialize()) { std::cerr << "WARN: Virtual input initialization failed. Networking will continue, but local mouse/keyboard injection is disabled until /dev/uinput is accessible." << std::endl; } + ConfigureTopologyPreview(screenSize); m_dispatcher.Start(); m_network = std::make_unique(m_options.host, m_options.port, m_options.key); diff --git a/src/ClientRuntime.h b/src/ClientRuntime.h index 0b5ac76..7d62d24 100644 --- a/src/ClientRuntime.h +++ b/src/ClientRuntime.h @@ -1,18 +1,20 @@ #pragma once #include +#include +#include #include #include #include #include #include -#include #include "ClipboardManager.h" #include "InputDispatcher.h" #include "InputLatencyStats.h" #include "InputManager.h" #include "NetworkManager.h" +#include "TopologyModel.h" namespace mwb { @@ -38,6 +40,8 @@ struct RuntimeOptions { bool debugKeyLogging{false}; bool debugShortcutLogging{false}; bool latencyReport{false}; + bool topologyRuntimeEnabled{false}; + std::filesystem::path topologyFilePath; std::function onSessionEstablished; std::function onSessionDisconnected; }; @@ -65,6 +69,7 @@ class ClientRuntime { private: ScreenSize DetectScreenSize() const; + void ConfigureTopologyPreview(const ScreenSize& screenSize); void StartClipboardWatcher(); void StopClipboardWatcher(); @@ -73,6 +78,7 @@ class ClientRuntime { InputManager m_input; std::shared_ptr m_latencyStats; InputDispatcher m_dispatcher; + std::shared_ptr m_topology; std::unique_ptr m_network; std::unique_ptr m_clipboard; std::atomic m_clipboardWatcherRunning{false}; diff --git a/src/InputDispatcher.cpp b/src/InputDispatcher.cpp index f393c85..4cd6800 100644 --- a/src/InputDispatcher.cpp +++ b/src/InputDispatcher.cpp @@ -1,6 +1,7 @@ #include "InputDispatcher.h" #include +#include #include namespace mwb { @@ -113,6 +114,14 @@ void InputDispatcher::SubmitKeyboard(const KeyboardData& keyboard) { }); } +void InputDispatcher::SetTopologyPreview(std::shared_ptr topology, + std::string sourceDisplayId, + bool traceEnabled) { + m_topology = std::move(topology); + m_topologySourceDisplayId = std::move(sourceDisplayId); + m_topologyTraceEnabled = traceEnabled; +} + void InputDispatcher::Enqueue(InputEvent event) { std::size_t queueDepth = 0; const auto enqueuedKind = @@ -180,6 +189,26 @@ bool InputDispatcher::PopNext(InputEvent& event) { return true; } +std::optional InputDispatcher::ResolveTopologyPreviewTransition(const MouseData& mouse) const { + if (!m_topology || m_topologySourceDisplayId.empty() || mouse.wParam != 0x0200 || IsRelativeMouseMove(mouse)) { + return std::nullopt; + } + + return ResolveTopologyPointerTransition(*m_topology, m_topologySourceDisplayId, mouse.x, mouse.y); +} + +void InputDispatcher::TraceTopologyPreviewTransition(const TopologyPointerTransition& transition) const { + if (!m_topologyTraceEnabled) { + return; + } + + std::cout << "[TOPOLOGY] Dry-run transition " + << transition.sourceDisplayId << "." << edgeDirectionName(transition.exitEdge) + << " -> " << transition.targetDisplayId << "." << edgeDirectionName(transition.entryEdge) + << " coordinate=" << transition.coordinate + << " (input preserved)" << std::endl; +} + void InputDispatcher::Run() { while (true) { InputEvent event{}; @@ -195,6 +224,9 @@ void InputDispatcher::Run() { } if (event.kind == InputEvent::Kind::Mouse) { + if (const auto transition = ResolveTopologyPreviewTransition(event.mouse); transition.has_value()) { + TraceTopologyPreviewTransition(*transition); + } m_input.InjectMouse(event.mouse); } else { m_input.InjectKeyboard(event.keyboard); diff --git a/src/InputDispatcher.h b/src/InputDispatcher.h index 5f05a14..3732a6d 100644 --- a/src/InputDispatcher.h +++ b/src/InputDispatcher.h @@ -5,11 +5,14 @@ #include #include #include +#include +#include #include #include "InputManager.h" #include "InputLatencyStats.h" #include "Protocol.h" +#include "TopologyModel.h" namespace mwb { @@ -23,6 +26,9 @@ class InputDispatcher { void ResetInputState(); void SubmitMouse(const MouseData& mouse); void SubmitKeyboard(const KeyboardData& keyboard); + void SetTopologyPreview(std::shared_ptr topology, + std::string sourceDisplayId, + bool traceEnabled = true); private: struct InputEvent { @@ -40,9 +46,14 @@ class InputDispatcher { void Enqueue(InputEvent event); bool PopNext(InputEvent& event); void Run(); + std::optional ResolveTopologyPreviewTransition(const MouseData& mouse) const; + void TraceTopologyPreviewTransition(const TopologyPointerTransition& transition) const; InputManager& m_input; std::shared_ptr m_latencyStats; + std::shared_ptr m_topology; + std::string m_topologySourceDisplayId; + bool m_topologyTraceEnabled{false}; std::mutex m_mutex; std::condition_variable m_cv; std::deque m_queue; diff --git a/src/TopologyModel.cpp b/src/TopologyModel.cpp index e2035e4..39ce9e0 100644 --- a/src/TopologyModel.cpp +++ b/src/TopologyModel.cpp @@ -1,9 +1,13 @@ #include "TopologyModel.h" #include +#include +#include +#include #include #include #include +#include namespace mwb { namespace { @@ -20,6 +24,117 @@ struct EdgeKey { } }; +std::string_view trim(std::string_view value) { + size_t start = 0; + while (start < value.size() && std::isspace(static_cast(value[start])) != 0) { + ++start; + } + + size_t end = value.size(); + while (end > start && std::isspace(static_cast(value[end - 1])) != 0) { + --end; + } + + return value.substr(start, end - start); +} + +std::string toLower(std::string_view value) { + std::string lowered; + lowered.reserve(value.size()); + for (const char ch : value) { + lowered.push_back(static_cast(std::tolower(static_cast(ch)))); + } + return lowered; +} + +void setError(std::string* errorMessage, std::string message) { + if (errorMessage != nullptr) { + *errorMessage = std::move(message); + } +} + +std::vector splitCommaList(std::string_view value) { + std::vector parts; + size_t start = 0; + while (start <= value.size()) { + const size_t comma = value.find(',', start); + const size_t end = (comma == std::string_view::npos) ? value.size() : comma; + parts.emplace_back(trim(value.substr(start, end - start))); + if (comma == std::string_view::npos) { + break; + } + start = comma + 1; + } + return parts; +} + +bool parseInt(std::string_view value, int& out) { + const std::string text(trim(value)); + if (text.empty()) { + return false; + } + + try { + size_t end = 0; + const long long parsed = std::stoll(text, &end, 10); + if (end != text.size() || + parsed < std::numeric_limits::min() || + parsed > std::numeric_limits::max()) { + return false; + } + out = static_cast(parsed); + return true; + } catch (...) { + return false; + } +} + +std::optional parseEdgeDirection(std::string_view value) { + const std::string lowered = toLower(trim(value)); + if (lowered == "left") { + return EdgeDirection::Left; + } + if (lowered == "right") { + return EdgeDirection::Right; + } + if (lowered == "up") { + return EdgeDirection::Up; + } + if (lowered == "down") { + return EdgeDirection::Down; + } + return std::nullopt; +} + +std::optional parseWrapPolicy(std::string_view value) { + const std::string lowered = toLower(trim(value)); + if (lowered == "none") { + return WrapPolicy::None; + } + if (lowered == "horizontal") { + return WrapPolicy::Horizontal; + } + if (lowered == "vertical") { + return WrapPolicy::Vertical; + } + if (lowered == "both") { + return WrapPolicy::Both; + } + return std::nullopt; +} + +bool isAbsolutePointerCoordinate(int value) { + return value >= 0 && value <= 65535; +} + +int mapNormalizedCoordinate(int normalized, int length) { + if (length <= 1) { + return 0; + } + const int clamped = std::clamp(normalized, 0, 65535); + return static_cast(static_cast(clamped) * (length - 1) / 65535); +} + int rightOf(const Display& display) { return display.x + display.width; } @@ -418,4 +533,161 @@ const char* topologyIssueCodeName(TopologyIssueCode code) { return "unknown"; } +std::optional ResolveTopologyPointerTransition( + const TopologyModel& model, + const std::string& sourceDisplayId, + int normalizedX, + int normalizedY) { + const Display* source = findDisplay(model.displays(), sourceDisplayId); + if (source == nullptr || + !isAbsolutePointerCoordinate(normalizedX) || + !isAbsolutePointerCoordinate(normalizedY)) { + return std::nullopt; + } + + std::optional exitEdge; + int coordinate = 0; + if (normalizedX <= 0) { + exitEdge = EdgeDirection::Left; + coordinate = mapNormalizedCoordinate(normalizedY, source->height); + } else if (normalizedX >= 65535) { + exitEdge = EdgeDirection::Right; + coordinate = mapNormalizedCoordinate(normalizedY, source->height); + } else if (normalizedY <= 0) { + exitEdge = EdgeDirection::Up; + coordinate = mapNormalizedCoordinate(normalizedX, source->width); + } else if (normalizedY >= 65535) { + exitEdge = EdgeDirection::Down; + coordinate = mapNormalizedCoordinate(normalizedX, source->width); + } + + if (!exitEdge.has_value()) { + return std::nullopt; + } + + const auto transition = model.transitionFromEdge(sourceDisplayId, *exitEdge, coordinate); + if (!transition.has_value()) { + return std::nullopt; + } + + return TopologyPointerTransition{ + sourceDisplayId, + *exitEdge, + transition->targetDisplayId, + transition->entryEdge, + transition->coordinate, + }; +} + +bool ParseTopologyConfig(std::string_view text, TopologyModel& outModel, std::string* errorMessage) { + TopologyModel parsed; + std::istringstream stream{std::string(text)}; + std::string line; + size_t lineNumber = 0; + + while (std::getline(stream, line)) { + ++lineNumber; + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + + std::string_view trimmed = trim(line); + if (trimmed.empty() || trimmed.front() == '#' || trimmed.front() == ';') { + continue; + } + + const size_t separator = trimmed.find('='); + if (separator == std::string_view::npos) { + setError(errorMessage, "Topology line " + std::to_string(lineNumber) + " is missing '='."); + return false; + } + + const std::string key(toLower(trim(trimmed.substr(0, separator)))); + const std::string_view value = trim(trimmed.substr(separator + 1)); + if (key.empty()) { + setError(errorMessage, "Topology line " + std::to_string(lineNumber) + " has an empty key."); + return false; + } + + if (key == "machine") { + if (value.empty()) { + setError(errorMessage, "Topology line " + std::to_string(lineNumber) + " has an empty machine id."); + return false; + } + parsed.addMachine({std::string(value)}); + continue; + } + + if (key == "display") { + const auto parts = splitCommaList(value); + if (parts.size() != 6) { + setError(errorMessage, "Topology line " + std::to_string(lineNumber) + " display expects ID,MACHINE,X,Y,W,H."); + return false; + } + int x = 0; + int y = 0; + int width = 0; + int height = 0; + if (parts[0].empty() || parts[1].empty() || + !parseInt(parts[2], x) || !parseInt(parts[3], y) || + !parseInt(parts[4], width) || !parseInt(parts[5], height) || + width <= 0 || height <= 0) { + setError(errorMessage, "Topology line " + std::to_string(lineNumber) + " has an invalid display."); + return false; + } + parsed.addDisplay({parts[0], parts[1], x, y, width, height}); + continue; + } + + if (key == "link") { + const auto parts = splitCommaList(value); + if (parts.size() != 4) { + setError(errorMessage, "Topology line " + std::to_string(lineNumber) + " link expects SRC,EDGE,TGT,ENTRY."); + return false; + } + const auto exitEdge = parseEdgeDirection(parts[1]); + const auto entryEdge = parseEdgeDirection(parts[3]); + if (parts[0].empty() || parts[2].empty() || !exitEdge.has_value() || !entryEdge.has_value()) { + setError(errorMessage, "Topology line " + std::to_string(lineNumber) + " has an invalid link."); + return false; + } + parsed.addBorderLink({parts[0], *exitEdge, parts[2], *entryEdge}); + continue; + } + + if (key == "wrap") { + const auto policy = parseWrapPolicy(value); + if (!policy.has_value()) { + setError(errorMessage, "Topology line " + std::to_string(lineNumber) + " wrap expects none, horizontal, vertical, or both."); + return false; + } + parsed.setWrapPolicy(*policy); + continue; + } + + setError(errorMessage, "Unknown topology key '" + key + "' on line " + std::to_string(lineNumber) + "."); + return false; + } + + outModel = std::move(parsed); + return true; +} + +bool LoadTopologyConfig(const std::filesystem::path& path, TopologyModel& outModel, std::string* errorMessage) { + std::ifstream file(path); + if (!file) { + setError(errorMessage, "Failed to open topology file: " + path.string()); + return false; + } + + std::ostringstream buffer; + buffer << file.rdbuf(); + if (!file.good() && !file.eof()) { + setError(errorMessage, "Failed to read topology file: " + path.string()); + return false; + } + + return ParseTopologyConfig(buffer.str(), outModel, errorMessage); +} + } // namespace mwb diff --git a/src/TopologyModel.h b/src/TopologyModel.h index 6411e04..29d5935 100644 --- a/src/TopologyModel.h +++ b/src/TopologyModel.h @@ -1,7 +1,9 @@ #pragma once +#include #include #include +#include #include namespace mwb { @@ -46,6 +48,14 @@ struct TransitionResult { int coordinate{0}; }; +struct TopologyPointerTransition { + std::string sourceDisplayId; + EdgeDirection exitEdge{EdgeDirection::Right}; + std::string targetDisplayId; + EdgeDirection entryEdge{EdgeDirection::Left}; + int coordinate{0}; +}; + enum class TopologyIssueCode { DuplicateMachine, DuplicateDisplay, @@ -95,4 +105,13 @@ EdgeDirection oppositeEdge(EdgeDirection direction); const char* edgeDirectionName(EdgeDirection direction); const char* topologyIssueCodeName(TopologyIssueCode code); +std::optional ResolveTopologyPointerTransition( + const TopologyModel& model, + const std::string& sourceDisplayId, + int normalizedX, + int normalizedY); + +bool ParseTopologyConfig(std::string_view text, TopologyModel& outModel, std::string* errorMessage = nullptr); +bool LoadTopologyConfig(const std::filesystem::path& path, TopologyModel& outModel, std::string* errorMessage = nullptr); + } // namespace mwb diff --git a/src/main.cpp b/src/main.cpp index 12119c8..722ee22 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1168,6 +1168,8 @@ int RunClient(const mwb::AppConfig& config, options.debugKeyLogging = IsTruthyEnv("MWB_DEBUG_KEYS"); options.debugShortcutLogging = IsTruthyEnv("MWB_DEBUG_SHORTCUTS"); options.latencyReport = runtimeConfig.latencyReport; + options.topologyRuntimeEnabled = runtimeConfig.topologyRuntimeEnabled; + options.topologyFilePath = runtimeConfig.topologyFile; options.onSessionEstablished = [&](const std::string& host, int port, const std::string& remoteName, uint32_t, uint32_t localMachineId) { std::lock_guard lock(stateMutex); mwb::MarkSessionEstablished(state, host, port, remoteName, localMachineId, CurrentEpochSeconds()); diff --git a/tests/test_main.cpp b/tests/test_main.cpp index 1d24f75..bae1a40 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -51,6 +51,8 @@ void TestAppConfigRoundTrip() { config.mprisMediaKeysEnabled = false; config.mprisPlayer = "spotify"; config.latencyReport = true; + config.topologyRuntimeEnabled = true; + config.topologyFile = "topology.conf"; const std::filesystem::path path = MakeTempPath("mwb-config-test.ini"); std::string error; @@ -74,6 +76,10 @@ void TestAppConfigRoundTrip() { "Rendered config should keep mpris_player"); ExpectRenderedLine(rendered, "latency_report", "true", "Rendered config should keep latency_report"); + ExpectRenderedLine(rendered, "topology_enabled", "true", + "Rendered config should keep topology_enabled"); + ExpectRenderedLine(rendered, "topology_file", "topology.conf", + "Rendered config should keep topology_file"); mwb::AppConfig loaded; Expect(mwb::LoadConfigFile(path, loaded, error), "LoadConfigFile should succeed"); @@ -99,6 +105,10 @@ void TestAppConfigRoundTrip() { "Loaded config should keep mpris_player"); ExpectRenderedLine(loadedRendered, "latency_report", "true", "Loaded config should keep latency_report"); + ExpectRenderedLine(loadedRendered, "topology_enabled", "true", + "Loaded config should keep topology_enabled"); + ExpectRenderedLine(loadedRendered, "topology_file", "topology.conf", + "Loaded config should keep topology_file"); Expect(loaded.machineName == config.machineName, "Config machine_name round-trip"); Expect(loaded.port == config.port, "Config port round-trip"); Expect(loaded.autoConnectEnabled == config.autoConnectEnabled, "Config autoConnectEnabled round-trip"); @@ -118,6 +128,8 @@ void TestAppConfigRoundTrip() { "Config mprisMediaKeysEnabled round-trip"); Expect(loaded.mprisPlayer == config.mprisPlayer, "Config mprisPlayer round-trip"); Expect(loaded.latencyReport == config.latencyReport, "Config latencyReport round-trip"); + Expect(loaded.topologyRuntimeEnabled == config.topologyRuntimeEnabled, "Config topologyRuntimeEnabled round-trip"); + Expect(loaded.topologyFile == config.topologyFile, "Config topologyFile round-trip"); std::error_code ignore; std::filesystem::remove(path, ignore); } diff --git a/tests/test_topology_model.cpp b/tests/test_topology_model.cpp index 53fab94..b341d15 100644 --- a/tests/test_topology_model.cpp +++ b/tests/test_topology_model.cpp @@ -211,6 +211,63 @@ void TestValidationRejectsImpossibleEdgeMappings() { "incompatible, diagonal, or self edge mappings should be reported"); } +void TestParseTopologyConfigAcceptsLineBasedFormat() { + mwb::TopologyModel model; + std::string error; + const std::string text = + "# simple two-machine layout\n" + "machine=A\n" + "machine=B\n" + "display=A1,A,0,0,1920,1080\n" + "display=B1,B,1920,0,2560,1440\n" + "link=A1,right,B1,left\n" + "wrap=none\n"; + + Expect(mwb::ParseTopologyConfig(text, model, &error), "ParseTopologyConfig should accept valid text"); + Expect(error.empty(), "Valid topology parse should not set an error"); + Expect(model.machines().size() == 2, "Parsed topology should keep machines"); + Expect(model.displays().size() == 2, "Parsed topology should keep displays"); + Expect(model.borderLinks().size() == 1, "Parsed topology should keep links"); + Expect(model.validate().empty(), "Parsed topology should validate"); + + const auto transition = model.transitionFromEdge("A1", mwb::EdgeDirection::Right, 540); + Expect(transition.has_value(), "Parsed topology should route configured link"); + if (transition.has_value()) { + ExpectEqual(transition->targetDisplayId, "B1", "Parsed topology target display"); + Expect(transition->entryEdge == mwb::EdgeDirection::Left, "Parsed topology entry edge"); + } +} + +void TestParseTopologyConfigRejectsInvalidLines() { + mwb::TopologyModel model; + std::string error; + const std::string text = + "machine=A\n" + "display=A1,A,0,0,not-a-width,1080\n"; + + Expect(!mwb::ParseTopologyConfig(text, model, &error), "ParseTopologyConfig should reject invalid display values"); + Expect(error.find("line 2") != std::string::npos, "Invalid topology parse should report line number"); +} + +void TestPointerTransitionResolverUsesAbsoluteEdges() { + mwb::TopologyModel model = BaseModel(); + model.addDisplay({"A1", "A", 0, 0, 1920, 1080}); + model.addDisplay({"B1", "B", 1920, 0, 2560, 1440}); + model.addBorderLink({"A1", mwb::EdgeDirection::Right, "B1", mwb::EdgeDirection::Left}); + + const auto transition = mwb::ResolveTopologyPointerTransition(model, "A1", 65535, 32767); + Expect(transition.has_value(), "Absolute right edge should resolve topology transition"); + if (transition.has_value()) { + ExpectEqual(transition->sourceDisplayId, "A1", "Pointer transition source display"); + Expect(transition->exitEdge == mwb::EdgeDirection::Right, "Pointer transition exit edge"); + ExpectEqual(transition->targetDisplayId, "B1", "Pointer transition target display"); + Expect(transition->entryEdge == mwb::EdgeDirection::Left, "Pointer transition entry edge"); + } + + Expect(!mwb::ResolveTopologyPointerTransition(model, "A1", 32000, 32767).has_value(), + "Non-edge absolute pointer move should not resolve transition"); +} + } // namespace int main() { @@ -224,6 +281,9 @@ int main() { TestValidationRejectsMissingDisplaysForLinks(); TestValidationRejectsContradictoryDuplicateEdgeLinks(); TestValidationRejectsImpossibleEdgeMappings(); + TestParseTopologyConfigAcceptsLineBasedFormat(); + TestParseTopologyConfigRejectsInvalidLines(); + TestPointerTransitionResolverUsesAbsoluteEdges(); if (g_failures == 0) { std::cout << "Topology model tests passed." << std::endl; diff --git a/tests/topology_config_docs_test.py b/tests/topology_config_docs_test.py new file mode 100644 index 0000000..ce1cd56 --- /dev/null +++ b/tests/topology_config_docs_test.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 + +import re +import sys +from pathlib import Path + + +EDGE_NAMES = {"left", "right", "up", "down"} +WRAP_POLICIES = {"none", "horizontal", "vertical", "both"} +EXPECTED_EXAMPLES = {"aab", "baa", "aba", "stacked", "asymmetric", "wrap-horizontal"} + + +def fail(message): + print(f"FAIL: {message}", file=sys.stderr) + sys.exit(1) + + +def parse_examples(text): + examples = [] + for match in re.finditer(r"```ini\n(.*?)\n```", text, re.DOTALL): + block = match.group(1) + name_match = re.search(r"^\s*#\s*topology-example:\s*([A-Za-z0-9_-]+)\s*$", block, re.MULTILINE) + if name_match: + examples.append((name_match.group(1), block)) + return examples + + +def parse_int(value, context, minimum=None): + try: + parsed = int(value) + except ValueError: + fail(f"{context} must be an integer") + if minimum is not None and parsed < minimum: + fail(f"{context} must be >= {minimum}") + return parsed + + +def parse_line_list(value, expected, context): + parts = [part.strip() for part in value.split(",")] + if len(parts) != expected or any(part == "" for part in parts): + fail(f"{context} expects {expected} comma-separated values") + return parts + + +def validate_example(name, block): + machines = set() + displays = {} + links = [] + wrap_seen = False + + for line_number, raw_line in enumerate(block.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#") or line.startswith(";"): + continue + if "=" not in line: + fail(f"{name}: line {line_number} is missing '='") + key, value = (part.strip() for part in line.split("=", 1)) + + if key == "wrap": + if value not in WRAP_POLICIES: + fail(f"{name}: line {line_number} wrap is invalid") + wrap_seen = True + continue + + if key == "machine": + machines.add(value) + continue + + if key == "display": + display_id, machine_id, x, y, width, height = parse_line_list(value, 6, f"{name}: line {line_number} display") + displays[display_id] = { + "machine": machine_id, + "x": parse_int(x, f"{name}: {display_id}.x"), + "y": parse_int(y, f"{name}: {display_id}.y"), + "width": parse_int(width, f"{name}: {display_id}.width", minimum=1), + "height": parse_int(height, f"{name}: {display_id}.height", minimum=1), + } + continue + + if key == "link": + source_display, exit_edge, target_display, entry_edge = parse_line_list(value, 4, f"{name}: line {line_number} link") + links.append((source_display, exit_edge, target_display, entry_edge)) + continue + + fail(f"{name}: line {line_number} unknown key {key}") + + if not wrap_seen: + fail(f"{name}: missing wrap policy") + if not machines: + fail(f"{name}: no machines declared") + if not displays: + fail(f"{name}: no displays declared") + + for display_id, display in displays.items(): + if display["machine"] not in machines: + fail(f"{name}: display {display_id} references missing machine {display['machine']}") + + seen_edges = set() + for source_display, exit_edge, target_display, entry_edge in links: + if source_display not in displays: + fail(f"{name}: link source display is missing: {source_display}") + if target_display not in displays: + fail(f"{name}: link target display is missing: {target_display}") + if exit_edge not in EDGE_NAMES: + fail(f"{name}: link exit edge is invalid: {exit_edge}") + if entry_edge not in EDGE_NAMES: + fail(f"{name}: link entry edge is invalid: {entry_edge}") + edge_key = (source_display, exit_edge) + if edge_key in seen_edges: + fail(f"{name}: duplicate explicit link for {source_display}.{exit_edge}") + seen_edges.add(edge_key) + + displays_by_machine = {} + for display_id, display in displays.items(): + displays_by_machine.setdefault(display["machine"], []).append((display_id, display)) + for machine_id, machine_displays in displays_by_machine.items(): + for index, (left_id, left) in enumerate(machine_displays): + for right_id, right in machine_displays[index + 1:]: + separated = ( + left["x"] + left["width"] <= right["x"] + or right["x"] + right["width"] <= left["x"] + or left["y"] + left["height"] <= right["y"] + or right["y"] + right["height"] <= left["y"] + ) + if not separated: + fail(f"{name}: displays {left_id} and {right_id} overlap on machine {machine_id}") + + +def main(): + if len(sys.argv) != 2: + fail("usage: topology_config_docs_test.py ") + + doc_path = Path(sys.argv[1]) + examples = parse_examples(doc_path.read_text(encoding="utf-8")) + names = {name for name, _ in examples} + if names != EXPECTED_EXAMPLES: + fail(f"topology examples mismatch: expected {sorted(EXPECTED_EXAMPLES)}, got {sorted(names)}") + + for name, block in examples: + validate_example(name, block) + + print(f"Validated {len(examples)} topology doc examples.") + + +if __name__ == "__main__": + main() From 2c3b2a73ab1829bb921217af870468e62b6c8977 Mon Sep 17 00:00:00 2001 From: daredoole Date: Tue, 28 Apr 2026 20:42:55 -0400 Subject: [PATCH 2/5] Enforce topology edge handoff --- README.md | 4 +- docs/beta-workflow.md | 2 +- docs/compatibility.md | 6 +- docs/migration.md | 6 +- docs/topology.md | 2 +- mwb-desktop-ui.sh | 8 +-- src/AppConfig.cpp | 2 +- src/ClientRuntime.cpp | 16 ++++- src/InputDispatcher.cpp | 73 ++++++++++++++++++- src/InputDispatcher.h | 15 ++++ src/NetworkManager.cpp | 8 +++ src/NetworkManager.h | 1 + src/TopologyModel.cpp | 128 ++++++++++++++++++++++++++++++++++ src/TopologyModel.h | 19 +++++ tests/test_topology_model.cpp | 54 ++++++++++++++ 15 files changed, 323 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 27de028..9cc15c5 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ User-facing beta operations: - [Health checks and diagnostics bundle](docs/beta-workflow.md#health-check) - [Connection quality and latency reporting](docs/beta-workflow.md#connection-quality) - [Packaging verification](docs/beta-workflow.md#packaging-verification) -- [Topology config contract and layout wizard dry-run expectations](docs/topology.md) +- [Topology config contract and layout wizard expectations](docs/topology.md) - [Migration from other keyboard/mouse sharing tools](docs/migration.md) - [Compatibility matrix and platform caveats](docs/compatibility.md) @@ -118,7 +118,7 @@ This repository started as a fork of [chrischip/mwb-client-linux](https://github ### Configuration (`config.ini`) Supports `key_file`, `key_secret_id` (keyring), `screen_width/height` overrides, `topology_enabled`, `topology_file`, and more. Default path: `~/.config/mwb-client/config.ini`. -Display-level topology is a separate opt-in preview contract. The default runtime remains MWB-compatible machine placement unless topology is explicitly enabled; see [docs/topology.md](docs/topology.md) for examples, wrap policies, and dry-run validation expectations. +Display-level topology is a separate opt-in contract. The default runtime remains MWB-compatible machine placement unless topology is explicitly enabled; see [docs/topology.md](docs/topology.md) for examples, wrap policies, validation, and cross-machine handoff behavior. ### Screen Sizing The client detects screen size in this order: diff --git a/docs/beta-workflow.md b/docs/beta-workflow.md index c8052ef..daa6394 100644 --- a/docs/beta-workflow.md +++ b/docs/beta-workflow.md @@ -49,7 +49,7 @@ topology_enabled=true topology_file=/home/example/.config/mwb-client/topology-side-by-side.topology ``` -Current limitation: the topology file is saved for topology-aware runtime builds, but runtime handoff is still resolver/trace-gated. Verify behavior with PowerToys MWB and the exported helper until direct cross-machine handoff enforcement lands. +When topology is enabled, configured cross-machine edge transitions are enforced at runtime. Same-machine transitions remain local, and invalid topology falls back to the existing behavior with a warning. ## Health Check diff --git a/docs/compatibility.md b/docs/compatibility.md index 4ef0812..86f9805 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -18,7 +18,7 @@ InputFlow is a native Linux peer for Microsoft PowerToys Mouse Without Borders ( | Clipboard receive/send | Supported beta path | Requires local helpers: `wl-clipboard` on Wayland or `xclip`/`xsel` on X11. Availability is reported by `doctor`. | | systemd user service | Opt-in | Packaging includes a user unit, but users should enable/start it only after validating config, key source, and `/dev/uinput` access. | | Network trust model | Trusted LAN/subnet | Use on a trusted local network. Do not expose MWB ports to untrusted networks or the public internet. | -| Display-level topology config | Opt-in preview | The contract is documented in [Topology Config Contract](topology.md), but the default runtime remains MWB-compatible machine placement while handoff behavior matures. | +| Display-level topology config | Opt-in | The contract is documented in [Topology Config Contract](topology.md), and the default runtime remains MWB-compatible machine placement unless topology is enabled. | ## Linux Session Details @@ -54,6 +54,6 @@ The systemd user service is a convenience, not a required first step. During mig ## Topology Expectations -Current compatibility is machine-level MWB placement. Display-level topology is intentionally gated and opt-in so InputFlow can remain compatible with PowerToys MWB while the runtime handoff behavior matures. +Current default compatibility is machine-level MWB placement. Display-level topology is intentionally gated and opt-in so InputFlow can remain compatible with PowerToys MWB unless the user enables explicit machine/display links. -The topology contract separates machines from displays and supports configurable wrap policies, AAB/BAA/ABA layouts, stacked layouts, asymmetric layouts, and dry-run path previews. See [Topology Config Contract](topology.md) for the preview file format and validation expectations. +The topology contract separates machines from displays and supports configurable wrap policies, AAB/BAA/ABA layouts, stacked layouts, asymmetric layouts, and cross-machine edge handoff. See [Topology Config Contract](topology.md) for the file format and validation expectations. diff --git a/docs/migration.md b/docs/migration.md index f11f3ac..787cdd8 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -23,7 +23,7 @@ For the guided pairing flow, see [Public Beta Workflow](beta-workflow.md#guided- | --- | --- | | Server | A peer that currently owns the local pointer and sends input to another peer. This role is situational, not a fixed machine type. | | Client | A peer receiving remote input. This role is also situational. | -| Screen | A machine entry in the current MWB layout. Display-level topology is a separate opt-in preview contract. | +| Screen | A machine entry in the current MWB layout. Display-level topology is a separate opt-in contract. | | Screen name | `machine_name` / MWB peer name. Names must match what the other peer expects. | | Configuration file | `~/.config/mwb-client/config.ini` for InputFlow; PowerToys MWB settings on Windows. | | Shared secret / password | MWB security key. InputFlow can read it from an inline `key`, `key_file`, or Secret Service `key_secret_id`. | @@ -75,6 +75,6 @@ Avoid publishing configs, helper scripts, logs, or screenshots that expose keys, ## Topology Roadmap -InputFlow currently focuses on MWB-compatible machine placement. The topology preview adds a cleaner machine/display split, explicit wrap policies, AAB/BAA/ABA layouts, stacked layouts, asymmetric layouts, and dry-run path previews so users can inspect pointer transitions before applying them. +InputFlow defaults to MWB-compatible machine placement. Optional topology adds a cleaner machine/display split, explicit wrap policies, AAB/BAA/ABA layouts, stacked layouts, asymmetric layouts, and configured cross-machine edge handoff. -Until the runtime topology feature gate is enabled and validated for your setup, treat topology as machine-level MWB placement and verify changes in PowerToys MWB after exporting. If you are testing the layout wizard or runtime topology branch, use the [Topology Config Contract](topology.md) and keep dry-run enabled until validation and preview output match the intended handoff behavior. +Until the runtime topology feature gate is enabled and validated for your setup, treat topology as machine-level MWB placement and verify changes in PowerToys MWB after exporting. If you are testing the layout wizard or runtime topology branch, use the [Topology Config Contract](topology.md) and keep `wrap=none` with explicit links until validation output matches the intended handoff behavior. diff --git a/docs/topology.md b/docs/topology.md index 71a03f3..4530053 100644 --- a/docs/topology.md +++ b/docs/topology.md @@ -113,7 +113,7 @@ link=B1,left,A2,right `topology_enabled=false` is the default. Enabling topology loads and validates the topology file during startup. Invalid topology logs a warning and falls back to the existing behavior instead of blocking startup. -The current runtime uses topology to resolve and log edge transitions for dry-run verification. It does not yet replace the protocol handoff path. This lets beta users validate AAB, BAA, ABA, stacked, asymmetric, and wrap layouts without changing remote-control behavior by default. +The current runtime uses topology to resolve edge transitions before local mouse injection. Same-machine transitions stay local. Cross-machine transitions send a mapped MWB mouse move back to the active peer and suppress the local edge move, so the pointer can return to the Windows side on configured borders. ## Troubleshooting diff --git a/mwb-desktop-ui.sh b/mwb-desktop-ui.sh index b3dcf49..4e09a1d 100755 --- a/mwb-desktop-ui.sh +++ b/mwb-desktop-ui.sh @@ -1049,10 +1049,8 @@ generate_topology_content() { # InputFlow topology file # format=inputflow-topology-draft-v1 # preset=$preset -# Current limitation: runtime handoff is resolver/trace-gated. This file and -# $TOPOLOGY_FILE_CONFIG_KEY/$TOPOLOGY_ENABLED_CONFIG_KEY config keys are saved -# for topology-aware runtime builds, but the current service logs dry-run -# transitions until direct cross-machine handoff enforcement lands. +# $TOPOLOGY_FILE_CONFIG_KEY/$TOPOLOGY_ENABLED_CONFIG_KEY enable topology-aware +# runtime handoff. The preview step below only shows the file before writing it. wrap=$wrap_policy machine=$machine_a machine=$machine_b @@ -1167,7 +1165,7 @@ layout_wizard() { rm -f "$preview_path" if ! zenity --question --title="$APP_NAME topology/layout wizard" --width=620 \ - --text="Apply this topology?\n\nWill write:\n$topology_path\n\nWill set:\n$TOPOLOGY_ENABLED_CONFIG_KEY=true\n$TOPOLOGY_FILE_CONFIG_KEY=$topology_path\n\nCurrent limitation: runtime handoff is resolver/trace-gated until direct cross-machine handoff enforcement lands."; then + --text="Apply this topology?\n\nWill write:\n$topology_path\n\nWill set:\n$TOPOLOGY_ENABLED_CONFIG_KEY=true\n$TOPOLOGY_FILE_CONFIG_KEY=$topology_path\n\nTopology will enforce configured cross-machine edge handoffs at runtime. Same-machine edges remain local."; then return 1 fi diff --git a/src/AppConfig.cpp b/src/AppConfig.cpp index d089b2a..3bac4b5 100644 --- a/src/AppConfig.cpp +++ b/src/AppConfig.cpp @@ -451,7 +451,7 @@ std::string RenderSampleAppConfig() { out << "# Relative key_file paths resolve against the directory containing config.ini.\n"; out << "# Set auto_connect_enabled=false to keep the service idle until you re-enable it.\n"; out << "# Set screen_width and screen_height to your local desktop size when needed.\n"; - out << "# Set topology_enabled=true and topology_file=... to preview runtime topology transitions.\n"; + out << "# Set topology_enabled=true and topology_file=... to enable runtime topology handoff.\n"; out << RenderAppConfig(sample); return out.str(); } diff --git a/src/ClientRuntime.cpp b/src/ClientRuntime.cpp index 590f6e2..a85212c 100644 --- a/src/ClientRuntime.cpp +++ b/src/ClientRuntime.cpp @@ -280,6 +280,7 @@ ClientRuntime::ScreenSize ClientRuntime::DetectScreenSize() const { void ClientRuntime::ConfigureTopologyPreview(const ScreenSize& screenSize) { m_dispatcher.SetTopologyPreview(nullptr, {}, false); + m_dispatcher.SetTopologyHandoff({}, 0, 0, false, {}); m_topology.reset(); if (!m_options.topologyRuntimeEnabled) { @@ -321,9 +322,20 @@ void ClientRuntime::ConfigureTopologyPreview(const ScreenSize& screenSize) { m_topology = std::make_shared(std::move(loaded)); m_dispatcher.SetTopologyPreview(m_topology, sourceDisplayId, true); - std::cout << "[TOPOLOGY] Loaded dry-run topology preview from " + m_dispatcher.SetTopologyHandoff( + m_options.localMachineName, + screenSize.width, + screenSize.height, + true, + [this](const MouseData& mouse, + const TopologyPointerTransition&, + const std::string&) { + return m_network && m_network->SendMouse(mouse); + }); + std::cout << "[TOPOLOGY] Loaded topology from " << m_options.topologyFilePath.string() - << " using source display " << sourceDisplayId << "." << std::endl; + << " using source display " << sourceDisplayId + << " with cross-machine handoff enforcement enabled." << std::endl; } int ClientRuntime::Run() { diff --git a/src/InputDispatcher.cpp b/src/InputDispatcher.cpp index 4cd6800..46e7760 100644 --- a/src/InputDispatcher.cpp +++ b/src/InputDispatcher.cpp @@ -122,6 +122,18 @@ void InputDispatcher::SetTopologyPreview(std::shared_ptr to m_topologyTraceEnabled = traceEnabled; } +void InputDispatcher::SetTopologyHandoff(std::string localMachineId, + int desktopWidth, + int desktopHeight, + bool enabled, + TopologyHandoffCallback callback) { + m_topologyLocalMachineId = std::move(localMachineId); + m_topologyDesktopWidth = desktopWidth; + m_topologyDesktopHeight = desktopHeight; + m_topologyHandoffEnabled = enabled; + m_topologyHandoffCallback = std::move(callback); +} + void InputDispatcher::Enqueue(InputEvent event) { std::size_t queueDepth = 0; const auto enqueuedKind = @@ -194,6 +206,21 @@ std::optional InputDispatcher::ResolveTopologyPreview return std::nullopt; } + if (!m_topologyLocalMachineId.empty() && + m_topologyDesktopWidth > 0 && + m_topologyDesktopHeight > 0) { + if (const auto transition = ResolveTopologyPointerTransitionForMachine( + *m_topology, + m_topologyLocalMachineId, + m_topologyDesktopWidth, + m_topologyDesktopHeight, + mouse.x, + mouse.y); + transition.has_value()) { + return transition; + } + } + return ResolveTopologyPointerTransition(*m_topology, m_topologySourceDisplayId, mouse.x, mouse.y); } @@ -202,11 +229,45 @@ void InputDispatcher::TraceTopologyPreviewTransition(const TopologyPointerTransi return; } - std::cout << "[TOPOLOGY] Dry-run transition " + std::cout << "[TOPOLOGY] Resolved transition " + << transition.sourceDisplayId << "." << edgeDirectionName(transition.exitEdge) + << " -> " << transition.targetDisplayId << "." << edgeDirectionName(transition.entryEdge) + << " coordinate=" << transition.coordinate << std::endl; +} + +bool InputDispatcher::TryEnforceTopologyHandoff(const MouseData& mouse, const TopologyPointerTransition& transition) { + if (!m_topologyHandoffEnabled || !m_topologyHandoffCallback || !m_topology) { + return false; + } + + const auto sourceMachineId = m_topology->machineIdForDisplay(transition.sourceDisplayId); + const auto targetMachineId = m_topology->machineIdForDisplay(transition.targetDisplayId); + if (!sourceMachineId.has_value() || + !targetMachineId.has_value() || + *sourceMachineId == *targetMachineId || + *targetMachineId == m_topologyLocalMachineId) { + return false; + } + + const auto point = MapTransitionToTargetNormalizedPoint(*m_topology, transition); + if (!point.has_value()) { + return false; + } + + MouseData handoffMouse = mouse; + handoffMouse.x = point->x; + handoffMouse.y = point->y; + if (!m_topologyHandoffCallback(handoffMouse, transition, *targetMachineId)) { + std::cerr << "WARN: Topology handoff to " << *targetMachineId << " failed; injecting locally." << std::endl; + return false; + } + + std::cout << "[TOPOLOGY] Enforced handoff " << transition.sourceDisplayId << "." << edgeDirectionName(transition.exitEdge) << " -> " << transition.targetDisplayId << "." << edgeDirectionName(transition.entryEdge) - << " coordinate=" << transition.coordinate - << " (input preserved)" << std::endl; + << " as mouse x=" << handoffMouse.x << " y=" << handoffMouse.y + << " targetMachine=" << *targetMachineId << std::endl; + return true; } void InputDispatcher::Run() { @@ -226,6 +287,12 @@ void InputDispatcher::Run() { if (event.kind == InputEvent::Kind::Mouse) { if (const auto transition = ResolveTopologyPreviewTransition(event.mouse); transition.has_value()) { TraceTopologyPreviewTransition(*transition); + if (TryEnforceTopologyHandoff(event.mouse, *transition)) { + if (m_latencyStats) { + m_latencyStats->RecordInjectDuration(kind, std::chrono::steady_clock::now() - dispatchStarted); + } + continue; + } } m_input.InjectMouse(event.mouse); } else { diff --git a/src/InputDispatcher.h b/src/InputDispatcher.h index 3732a6d..816150e 100644 --- a/src/InputDispatcher.h +++ b/src/InputDispatcher.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,9 @@ namespace mwb { class InputDispatcher { public: + using TopologyHandoffCallback = + std::function; + explicit InputDispatcher(InputManager& input, std::shared_ptr latencyStats = nullptr); ~InputDispatcher(); @@ -29,6 +33,11 @@ class InputDispatcher { void SetTopologyPreview(std::shared_ptr topology, std::string sourceDisplayId, bool traceEnabled = true); + void SetTopologyHandoff(std::string localMachineId, + int desktopWidth, + int desktopHeight, + bool enabled, + TopologyHandoffCallback callback); private: struct InputEvent { @@ -48,12 +57,18 @@ class InputDispatcher { void Run(); std::optional ResolveTopologyPreviewTransition(const MouseData& mouse) const; void TraceTopologyPreviewTransition(const TopologyPointerTransition& transition) const; + bool TryEnforceTopologyHandoff(const MouseData& mouse, const TopologyPointerTransition& transition); InputManager& m_input; std::shared_ptr m_latencyStats; std::shared_ptr m_topology; std::string m_topologySourceDisplayId; + std::string m_topologyLocalMachineId; + int m_topologyDesktopWidth{0}; + int m_topologyDesktopHeight{0}; bool m_topologyTraceEnabled{false}; + bool m_topologyHandoffEnabled{false}; + TopologyHandoffCallback m_topologyHandoffCallback; std::mutex m_mutex; std::condition_variable m_cv; std::deque m_queue; diff --git a/src/NetworkManager.cpp b/src/NetworkManager.cpp index 544c916..a9b16f1 100644 --- a/src/NetworkManager.cpp +++ b/src/NetworkManager.cpp @@ -1081,6 +1081,14 @@ bool NetworkManager::SendPacket(MWBPacket& packet, bool isBig) { m_desId); } +bool NetworkManager::SendMouse(const MouseData& mouse) { + MWBPacket packet; + std::memset(&packet, 0, sizeof(packet)); + packet.type = static_cast(PackageType::Mouse); + std::memcpy(packet.data, &mouse, sizeof(mouse)); + return SendPacket(packet, false); +} + void NetworkManager::SendHello() { if (DebugSkipIdentityEnabled()) { if (DebugNetworkLoggingEnabled()) { diff --git a/src/NetworkManager.h b/src/NetworkManager.h index f8ed7fc..6136fba 100644 --- a/src/NetworkManager.h +++ b/src/NetworkManager.h @@ -37,6 +37,7 @@ class NetworkManager { void SetReconnectBackoff(int initialBackoffMs, int maxBackoffMs, int idleRetryMs); bool Connect(); void RunLoop(); + bool SendMouse(const MouseData& mouse); bool SendPacket(MWBPacket& packet, bool isBig); void Stop(); void SetScreenSize(int w, int h) { m_screenW = w; m_screenH = h; } diff --git a/src/TopologyModel.cpp b/src/TopologyModel.cpp index 39ce9e0..2ea3817 100644 --- a/src/TopologyModel.cpp +++ b/src/TopologyModel.cpp @@ -135,6 +135,14 @@ int mapNormalizedCoordinate(int normalized, int length) { return static_cast(static_cast(clamped) * (length - 1) / 65535); } +int normalizeCoordinate(int coordinate, int length) { + if (length <= 1) { + return 0; + } + const int clamped = std::clamp(coordinate, 0, length - 1); + return static_cast(static_cast(clamped) * 65535 / (length - 1)); +} + int rightOf(const Display& display) { return display.x + display.width; } @@ -268,6 +276,18 @@ WrapPolicy TopologyModel::wrapPolicy() const { return wrapPolicy_; } +const Display* TopologyModel::displayById(const std::string& displayId) const { + return findDisplay(displays_, displayId); +} + +std::optional TopologyModel::machineIdForDisplay(const std::string& displayId) const { + const Display* display = findDisplay(displays_, displayId); + if (display == nullptr) { + return std::nullopt; + } + return display->machineId; +} + std::vector TopologyModel::validate() const { std::vector issues; std::set machineIds; @@ -579,6 +599,114 @@ std::optional ResolveTopologyPointerTransition( }; } +std::optional ResolveTopologyPointerTransitionForMachine( + const TopologyModel& model, + const std::string& machineId, + int desktopWidth, + int desktopHeight, + int normalizedX, + int normalizedY) { + if (machineId.empty() || + desktopWidth <= 0 || + desktopHeight <= 0 || + !isAbsolutePointerCoordinate(normalizedX) || + !isAbsolutePointerCoordinate(normalizedY)) { + return std::nullopt; + } + + std::optional exitEdge; + if (normalizedX <= 0) { + exitEdge = EdgeDirection::Left; + } else if (normalizedX >= 65535) { + exitEdge = EdgeDirection::Right; + } else if (normalizedY <= 0) { + exitEdge = EdgeDirection::Up; + } else if (normalizedY >= 65535) { + exitEdge = EdgeDirection::Down; + } else { + return std::nullopt; + } + + const int globalX = mapNormalizedCoordinate(normalizedX, desktopWidth); + const int globalY = mapNormalizedCoordinate(normalizedY, desktopHeight); + + const Display* source = nullptr; + int edgeCoordinate = -1; + for (const auto& display : model.displays()) { + if (display.machineId != machineId) { + continue; + } + + bool candidate = false; + int coordinate = -1; + switch (*exitEdge) { + case EdgeDirection::Left: + candidate = display.x == globalX && globalY >= display.y && globalY < bottomOf(display); + coordinate = globalY - display.y; + break; + case EdgeDirection::Right: + candidate = rightOf(display) - 1 == globalX && globalY >= display.y && globalY < bottomOf(display); + coordinate = globalY - display.y; + break; + case EdgeDirection::Up: + candidate = display.y == globalY && globalX >= display.x && globalX < rightOf(display); + coordinate = globalX - display.x; + break; + case EdgeDirection::Down: + candidate = bottomOf(display) - 1 == globalY && globalX >= display.x && globalX < rightOf(display); + coordinate = globalX - display.x; + break; + } + + if (!candidate) { + continue; + } + if (source != nullptr) { + return std::nullopt; + } + source = &display; + edgeCoordinate = coordinate; + } + + if (source == nullptr || edgeCoordinate < 0) { + return std::nullopt; + } + + const auto transition = model.transitionFromEdge(source->id, *exitEdge, edgeCoordinate); + if (!transition.has_value()) { + return std::nullopt; + } + + return TopologyPointerTransition{ + source->id, + *exitEdge, + transition->targetDisplayId, + transition->entryEdge, + transition->coordinate, + }; +} + +std::optional MapTransitionToTargetNormalizedPoint( + const TopologyModel& model, + const TopologyPointerTransition& transition) { + const Display* target = model.displayById(transition.targetDisplayId); + if (target == nullptr) { + return std::nullopt; + } + + switch (transition.entryEdge) { + case EdgeDirection::Left: + return TopologyNormalizedPoint{0, normalizeCoordinate(transition.coordinate, target->height)}; + case EdgeDirection::Right: + return TopologyNormalizedPoint{65535, normalizeCoordinate(transition.coordinate, target->height)}; + case EdgeDirection::Up: + return TopologyNormalizedPoint{normalizeCoordinate(transition.coordinate, target->width), 0}; + case EdgeDirection::Down: + return TopologyNormalizedPoint{normalizeCoordinate(transition.coordinate, target->width), 65535}; + } + return std::nullopt; +} + bool ParseTopologyConfig(std::string_view text, TopologyModel& outModel, std::string* errorMessage) { TopologyModel parsed; std::istringstream stream{std::string(text)}; diff --git a/src/TopologyModel.h b/src/TopologyModel.h index 29d5935..b3ab345 100644 --- a/src/TopologyModel.h +++ b/src/TopologyModel.h @@ -56,6 +56,11 @@ struct TopologyPointerTransition { int coordinate{0}; }; +struct TopologyNormalizedPoint { + int x{0}; + int y{0}; +}; + enum class TopologyIssueCode { DuplicateMachine, DuplicateDisplay, @@ -86,6 +91,8 @@ class TopologyModel { const std::vector& displays() const; const std::vector& borderLinks() const; WrapPolicy wrapPolicy() const; + const Display* displayById(const std::string& displayId) const; + std::optional machineIdForDisplay(const std::string& displayId) const; std::vector validate() const; @@ -111,6 +118,18 @@ std::optional ResolveTopologyPointerTransition( int normalizedX, int normalizedY); +std::optional ResolveTopologyPointerTransitionForMachine( + const TopologyModel& model, + const std::string& machineId, + int desktopWidth, + int desktopHeight, + int normalizedX, + int normalizedY); + +std::optional MapTransitionToTargetNormalizedPoint( + const TopologyModel& model, + const TopologyPointerTransition& transition); + bool ParseTopologyConfig(std::string_view text, TopologyModel& outModel, std::string* errorMessage = nullptr); bool LoadTopologyConfig(const std::filesystem::path& path, TopologyModel& outModel, std::string* errorMessage = nullptr); diff --git a/tests/test_topology_model.cpp b/tests/test_topology_model.cpp index b341d15..22eff0f 100644 --- a/tests/test_topology_model.cpp +++ b/tests/test_topology_model.cpp @@ -268,6 +268,57 @@ void TestPointerTransitionResolverUsesAbsoluteEdges() { "Non-edge absolute pointer move should not resolve transition"); } +void TestMachineScopedPointerResolverFindsDisplayAtDesktopEdge() { + mwb::TopologyModel model = BaseModel(); + model.addDisplay({"A1", "A", 0, 0, 1920, 1080}); + model.addDisplay({"A2", "A", 1920, 0, 1920, 1080}); + model.addDisplay({"B1", "B", 0, 0, 1920, 1080}); + model.addBorderLink({"A2", mwb::EdgeDirection::Right, "B1", mwb::EdgeDirection::Left}); + + const auto transition = mwb::ResolveTopologyPointerTransitionForMachine( + model, + "A", + 3840, + 1080, + 65535, + 32767); + Expect(transition.has_value(), "Machine-scoped resolver should use the local display at desktop edge"); + if (transition.has_value()) { + ExpectEqual(transition->sourceDisplayId, "A2", "Machine-scoped resolver source display"); + ExpectEqual(transition->targetDisplayId, "B1", "Machine-scoped resolver target display"); + } +} + +void TestMachineScopedPointerResolverRejectsUnlinkedEdges() { + mwb::TopologyModel model = BaseModel(); + model.addDisplay({"A1", "A", 0, 0, 1920, 1080}); + model.addDisplay({"B1", "B", 1920, 0, 1920, 1080}); + + const auto transition = mwb::ResolveTopologyPointerTransitionForMachine( + model, + "A", + 1920, + 1080, + 65535, + 32767); + Expect(!transition.has_value(), "Machine-scoped resolver should reject unlinked desktop edges"); +} + +void TestTargetNormalizedPointMapsEntryEdge() { + mwb::TopologyModel model = BaseModel(); + model.addDisplay({"A1", "A", 0, 0, 1920, 1080}); + model.addDisplay({"B1", "B", 0, 0, 2560, 1440}); + + const auto point = mwb::MapTransitionToTargetNormalizedPoint( + model, + {"A1", mwb::EdgeDirection::Right, "B1", mwb::EdgeDirection::Left, 720}); + Expect(point.has_value(), "Handoff mapping should produce a normalized target point"); + if (point.has_value()) { + ExpectEqual(point->x, 0, "Left-edge handoff should enter at normalized x=0"); + ExpectEqual(point->y, 32790, "Target midpoint should be normalized for target display height"); + } +} + } // namespace int main() { @@ -284,6 +335,9 @@ int main() { TestParseTopologyConfigAcceptsLineBasedFormat(); TestParseTopologyConfigRejectsInvalidLines(); TestPointerTransitionResolverUsesAbsoluteEdges(); + TestMachineScopedPointerResolverFindsDisplayAtDesktopEdge(); + TestMachineScopedPointerResolverRejectsUnlinkedEdges(); + TestTargetNormalizedPointMapsEntryEdge(); if (g_failures == 0) { std::cout << "Topology model tests passed." << std::endl; From 138e49036a0aa2996e71b235e2243c121336b16c Mon Sep 17 00:00:00 2001 From: daredoole Date: Tue, 28 Apr 2026 20:55:12 -0400 Subject: [PATCH 3/5] Make topology setup user-readable --- .gitignore | 1 + CMakeLists.txt | 3 + README.md | 6 +- docs/beta-workflow.md | 6 +- docs/topology.md | 38 ++++++++++- mwb-desktop-ui.sh | 67 ++++++++++++------- src/main.cpp | 147 ++++++++++++++++++++++++++++++++++++++++++ tests/simple.topology | 7 ++ 8 files changed, 249 insertions(+), 26 deletions(-) create mode 100644 tests/simple.topology diff --git a/.gitignore b/.gitignore index 250c2af..93f29e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Build artifacts build/ build-*/ +.rpmbuild-local/ # Working/research files not for public release gemini/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 8c595df..c9aa86a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -200,6 +200,9 @@ if (BUILD_TESTING) add_test(NAME mwb_clipboard_socket_security_tests COMMAND mwb_clipboard_socket_security_tests) add_test(NAME mwb_client_help COMMAND mwb_client --help) add_test(NAME mwb_client_doctor COMMAND mwb_client doctor --config "${CMAKE_CURRENT_BINARY_DIR}/missing-doctor-config.ini") + add_test(NAME mwb_client_topology_explain + COMMAND mwb_client topology explain "${CMAKE_CURRENT_SOURCE_DIR}/tests/simple.topology" + ) add_test(NAME mwb_client_doctor_categories COMMAND ${CMAKE_COMMAND} "-DMWB_CLIENT=$" diff --git a/README.md b/README.md index 9cc15c5..26634df 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ Recommended first-run flow for most users: - Go to **Settings** -> Enter your Windows Host IP and Security Key. 4. **Choose layout (optional):** - Open **Topology/Layout Wizard** for side-by-side, stacked, AAB, BAA, ABA, or asymmetric/manual presets. - - Confirm the dry-run preview to write `~/.config/mwb-client/*.topology` and enable `topology_file`/`topology_enabled`. + - Prefer the plain-language choices: `Linux left, Windows right`, `Linux above Windows`, `Linux | Linux | Windows`, `Windows | Linux | Linux`, or `Linux | Windows | Linux`. + - Confirm the preview to write `~/.config/mwb-client/*.topology` and enable `topology_file`/`topology_enabled`. + - Use **Explain Current Topology** or `mwb_client topology explain --config ~/.config/mwb-client/config.ini` to verify the English explanation matches your desk. 5. **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. @@ -120,6 +122,8 @@ Supports `key_file`, `key_secret_id` (keyring), `screen_width/height` overrides, Display-level topology is a separate opt-in contract. The default runtime remains MWB-compatible machine placement unless topology is explicitly enabled; see [docs/topology.md](docs/topology.md) for examples, wrap policies, validation, and cross-machine handoff behavior. +Windows PowerToys still owns the Windows-side machine layout. InputFlow topology does not edit PowerToys per-display geometry; it only tells Linux which local display edge should hand off back to Windows. Keep the PowerToys machine position and the InputFlow topology links consistent. + ### Screen Sizing The client detects screen size in this order: 1. Config/CLI overrides diff --git a/docs/beta-workflow.md b/docs/beta-workflow.md index daa6394..162787a 100644 --- a/docs/beta-workflow.md +++ b/docs/beta-workflow.md @@ -12,7 +12,7 @@ Use the desktop controller for the guided path: 1. Open **Settings** and enter the Windows host IP, local machine name, port, and exactly one authentication source: inline key, `key_file`, or Secret Service key ID. 2. Use **Connection Behavior** to choose automatic reconnect behavior before starting the service. -3. Optionally run **Topology/Layout Wizard** before exporting if you want to draft a side-by-side, stacked, AAB, BAA, ABA, or asymmetric/manual layout. +3. Optionally run **Topology/Layout Wizard** before exporting if you want to draft a plain-language layout such as Linux left of Windows, Linux above Windows, Linux | Linux | Windows, Windows | Linux | Linux, Linux | Windows | Linux, or advanced/manual. 4. Export a Windows helper from Linux when the UI exposes the action, or use the CLI fallback: ```bash @@ -40,7 +40,7 @@ Open the wizard from the desktop controller: ./mwb-desktop-ui.sh layout-wizard ``` -The wizard asks for a preset, machine labels, display size, wrap policy, and output file name. It shows a dry-run preview of the exact topology file before making changes. +The wizard asks for a plain-language layout, Linux/Windows machine names, display size, wrap policy, and output file name. It shows a preview of the exact topology file before making changes. Only after confirmation, the wizard writes the topology file under `~/.config/mwb-client/` and updates `config.ini`: @@ -51,6 +51,8 @@ topology_file=/home/example/.config/mwb-client/topology-side-by-side.topology When topology is enabled, configured cross-machine edge transitions are enforced at runtime. Same-machine transitions remain local, and invalid topology falls back to the existing behavior with a warning. +Use **Explain Current Topology** after saving. It translates the topology into English and reminds users that Windows PowerToys still owns the Windows-side machine layout. Keep the PowerToys machine placement and the InputFlow topology edges consistent. + ## Health Check Run the built-in doctor before filing a beta issue or after changing package/service setup: diff --git a/docs/topology.md b/docs/topology.md index 4530053..125856d 100644 --- a/docs/topology.md +++ b/docs/topology.md @@ -1,9 +1,45 @@ # Topology Files -InputFlow topology files describe machines, their individual displays, explicit border links, and optional wrap fallback. The current runtime consumes these files only when `topology_enabled=true` and `topology_file=...` are set in `config.ini`. +InputFlow topology files describe machines, their individual displays, explicit border links, and optional wrap fallback. The runtime consumes these files only when `topology_enabled=true` and `topology_file=...` are set in `config.ini`. This is additive to the beta flow. If topology is disabled, missing, or invalid, InputFlow keeps the existing single-screen runtime behavior. +## Simple Setup + +Use the tray/controller unless you have a weird layout: + +1. Open **InputFlow Controller**. +2. Click **Topology/Layout Wizard**. +3. Pick the layout that matches your desk: + - **Linux left, Windows right**: one Linux display beside Windows. + - **Linux above Windows**: one display stacked above the other. + - **Two Linux displays, then Windows**: Linux | Linux | Windows. + - **Windows, then two Linux displays**: Windows | Linux | Linux. + - **Linux split around Windows**: Linux | Windows | Linux. + - **Advanced/manual topology**: asymmetric or unusual layouts. +4. Confirm the preview. +5. Click **Explain Current Topology** and confirm the English explanation matches your desk. +6. Restart the InputFlow service when prompted. + +CLI equivalent: + +```bash +mwb_client topology explain --config ~/.config/mwb-client/config.ini +``` + +## PowerToys Layout Interaction + +Windows PowerToys Mouse Without Borders still owns the Windows-side machine layout. InputFlow topology does not rewrite PowerToys `settings.json` or per-display geometry on Windows. + +Keep both sides consistent: + +- Use **export-windows-pair** or the Windows helper to place the Linux machine next to Windows at the MWB machine level. +- Use InputFlow topology to describe the Linux-side displays and the exact Linux edge that returns control to Windows. +- If PowerToys says Linux is left of Windows, do not make the Linux topology return to Windows from an unrelated edge. +- If these disagree, cursor movement will feel wrong because Windows and Linux will be making different assumptions. + +Mental model: PowerToys decides **which machines are neighbors**. InputFlow topology decides **which Linux display edge performs the handoff**. + ## Format Topology files are line-based `key=value` files: diff --git a/mwb-desktop-ui.sh b/mwb-desktop-ui.sh index 4e09a1d..ac6b34f 100755 --- a/mwb-desktop-ui.sh +++ b/mwb-desktop-ui.sh @@ -1103,21 +1103,24 @@ layout_wizard() { local preset preset_label machine_a machine_b display_width display_height wrap_policy file_name local fields values gui_output topology_dir topology_path topology_content preview_path manual_template - preset_label="$(zenity --list --title="$APP_NAME topology/layout wizard" --width=620 --height=360 \ - --text="Choose a common topology preset. A usually means this Linux machine; B usually means the Windows peer." \ - --column="Preset" --column="Description" \ - "Side-by-side" "A left of B" \ - "Stacked" "A above B" \ - "AAB" "Two A displays followed by B" \ - "BAA" "B followed by two A displays" \ - "ABA" "A split around B" \ - "Asymmetric/manual" "Edit the topology file text directly" || true)" + preset_label="$(zenity --list --title="$APP_NAME topology/layout wizard" --width=760 --height=390 \ + --text="Pick the layout that matches your desk. Linux means this machine; Windows means the PowerToys peer. Keep this consistent with the Windows PowerToys machine layout." \ + --column="Layout" --column="Diagram" --column="Use when" \ + "Linux left, Windows right" "Linux | Windows" "One Linux display beside Windows" \ + "Linux above Windows" "Linux / Windows" "One display stacked above the other" \ + "Two Linux displays, then Windows" "Linux | Linux | Windows" "AAB: dual Linux monitors with Windows on the far right" \ + "Windows, then two Linux displays" "Windows | Linux | Linux" "BAA: Windows on the far left" \ + "Linux split around Windows" "Linux | Windows | Linux" "ABA: Windows between two Linux displays" \ + "Advanced/manual topology" "custom" "Asymmetric, unusual, or hand-edited layouts" || true)" [[ -n "$preset_label" ]] || return 1 case "$preset_label" in - "Side-by-side") preset="side-by-side" ;; - "Stacked") preset="stacked" ;; - "Asymmetric/manual") preset="manual" ;; + "Linux left, Windows right") preset="side-by-side" ;; + "Linux above Windows") preset="stacked" ;; + "Two Linux displays, then Windows") preset="AAB" ;; + "Windows, then two Linux displays") preset="BAA" ;; + "Linux split around Windows") preset="ABA" ;; + "Advanced/manual topology") preset="manual" ;; *) preset="$preset_label" ;; esac @@ -1137,7 +1140,7 @@ layout_wizard() { rm -f "$preview_path" [[ -n "$topology_content" ]] || return 1 else - fields="machine_a:Machine A (Linux/current):entry||machine_b:Machine B (Windows/peer):entry||display_width:Display Width:entry||display_height:Display Height:entry||wrap_policy:Wrap Policy|none|horizontal|vertical|both:combo||file_name:Topology File Name:entry" + fields="machine_a:Linux Machine Name:entry||machine_b:Windows Machine Name:entry||display_width:Display Width:entry||display_height:Display Height:entry||wrap_policy:Wrap Policy|none|horizontal|vertical|both:combo||file_name:Topology File Name:entry" values="$machine_a|$machine_b|$display_width|$display_height|$wrap_policy|$file_name" gui_output="$(python3 "$SCRIPT_DIR/src/ConfigDialog.py" "$APP_NAME topology/layout wizard" "$fields" "$values" || true)" [[ -n "$gui_output" ]] || return 1 @@ -1172,10 +1175,17 @@ layout_wizard() { mkdir -p "$topology_dir" printf '%s\n' "$topology_content" >"$topology_path" write_topology_config_keys "$topology_path" - zenity --info --width=620 --text="Topology saved.\n\nFile: $topology_path\nConfig: $CONFIG_PATH" + zenity --info --width=680 --text="Topology saved.\n\nFile: $topology_path\nConfig: $CONFIG_PATH\n\nWindows PowerToys still owns the Windows-side machine layout. Keep the PowerToys Linux/Windows machine position consistent with this topology." offer_service_restart_if_active "Topology settings updated." } +explain_topology() { + require_client_binary || return 1 + local explanation + explanation="$("$APP_BIN" topology explain --config "$CONFIG_PATH" 2>&1 || true)" + zenity --text-info --title="$APP_NAME topology explanation" --width=860 --height=620 <<<"$explanation" +} + guided_pairing() { while true; do local choice @@ -1185,17 +1195,19 @@ guided_pairing() { "1. Discover Windows peer and save settings" \ "2. Edit settings manually" \ "3. Topology/layout wizard" \ - "4. Export Windows helper" \ - "5. Start service" \ - "6. Run health check" \ + "4. Explain current topology" \ + "5. Export Windows helper" \ + "6. Start service" \ + "7. Run health check" \ "Back" || true)" case "$choice" in "1. Discover Windows peer and save settings") discover_and_save_peer ;; "2. Edit settings manually") edit_settings ;; "3. Topology/layout wizard") layout_wizard ;; - "4. Export Windows helper") export_windows_helper ;; - "5. Start service") start_session ;; - "6. Run health check") health_check ;; + "4. Explain current topology") explain_topology ;; + "5. Export Windows helper") export_windows_helper ;; + "6. Start service") start_session ;; + "7. Run health check") health_check ;; ""|"Back") return 0 ;; esac done @@ -1417,7 +1429,7 @@ Terminal=false Categories=Utility;Network; Keywords=mouse;keyboard;sharing;input;controller; StartupNotify=false -Actions=GuidedPairing;HealthCheck;DiagnosticsBundle;ConnectionQuality;OpenSettings;OpenConnectionBehavior;ShowTrayHelp;ShowStatus;StartService;RestartService;StopService; +Actions=GuidedPairing;TopologyWizard;ExplainTopology;HealthCheck;DiagnosticsBundle;ConnectionQuality;OpenSettings;OpenConnectionBehavior;ShowTrayHelp;ShowStatus;StartService;RestartService;StopService; [Desktop Action GuidedPairing] Name=Guided Pairing @@ -1427,6 +1439,14 @@ Exec=$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}") guided-pairing Name=Health Check Exec=$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}") health-check +[Desktop Action TopologyWizard] +Name=Topology/Layout Wizard +Exec=$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}") layout-wizard + +[Desktop Action ExplainTopology] +Name=Explain Current Topology +Exec=$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}") explain-topology + [Desktop Action DiagnosticsBundle] Name=Diagnostics Bundle Exec=$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}") diagnostics-bundle @@ -1492,6 +1512,7 @@ main_menu() { --column="Action" \ "Guided Pairing" \ "Topology/Layout Wizard" \ + "Explain Current Topology" \ "Health Check" \ "Diagnostics Bundle" \ "Connection Quality" \ @@ -1509,6 +1530,7 @@ main_menu() { case "$choice" in "Guided Pairing") guided_pairing ;; "Topology/Layout Wizard") layout_wizard ;; + "Explain Current Topology") explain_topology ;; "Health Check") health_check ;; "Diagnostics Bundle") diagnostics_bundle ;; "Connection Quality") connection_quality ;; @@ -1538,6 +1560,7 @@ case "${1:-menu}" in ""|menu) main_menu ;; guided-pairing|pairing|export-helper) guided_pairing ;; layout-wizard|topology-wizard|topology|layout) layout_wizard ;; + explain-topology|topology-explain) explain_topology ;; health-check|doctor) health_check ;; diagnostics-bundle|diagnostics) diagnostics_bundle ;; connection-quality|quality) connection_quality ;; @@ -1553,7 +1576,7 @@ case "${1:-menu}" in tray) start_tray ;; install-desktop-entry|install-desktop-entries) install_desktop_entry ;; help|-h|--help) - printf 'Usage: %s [menu|guided-pairing|layout-wizard|health-check|diagnostics-bundle|connection-quality|settings|connection|discover|peers|tray-help|status|start|restart|stop|tray|install-desktop-entry]\n' "$(basename "${BASH_SOURCE[0]}")" + printf 'Usage: %s [menu|guided-pairing|layout-wizard|explain-topology|health-check|diagnostics-bundle|connection-quality|settings|connection|discover|peers|tray-help|status|start|restart|stop|tray|install-desktop-entry]\n' "$(basename "${BASH_SOURCE[0]}")" ;; *) zenity --error --text="Unknown action: $1" diff --git a/src/main.cpp b/src/main.cpp index 722ee22..8461048 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -32,6 +32,7 @@ #include "Discovery.h" #include "PeerRecovery.h" #include "SecretStore.h" +#include "TopologyModel.h" namespace { @@ -58,6 +59,7 @@ void PrintGeneralUsage(std::ostream& out, const char* argv0) { << " [--latency-report]\n"; out << " " << binary << " discover [--state PATH] [--port PORT] [--timeout-ms MS] [--max-hosts N]\n"; out << " " << binary << " doctor [--config PATH] [--state PATH]\n"; + out << " " << binary << " topology explain [PATH] [--config PATH]\n"; out << " " << binary << " init-config [--config PATH] [--force] [--host IP] [--key KEY | --key-file PATH | --key-secret-id ID] [--name NAME] [--port PORT]\n"; out << " " << binary << " export-windows-pair [--config PATH] [--output PATH] [--force] [--dry-run] [--check] [--linux-ip IP] [--position auto|top-left|top-right|bottom-left|bottom-right] [--key KEY | --key-file PATH | --key-secret-id ID] [--name NAME]\n"; out << " " << binary << " install-user-service [--config PATH] [--unit PATH] [--force]\n"; @@ -2462,6 +2464,147 @@ int HandleInstallUserServiceCommand(const std::vector& args) { return 0; } +const char* PlainEdgeName(mwb::EdgeDirection edge) { + switch (edge) { + case mwb::EdgeDirection::Left: + return "left"; + case mwb::EdgeDirection::Right: + return "right"; + case mwb::EdgeDirection::Up: + return "top"; + case mwb::EdgeDirection::Down: + return "bottom"; + } + return "edge"; +} + +int HandleTopologyCommand(const std::vector& args) { + if (args.empty() || args[0] == "--help" || args[0] == "-h") { + std::cout << "Usage: mwb_client topology explain [PATH] [--config PATH]\n"; + std::cout << "Explains a topology file in plain English. If PATH is omitted, topology_file is read from config.\n"; + return args.empty() ? 1 : 0; + } + + if (args[0] != "explain") { + std::cerr << "ERR: Unknown topology subcommand: " << args[0] << std::endl; + return 1; + } + + std::filesystem::path configPath = mwb::DefaultConfigPath(); + std::filesystem::path topologyPath; + + for (std::size_t index = 1; index < args.size(); ++index) { + const std::string& arg = args[index]; + auto requireValue = [&](const char* flag) -> std::optional { + if (index + 1 >= args.size()) { + std::cerr << "ERR: Missing value for " << flag << "." << std::endl; + return std::nullopt; + } + return args[++index]; + }; + + if (arg == "--config") { + const auto value = requireValue("--config"); + if (!value) { + return 1; + } + configPath = *value; + } else if (arg.rfind("--", 0) == 0) { + std::cerr << "ERR: Unknown topology explain option: " << arg << std::endl; + return 1; + } else if (topologyPath.empty()) { + topologyPath = arg; + } else { + std::cerr << "ERR: topology explain accepts only one topology file path." << std::endl; + return 1; + } + } + + mwb::AppConfig config; + if (topologyPath.empty()) { + std::string configError; + if (!mwb::LoadConfigFile(configPath, config, configError)) { + std::cerr << "ERR: " << configError << std::endl; + return 1; + } + if (!config.topologyRuntimeEnabled) { + std::cout << "Topology is disabled in " << configPath << ". Set topology_enabled=true to enforce it." << std::endl; + } + if (config.topologyFile.empty()) { + std::cerr << "ERR: No topology file configured. Set topology_file=... or pass a file path." << std::endl; + return 1; + } + topologyPath = config.topologyFile; + } + + mwb::TopologyModel topology; + std::string error; + if (!mwb::LoadTopologyConfig(topologyPath, topology, &error)) { + std::cerr << "ERR: " << error << std::endl; + return 1; + } + + const auto issues = topology.validate(); + std::cout << "Topology file: " << topologyPath << "\n\n"; + if (!issues.empty()) { + std::cout << "Status: INVALID\n"; + for (const auto& issue : issues) { + std::cout << "- " << mwb::topologyIssueCodeName(issue.code) << ": " << issue.message << "\n"; + } + return 1; + } + + std::cout << "Status: valid\n"; + std::cout << "Wrap: "; + switch (topology.wrapPolicy()) { + case mwb::WrapPolicy::None: + std::cout << "off. Only explicit links move between displays/machines.\n"; + break; + case mwb::WrapPolicy::Horizontal: + std::cout << "horizontal. Unlinked left/right edges may loop within the same machine.\n"; + break; + case mwb::WrapPolicy::Vertical: + std::cout << "vertical. Unlinked top/bottom edges may loop within the same machine.\n"; + break; + case mwb::WrapPolicy::Both: + std::cout << "both. Unlinked edges may loop within the same machine.\n"; + break; + } + + std::cout << "\nMachines and displays:\n"; + for (const auto& display : topology.displays()) { + std::cout << "- " << display.machineId << " display " << display.id + << ": " << display.width << "x" << display.height + << " at " << display.x << "," << display.y << "\n"; + } + + std::cout << "\nEdge behavior:\n"; + if (topology.borderLinks().empty()) { + std::cout << "- No explicit edge links configured.\n"; + } + for (const auto& link : topology.borderLinks()) { + const auto sourceMachine = topology.machineIdForDisplay(link.sourceDisplayId).value_or("unknown"); + const auto targetMachine = topology.machineIdForDisplay(link.targetDisplayId).value_or("unknown"); + std::cout << "- Leave the " << PlainEdgeName(link.exitEdge) + << " edge of " << sourceMachine << " display " << link.sourceDisplayId + << " -> enter the " << PlainEdgeName(link.entryEdge) + << " edge of " << targetMachine << " display " << link.targetDisplayId; + if (sourceMachine == targetMachine) { + std::cout << " (local display move)"; + } else { + std::cout << " (cross-machine handoff)"; + } + std::cout << "\n"; + } + + std::cout << "\nPowerToys layout relationship:\n"; + std::cout << "- Windows PowerToys Mouse Without Borders still owns the Windows-side machine layout.\n"; + std::cout << "- InputFlow topology does not edit PowerToys settings.json or per-display layout on Windows.\n"; + std::cout << "- Keep the PowerToys machine position consistent with the cross-machine links above; use export-windows-pair to seed that machine-level placement.\n"; + std::cout << "- InputFlow topology only controls how the Linux side resolves Linux displays and returns handoff events once topology_enabled=true.\n"; + return 0; +} + } // namespace int main(int argc, char** argv) { @@ -2475,6 +2618,7 @@ int main(int argc, char** argv) { if (argc >= 3 && argc <= 4 && std::string(argv[1]) != "run" && std::string(argv[1]) != "discover" && std::string(argv[1]) != "doctor" && + std::string(argv[1]) != "topology" && std::string(argv[1]) != "init-config" && std::string(argv[1]) != "export-windows-pair" && std::string(argv[1]) != "install-user-service" && @@ -2502,6 +2646,9 @@ int main(int argc, char** argv) { if (command == "doctor") { return HandleDoctorCommand(args); } + if (command == "topology") { + return HandleTopologyCommand(args); + } if (command == "init-config") { return HandleInitConfigCommand(args); } diff --git a/tests/simple.topology b/tests/simple.topology new file mode 100644 index 0000000..2fc2bc2 --- /dev/null +++ b/tests/simple.topology @@ -0,0 +1,7 @@ +wrap=none +machine=linux +machine=windows +display=linux-1,linux,0,0,1920,1080 +display=windows-1,windows,1920,0,1920,1080 +link=linux-1,right,windows-1,left +link=windows-1,left,linux-1,right From 6eb75dd9b671df625b6678c5d7dd868ef1798301 Mon Sep 17 00:00:00 2001 From: daredoole Date: Tue, 28 Apr 2026 21:09:00 -0400 Subject: [PATCH 4/5] Make topology optional for simple layouts --- README.md | 9 ++--- docs/beta-workflow.md | 11 +++--- docs/topology.md | 18 +++++++-- mwb-desktop-ui.sh | 87 +++++++++++++++++++++++++++++++++---------- 4 files changed, 92 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 26634df..9403b89 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,14 @@ Recommended first-run flow for most users: 2. **Launch Setup UI:** Run `./mwb-desktop-ui.sh menu` 3. **Configure:** - Go to **Settings** -> Enter your Windows Host IP and Security Key. -4. **Choose layout (optional):** - - Open **Topology/Layout Wizard** for side-by-side, stacked, AAB, BAA, ABA, or asymmetric/manual presets. - - Prefer the plain-language choices: `Linux left, Windows right`, `Linux above Windows`, `Linux | Linux | Windows`, `Windows | Linux | Linux`, or `Linux | Windows | Linux`. - - Confirm the preview to write `~/.config/mwb-client/*.topology` and enable `topology_file`/`topology_enabled`. - - Use **Explain Current Topology** or `mwb_client topology explain --config ~/.config/mwb-client/config.ini` to verify the English explanation matches your desk. +4. **Use PowerToys layout for normal setups:** + - If this Linux/Fedora machine has one monitor, do not configure topology. Let Windows PowerToys Mouse Without Borders own the Linux/Windows machine placement. + - If topology was enabled while testing, choose **Use PowerToys Layout Only** to set `topology_enabled=false`. 5. **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. 6. **Start:** Choose **Start Service** or launch the tray with `./build/mwb_tray`. +7. **Advanced layouts only:** Open **Advanced Topology/Layout** if you have multiple Linux monitors, stacked/asymmetric edges, wrap behavior, or wrong-edge handoff problems. For the full beta setup, health-check, diagnostics, connection-quality, and packaging-verification workflow, see [docs/beta-workflow.md](docs/beta-workflow.md). diff --git a/docs/beta-workflow.md b/docs/beta-workflow.md index 162787a..131f432 100644 --- a/docs/beta-workflow.md +++ b/docs/beta-workflow.md @@ -12,8 +12,9 @@ Use the desktop controller for the guided path: 1. Open **Settings** and enter the Windows host IP, local machine name, port, and exactly one authentication source: inline key, `key_file`, or Secret Service key ID. 2. Use **Connection Behavior** to choose automatic reconnect behavior before starting the service. -3. Optionally run **Topology/Layout Wizard** before exporting if you want to draft a plain-language layout such as Linux left of Windows, Linux above Windows, Linux | Linux | Windows, Windows | Linux | Linux, Linux | Windows | Linux, or advanced/manual. -4. Export a Windows helper from Linux when the UI exposes the action, or use the CLI fallback: +3. For one Linux/Fedora monitor, leave topology disabled and use the normal PowerToys layout path. +4. Optionally run **Advanced Topology/Layout** only if you have multiple Linux displays, stacked/asymmetric edges, wrap behavior, or wrong-edge handoff problems. +5. Export a Windows helper from Linux when the UI exposes the action, or use the CLI fallback: ```bash ./build/mwb_client export-windows-pair \ @@ -32,15 +33,15 @@ Keep the exported `.ps1` private because it contains pairing material. Delete it ![Pairing helper walkthrough](screenshots/pairing-helper.svg) -## Topology/Layout Wizard +## Advanced Topology/Layout Wizard -Open the wizard from the desktop controller: +Open the wizard from the desktop controller only when the normal PowerToys layout is not enough: ```bash ./mwb-desktop-ui.sh layout-wizard ``` -The wizard asks for a plain-language layout, Linux/Windows machine names, display size, wrap policy, and output file name. It shows a preview of the exact topology file before making changes. +The wizard asks for a plain-language layout, Linux/Windows machine names, display size, wrap policy, and output file name. It shows a preview of the exact topology file before making changes. For one Linux monitor, prefer **Use PowerToys Layout Only** instead. Only after confirmation, the wizard writes the topology file under `~/.config/mwb-client/` and updates `config.ini`: diff --git a/docs/topology.md b/docs/topology.md index 125856d..4e6feb0 100644 --- a/docs/topology.md +++ b/docs/topology.md @@ -4,12 +4,24 @@ InputFlow topology files describe machines, their individual displays, explicit This is additive to the beta flow. If topology is disabled, missing, or invalid, InputFlow keeps the existing single-screen runtime behavior. -## Simple Setup +## Normal Single-Monitor Setup -Use the tray/controller unless you have a weird layout: +Do not use topology for a normal one-monitor Linux/Fedora setup. Keep `topology_enabled=false` and let Windows PowerToys Mouse Without Borders own the Linux/Windows machine placement. + +Use the controller action **Use PowerToys Layout Only** if you enabled topology while testing and want to return to the simple path. + +CLI equivalent: + +```bash +./mwb-desktop-ui.sh disable-topology +``` + +## Advanced Topology Setup + +Use topology only when the normal PowerToys-style machine layout is not enough: 1. Open **InputFlow Controller**. -2. Click **Topology/Layout Wizard**. +2. Click **Advanced Topology/Layout**. 3. Pick the layout that matches your desk: - **Linux left, Windows right**: one Linux display beside Windows. - **Linux above Windows**: one display stacked above the other. diff --git a/mwb-desktop-ui.sh b/mwb-desktop-ui.sh index ac6b34f..534db2a 100755 --- a/mwb-desktop-ui.sh +++ b/mwb-desktop-ui.sh @@ -269,6 +269,31 @@ write_topology_config_keys() { mv "$tmp_path" "$CONFIG_PATH" } +disable_topology_config() { + local tmp_path line line_key + local saw_enabled=false + + mkdir -p "$(dirname "$CONFIG_PATH")" + tmp_path="$(mktemp "${CONFIG_PATH}.tmp.XXXXXX")" + + if [[ -f "$CONFIG_PATH" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" =~ ^[[:space:]]*([A-Za-z0-9_.-]+)[[:space:]]*= ]]; then + line_key="${BASH_REMATCH[1]}" + if [[ "$line_key" == "$TOPOLOGY_ENABLED_CONFIG_KEY" ]]; then + printf '%s=false\n' "$TOPOLOGY_ENABLED_CONFIG_KEY" >>"$tmp_path" + saw_enabled=true + continue + fi + fi + printf '%s\n' "$line" >>"$tmp_path" + done <"$CONFIG_PATH" + fi + + [[ "$saw_enabled" == true ]] || printf '%s=false\n' "$TOPOLOGY_ENABLED_CONFIG_KEY" >>"$tmp_path" + mv "$tmp_path" "$CONFIG_PATH" +} + write_config() { local host="$1" key="$2" key_file="$3" secret_id="$4" machine_name="$5" port="$6" auto_connect_enabled="$7" reconnect_initial_backoff_ms="$8" reconnect_max_backoff_ms="$9" reconnect_idle_retry_ms="${10}" clipboard_enabled="${11}" clipboard_send_enabled="${12}" clipboard_force_poll="${13}" clipboard_poll_ms="${14}" screen_width="${15}" screen_height="${16}" mpris_media_keys_enabled="${17}" mpris_player="${18}" latency_report="${19}" local secret_key_name="${20:-$(detect_secret_id_key_name)}" @@ -1103,9 +1128,10 @@ layout_wizard() { local preset preset_label machine_a machine_b display_width display_height wrap_policy file_name local fields values gui_output topology_dir topology_path topology_content preview_path manual_template - preset_label="$(zenity --list --title="$APP_NAME topology/layout wizard" --width=760 --height=390 \ - --text="Pick the layout that matches your desk. Linux means this machine; Windows means the PowerToys peer. Keep this consistent with the Windows PowerToys machine layout." \ + preset_label="$(zenity --list --title="$APP_NAME advanced topology/layout wizard" --width=820 --height=430 \ + --text="Topology is optional. If this Fedora/Linux machine has one monitor, use PowerToys layout only and skip topology. Use topology only for multiple Linux displays, wrap, stacked/asymmetric layouts, or wrong-edge handoff problems." \ --column="Layout" --column="Diagram" --column="Use when" \ + "Use PowerToys layout only" "no topology file" "One Linux/Fedora monitor; normal MWB-style setup" \ "Linux left, Windows right" "Linux | Windows" "One Linux display beside Windows" \ "Linux above Windows" "Linux / Windows" "One display stacked above the other" \ "Two Linux displays, then Windows" "Linux | Linux | Windows" "AAB: dual Linux monitors with Windows on the far right" \ @@ -1115,6 +1141,7 @@ layout_wizard() { [[ -n "$preset_label" ]] || return 1 case "$preset_label" in + "Use PowerToys layout only") disable_topology; return $? ;; "Linux left, Windows right") preset="side-by-side" ;; "Linux above Windows") preset="stacked" ;; "Two Linux displays, then Windows") preset="AAB" ;; @@ -1167,8 +1194,8 @@ layout_wizard() { fi rm -f "$preview_path" - if ! zenity --question --title="$APP_NAME topology/layout wizard" --width=620 \ - --text="Apply this topology?\n\nWill write:\n$topology_path\n\nWill set:\n$TOPOLOGY_ENABLED_CONFIG_KEY=true\n$TOPOLOGY_FILE_CONFIG_KEY=$topology_path\n\nTopology will enforce configured cross-machine edge handoffs at runtime. Same-machine edges remain local."; then + if ! zenity --question --title="$APP_NAME advanced topology/layout wizard" --width=620 \ + --text="Apply this advanced topology?\n\nFor one Linux monitor, cancel and use PowerToys layout only.\n\nWill write:\n$topology_path\n\nWill set:\n$TOPOLOGY_ENABLED_CONFIG_KEY=true\n$TOPOLOGY_FILE_CONFIG_KEY=$topology_path\n\nTopology will enforce configured cross-machine edge handoffs at runtime. Same-machine edges remain local."; then return 1 fi @@ -1179,6 +1206,17 @@ layout_wizard() { offer_service_restart_if_active "Topology settings updated." } +disable_topology() { + if ! zenity --question --title="$APP_NAME topology" --width=620 \ + --text="Use PowerToys layout only?\n\nThis disables InputFlow topology by setting:\n$TOPOLOGY_ENABLED_CONFIG_KEY=false\n\nThis is the recommended mode for a single Fedora/Linux monitor. PowerToys continues to decide the Linux/Windows machine placement."; then + return 1 + fi + + disable_topology_config + zenity --info --width=620 --text="Topology disabled.\n\nInputFlow will use the normal PowerToys/MWB-style machine layout path. No topology file is required for a single Linux monitor." + offer_service_restart_if_active "Topology disabled." +} + explain_topology() { require_client_binary || return 1 local explanation @@ -1190,24 +1228,26 @@ guided_pairing() { while true; do local choice choice="$(zenity --list --title="$APP_NAME guided pairing" --width=620 --height=390 \ - --text="Use this flow to discover Windows, save Linux settings, export the Windows helper, then verify the setup." \ + --text="Use this flow to discover Windows, save Linux settings, export the Windows helper, then verify the setup. Topology is optional; skip it for one Linux monitor." \ --column="Step" \ "1. Discover Windows peer and save settings" \ "2. Edit settings manually" \ - "3. Topology/layout wizard" \ - "4. Explain current topology" \ - "5. Export Windows helper" \ - "6. Start service" \ - "7. Run health check" \ + "3. Export Windows helper" \ + "4. Start service" \ + "5. Run health check" \ + "Optional: Advanced topology/layout" \ + "Optional: Use PowerToys layout only" \ + "Optional: Explain current topology" \ "Back" || true)" case "$choice" in "1. Discover Windows peer and save settings") discover_and_save_peer ;; "2. Edit settings manually") edit_settings ;; - "3. Topology/layout wizard") layout_wizard ;; - "4. Explain current topology") explain_topology ;; - "5. Export Windows helper") export_windows_helper ;; - "6. Start service") start_session ;; - "7. Run health check") health_check ;; + "3. Export Windows helper") export_windows_helper ;; + "4. Start service") start_session ;; + "5. Run health check") health_check ;; + "Optional: Advanced topology/layout") layout_wizard ;; + "Optional: Use PowerToys layout only") disable_topology ;; + "Optional: Explain current topology") explain_topology ;; ""|"Back") return 0 ;; esac done @@ -1429,7 +1469,7 @@ Terminal=false Categories=Utility;Network; Keywords=mouse;keyboard;sharing;input;controller; StartupNotify=false -Actions=GuidedPairing;TopologyWizard;ExplainTopology;HealthCheck;DiagnosticsBundle;ConnectionQuality;OpenSettings;OpenConnectionBehavior;ShowTrayHelp;ShowStatus;StartService;RestartService;StopService; +Actions=GuidedPairing;DisableTopology;TopologyWizard;ExplainTopology;HealthCheck;DiagnosticsBundle;ConnectionQuality;OpenSettings;OpenConnectionBehavior;ShowTrayHelp;ShowStatus;StartService;RestartService;StopService; [Desktop Action GuidedPairing] Name=Guided Pairing @@ -1439,8 +1479,12 @@ Exec=$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}") guided-pairing Name=Health Check Exec=$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}") health-check +[Desktop Action DisableTopology] +Name=Use PowerToys Layout Only +Exec=$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}") disable-topology + [Desktop Action TopologyWizard] -Name=Topology/Layout Wizard +Name=Advanced Topology/Layout Exec=$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}") layout-wizard [Desktop Action ExplainTopology] @@ -1511,7 +1555,8 @@ main_menu() { choice="$(zenity --list --title="$APP_NAME" --text="$(menu_summary_text)" --width=540 --height=430 \ --column="Action" \ "Guided Pairing" \ - "Topology/Layout Wizard" \ + "Use PowerToys Layout Only" \ + "Advanced Topology/Layout" \ "Explain Current Topology" \ "Health Check" \ "Diagnostics Bundle" \ @@ -1529,7 +1574,8 @@ main_menu() { case "$choice" in "Guided Pairing") guided_pairing ;; - "Topology/Layout Wizard") layout_wizard ;; + "Use PowerToys Layout Only") disable_topology ;; + "Advanced Topology/Layout") layout_wizard ;; "Explain Current Topology") explain_topology ;; "Health Check") health_check ;; "Diagnostics Bundle") diagnostics_bundle ;; @@ -1559,6 +1605,7 @@ require_ui case "${1:-menu}" in ""|menu) main_menu ;; guided-pairing|pairing|export-helper) guided_pairing ;; + disable-topology|powertoys-layout-only|simple-layout) disable_topology ;; layout-wizard|topology-wizard|topology|layout) layout_wizard ;; explain-topology|topology-explain) explain_topology ;; health-check|doctor) health_check ;; @@ -1576,7 +1623,7 @@ case "${1:-menu}" in tray) start_tray ;; install-desktop-entry|install-desktop-entries) install_desktop_entry ;; help|-h|--help) - printf 'Usage: %s [menu|guided-pairing|layout-wizard|explain-topology|health-check|diagnostics-bundle|connection-quality|settings|connection|discover|peers|tray-help|status|start|restart|stop|tray|install-desktop-entry]\n' "$(basename "${BASH_SOURCE[0]}")" + printf 'Usage: %s [menu|guided-pairing|disable-topology|layout-wizard|explain-topology|health-check|diagnostics-bundle|connection-quality|settings|connection|discover|peers|tray-help|status|start|restart|stop|tray|install-desktop-entry]\n' "$(basename "${BASH_SOURCE[0]}")" ;; *) zenity --error --text="Unknown action: $1" From 2bcd2cca749fac2672cce76ad1affcff88d6dbd6 Mon Sep 17 00:00:00 2001 From: daredoole Date: Thu, 30 Apr 2026 15:29:04 -0400 Subject: [PATCH 5/5] Prioritize peer names during address recovery --- mwb-desktop-ui.sh | 61 +++++++++++++++++++++++++++++++++++++++++--- src/Discovery.cpp | 58 +++++++++++++++++++++++++++++++++++++---- src/PeerRecovery.cpp | 41 +++++++++++++++++++++++++++++ src/PeerRecovery.h | 5 ++++ src/main.cpp | 38 ++++++++++++++++++++------- tests/test_main.cpp | 37 +++++++++++++++++++++++++++ 6 files changed, 223 insertions(+), 17 deletions(-) diff --git a/mwb-desktop-ui.sh b/mwb-desktop-ui.sh index 534db2a..521d6cb 100755 --- a/mwb-desktop-ui.sh +++ b/mwb-desktop-ui.sh @@ -529,6 +529,55 @@ read_peer_state() { return 1 } +normalize_host_label() { + local value="$1" + value="$(trim_value "$value")" + value="${value%%.*}" + printf '%s\n' "$value" | tr '[:upper:]' '[:lower:]' +} + +host_labels_match() { + local left right + left="$(normalize_host_label "$1")" + right="$(normalize_host_label "$2")" + [[ -n "$left" && "$left" == "$right" ]] +} + +read_peer_state_by_verified_name() { + local wanted_name="$1" wanted_port="$2" + local line host name port approved connected_now last_seen last_connected + local best_name="" best_connected="false" best_last_seen="0" best_last_connected="0" found="false" + + [[ -n "$(normalize_host_label "$wanted_name")" ]] || return 1 + [[ -f "$STATE_PATH" ]] || return 1 + + while IFS= read -r line; do + [[ "$line" == peer=* ]] || continue + IFS=$'\t' read -r host name port approved connected_now last_seen last_connected <<<"${line#peer=}" + [[ "$port" == "$wanted_port" && "$approved" == "true" ]] || continue + host_labels_match "$name" "$wanted_name" || continue + if [[ -z "$last_connected" ]]; then + last_connected="${last_seen:-0}" + last_seen="${connected_now:-0}" + connected_now="false" + fi + [[ "$last_seen" =~ ^[0-9]+$ ]] || last_seen="0" + [[ "$last_connected" =~ ^[0-9]+$ ]] || last_connected="0" + if [[ "$found" != "true" || "$last_connected" -gt "$best_last_connected" ]]; then + best_name="${name:-$wanted_name}" + best_last_seen="$last_seen" + best_last_connected="$last_connected" + found="true" + fi + if [[ "$connected_now" == "true" ]]; then + best_connected="true" + fi + done <"$STATE_PATH" + + [[ "$found" == "true" ]] || return 1 + printf '%s\ttrue\t%s\t%s\t%s\n' "$best_name" "$best_connected" "$best_last_seen" "$best_last_connected" +} + resolve_config_relative_path() { local path_value="$1" @@ -943,15 +992,18 @@ discover_peers() { /^ / { ip = $1 name = "(unknown)" + verified = "no" network = "(default)" for (i = 2; i <= NF; i++) { if ($i ~ /^name=/) { name = substr($i, 6) + } else if ($i ~ /^verified=/) { + verified = substr($i, 10) } else if ($i ~ /^iface=/) { network = substr($i, 7) } } - print ip "|" name "|" network + print ip "|" name "|" verified "|" network } ') if [[ "${#candidates[@]}" -eq 0 ]]; then @@ -960,9 +1012,9 @@ discover_peers() { fi local rows=() - local ip item name network paired_label connected_label configured_label last_connected_label state_name state_approved state_connected state_last_seen state_last_connected + local ip item name verified network paired_label connected_label configured_label last_connected_label state_name state_approved state_connected state_last_seen state_last_connected for item in "${candidates[@]}"; do - IFS='|' read -r ip name network <<< "$item" + IFS='|' read -r ip name verified network <<< "$item" state_name="" state_approved="false" state_connected="false" @@ -970,6 +1022,9 @@ discover_peers() { state_last_connected="0" if IFS=$'\t' read -r state_name state_approved state_connected state_last_seen state_last_connected < <(read_peer_state "$ip" "$port" || true); then : + elif [[ "$name" != "(unknown)" ]] && + IFS=$'\t' read -r state_name state_approved state_connected state_last_seen state_last_connected < <(read_peer_state_by_verified_name "$name" "$port" || true); then + : fi paired_label="$(format_paired_label "$state_approved")" connected_label="$(format_yes_no "$([[ "$service_running" == "true" && "$state_connected" == "true" ]] && printf 'true' || printf 'false')")" diff --git a/src/Discovery.cpp b/src/Discovery.cpp index 26202f8..6dde63f 100644 --- a/src/Discovery.cpp +++ b/src/Discovery.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +32,7 @@ namespace { constexpr std::size_t kAbsoluteMaxHostsPerSubnet = 4096; constexpr std::size_t kAbsoluteMaxConcurrentProbes = 128; constexpr int kMinimumConnectTimeoutMs = 25; +constexpr int kMinimumHostNameLookupTimeoutMs = 1000; constexpr uint16_t kMdnsPort = 5353; constexpr uint16_t kNetbiosNameServicePort = 137; @@ -246,7 +248,7 @@ std::string NormalizeDiscoveredName(std::string value) { return value; } -std::string SelectCorroboratedDiscoveredName(const std::array& names) { +std::string SelectCorroboratedDiscoveredName(const std::array& names) { for (std::size_t left = 0; left < names.size(); ++left) { if (names[left].empty()) { continue; @@ -260,7 +262,7 @@ std::string SelectCorroboratedDiscoveredName(const std::array& n return {}; } -std::string SelectFirstDiscoveredName(const std::array& names) { +std::string SelectFirstDiscoveredName(const std::array& names) { for (const auto& name : names) { if (!name.empty()) { return name; @@ -301,6 +303,50 @@ std::string AvahiResolveAddress(uint32_t address) { return NormalizeDiscoveredName(name); } +std::string NmblookupResolveAddress(uint32_t address, int timeoutMs) { + constexpr const char* kTimeoutPath = "/usr/bin/timeout"; + constexpr const char* kNmblookupPath = "/usr/bin/nmblookup"; + if (access(kTimeoutPath, X_OK) != 0 || access(kNmblookupPath, X_OK) != 0) { + return {}; + } + + const std::string ipAddress = FormatIpv4Address(address); + const int timeoutSeconds = std::max(1, (std::min(timeoutMs, 3000) + 999) / 1000); + const std::string command = std::string(kTimeoutPath) + " " + std::to_string(timeoutSeconds) + "s " + + kNmblookupPath + " -A " + ipAddress + " 2>/dev/null"; + std::unique_ptr pipe(popen(command.c_str(), "r"), pclose); + if (!pipe) { + return {}; + } + + std::array buffer{}; + std::string fallbackName; + while (fgets(buffer.data(), static_cast(buffer.size()), pipe.get()) != nullptr) { + const std::string line = TrimDiscoveredName(buffer.data()); + if (line.find("") != std::string::npos) { + continue; + } + if (line.find("<00>") == std::string::npos && line.find("<20>") == std::string::npos) { + continue; + } + + std::istringstream stream(line); + std::string name; + stream >> name; + if (name.empty() || name == "MAC") { + continue; + } + if (line.find("<00>") != std::string::npos) { + return NormalizeDiscoveredName(name); + } + if (fallbackName.empty()) { + fallbackName = NormalizeDiscoveredName(name); + } + } + + return fallbackName; +} + void AppendUint16(std::vector& bytes, uint16_t value) { bytes.push_back(static_cast((value >> 8) & 0xffu)); bytes.push_back(static_cast(value & 0xffu)); @@ -835,10 +881,12 @@ DiscoveryCandidate ProbeCandidate(const ProbeTarget& target, const DiscoveryOpti candidate.interfaceName = target.interfaceName; candidate.status = ProbeTcpPort(target.address, options.port, options.connectTimeoutMs); if (options.resolveHostNames && candidate.status == DiscoveryStatus::Open) { - std::array resolvedNames = { + const int nameLookupTimeoutMs = std::max(options.connectTimeoutMs, kMinimumHostNameLookupTimeoutMs); + std::array resolvedNames = { NormalizeDiscoveredName(AvahiResolveAddress(target.address)), - NormalizeDiscoveredName(MdnsReverseLookup(target.address, options.connectTimeoutMs)), - NormalizeDiscoveredName(NetbiosNodeStatusLookup(target.address, options.connectTimeoutMs)), + NormalizeDiscoveredName(MdnsReverseLookup(target.address, nameLookupTimeoutMs)), + NormalizeDiscoveredName(NetbiosNodeStatusLookup(target.address, nameLookupTimeoutMs)), + NormalizeDiscoveredName(NmblookupResolveAddress(target.address, nameLookupTimeoutMs)), }; candidate.hostName = SelectCorroboratedDiscoveredName(resolvedNames); candidate.hostNameVerified = !candidate.hostName.empty(); diff --git a/src/PeerRecovery.cpp b/src/PeerRecovery.cpp index ff04dde..eb2bd6a 100644 --- a/src/PeerRecovery.cpp +++ b/src/PeerRecovery.cpp @@ -169,4 +169,45 @@ std::vector CollectRecoveryCandidateHosts(const AppState& state, return CollectRecoveryPeerHosts(state, recoveryNames, configuredHost, port); } +std::vector CollectRecoveryDiscoveredHosts(const AppState& state, + std::string_view configuredHost, + int port, + const std::vector& candidates) { + const std::vector recoveryNames = CollectRecoveryPeerNames(state, configuredHost, port); + if (recoveryNames.empty()) { + return {}; + } + + std::vector normalizedNames; + normalizedNames.reserve(recoveryNames.size()); + for (const auto& name : recoveryNames) { + const std::string normalized = NormalizeHostLabel(name); + if (!normalized.empty() && + std::find(normalizedNames.begin(), normalizedNames.end(), normalized) == normalizedNames.end()) { + normalizedNames.push_back(normalized); + } + } + + std::vector hosts; + for (const auto& candidate : candidates) { + if (candidate.status != DiscoveryStatus::Open || + candidate.hostName.empty() || + !IsIpv4Literal(candidate.ipAddress) || + candidate.ipAddress == configuredHost) { + continue; + } + + const std::string normalized = NormalizeHostLabel(candidate.hostName); + if (std::find(normalizedNames.begin(), normalizedNames.end(), normalized) == normalizedNames.end()) { + continue; + } + if (std::find(hosts.begin(), hosts.end(), candidate.ipAddress) != hosts.end()) { + continue; + } + hosts.push_back(candidate.ipAddress); + } + + return hosts; +} + } // namespace mwb diff --git a/src/PeerRecovery.h b/src/PeerRecovery.h index b063e30..b67dff9 100644 --- a/src/PeerRecovery.h +++ b/src/PeerRecovery.h @@ -5,6 +5,7 @@ #include #include "AppState.h" +#include "Discovery.h" namespace mwb { @@ -22,5 +23,9 @@ std::vector CollectRecoveryPeerHosts(const AppState& state, std::vector CollectRecoveryCandidateHosts(const AppState& state, std::string_view configuredHost, int port); +std::vector CollectRecoveryDiscoveredHosts(const AppState& state, + std::string_view configuredHost, + int port, + const std::vector& candidates); } // namespace mwb diff --git a/src/main.cpp b/src/main.cpp index 8461048..53a04ed 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -962,21 +962,40 @@ std::optional ProbeReachableIpv4Host(const std::string& host, int p } std::optional TryRecoverHostFromKnownPeers(const mwb::AppConfig& config, - const mwb::AppState& state) { + const mwb::AppState& state) { const bool configuredHostIsIpv4 = mwb::IsIpv4Literal(config.host); - if (configuredHostIsIpv4 && ProbeReachableIpv4Host(config.host, config.port, 200).has_value()) { - return std::nullopt; - } - - for (const auto& host : mwb::CollectRecoveryCandidateHosts(state, config.host, config.port)) { + const auto knownPeerHosts = mwb::CollectRecoveryCandidateHosts(state, config.host, config.port); + for (const auto& host : knownPeerHosts) { if (auto reachable = ProbeReachableIpv4Host(host, config.port, 250)) { std::cout << "[RECOVERY] Configured peer " << config.host - << " is unavailable; reusing verified peer address " - << *reachable << std::endl; + << " has a verified same-name address " + << *reachable; + if (configuredHostIsIpv4) { + std::cout << "; using name-priority recovery before trusting the configured IP"; + } else { + std::cout << "; reusing verified peer address"; + } + std::cout << std::endl; return reachable; } } + if (configuredHostIsIpv4 && ProbeReachableIpv4Host(config.host, config.port, 200).has_value()) { + return std::nullopt; + } + + mwb::DiscoveryOptions discoveryOptions; + discoveryOptions.port = static_cast(config.port); + discoveryOptions.connectTimeoutMs = 200; + discoveryOptions.maxHostsPerSubnet = 256; + const auto candidates = mwb::DiscoverLanCandidates(discoveryOptions); + for (const auto& host : mwb::CollectRecoveryDiscoveredHosts(state, config.host, config.port, candidates)) { + std::cout << "[RECOVERY] Configured peer " << config.host + << " is unavailable; using discovered address " + << host << " for the approved peer name" << std::endl; + return host; + } + return std::nullopt; } @@ -1584,7 +1603,8 @@ int HandleDiscoverCommand(const std::string& binary, const std::vector candidates; + candidates.push_back(mwb::DiscoveryCandidate{ + "192.0.2.156", + "WIN-PC.local", + true, + "eth0", + mwb::DiscoveryStatus::Open, + }); + candidates.push_back(mwb::DiscoveryCandidate{ + "192.0.2.157", + "WIN-PC", + false, + "eth0", + mwb::DiscoveryStatus::Open, + }); + candidates.push_back(mwb::DiscoveryCandidate{ + "192.0.2.158", + "OTHER-PC", + true, + "eth0", + mwb::DiscoveryStatus::Open, + }); + + const auto hosts = mwb::CollectRecoveryDiscoveredHosts(state, "192.0.2.107", 15101, candidates); + Expect(hosts.size() == 2, "Discovery recovery should include discovered names matching the approved peer"); + if (hosts.size() >= 2) { + Expect(hosts.front() == "192.0.2.156", "Discovery recovery should return the moved approved peer IP"); + Expect(hosts[1] == "192.0.2.157", "Discovery recovery can try unverified names because the session key authenticates"); + } +} + void TestDiscoveryZeroHosts() { mwb::DiscoveryOptions options; options.maxHostsPerSubnet = 0; @@ -573,6 +609,7 @@ int main() { TestCollectRecoveryPeerNamesForConfiguredHostname(); TestCollectRecoveryCandidateHostsForConfiguredIpv4(); TestCollectRecoveryCandidateHostsForConfiguredHostname(); + TestCollectRecoveryDiscoveredHostsUsesApprovedNamesOnly(); TestDiscoveryZeroHosts(); TestKScreenDoctorSingleOutputGeometry(); TestKScreenDoctorMultiOutputBoundingBox();