From 4c60f546ce2cffa40193282304ff1a18ac3e5502 Mon Sep 17 00:00:00 2001 From: daredoole Date: Tue, 28 Apr 2026 19:52:37 -0400 Subject: [PATCH 01/14] Add beta diagnostics summary and issue template --- .github/ISSUE_TEMPLATE/bug_report.yml | 97 +++++++++++++++++++++++++ docs/beta-workflow.md | 13 ++-- scripts/inputflow-diagnostics-bundle.sh | 94 +++++++++++++++++++++++- src/main.cpp | 20 ++++- 4 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..0286ab4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,97 @@ +name: Beta bug report +description: Report an InputFlow Linux beta problem with PowerToys Mouse Without Borders. +title: "[Beta Bug]: " +labels: ["bug", "beta"] +body: + - type: markdown + attributes: + value: | + Attach the diagnostics bundle from `scripts/inputflow-diagnostics-bundle.sh` when possible. Review it before posting publicly; the bundle redacts secrets by best effort. + - type: textarea + id: summary + attributes: + label: Problem summary + description: What failed, and what did you expect instead? + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Diagnostics bundle + description: Attach the generated archive or paste the path/name if sharing privately. + placeholder: inputflow-diagnostics-YYYYMMDD-HHMMSS-PID.tar.gz + validations: + required: true + - type: input + id: linux-distro + attributes: + label: Linux distro and version + placeholder: Fedora 40, Ubuntu 24.04, Arch, etc. + validations: + required: true + - type: dropdown + id: session-type + attributes: + label: Linux session type + options: + - X11 + - Wayland + - XWayland/mixed + - Unknown + validations: + required: true + - type: input + id: desktop + attributes: + label: Desktop/compositor + placeholder: GNOME, KDE Plasma, Sway, Hyprland, etc. + - type: input + id: windows-version + attributes: + label: Windows version + placeholder: Windows 11 24H2 + validations: + required: true + - type: input + id: powertoys-version + attributes: + label: PowerToys version + placeholder: v0.xx.x + validations: + required: true + - type: textarea + id: monitor-layout + attributes: + label: Monitor and machine layout + description: Include machine order and display layout, for example AAB, BAA, ABA, stacked, mixed DPI/resolution, wrap on/off. + validations: + required: true + - type: dropdown + id: auth-source + attributes: + label: Linux key source + options: + - key_file + - key_secret_id + - inline key + - not configured + - unknown + - type: dropdown + id: clipboard + attributes: + label: Clipboard mode + options: + - enabled send/receive + - enabled receive-only + - disabled + - unknown + - type: textarea + id: steps + attributes: + label: Reproduction steps + placeholder: | + 1. Start PowerToys Mouse Without Borders on Windows. + 2. Start InputFlow on Linux. + 3. Move cursor from ... + validations: + required: true diff --git a/docs/beta-workflow.md b/docs/beta-workflow.md index d7cd3b9..47fc90c 100644 --- a/docs/beta-workflow.md +++ b/docs/beta-workflow.md @@ -45,19 +45,20 @@ The desktop controller shows the same health check together with the user servic ./mwb-desktop-ui.sh status ``` -Review warnings for missing `/dev/uinput` access, missing `inputflow` group membership, missing packaged files, unavailable clipboard helpers, or invalid authentication configuration. +Review warnings for missing `/dev/uinput` access, Wayland input-gating, missing `inputflow` group membership, missing packaged files, unavailable clipboard helpers, unavailable Secret Service/session bus, or invalid authentication configuration. ## Diagnostics Bundle -Until a one-click diagnostics bundle command is available, collect this minimal bundle for beta reports: +Create a redacted support bundle before filing beta reports: ```bash -./build/mwb_client doctor --config ~/.config/mwb-client/config.ini > inputflow-doctor.txt -systemctl --user status --no-pager mwb-client.service > inputflow-service-status.txt -journalctl --user -u mwb-client.service --since "30 minutes ago" --no-pager > inputflow-service-log.txt +./scripts/inputflow-diagnostics-bundle.sh \ + --config ~/.config/mwb-client/config.ini \ + --state ~/.local/state/mwb-client/state.ini \ + --output . ``` -Also include `~/.config/mwb-client/config.ini` with `key`, `key_file`, `key_secret_id`, Windows IPs, and hostnames redacted as needed. Do not attach exported Windows helper scripts or unredacted Secret Service identifiers to public issues. +The archive includes `summary.json` for machine-readable triage, redacted config/state summaries, session and `/dev/uinput` checks, package/build details, service status, recent user-service journal lines, and `mwb_client doctor` output. Review the archive before posting publicly. Do not attach exported Windows helper scripts or unredacted Secret Service identifiers to public issues. ## Connection Quality diff --git a/scripts/inputflow-diagnostics-bundle.sh b/scripts/inputflow-diagnostics-bundle.sh index 60d64e4..0206407 100755 --- a/scripts/inputflow-diagnostics-bundle.sh +++ b/scripts/inputflow-diagnostics-bundle.sh @@ -146,6 +146,95 @@ redacted_copy_or_note() { } >"$output_file" } +json_escape() { + local value="${1:-}" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + value="${value//$'\n'/\\n}" + value="${value//$'\r'/\\r}" + value="${value//$'\t'/\\t}" + printf '%s' "$value" +} + +json_string() { + printf '"%s"' "$(json_escape "${1:-}")" +} + +json_bool() { + if [[ "${1:-}" == "yes" || "${1:-}" == "true" || "${1:-}" == "1" ]]; then + printf 'true' + else + printf 'false' + fi +} + +config_value() { + local lookup="$1" + local line trimmed key value + [[ -r "$CONFIG_PATH" ]] || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + trimmed="${line#"${line%%[![:space:]]*}"}" + trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" + [[ -z "$trimmed" || "$trimmed" == \#* || "$trimmed" == \;* || "$trimmed" != *"="* ]] && continue + key="${trimmed%%=*}" + value="${trimmed#*=}" + key="${key%"${key##*[![:space:]]}"}" + value="${value#"${value%%[![:space:]]*}"}" + if [[ "$key" == "$lookup" ]]; then + printf '%s\n' "$value" + return 0 + fi + done <"$CONFIG_PATH" +} + +write_json_summary() { + local output_file="$1" + local host machine_name port key_source clipboard_enabled clipboard_send_enabled screen_width screen_height + local config_present=no config_readable=no state_present=no state_readable=no uinput_present=no uinput_writable=no uinput_module=no + local peer_lines=0 + + [[ -e "$CONFIG_PATH" ]] && config_present=yes + [[ -r "$CONFIG_PATH" ]] && config_readable=yes + [[ -e "$STATE_PATH" ]] && state_present=yes + [[ -r "$STATE_PATH" ]] && state_readable=yes + [[ -e /dev/uinput ]] && uinput_present=yes + [[ -w /dev/uinput ]] && uinput_writable=yes + [[ -d /sys/module/uinput ]] && uinput_module=yes + if [[ -r "$STATE_PATH" ]]; then + peer_lines="$(grep -c '^peer=' "$STATE_PATH" 2>/dev/null || true)" + peer_lines="${peer_lines:-0}" + fi + + host="$(config_value host)" + machine_name="$(config_value machine_name)" + port="$(config_value port)" + clipboard_enabled="$(config_value clipboard_enabled)" + clipboard_send_enabled="$(config_value clipboard_send_enabled)" + screen_width="$(config_value screen_width)" + screen_height="$(config_value screen_height)" + if [[ -n "$(config_value key_secret_id)" ]]; then + key_source="secret_service" + elif [[ -n "$(config_value key_file)" ]]; then + key_source="key_file" + elif [[ -n "$(config_value key)" ]]; then + key_source="inline" + else + key_source="missing" + fi + + { + printf '{\n' + printf ' "schema_version": 1,\n' + printf ' "created_at": '; json_string "$(date -Is 2>/dev/null || date)"; printf ',\n' + printf ' "config": {"path": '; json_string "$CONFIG_PATH_DISPLAY"; printf ', "present": '; json_bool "$config_present"; printf ', "readable": '; json_bool "$config_readable"; printf ', "host_configured": '; [[ -n "$host" ]] && printf true || printf false; printf ', "machine_name_configured": '; [[ -n "$machine_name" ]] && printf true || printf false; printf ', "port": '; json_string "$port"; printf ', "key_source": '; json_string "$key_source"; printf ', "clipboard_enabled": '; json_string "$clipboard_enabled"; printf ', "clipboard_send_enabled": '; json_string "$clipboard_send_enabled"; printf ', "screen_override": '; json_string "${screen_width}x${screen_height}"; printf '},\n' + printf ' "state": {"path": '; json_string "$STATE_PATH_DISPLAY"; printf ', "present": '; json_bool "$state_present"; printf ', "readable": '; json_bool "$state_readable"; printf ', "peer_lines": '; printf '%s' "$peer_lines"; printf '},\n' + printf ' "session": {"xdg_session_type": '; json_string "${XDG_SESSION_TYPE:-}"; printf ', "xdg_current_desktop": '; json_string "${XDG_CURRENT_DESKTOP:-}"; printf ', "desktop_session": '; json_string "${DESKTOP_SESSION:-}"; printf ', "wayland_display_set": '; [[ -n "${WAYLAND_DISPLAY:-}" ]] && printf true || printf false; printf ', "display_set": '; [[ -n "${DISPLAY:-}" ]] && printf true || printf false; printf ', "dbus_session_bus_set": '; [[ -n "${DBUS_SESSION_BUS_ADDRESS:-}" ]] && printf true || printf false; printf '},\n' + printf ' "input": {"uinput_present": '; json_bool "$uinput_present"; printf ', "uinput_writable": '; json_bool "$uinput_writable"; printf ', "uinput_module_loaded": '; json_bool "$uinput_module"; printf '},\n' + printf ' "tools": {"wl_copy": '; have wl-copy && printf true || printf false; printf ', "wl_paste": '; have wl-paste && printf true || printf false; printf ', "xclip": '; have xclip && printf true || printf false; printf ', "xsel": '; have xsel && printf true || printf false; printf ', "secret_tool": '; have secret-tool && printf true || printf false; printf ', "systemctl": '; have systemctl && printf true || printf false; printf ', "journalctl": '; have journalctl && printf true || printf false; printf ', "ip": '; have ip && printf true || printf false; printf ', "ss": '; have ss && printf true || printf false; printf '}\n' + printf '}\n' + } >"$output_file" +} + write_config_summary() { local output_file="$1" { @@ -206,7 +295,9 @@ modified=%y' "$STATE_PATH" 2>/dev/null || true return fi printf 'readable=yes\n' - printf 'peer_lines=%s\n' "$(grep -c '^peer=' "$STATE_PATH" 2>/dev/null || printf '0')" + local peer_lines + peer_lines="$(grep -c '^peer=' "$STATE_PATH" 2>/dev/null || true)" + printf 'peer_lines=%s\n' "${peer_lines:-0}" printf '\n[redacted state]\n' redact_stream <"$STATE_PATH" } >"$output_file" @@ -227,6 +318,7 @@ EOF } write_manifest +write_json_summary "$BUNDLE_DIR/summary.json" write_config_summary "$BUNDLE_DIR/config-summary.txt" write_state_summary "$BUNDLE_DIR/app-state.txt" diff --git a/src/main.cpp b/src/main.cpp index 8566222..7f23a7d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1648,9 +1648,11 @@ int HandleDoctorCommand(const std::vector& args) { PrintDoctorLine("INFO", "drm", DrmSummary()); const std::filesystem::path uinputPath("/dev/uinput"); + const bool uinputExists = std::filesystem::exists(uinputPath); + const bool uinputWritable = uinputExists && access(uinputPath.c_str(), W_OK) == 0; PrintDoctorLine(std::filesystem::exists("/sys/module/uinput") ? "OK" : "WARN", "uinput module", std::filesystem::exists("/sys/module/uinput") ? "loaded" : "not loaded"); - if (!std::filesystem::exists(uinputPath)) { + if (!uinputExists) { PrintDoctorLine("WARN", "uinput", "/dev/uinput missing; load the uinput kernel module"); } else { struct stat uinputStat {}; @@ -1658,7 +1660,7 @@ int HandleDoctorCommand(const std::vector& args) { const std::string detail = haveStat ? "/dev/uinput group=" + GroupName(uinputStat.st_gid) + " mode=" + FormatMode(uinputStat.st_mode) : "/dev/uinput exists"; - PrintDoctorLine(access(uinputPath.c_str(), W_OK) == 0 ? "OK" : "WARN", "uinput", detail); + PrintDoctorLine(uinputWritable ? "OK" : "WARN", "uinput", detail); } if (getgrnam("inputflow") == nullptr) { @@ -1688,6 +1690,12 @@ int HandleDoctorCommand(const std::vector& args) { } else { PrintDoctorLine("WARN", "session", "no Wayland or X11 session variables detected"); } + const bool waylandSession = sessionType != nullptr && std::string(sessionType) == "wayland"; + if (waylandSession && !uinputWritable) { + PrintDoctorLine("WARN", "wayland input", "Wayland session detected but /dev/uinput is not writable; compositor-mediated injection may still require user approval"); + } else if (waylandSession) { + PrintDoctorLine("INFO", "wayland input", "/dev/uinput is writable; compositor policy may still gate input injection"); + } if (const char* desktop = std::getenv("XDG_CURRENT_DESKTOP"); desktop != nullptr && *desktop != '\0') { PrintDoctorLine("INFO", "desktop", desktop); @@ -1697,6 +1705,14 @@ int HandleDoctorCommand(const std::vector& args) { " DISPLAY=" + EnvValueOrUnset("DISPLAY")); PrintDoctorLine("INFO", "runtime env", "XDG_RUNTIME_DIR=" + EnvValueOrUnset("XDG_RUNTIME_DIR") + " DBUS_SESSION_BUS_ADDRESS=" + EnvValueOrUnset("DBUS_SESSION_BUS_ADDRESS")); + if (FindExecutableInPath("secret-tool")) { + PrintDoctorLine("OK", "secret service tool", "secret-tool available"); + } else { + PrintDoctorLine("INFO", "secret service tool", "secret-tool unavailable; key_secret_id depends on libsecret support and an unlocked session"); + } + if (!config.keySecretId.empty() && EnvValueOrUnset("DBUS_SESSION_BUS_ADDRESS") == "") { + PrintDoctorLine("WARN", "secret service", "key_secret_id is configured but the D-Bus session bus is not advertised"); + } if (FindExecutableInPath("wl-copy") && FindExecutableInPath("wl-paste")) { PrintDoctorLine("OK", "clipboard helpers", "wl-clipboard"); From 06edfb6c7bdf7b8b5bcee2cb8518c7389ca158d0 Mon Sep 17 00:00:00 2001 From: daredoole Date: Tue, 28 Apr 2026 20:08:46 -0400 Subject: [PATCH 02/14] Add topology foundation and safer Windows helper --- CMakeLists.txt | 15 + README.md | 2 + docs/compatibility.md | 56 ++++ docs/migration.md | 80 +++++ src/TopologyModel.cpp | 421 +++++++++++++++++++++++++ src/TopologyModel.h | 98 ++++++ src/main.cpp | 104 ++++-- tests/export_windows_pair_helper.cmake | 120 +++++++ tests/test_topology_model.cpp | 235 ++++++++++++++ 9 files changed, 1107 insertions(+), 24 deletions(-) create mode 100644 docs/compatibility.md create mode 100644 docs/migration.md create mode 100644 src/TopologyModel.cpp create mode 100644 src/TopologyModel.h create mode 100644 tests/export_windows_pair_helper.cmake create mode 100644 tests/test_topology_model.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d11af13..6cd1945 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -125,6 +125,14 @@ if (BUILD_TESTING) target_compile_options(mwb_input_latency_tests PRIVATE -Wall -Wextra -Wpedantic) mwb_apply_sanitizers(mwb_input_latency_tests) + add_executable(mwb_topology_model_tests + tests/test_topology_model.cpp + src/TopologyModel.cpp + ) + target_include_directories(mwb_topology_model_tests PRIVATE src) + target_compile_options(mwb_topology_model_tests PRIVATE -Wall -Wextra -Wpedantic) + mwb_apply_sanitizers(mwb_topology_model_tests) + add_executable(mwb_mouse_trace_tests tests/test_mouse_trace.cpp src/InputManager.cpp @@ -175,6 +183,7 @@ if (BUILD_TESTING) add_test(NAME mwb_inject_mouse_abs_tests COMMAND mwb_inject_mouse_abs_tests) 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) 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) @@ -187,6 +196,12 @@ if (BUILD_TESTING) "-DCONFIG_PATH=${CMAKE_CURRENT_BINARY_DIR}/missing-doctor-config.ini" -P "${CMAKE_CURRENT_SOURCE_DIR}/tests/doctor_categories.cmake" ) + add_test(NAME mwb_export_windows_pair_helper + COMMAND ${CMAKE_COMMAND} + "-DMWB_CLIENT=$" + "-DTEST_DIR=${CMAKE_CURRENT_BINARY_DIR}/export_windows_pair" + -P "${CMAKE_CURRENT_SOURCE_DIR}/tests/export_windows_pair_helper.cmake" + ) add_test(NAME mwb_client_doctor_invalid_config COMMAND mwb_client doctor --config "${CMAKE_CURRENT_SOURCE_DIR}/tests/invalid_config.ini") set_tests_properties(mwb_client_doctor_invalid_config PROPERTIES WILL_FAIL TRUE) endif() diff --git a/README.md b/README.md index e0c024f..3ac7156 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ 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) +- [Migration from other keyboard/mouse sharing tools](docs/migration.md) +- [Compatibility matrix and platform caveats](docs/compatibility.md) ## Detailed Documentation diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..3c43421 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,56 @@ +# Compatibility Guide + +InputFlow is a native Linux peer for Microsoft PowerToys Mouse Without Borders (MWB). It targets PowerToys MWB interoperability with Linux input, clipboard, service, and configuration integration. It is not a Barrier, Synergy, Input Leap, Deskflow, or Cursr protocol implementation. + +## Compatibility Matrix + +| Area | Status | Notes | +| --- | --- | --- | +| Linux on X11 | Supported beta path | Input delivery uses `/dev/uinput`. Clipboard sync needs `xclip` or `xsel` when clipboard features are enabled. | +| Linux on Wayland | Supported with caveats | Input delivery still needs writable `/dev/uinput`. Some compositors may apply extra policy, prompts, or restrictions for synthetic input. Clipboard sync needs `wl-clipboard`, and polling may be needed in some sessions. | +| `/dev/uinput` | Required for input injection | Load the `uinput` module and grant the user access, usually through the packaged `inputflow` group and udev rule. | +| PowerToys MWB on Windows | Target peer | Pair InputFlow with the PowerToys Mouse Without Borders feature on Windows. Exported helpers seed `MachinePool`, `MachineMatrixString`, `Name2IP`, peer name, address, layout, and key material. | +| Barrier / Synergy / Input Leap / Deskflow | Not protocol compatible | Use the migration guide to translate concepts, not configuration files. | +| Cursr | Not protocol compatible | InputFlow does not join Cursr groups. Use MWB peer placement instead. | +| Authentication: Secret Service | Supported when available | Use `key_secret_id=` when a session bus and keyring are available. Headless sessions may not provide this. | +| Authentication: `key_file` | Supported | Good default for service usage when file permissions are managed carefully. | +| Authentication: inline `key` | Supported | Useful for quick setup, but avoid sharing configs because the key is stored directly. | +| 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. | + +## Linux Session Details + +X11 is the simpler path because clipboard helpers and desktop automation policy are more predictable. Wayland can work, but compositor policy matters: even with `/dev/uinput` access, the compositor or desktop environment may restrict, gate, or prompt around synthetic input behavior. + +Run the health check after setup: + +```bash +./build/mwb_client doctor --config ~/.config/mwb-client/config.ini +``` + +Review warnings for session type, `/dev/uinput`, group membership, clipboard helpers, Secret Service availability, and authentication conflicts. + +## Windows PowerToys MWB + +InputFlow is intended to pair with the Windows PowerToys MWB implementation. Use the exported Windows helper when possible because it writes the MWB settings that are easy to mistype by hand: peer name, address, shared key, `MachinePool`, `MachineMatrixString`, and `Name2IP`. + +InputFlow is independent and not affiliated with Microsoft. Compatibility is based on the open-source PowerToys MWB behavior. + +## Network Assumptions + +InputFlow assumes a trusted local network where peers can reach each other directly by IP or resolvable host name. Keep MWB traffic on a private LAN, VPN, or otherwise trusted subnet. + +Avoid: + +- Forwarding MWB ports from the internet. +- Pairing across networks you do not control. +- Publishing configs or exported helper scripts that include keys or private addresses. + +## Service Expectations + +The systemd user service is a convenience, not a required first step. During migration or first setup, run the desktop UI or CLI manually, confirm `doctor` output, and verify Windows pairing. Enable the user service only after those checks pass. + +## 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. diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..e092783 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,80 @@ +# Migration Guide + +InputFlow is a native Linux peer for PowerToys Mouse Without Borders (MWB). It is built to interoperate with the Windows PowerToys MWB implementation; it is not a generic Synergy-family protocol clone and should not be expected to join Barrier, Synergy, Input Leap, Deskflow, or Cursr groups directly. + +Use this guide when moving from another keyboard/mouse sharing setup, from Wine-based MWB experiments, or from an older InputFlow configuration. + +## Mental Model + +PowerToys MWB and InputFlow use a peer model. Each machine has a name, address, shared security key, and layout position. There is no permanent "server" machine that owns every other client in the way Synergy-family tools commonly describe the topology. + +In practice: + +- The Windows machine runs PowerToys Mouse Without Borders. +- The Linux machine runs InputFlow. +- Both sides must agree on the shared key, peer names, peer addresses, and layout. +- InputFlow can export a Windows helper script that seeds the Linux peer into PowerToys MWB settings. + +For the guided pairing flow, see [Public Beta Workflow](beta-workflow.md#guided-pairing-and-export-helper). + +## Legacy Term Map + +| Legacy term | InputFlow / PowerToys MWB concept | +| --- | --- | +| 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 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`. | +| Edge transition | MWB layout adjacency between peers. | +| Clipboard sharing | InputFlow clipboard sync using local helper tools and MWB clipboard transport. | +| Barrier / Synergy protocol | Not applicable. InputFlow targets PowerToys MWB compatibility instead. | + +This map is only a vocabulary bridge. It does not claim that the named projects are maintained, unmaintained, compatible, or incompatible beyond the protocol distinction above. + +## Migrating From Barrier, Synergy, Input Leap, Deskflow, Or Cursr + +Do not reuse old server/client topology files as-is. Convert the intent instead: + +1. Pick the Windows PowerToys MWB machine and Linux InputFlow machine names. +2. Choose or copy the MWB security key into one InputFlow authentication source. +3. Set the Windows host IP and InputFlow local machine name in `config.ini` or the desktop UI. +4. Export the Windows pairing helper from InputFlow and run it on Windows. +5. Verify the peer layout in PowerToys MWB before starting regular use. + +Common differences to expect: + +- InputFlow joins a PowerToys MWB peer group, not a Synergy-family server process. +- Layout is expressed through MWB peer placement, not a separate Synergy-style screen graph. +- Input injection on Linux depends on `/dev/uinput`; Wayland may also require compositor policy or user approval. +- Clipboard support depends on installed local helpers. Install `wl-clipboard` for Wayland or `xclip`/`xsel` for X11 when needed. + +## Migrating From Wine Or Windows MWB Attempts + +Running Windows PowerToys MWB through Wine is not the intended Linux path. InputFlow replaces that approach with a native Linux peer that talks to PowerToys MWB on Windows. + +Before migrating: + +- Stop any Wine-hosted MWB process so it does not compete for ports or peer names. +- Keep the PowerToys MWB security key if you want the same trusted group. +- Recreate the Linux peer through InputFlow export rather than copying Wine registry or settings files. +- Expect Linux input delivery to use `/dev/uinput`, not Windows input APIs. + +If a previous Wine setup used the same Linux machine name, remove stale duplicate peer entries from PowerToys MWB or overwrite them with the exported helper. + +## Authentication Sources + +Configure exactly one practical key source for normal use: + +- `key=` stores the MWB security key inline in `config.ini`; simple but easiest to expose. +- `key_file=` reads the key from a local file; preferable for scripts and backups. +- `key_secret_id=` reads from Secret Service through the desktop session keyring; preferable when a session bus and keyring are available. + +Avoid publishing configs, helper scripts, logs, or screenshots that expose keys, peer IPs, or Secret Service identifiers. + +## 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. + +Until those features are user-facing, treat topology as machine-level MWB placement and verify changes in PowerToys MWB after exporting. diff --git a/src/TopologyModel.cpp b/src/TopologyModel.cpp new file mode 100644 index 0000000..e2035e4 --- /dev/null +++ b/src/TopologyModel.cpp @@ -0,0 +1,421 @@ +#include "TopologyModel.h" + +#include +#include +#include +#include + +namespace mwb { +namespace { + +struct EdgeKey { + std::string displayId; + EdgeDirection edge{EdgeDirection::Right}; + + bool operator<(const EdgeKey& other) const { + if (displayId != other.displayId) { + return displayId < other.displayId; + } + return static_cast(edge) < static_cast(other.edge); + } +}; + +int rightOf(const Display& display) { + return display.x + display.width; +} + +int bottomOf(const Display& display) { + return display.y + display.height; +} + +bool rangesOverlap(int startA, int endA, int startB, int endB) { + return startA < endB && startB < endA; +} + +bool displaysOverlap(const Display& a, const Display& b) { + return rangesOverlap(a.x, rightOf(a), b.x, rightOf(b)) && + rangesOverlap(a.y, bottomOf(a), b.y, bottomOf(b)); +} + +int edgeLength(const Display& display, EdgeDirection edge) { + switch (edge) { + case EdgeDirection::Left: + case EdgeDirection::Right: + return display.height; + case EdgeDirection::Up: + case EdgeDirection::Down: + return display.width; + } + return 0; +} + +int mapCoordinate(int coordinate, int sourceLength, int targetLength) { + if (targetLength <= 1 || sourceLength <= 1) { + return 0; + } + + const long long numerator = + static_cast(coordinate) * static_cast(targetLength - 1); + const long long denominator = sourceLength - 1; + const int mapped = static_cast((numerator + denominator / 2) / denominator); + return std::max(0, std::min(mapped, targetLength - 1)); +} + +std::string describeEdge(const std::string& displayId, EdgeDirection edge) { + std::ostringstream out; + out << displayId << "." << edgeDirectionName(edge); + return out.str(); +} + +void addIssue(std::vector& issues, TopologyIssueCode code, std::string message) { + issues.push_back({code, std::move(message)}); +} + +const Display* findDisplay(const std::vector& displays, const std::string& id) { + for (const auto& display : displays) { + if (display.id == id) { + return &display; + } + } + return nullptr; +} + +bool sameAxis(EdgeDirection a, EdgeDirection b) { + const bool aHorizontal = a == EdgeDirection::Left || a == EdgeDirection::Right; + const bool bHorizontal = b == EdgeDirection::Left || b == EdgeDirection::Right; + return aHorizontal == bHorizontal; +} + +bool targetFacesSource(const Display& source, EdgeDirection exitEdge, const Display& target) { + switch (exitEdge) { + case EdgeDirection::Left: + return rightOf(target) <= source.x && + rangesOverlap(source.y, bottomOf(source), target.y, bottomOf(target)); + case EdgeDirection::Right: + return rightOf(source) <= target.x && + rangesOverlap(source.y, bottomOf(source), target.y, bottomOf(target)); + case EdgeDirection::Up: + return bottomOf(target) <= source.y && + rangesOverlap(source.x, rightOf(source), target.x, rightOf(target)); + case EdgeDirection::Down: + return bottomOf(source) <= target.y && + rangesOverlap(source.x, rightOf(source), target.x, rightOf(target)); + } + return false; +} + +bool wrapAllows(WrapPolicy policy, EdgeDirection direction) { + const bool horizontal = direction == EdgeDirection::Left || direction == EdgeDirection::Right; + switch (policy) { + case WrapPolicy::None: + return false; + case WrapPolicy::Horizontal: + return horizontal; + case WrapPolicy::Vertical: + return !horizontal; + case WrapPolicy::Both: + return true; + } + return false; +} + +} // namespace + +void TopologyModel::addMachine(Machine machine) { + machines_.push_back(std::move(machine)); +} + +void TopologyModel::addDisplay(Display display) { + displays_.push_back(std::move(display)); +} + +void TopologyModel::addBorderLink(BorderLink link) { + borderLinks_.push_back(std::move(link)); +} + +void TopologyModel::setWrapPolicy(WrapPolicy policy) { + wrapPolicy_ = policy; +} + +const std::vector& TopologyModel::machines() const { + return machines_; +} + +const std::vector& TopologyModel::displays() const { + return displays_; +} + +const std::vector& TopologyModel::borderLinks() const { + return borderLinks_; +} + +WrapPolicy TopologyModel::wrapPolicy() const { + return wrapPolicy_; +} + +std::vector TopologyModel::validate() const { + std::vector issues; + std::set machineIds; + std::set displayIds; + + for (const auto& machine : machines_) { + if (!machineIds.insert(machine.id).second) { + addIssue(issues, TopologyIssueCode::DuplicateMachine, + "duplicate machine id: " + machine.id); + } + } + + for (const auto& display : displays_) { + if (!displayIds.insert(display.id).second) { + addIssue(issues, TopologyIssueCode::DuplicateDisplay, + "duplicate display id: " + display.id); + } + if (machineIds.find(display.machineId) == machineIds.end()) { + addIssue(issues, TopologyIssueCode::MissingMachine, + "display " + display.id + " references missing machine " + display.machineId); + } + if (display.width <= 0 || display.height <= 0) { + addIssue(issues, TopologyIssueCode::InvalidDisplayBounds, + "display " + display.id + " has non-positive dimensions"); + } + } + + for (std::size_t i = 0; i < displays_.size(); ++i) { + for (std::size_t j = i + 1; j < displays_.size(); ++j) { + if (displays_[i].machineId == displays_[j].machineId && + displaysOverlap(displays_[i], displays_[j])) { + addIssue(issues, TopologyIssueCode::OverlappingDisplays, + "displays " + displays_[i].id + " and " + displays_[j].id + + " overlap on machine " + displays_[i].machineId); + } + } + } + + std::map linksBySourceEdge; + for (const auto& link : borderLinks_) { + const Display* source = findDisplay(displays_, link.sourceDisplayId); + const Display* target = findDisplay(displays_, link.targetDisplayId); + if (source == nullptr) { + addIssue(issues, TopologyIssueCode::MissingSourceDisplay, + "link source display is missing: " + link.sourceDisplayId); + } + if (target == nullptr) { + addIssue(issues, TopologyIssueCode::MissingTargetDisplay, + "link target display is missing: " + link.targetDisplayId); + } + + const EdgeKey key{link.sourceDisplayId, link.exitEdge}; + const auto existing = linksBySourceEdge.find(key); + if (existing != linksBySourceEdge.end()) { + if (existing->second.targetDisplayId == link.targetDisplayId && + existing->second.entryEdge == link.entryEdge) { + addIssue(issues, TopologyIssueCode::DuplicateEdgeLink, + "duplicate link for " + describeEdge(link.sourceDisplayId, link.exitEdge)); + } else { + addIssue(issues, TopologyIssueCode::ContradictoryDuplicateEdge, + "contradictory links for " + + describeEdge(link.sourceDisplayId, link.exitEdge)); + addIssue(issues, TopologyIssueCode::AmbiguousEdgeMapping, + "multiple targets for " + describeEdge(link.sourceDisplayId, link.exitEdge)); + } + } else { + linksBySourceEdge.emplace(key, link); + } + + if (source == nullptr || target == nullptr) { + continue; + } + if (source->id == target->id) { + addIssue(issues, TopologyIssueCode::ImpossibleEdgeMapping, + "link maps display " + source->id + " to itself"); + } + if (link.entryEdge != oppositeEdge(link.exitEdge) || !sameAxis(link.exitEdge, link.entryEdge)) { + addIssue(issues, TopologyIssueCode::ImpossibleEdgeMapping, + "link " + describeEdge(link.sourceDisplayId, link.exitEdge) + + " enters through incompatible edge " + edgeDirectionName(link.entryEdge)); + } + if (source->machineId == target->machineId && + !targetFacesSource(*source, link.exitEdge, *target)) { + addIssue(issues, TopologyIssueCode::ImpossibleEdgeMapping, + "same-machine link " + describeEdge(link.sourceDisplayId, link.exitEdge) + + " does not face target display " + target->id); + } + } + + return issues; +} + +std::optional TopologyModel::transitionFromEdge( + const std::string& displayId, + EdgeDirection direction, + int coordinate) const { + const Display* source = findDisplay(displays_, displayId); + if (source == nullptr) { + return std::nullopt; + } + + const int sourceLength = edgeLength(*source, direction); + if (coordinate < 0 || coordinate >= sourceLength) { + return std::nullopt; + } + + const BorderLink* match = nullptr; + for (const auto& link : borderLinks_) { + if (link.sourceDisplayId == displayId && link.exitEdge == direction) { + if (match != nullptr) { + return std::nullopt; + } + match = &link; + } + } + + if (match != nullptr) { + const Display* target = findDisplay(displays_, match->targetDisplayId); + if (target == nullptr || match->entryEdge != oppositeEdge(direction)) { + return std::nullopt; + } + return TransitionResult{ + target->id, + match->entryEdge, + mapCoordinate(coordinate, sourceLength, edgeLength(*target, match->entryEdge)), + }; + } + + if (!wrapAllows(wrapPolicy_, direction)) { + return std::nullopt; + } + + const Display* target = nullptr; + int targetCoordinate = -1; + int bestEdge = 0; + + for (const auto& candidate : displays_) { + if (candidate.machineId != source->machineId) { + continue; + } + + int candidateCoordinate = -1; + int candidateEdge = 0; + switch (direction) { + case EdgeDirection::Left: { + const int globalY = source->y + coordinate; + if (globalY < candidate.y || globalY >= bottomOf(candidate)) { + continue; + } + candidateCoordinate = globalY - candidate.y; + candidateEdge = rightOf(candidate); + if (target != nullptr && candidateEdge < bestEdge) { + continue; + } + break; + } + case EdgeDirection::Right: { + const int globalY = source->y + coordinate; + if (globalY < candidate.y || globalY >= bottomOf(candidate)) { + continue; + } + candidateCoordinate = globalY - candidate.y; + candidateEdge = candidate.x; + if (target != nullptr && candidateEdge > bestEdge) { + continue; + } + break; + } + case EdgeDirection::Up: { + const int globalX = source->x + coordinate; + if (globalX < candidate.x || globalX >= rightOf(candidate)) { + continue; + } + candidateCoordinate = globalX - candidate.x; + candidateEdge = bottomOf(candidate); + if (target != nullptr && candidateEdge < bestEdge) { + continue; + } + break; + } + case EdgeDirection::Down: { + const int globalX = source->x + coordinate; + if (globalX < candidate.x || globalX >= rightOf(candidate)) { + continue; + } + candidateCoordinate = globalX - candidate.x; + candidateEdge = candidate.y; + if (target != nullptr && candidateEdge > bestEdge) { + continue; + } + break; + } + } + + if (target != nullptr && candidateEdge == bestEdge) { + return std::nullopt; + } + target = &candidate; + targetCoordinate = candidateCoordinate; + bestEdge = candidateEdge; + } + + if (target == nullptr || targetCoordinate < 0) { + return std::nullopt; + } + + return TransitionResult{target->id, oppositeEdge(direction), targetCoordinate}; +} + +EdgeDirection oppositeEdge(EdgeDirection direction) { + switch (direction) { + case EdgeDirection::Left: + return EdgeDirection::Right; + case EdgeDirection::Right: + return EdgeDirection::Left; + case EdgeDirection::Up: + return EdgeDirection::Down; + case EdgeDirection::Down: + return EdgeDirection::Up; + } + return EdgeDirection::Left; +} + +const char* edgeDirectionName(EdgeDirection direction) { + switch (direction) { + case EdgeDirection::Left: + return "left"; + case EdgeDirection::Right: + return "right"; + case EdgeDirection::Up: + return "up"; + case EdgeDirection::Down: + return "down"; + } + return "unknown"; +} + +const char* topologyIssueCodeName(TopologyIssueCode code) { + switch (code) { + case TopologyIssueCode::DuplicateMachine: + return "duplicate-machine"; + case TopologyIssueCode::DuplicateDisplay: + return "duplicate-display"; + case TopologyIssueCode::MissingMachine: + return "missing-machine"; + case TopologyIssueCode::InvalidDisplayBounds: + return "invalid-display-bounds"; + case TopologyIssueCode::OverlappingDisplays: + return "overlapping-displays"; + case TopologyIssueCode::MissingSourceDisplay: + return "missing-source-display"; + case TopologyIssueCode::MissingTargetDisplay: + return "missing-target-display"; + case TopologyIssueCode::DuplicateEdgeLink: + return "duplicate-edge-link"; + case TopologyIssueCode::ContradictoryDuplicateEdge: + return "contradictory-duplicate-edge"; + case TopologyIssueCode::ImpossibleEdgeMapping: + return "impossible-edge-mapping"; + case TopologyIssueCode::AmbiguousEdgeMapping: + return "ambiguous-edge-mapping"; + } + return "unknown"; +} + +} // namespace mwb diff --git a/src/TopologyModel.h b/src/TopologyModel.h new file mode 100644 index 0000000..6411e04 --- /dev/null +++ b/src/TopologyModel.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include + +namespace mwb { + +enum class EdgeDirection { + Left, + Right, + Up, + Down, +}; + +enum class WrapPolicy { + None, + Horizontal, + Vertical, + Both, +}; + +struct Machine { + std::string id; +}; + +struct Display { + std::string id; + std::string machineId; + int x{0}; + int y{0}; + int width{0}; + int height{0}; +}; + +struct BorderLink { + std::string sourceDisplayId; + EdgeDirection exitEdge{EdgeDirection::Right}; + std::string targetDisplayId; + EdgeDirection entryEdge{EdgeDirection::Left}; +}; + +struct TransitionResult { + std::string targetDisplayId; + EdgeDirection entryEdge{EdgeDirection::Left}; + int coordinate{0}; +}; + +enum class TopologyIssueCode { + DuplicateMachine, + DuplicateDisplay, + MissingMachine, + InvalidDisplayBounds, + OverlappingDisplays, + MissingSourceDisplay, + MissingTargetDisplay, + DuplicateEdgeLink, + ContradictoryDuplicateEdge, + ImpossibleEdgeMapping, + AmbiguousEdgeMapping, +}; + +struct TopologyIssue { + TopologyIssueCode code{TopologyIssueCode::ImpossibleEdgeMapping}; + std::string message; +}; + +class TopologyModel { +public: + void addMachine(Machine machine); + void addDisplay(Display display); + void addBorderLink(BorderLink link); + void setWrapPolicy(WrapPolicy policy); + + const std::vector& machines() const; + const std::vector& displays() const; + const std::vector& borderLinks() const; + WrapPolicy wrapPolicy() const; + + std::vector validate() const; + + std::optional transitionFromEdge( + const std::string& displayId, + EdgeDirection direction, + int coordinate) const; + +private: + std::vector machines_; + std::vector displays_; + std::vector borderLinks_; + WrapPolicy wrapPolicy_{WrapPolicy::None}; +}; + +EdgeDirection oppositeEdge(EdgeDirection direction); +const char* edgeDirectionName(EdgeDirection direction); +const char* topologyIssueCodeName(TopologyIssueCode code); + +} // namespace mwb diff --git a/src/main.cpp b/src/main.cpp index 7f23a7d..12119c8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -59,7 +59,7 @@ void PrintGeneralUsage(std::ostream& out, const char* argv0) { out << " " << binary << " discover [--state PATH] [--port PORT] [--timeout-ms MS] [--max-hosts N]\n"; out << " " << binary << " doctor [--config PATH] [--state 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] [--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 << " 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"; out << " " << binary << " secret-store [--config PATH] --secret-id ID [--key KEY | --key-file PATH | --stdin]\n"; out << " " << binary << " secret-clear [--config PATH] [--secret-id ID]\n"; @@ -605,17 +605,14 @@ std::string RenderWindowsPairScript(const std::string& peerName, const std::string& peerPosition) { std::ostringstream out; out - << "param([switch]$ClosePowerToys)\n\n" + << "param([switch]$ClosePowerToys, [switch]$DryRun, [switch]$Check)\n\n" << "$ErrorActionPreference = 'Stop'\n" << "$PeerName = '" << EscapePowerShellSingleQuoted(peerName) << "'\n" << "$PeerIp = '" << EscapePowerShellSingleQuoted(peerIp) << "'\n" << "$SecurityKey = '" << EscapePowerShellSingleQuoted(securityKey) << "'\n\n" << "$PeerPosition = '" << EscapePowerShellSingleQuoted(peerPosition) << "'\n\n" - << "function Stop-PowerToysProcesses {\n" - << " $names = @('PowerToys', 'PowerToys.MouseWithoutBorders', 'MouseWithoutBorders')\n" - << " foreach ($name in $names) {\n" - << " Get-Process -Name $name -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue\n" - << " }\n" + << "if ($ClosePowerToys) {\n" + << " Write-Warning 'The -ClosePowerToys switch is accepted for compatibility but this helper no longer stops processes. Close PowerToys manually before writing if needed.'\n" << "}\n\n" << "function Ensure-ArrayLength {\n" << " param([System.Collections.IList]$List, [int]$Length)\n" @@ -623,6 +620,38 @@ std::string RenderWindowsPairScript(const std::string& peerName, << " $List.Add('') | Out-Null\n" << " }\n" << "}\n\n" + << "function Assert-ValueSetting {\n" + << " param($Props, [string]$Name)\n" + << " $property = $Props.PSObject.Properties[$Name]\n" + << " if ($null -eq $property) {\n" + << " throw ('Unsupported Mouse Without Borders settings schema: missing properties.' + $Name + '.')\n" + << " }\n" + << " if ($null -eq $property.Value -or $null -eq $property.Value.PSObject.Properties['value']) {\n" + << " throw ('Unsupported Mouse Without Borders settings schema: properties.' + $Name + '.value is missing.')\n" + << " }\n" + << "}\n\n" + << "function Assert-SettingsSchema {\n" + << " param($Settings)\n" + << " if ($null -eq $Settings) {\n" + << " throw 'Unsupported Mouse Without Borders settings schema: JSON root is empty.'\n" + << " }\n" + << " if ($null -ne $Settings.PSObject.Properties['version']) {\n" + << " $versionText = [string]$Settings.version\n" + << " if ([string]::IsNullOrWhiteSpace($versionText) -or $versionText -notmatch '^\\d+(\\.\\d+){0,3}$') {\n" + << " throw ('Unsupported Mouse Without Borders settings version: ' + $versionText)\n" + << " }\n" + << " }\n" + << " if ($null -eq $Settings.PSObject.Properties['properties'] -or $null -eq $Settings.properties) {\n" + << " throw 'Unsupported Mouse Without Borders settings schema: properties object is missing.'\n" + << " }\n" + << " $props = $Settings.properties\n" + << " if ($null -eq $props.PSObject.Properties['MachineMatrixString']) {\n" + << " throw 'Unsupported Mouse Without Borders settings schema: properties.MachineMatrixString is missing.'\n" + << " }\n" + << " Assert-ValueSetting -Props $props -Name 'SecurityKey'\n" + << " Assert-ValueSetting -Props $props -Name 'MachinePool'\n" + << " Assert-ValueSetting -Props $props -Name 'Name2IP'\n" + << "}\n\n" << "function Parse-MachinePool {\n" << " param([string]$Value)\n" << " $entries = New-Object System.Collections.ArrayList\n" @@ -719,20 +748,11 @@ std::string RenderWindowsPairScript(const std::string& peerName, << "if (-not (Test-Path -LiteralPath $settingsPath)) {\n" << " throw 'Mouse Without Borders settings.json was not found. Start PowerToys once before running this helper.'\n" << "}\n\n" - << "if ($ClosePowerToys) {\n" - << " Stop-PowerToysProcesses\n" - << "}\n\n" - << "$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'\n" - << "$backupPath = $settingsPath + '.bak-' + $timestamp\n" - << "Copy-Item -LiteralPath $settingsPath -Destination $backupPath -Force\n\n" << "$jsonText = Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8\n" << "$settings = $jsonText | ConvertFrom-Json\n" + << "Assert-SettingsSchema -Settings $settings\n" << "$props = $settings.properties\n\n" - << "if ($null -eq $props.SecurityKey) {\n" - << " $props | Add-Member -NotePropertyName SecurityKey -NotePropertyValue ([pscustomobject]@{ value = $SecurityKey })\n" - << "} else {\n" - << " $props.SecurityKey.value = $SecurityKey\n" - << "}\n\n" + << "$props.SecurityKey.value = $SecurityKey\n\n" << "$matrix = New-Object System.Collections.ArrayList\n" << "foreach ($item in $props.MachineMatrixString) {\n" << " [void]$matrix.Add([string]$item)\n" @@ -788,8 +808,6 @@ std::string RenderWindowsPairScript(const std::string& peerName, << "$existingPool = ''\n" << "if ($null -ne $props.MachinePool -and $null -ne $props.MachinePool.value) {\n" << " $existingPool = [string]$props.MachinePool.value\n" - << "} elseif ($null -eq $props.MachinePool) {\n" - << " $props | Add-Member -NotePropertyName MachinePool -NotePropertyValue ([pscustomobject]@{ value = '' })\n" << "}\n\n" << "$selfId = 'NONE'\n" << "$otherEntries = New-Object System.Collections.ArrayList\n" @@ -825,13 +843,30 @@ std::string RenderWindowsPairScript(const std::string& peerName, << "$existingName2IP = ''\n" << "if ($null -ne $props.Name2IP -and $null -ne $props.Name2IP.value) {\n" << " $existingName2IP = [string]$props.Name2IP.value\n" - << "} elseif ($null -eq $props.Name2IP) {\n" - << " $props | Add-Member -NotePropertyName Name2IP -NotePropertyValue ([pscustomobject]@{ value = '' })\n" << "}\n" << "$props.Name2IP.value = Upsert-Name2IP -Value $existingName2IP -Name $PeerName -Ip $PeerIp\n\n" + << "if ($Check) {\n" + << " Write-Host 'Check passed: settings path, schema, version, and requested peer placement are compatible.'\n" + << " Write-Host 'No changes written.'\n" + << " Write-Host ('Planned MachineMatrixString: ' + (($props.MachineMatrixString | ForEach-Object { [string]$_ }) -join ','))\n" + << " Write-Host ('Planned MachinePool: ' + [string]$props.MachinePool.value)\n" + << " Write-Host ('Planned Name2IP: ' + [string]$props.Name2IP.value)\n" + << " return\n" + << "}\n" + << "if ($DryRun) {\n" + << " Write-Host 'Dry run: no changes written.'\n" + << " Write-Host ('Planned MachineMatrixString: ' + (($props.MachineMatrixString | ForEach-Object { [string]$_ }) -join ','))\n" + << " Write-Host ('Planned MachinePool: ' + [string]$props.MachinePool.value)\n" + << " Write-Host ('Planned Name2IP: ' + [string]$props.Name2IP.value)\n" + << " return\n" + << "}\n\n" + << "$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'\n" + << "$backupPath = $settingsPath + '.bak-' + $timestamp\n" + << "Copy-Item -LiteralPath $settingsPath -Destination $backupPath -Force\n" << "$settings | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $settingsPath -Encoding UTF8\n\n" << "Write-Host ('Updated settings: ' + $settingsPath)\n" << "Write-Host ('Backup written: ' + $backupPath)\n" + << "Write-Host ('Restore command: Copy-Item -LiteralPath \"' + $backupPath + '\" -Destination \"' + $settingsPath + '\" -Force')\n" << "Write-Host ('SecurityKey synchronized for ' + $PeerName)\n" << "Write-Host ('PeerPosition: ' + $PeerPosition)\n" << "Write-Host ('MachineMatrixString: ' + (($props.MachineMatrixString | ForEach-Object { [string]$_ }) -join ','))\n" @@ -1907,6 +1942,8 @@ int HandleExportWindowsPairCommand(const std::vector& args) { std::optional keyFileBaseDir; std::filesystem::path outputPath; bool force = false; + bool dryRun = false; + bool checkOnly = false; bool outputRequested = false; std::optional linuxIpOverride; std::string peerPosition = "Auto"; @@ -1963,6 +2000,10 @@ int HandleExportWindowsPairCommand(const std::vector& args) { outputPath = *value; } else if (arg == "--force") { force = true; + } else if (arg == "--dry-run") { + dryRun = true; + } else if (arg == "--check") { + checkOnly = true; } else if (arg == "--linux-ip") { const auto value = requireValue("--linux-ip"); if (!value) { @@ -2041,13 +2082,21 @@ int HandleExportWindowsPairCommand(const std::vector& args) { ("inputflow-windows-pair-" + SanitizeFileStem(config.machineName) + ".ps1"); } - if (std::filesystem::exists(outputPath) && !force) { + if (!dryRun && !checkOnly && std::filesystem::exists(outputPath) && !force) { std::cerr << "ERR: Output file already exists: " << outputPath << ". Use --force to overwrite." << std::endl; return 1; } const std::string script = RenderWindowsPairScript(config.machineName, linuxIp, config.key, peerPosition); + if (dryRun || checkOnly) { + std::cout << script; + if (checkOnly) { + std::cerr << "INFO: No file written. Save this helper on Windows and run it with -Check to validate PowerToys Mouse Without Borders settings without writing." << std::endl; + } + return 0; + } + try { const std::filesystem::path parent = outputPath.parent_path(); if (!parent.empty()) { @@ -2085,9 +2134,16 @@ int HandleExportWindowsPairCommand(const std::vector& args) { std::cout << "Wrote Windows pairing helper to " << outputPath << std::endl; std::cout << "Run on Windows:" << std::endl; std::cout << " powershell -ExecutionPolicy Bypass -File .\\" - << outputPath.filename().string() << " -ClosePowerToys" << std::endl; + << outputPath.filename().string() << std::endl; + std::cout << "Check without writing:" << std::endl; + std::cout << " powershell -ExecutionPolicy Bypass -File .\\" + << outputPath.filename().string() << " -Check" << std::endl; + std::cout << "Dry-run planned changes:" << std::endl; + std::cout << " powershell -ExecutionPolicy Bypass -File .\\" + << outputPath.filename().string() << " -DryRun" << std::endl; std::cout << "This helper synchronizes the shared key plus MachineMatrixString, MachinePool, and Name2IP for '" << config.machineName << "'." << std::endl; + std::cout << "On update, the helper prints a backup path and restore command before reporting success." << std::endl; std::cout << "Configured peer position: " << peerPosition << std::endl; return 0; } diff --git a/tests/export_windows_pair_helper.cmake b/tests/export_windows_pair_helper.cmake new file mode 100644 index 0000000..4bffcc2 --- /dev/null +++ b/tests/export_windows_pair_helper.cmake @@ -0,0 +1,120 @@ +if (NOT DEFINED MWB_CLIENT) + message(FATAL_ERROR "MWB_CLIENT is required") +endif() +if (NOT DEFINED TEST_DIR) + message(FATAL_ERROR "TEST_DIR is required") +endif() + +file(MAKE_DIRECTORY "${TEST_DIR}") +set(helper "${TEST_DIR}/export-windows-pair-helper.ps1") +file(REMOVE "${helper}") + +execute_process( + COMMAND "${MWB_CLIENT}" export-windows-pair + --output "${helper}" + --force + --linux-ip 192.0.2.10 + --name LinuxPeer + --key 1234567890123456 + --position top-right + RESULT_VARIABLE export_result + OUTPUT_VARIABLE export_stdout + ERROR_VARIABLE export_stderr +) +if (NOT export_result EQUAL 0) + message(FATAL_ERROR "export-windows-pair failed: ${export_stderr}") +endif() +if (NOT EXISTS "${helper}") + message(FATAL_ERROR "export-windows-pair did not write helper") +endif() + +file(READ "${helper}" script) + +foreach (required + "[switch]$DryRun" + "[switch]$Check" + "Assert-SettingsSchema" + "Check passed: settings path, schema, version, and requested peer placement are compatible." + "Dry run: no changes written." + "Copy-Item -LiteralPath $settingsPath -Destination $backupPath -Force" + "Backup written:" + "Restore command:" + "No changes written." +) + string(FIND "${script}" "${required}" required_pos) + if (required_pos EQUAL -1) + message(FATAL_ERROR "helper is missing required text: ${required}") + endif() +endforeach() + +foreach (forbidden + "taskkill" + "Stop-Service" + "Stop-Process" + "Stop-PowerToysProcesses" + "sc.exe" + "net stop" +) + string(FIND "${script}" "${forbidden}" forbidden_pos) + if (NOT forbidden_pos EQUAL -1) + message(FATAL_ERROR "helper contains destructive process/service management text: ${forbidden}") + endif() +endforeach() + +string(FIND "${script}" "Copy-Item -LiteralPath $settingsPath -Destination $backupPath -Force" backup_pos) +string(FIND "${script}" "Set-Content -LiteralPath $settingsPath" write_pos) +if (backup_pos EQUAL -1 OR write_pos EQUAL -1 OR NOT backup_pos LESS write_pos) + message(FATAL_ERROR "helper must back up settings before writing them") +endif() + +set(dry_helper "${TEST_DIR}/export-windows-pair-dry-run.ps1") +file(REMOVE "${dry_helper}") +execute_process( + COMMAND "${MWB_CLIENT}" export-windows-pair + --output "${dry_helper}" + --dry-run + --linux-ip 192.0.2.10 + --name LinuxPeer + --key 1234567890123456 + RESULT_VARIABLE dry_result + OUTPUT_VARIABLE dry_stdout + ERROR_VARIABLE dry_stderr +) +if (NOT dry_result EQUAL 0) + message(FATAL_ERROR "export-windows-pair --dry-run failed: ${dry_stderr}") +endif() +if (EXISTS "${dry_helper}") + message(FATAL_ERROR "export-windows-pair --dry-run wrote an output file") +endif() +string(FIND "${dry_stdout}" "[switch]$DryRun" dry_pos) +if (dry_pos EQUAL -1) + message(FATAL_ERROR "--dry-run output did not include generated helper text") +endif() + +set(check_helper "${TEST_DIR}/export-windows-pair-check.ps1") +file(REMOVE "${check_helper}") +execute_process( + COMMAND "${MWB_CLIENT}" export-windows-pair + --output "${check_helper}" + --check + --linux-ip 192.0.2.10 + --name LinuxPeer + --key 1234567890123456 + RESULT_VARIABLE check_result + OUTPUT_VARIABLE check_stdout + ERROR_VARIABLE check_stderr +) +if (NOT check_result EQUAL 0) + message(FATAL_ERROR "export-windows-pair --check failed: ${check_stderr}") +endif() +if (EXISTS "${check_helper}") + message(FATAL_ERROR "export-windows-pair --check wrote an output file") +endif() +string(FIND "${check_stdout}" "[switch]$Check" check_pos) +if (check_pos EQUAL -1) + message(FATAL_ERROR "--check output did not include generated helper text") +endif() +string(FIND "${check_stderr}" "run it with -Check" check_hint_pos) +if (check_hint_pos EQUAL -1) + message(FATAL_ERROR "--check did not report how to validate Windows settings without writing") +endif() diff --git a/tests/test_topology_model.cpp b/tests/test_topology_model.cpp new file mode 100644 index 0000000..53fab94 --- /dev/null +++ b/tests/test_topology_model.cpp @@ -0,0 +1,235 @@ +#include +#include +#include + +#include "TopologyModel.h" + +namespace { + +int g_failures = 0; + +void Expect(bool condition, const std::string& message) { + if (!condition) { + ++g_failures; + std::cerr << "FAIL: " << message << std::endl; + } +} + +void ExpectEqual(const std::string& actual, const std::string& expected, const std::string& message) { + if (actual != expected) { + ++g_failures; + std::cerr << "FAIL: " << message + << " expected=" << expected + << " actual=" << actual << std::endl; + } +} + +void ExpectEqual(int actual, int expected, const std::string& message) { + if (actual != expected) { + ++g_failures; + std::cerr << "FAIL: " << message + << " expected=" << expected + << " actual=" << actual << std::endl; + } +} + +bool HasIssue(const std::vector& issues, mwb::TopologyIssueCode code) { + for (const auto& issue : issues) { + if (issue.code == code) { + return true; + } + } + return false; +} + +mwb::TopologyModel BaseModel() { + mwb::TopologyModel model; + model.addMachine({"A"}); + model.addMachine({"B"}); + return model; +} + +void TestAABFixtureRoutesFromSecondADisplayToB() { + auto 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}); + model.addBorderLink({"B1", mwb::EdgeDirection::Left, "A2", mwb::EdgeDirection::Right}); + + Expect(model.validate().empty(), "AAB fixture should validate"); + const auto transition = model.transitionFromEdge("A2", mwb::EdgeDirection::Right, 500); + Expect(transition.has_value(), "AAB right edge should transition"); + if (transition.has_value()) { + ExpectEqual(transition->targetDisplayId, "B1", "AAB target display"); + Expect(transition->entryEdge == mwb::EdgeDirection::Left, "AAB should enter B1 left edge"); + ExpectEqual(transition->coordinate, 500, "AAB coordinate should be preserved"); + } +} + +void TestBAAFixtureRoutesFromBToFirstADisplay() { + auto model = BaseModel(); + model.addDisplay({"B1", "B", 0, 0, 1920, 1080}); + model.addDisplay({"A1", "A", 0, 0, 1920, 1080}); + model.addDisplay({"A2", "A", 1920, 0, 1920, 1080}); + model.addBorderLink({"B1", mwb::EdgeDirection::Right, "A1", mwb::EdgeDirection::Left}); + model.addBorderLink({"A1", mwb::EdgeDirection::Left, "B1", mwb::EdgeDirection::Right}); + + Expect(model.validate().empty(), "BAA fixture should validate"); + const auto transition = model.transitionFromEdge("B1", mwb::EdgeDirection::Right, 1079); + Expect(transition.has_value(), "BAA right edge should transition"); + if (transition.has_value()) { + ExpectEqual(transition->targetDisplayId, "A1", "BAA target display"); + ExpectEqual(transition->coordinate, 1079, "BAA bottom coordinate should be preserved"); + } +} + +void TestABAFixtureRoutesThroughMiddleMachine() { + auto model = BaseModel(); + model.addDisplay({"A1", "A", 0, 0, 1280, 1024}); + model.addDisplay({"B1", "B", 0, 0, 1280, 1024}); + model.addDisplay({"A2", "A", 1280, 0, 1280, 1024}); + model.addBorderLink({"A1", mwb::EdgeDirection::Right, "B1", mwb::EdgeDirection::Left}); + model.addBorderLink({"B1", mwb::EdgeDirection::Right, "A2", mwb::EdgeDirection::Left}); + + Expect(model.validate().empty(), "ABA fixture should validate"); + const auto first = model.transitionFromEdge("A1", mwb::EdgeDirection::Right, 100); + const auto second = model.transitionFromEdge("B1", mwb::EdgeDirection::Right, 100); + Expect(first.has_value(), "ABA A1 to B1 transition should exist"); + Expect(second.has_value(), "ABA B1 to A2 transition should exist"); + if (first.has_value()) { + ExpectEqual(first->targetDisplayId, "B1", "ABA first target"); + } + if (second.has_value()) { + ExpectEqual(second->targetDisplayId, "A2", "ABA second target"); + } +} + +void TestStackedFixtureRoutesVertically() { + mwb::TopologyModel model; + model.addMachine({"A"}); + model.addDisplay({"A-top", "A", 0, 0, 1600, 900}); + model.addDisplay({"A-bottom", "A", 0, 900, 1600, 900}); + model.addBorderLink({"A-top", mwb::EdgeDirection::Down, "A-bottom", mwb::EdgeDirection::Up}); + model.addBorderLink({"A-bottom", mwb::EdgeDirection::Up, "A-top", mwb::EdgeDirection::Down}); + + Expect(model.validate().empty(), "stacked fixture should validate"); + const auto transition = model.transitionFromEdge("A-top", mwb::EdgeDirection::Down, 1200); + Expect(transition.has_value(), "stacked bottom edge should transition"); + if (transition.has_value()) { + ExpectEqual(transition->targetDisplayId, "A-bottom", "stacked target display"); + ExpectEqual(transition->coordinate, 1200, "stacked x coordinate should be preserved"); + } +} + +void TestAsymmetricResolutionsScaleEdgeCoordinate() { + auto model = BaseModel(); + model.addDisplay({"A1", "A", 0, 0, 1920, 1080}); + model.addDisplay({"B1", "B", 0, 0, 2560, 1440}); + model.addBorderLink({"A1", mwb::EdgeDirection::Right, "B1", mwb::EdgeDirection::Left}); + + Expect(model.validate().empty(), "asymmetric fixture should validate"); + const auto transition = model.transitionFromEdge("A1", mwb::EdgeDirection::Right, 540); + Expect(transition.has_value(), "asymmetric right edge should transition"); + if (transition.has_value()) { + ExpectEqual(transition->targetDisplayId, "B1", "asymmetric target display"); + ExpectEqual(transition->coordinate, 720, "1080-high edge midpoint should scale to 1440-high edge"); + } +} + +void TestWrapPolicyOnOff() { + mwb::TopologyModel off; + off.addMachine({"A"}); + off.addDisplay({"A1", "A", 0, 0, 1920, 1080}); + Expect(!off.transitionFromEdge("A1", mwb::EdgeDirection::Right, 42).has_value(), + "wrap disabled should not transition"); + + mwb::TopologyModel horizontal; + horizontal.addMachine({"A"}); + horizontal.addDisplay({"A1", "A", 0, 0, 1920, 1080}); + horizontal.setWrapPolicy(mwb::WrapPolicy::Horizontal); + const auto wrapped = horizontal.transitionFromEdge("A1", mwb::EdgeDirection::Right, 42); + Expect(wrapped.has_value(), "horizontal wrap should transition"); + if (wrapped.has_value()) { + ExpectEqual(wrapped->targetDisplayId, "A1", "single display horizontal wrap target"); + Expect(wrapped->entryEdge == mwb::EdgeDirection::Left, + "right-edge wrap should enter left edge"); + ExpectEqual(wrapped->coordinate, 42, "horizontal wrap should preserve y coordinate"); + } + Expect(!horizontal.transitionFromEdge("A1", mwb::EdgeDirection::Down, 42).has_value(), + "horizontal wrap should not wrap vertically"); +} + +void TestValidationRejectsOverlappingDisplaysOnSameMachine() { + mwb::TopologyModel model; + model.addMachine({"A"}); + model.addDisplay({"A1", "A", 0, 0, 100, 100}); + model.addDisplay({"A2", "A", 50, 50, 100, 100}); + + Expect(HasIssue(model.validate(), mwb::TopologyIssueCode::OverlappingDisplays), + "overlapping same-machine displays should be reported"); +} + +void TestValidationRejectsMissingDisplaysForLinks() { + mwb::TopologyModel model; + model.addMachine({"A"}); + model.addDisplay({"A1", "A", 0, 0, 100, 100}); + model.addBorderLink({"A1", mwb::EdgeDirection::Right, "missing", mwb::EdgeDirection::Left}); + model.addBorderLink({"missing-source", mwb::EdgeDirection::Right, "A1", mwb::EdgeDirection::Left}); + const auto issues = model.validate(); + + Expect(HasIssue(issues, mwb::TopologyIssueCode::MissingTargetDisplay), + "missing link target should be reported"); + Expect(HasIssue(issues, mwb::TopologyIssueCode::MissingSourceDisplay), + "missing link source should be reported"); +} + +void TestValidationRejectsContradictoryDuplicateEdgeLinks() { + auto model = BaseModel(); + model.addDisplay({"A1", "A", 0, 0, 100, 100}); + model.addDisplay({"B1", "B", 0, 0, 100, 100}); + model.addDisplay({"B2", "B", 100, 0, 100, 100}); + model.addBorderLink({"A1", mwb::EdgeDirection::Right, "B1", mwb::EdgeDirection::Left}); + model.addBorderLink({"A1", mwb::EdgeDirection::Right, "B2", mwb::EdgeDirection::Left}); + const auto issues = model.validate(); + + Expect(HasIssue(issues, mwb::TopologyIssueCode::ContradictoryDuplicateEdge), + "contradictory duplicate edge link should be reported"); + Expect(HasIssue(issues, mwb::TopologyIssueCode::AmbiguousEdgeMapping), + "multiple targets from one edge should be ambiguous"); +} + +void TestValidationRejectsImpossibleEdgeMappings() { + mwb::TopologyModel model; + model.addMachine({"A"}); + model.addDisplay({"A1", "A", 0, 0, 100, 100}); + model.addDisplay({"A2", "A", 200, 200, 100, 100}); + model.addBorderLink({"A1", mwb::EdgeDirection::Right, "A2", mwb::EdgeDirection::Right}); + model.addBorderLink({"A2", mwb::EdgeDirection::Left, "A2", mwb::EdgeDirection::Right}); + + Expect(HasIssue(model.validate(), mwb::TopologyIssueCode::ImpossibleEdgeMapping), + "incompatible, diagonal, or self edge mappings should be reported"); +} + +} // namespace + +int main() { + TestAABFixtureRoutesFromSecondADisplayToB(); + TestBAAFixtureRoutesFromBToFirstADisplay(); + TestABAFixtureRoutesThroughMiddleMachine(); + TestStackedFixtureRoutesVertically(); + TestAsymmetricResolutionsScaleEdgeCoordinate(); + TestWrapPolicyOnOff(); + TestValidationRejectsOverlappingDisplaysOnSameMachine(); + TestValidationRejectsMissingDisplaysForLinks(); + TestValidationRejectsContradictoryDuplicateEdgeLinks(); + TestValidationRejectsImpossibleEdgeMappings(); + + if (g_failures == 0) { + std::cout << "Topology model tests passed." << std::endl; + return 0; + } + + std::cerr << g_failures << " topology model test(s) failed." << std::endl; + return 1; +} From dd08a04069a3f6092d184e5b428903b005034376 Mon Sep 17 00:00:00 2001 From: daredoole Date: Tue, 28 Apr 2026 20:31:20 -0400 Subject: [PATCH 03/14] 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 04/14] 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 05/14] 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 06/14] 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 07/14] 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(); From bd612623c4a422cd50afe092c9ce344aae13c1ae Mon Sep 17 00:00:00 2001 From: daredoole Date: Fri, 1 May 2026 10:20:17 -0400 Subject: [PATCH 08/14] Add Android controlled peer support --- .gitignore | 1 + CMakeLists.txt | 28 +- README.md | 3 +- android/.gitignore | 4 + android/app/build.gradle | 30 + android/app/src/main/AndroidManifest.xml | 71 ++ .../android/InputFlowAccessibilityService.kt | 693 +++++++++++ .../inputflow/android/InputFlowImeService.kt | 295 +++++ .../main/java/com/inputflow/android/KeyMap.kt | 79 ++ .../inputflow/android/KeyMapperActivity.kt | 125 ++ .../inputflow/android/LayoutEditorActivity.kt | 115 ++ .../com/inputflow/android/LayoutEditorView.kt | 194 +++ .../com/inputflow/android/MainActivity.kt | 218 ++++ .../android/RelayForegroundService.kt | 219 ++++ .../com/inputflow/android/RelayProtocol.kt | 50 + .../com/inputflow/android/SettingsActivity.kt | 167 +++ .../main/res/drawable/ic_accessibility.xml | 9 + .../src/main/res/drawable/ic_arrow_right.xml | 9 + android/app/src/main/res/drawable/ic_back.xml | 9 + android/app/src/main/res/drawable/ic_host.xml | 9 + .../src/main/res/drawable/ic_inputflow.xml | 15 + android/app/src/main/res/drawable/ic_key.xml | 9 + .../app/src/main/res/drawable/ic_keyboard.xml | 9 + .../src/main/res/drawable/ic_notification.xml | 9 + android/app/src/main/res/drawable/ic_port.xml | 9 + .../app/src/main/res/drawable/ic_settings.xml | 9 + .../app/src/main/res/drawable/status_dot.xml | 4 + .../main/res/layout/activity_key_mapper.xml | 39 + .../res/layout/activity_layout_editor.xml | 66 + .../app/src/main/res/layout/activity_main.xml | 334 +++++ .../src/main/res/layout/activity_settings.xml | 470 +++++++ .../app/src/main/res/layout/row_shortcut.xml | 37 + android/app/src/main/res/menu/main_menu.xml | 12 + .../app/src/main/res/values-night/colors.xml | 19 + android/app/src/main/res/values/colors.xml | 19 + android/app/src/main/res/values/strings.xml | 65 + android/app/src/main/res/values/styles.xml | 14 + .../res/xml/accessibility_service_config.xml | 8 + android/app/src/main/res/xml/input_method.xml | 3 + android/build.gradle | 4 + android/gradle.properties | 2 + android/settings.gradle | 18 + docs/android.md | 64 + src/AndroidRelay.cpp | 474 +++++++ src/AndroidRelay.h | 84 ++ src/AppConfig.cpp | 74 ++ src/AppConfig.h | 8 + src/ClientRuntime.cpp | 264 +++- src/ClientRuntime.h | 16 + src/LibeiInputCaptureBridge.cpp | 1095 +++++++++++++++++ src/LibeiInputCaptureBridge.h | 40 + src/LocalAndroidInputBridge.cpp | 531 ++++++++ src/LocalAndroidInputBridge.h | 39 + src/main.cpp | 78 ++ tests/test_main.cpp | 48 + 55 files changed, 6314 insertions(+), 3 deletions(-) create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt create mode 100644 android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt create mode 100644 android/app/src/main/java/com/inputflow/android/KeyMap.kt create mode 100644 android/app/src/main/java/com/inputflow/android/KeyMapperActivity.kt create mode 100644 android/app/src/main/java/com/inputflow/android/LayoutEditorActivity.kt create mode 100644 android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt create mode 100644 android/app/src/main/java/com/inputflow/android/MainActivity.kt create mode 100644 android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt create mode 100644 android/app/src/main/java/com/inputflow/android/RelayProtocol.kt create mode 100644 android/app/src/main/java/com/inputflow/android/SettingsActivity.kt create mode 100644 android/app/src/main/res/drawable/ic_accessibility.xml create mode 100644 android/app/src/main/res/drawable/ic_arrow_right.xml create mode 100644 android/app/src/main/res/drawable/ic_back.xml create mode 100644 android/app/src/main/res/drawable/ic_host.xml create mode 100644 android/app/src/main/res/drawable/ic_inputflow.xml create mode 100644 android/app/src/main/res/drawable/ic_key.xml create mode 100644 android/app/src/main/res/drawable/ic_keyboard.xml create mode 100644 android/app/src/main/res/drawable/ic_notification.xml create mode 100644 android/app/src/main/res/drawable/ic_port.xml create mode 100644 android/app/src/main/res/drawable/ic_settings.xml create mode 100644 android/app/src/main/res/drawable/status_dot.xml create mode 100644 android/app/src/main/res/layout/activity_key_mapper.xml create mode 100644 android/app/src/main/res/layout/activity_layout_editor.xml create mode 100644 android/app/src/main/res/layout/activity_main.xml create mode 100644 android/app/src/main/res/layout/activity_settings.xml create mode 100644 android/app/src/main/res/layout/row_shortcut.xml create mode 100644 android/app/src/main/res/menu/main_menu.xml create mode 100644 android/app/src/main/res/values-night/colors.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/main/res/xml/accessibility_service_config.xml create mode 100644 android/app/src/main/res/xml/input_method.xml create mode 100644 android/build.gradle create mode 100644 android/gradle.properties create mode 100644 android/settings.gradle create mode 100644 docs/android.md create mode 100644 src/AndroidRelay.cpp create mode 100644 src/AndroidRelay.h create mode 100644 src/LibeiInputCaptureBridge.cpp create mode 100644 src/LibeiInputCaptureBridge.h create mode 100644 src/LocalAndroidInputBridge.cpp create mode 100644 src/LocalAndroidInputBridge.h diff --git a/.gitignore b/.gitignore index 93f29e8..de960a8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ mwb-lock-report-*.txt mwb-socket-trace-*.txt inputflow-windows-pair-*.ps1 AGENTS.md +artifacts/ # Editor / OS noise .vscode/ diff --git a/CMakeLists.txt b/CMakeLists.txt index c9aa86a..e180d6b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,13 +36,17 @@ endfunction() # Find OpenSSL for AES decryption/encryption matching PowerToys MWB find_package(OpenSSL REQUIRED) find_package(ZLIB REQUIRED) +find_package(X11 REQUIRED) add_executable(mwb_client + src/AndroidRelay.cpp src/AppConfig.cpp src/AppState.cpp src/ClientRuntime.cpp src/Discovery.cpp src/InputDispatcher.cpp + src/LibeiInputCaptureBridge.cpp + src/LocalAndroidInputBridge.cpp src/main.cpp src/PeerRecovery.cpp src/SecretStore.cpp @@ -56,6 +60,7 @@ add_executable(mwb_client target_include_directories(mwb_client PRIVATE src) target_include_directories(mwb_client PRIVATE ${OPENSSL_INCLUDE_DIR}) +target_include_directories(mwb_client PRIVATE ${X11_INCLUDE_DIR}) if (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") target_compile_options(mwb_client PRIVATE @@ -78,18 +83,39 @@ target_link_libraries(mwb_client PRIVATE OpenSSL::SSL OpenSSL::Crypto ZLIB::ZLIB + ${X11_LIBRARIES} pthread ) include(CTest) find_package(PkgConfig QUIET) +if (PkgConfig_FOUND) + pkg_check_modules(LIBEI_INPUT_CAPTURE QUIET libei-1.0 gio-unix-2.0 gio-2.0 glib-2.0) + pkg_check_modules(LIBINPUT_GESTURES QUIET libinput) +endif() + +if (LIBEI_INPUT_CAPTURE_FOUND) + target_compile_definitions(mwb_client PRIVATE MWB_HAVE_LIBEI_INPUT_CAPTURE=1) + target_include_directories(mwb_client PRIVATE ${LIBEI_INPUT_CAPTURE_INCLUDE_DIRS}) + target_link_libraries(mwb_client PRIVATE ${LIBEI_INPUT_CAPTURE_LDFLAGS}) +endif() + +if (LIBINPUT_GESTURES_FOUND) + message(STATUS "Native libinput gesture monitor enabled") + target_compile_definitions(mwb_client PRIVATE MWB_HAVE_LIBINPUT_GESTURES=1) + target_include_directories(mwb_client PRIVATE ${LIBINPUT_GESTURES_INCLUDE_DIRS}) + target_link_libraries(mwb_client PRIVATE ${LIBINPUT_GESTURES_LDFLAGS}) +else() + message(STATUS "Native libinput gesture monitor disabled; install libinput-devel to enable it") +endif() if (BUILD_TESTING) find_program(PYTHON3_EXECUTABLE python3) add_executable(mwb_client_unit_tests tests/test_main.cpp + src/AndroidRelay.cpp src/AppConfig.cpp src/AppState.cpp src/Discovery.cpp @@ -97,7 +123,7 @@ if (BUILD_TESTING) ) target_include_directories(mwb_client_unit_tests PRIVATE src) target_compile_options(mwb_client_unit_tests PRIVATE -Wall -Wextra -Wpedantic) - target_link_libraries(mwb_client_unit_tests PRIVATE pthread) + target_link_libraries(mwb_client_unit_tests PRIVATE OpenSSL::Crypto pthread) mwb_apply_sanitizers(mwb_client_unit_tests) add_executable(mwb_input_mapping_tests diff --git a/README.md b/README.md index 9403b89..cade956 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ User-facing beta operations: - [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 expectations](docs/topology.md) +- [Android peer MVP](docs/android.md) - [Migration from other keyboard/mouse sharing tools](docs/migration.md) - [Compatibility matrix and platform caveats](docs/compatibility.md) @@ -117,7 +118,7 @@ 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, `topology_enabled`, `topology_file`, and more. Default path: `~/.config/mwb-client/config.ini`. +Supports `key_file`, `key_secret_id` (keyring), `screen_width/height` overrides, `topology_enabled`, `topology_file`, experimental `android_peers_enabled`, and more. Default path: `~/.config/mwb-client/config.ini`. 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. diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..bf54c68 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,4 @@ +.gradle/ +build/ +local.properties +app/build/ diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..ee45ce8 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,30 @@ +plugins { + id "com.android.application" + id "org.jetbrains.kotlin.android" +} + +android { + namespace "com.inputflow.android" + compileSdk 35 + + defaultConfig { + applicationId "com.inputflow.android" + minSdk 26 + targetSdk 35 + versionCode 1 + versionName "0.1.0" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation "com.google.android.material:material:1.12.0" +} + +kotlin { + jvmToolchain(8) +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5f61d33 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt new file mode 100644 index 0000000..13b366f --- /dev/null +++ b/android/app/src/main/java/com/inputflow/android/InputFlowAccessibilityService.kt @@ -0,0 +1,693 @@ +package com.inputflow.android + +import android.accessibilityservice.AccessibilityService +import android.accessibilityservice.GestureDescription +import android.content.Context +import android.graphics.Color +import android.graphics.Path +import android.graphics.PixelFormat +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.media.AudioManager +import android.os.Build +import android.os.PowerManager +import android.util.Log +import android.view.Gravity +import android.view.View +import android.view.WindowManager +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import org.json.JSONObject +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +class InputFlowAccessibilityService : AccessibilityService() { + private val mainHandler = Handler(Looper.getMainLooper()) + private var pointerX = 0f + private var pointerY = 0f + private var cursorView: View? = null + private var cursorParams: WindowManager.LayoutParams? = null + private var windowManager: WindowManager? = null + private var shiftDown = false + private var ctrlDown = false + private var altDown = false + private var metaDown = false + private var pendingScrollDx = 0.0 + private var pendingScrollDy = 0.0 + private var scrollFlushScheduled = false + @Suppress("DEPRECATION") + private var wakeLock: PowerManager.WakeLock? = null + + override fun onServiceConnected() { + instance = this + val metrics = resources.displayMetrics + pointerX = metrics.widthPixels / 2f + pointerY = metrics.heightPixels / 2f + @Suppress("DEPRECATION") + wakeLock = (getSystemService(PowerManager::class.java)) + .newWakeLock( + PowerManager.FULL_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP or PowerManager.ON_AFTER_RELEASE, + "inputflow:input" + ) + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) = Unit + + override fun onInterrupt() = Unit + + override fun onDestroy() { + hideCursorOverlay() + wakeLock?.let { if (it.isHeld) it.release() } + if (instance === this) instance = null + super.onDestroy() + } + + fun handleMouse(frame: JSONObject) { + ensureScreenOn() + val wParam = frame.optInt("wParam") + val x = frame.optInt("x") + val y = frame.optInt("y") + val mouseData = frame.optInt("mouseData") + when (wParam) { + WM_MOUSEMOVE -> moveToNormalized(x, y) + WM_LBUTTONUP -> tap(pointerX, pointerY) + WM_RBUTTONUP -> mainHandler.post { performGlobalAction(GLOBAL_ACTION_BACK) } + WM_MBUTTONUP -> mainHandler.post { performGlobalAction(GLOBAL_ACTION_HOME) } + WM_MOUSEWHEEL -> scroll(mouseData) + } + } + + fun setRemoteControlActive(active: Boolean) { + mainHandler.post { + if (active) { + showCursorOverlay() + } else { + pendingScrollDx = 0.0 + pendingScrollDy = 0.0 + scrollFlushScheduled = false + shiftDown = false + ctrlDown = false + altDown = false + metaDown = false + hideCursorOverlay() + } + } + } + + fun handleGesture(frame: JSONObject) { + val dx = frame.optDouble("dx") + val dy = frame.optDouble("dy") + ensureScreenOn() + when (frame.optString("kind")) { + "scroll" -> touchpadScroll(dx, dy) + "swipe2" -> handleSwipe2(dx, dy) + "swipe3" -> handleSwipe3(dx, dy) + "swipe4" -> handleSwipe4(dx, dy) + "pinch" -> handlePinch(dx) + "zoom" -> handlePinch(dx) + "tap2" -> handleTap2() + } + } + + fun handleKeyboard(frame: JSONObject) { + ensureScreenOn() + mainHandler.post { + val vkCode = frame.optInt("vkCode") + val flags = frame.optInt("flags") + val keyUp = (flags and LLKHF_UP) != 0 + updateModifier(vkCode, keyUp) + if (keyUp && handleCommandKeyUp(vkCode)) return@post + if (keyUp) return@post + if (checkKeyMap(vkCode)) return@post + if (handleCommandKey(vkCode)) return@post + when (vkCode) { + VK_BACK -> editFocusedText { it.dropLast(1) } + VK_ESCAPE -> performGlobalAction(GLOBAL_ACTION_BACK) + VK_RETURN -> editFocusedText { "$it\n" } + VK_SPACE -> editFocusedText { "$it " } + VK_LEFT -> navigateDirection(forward = false, vertical = false) + VK_UP -> navigateDirection(forward = false, vertical = true) + VK_RIGHT -> navigateDirection(forward = true, vertical = false) + VK_DOWN -> navigateDirection(forward = true, vertical = true) + VK_DELETE -> forwardDelete() + VK_TAB -> navigateFocus(!shiftDown) + VK_HOME -> moveCursorToLineEdge(toEnd = false) + VK_END -> moveCursorToLineEdge(toEnd = true) + VK_PRIOR -> pageScroll(false) + VK_NEXT -> pageScroll(true) + in VK_0..VK_9 -> appendText(keyChar(vkCode)) + in VK_A..VK_Z -> appendText(keyChar(vkCode)) + VK_OEM_COMMA, VK_OEM_PERIOD, VK_OEM_MINUS, VK_OEM_PLUS, + VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7 -> + appendText(keyChar(vkCode)) + } + } + } + + fun handleMappedKeyboard(frame: JSONObject): Boolean { + val vkCode = frame.optInt("vkCode") + val flags = frame.optInt("flags") + if ((flags and LLKHF_UP) != 0 || isModifierKey(vkCode)) { + return false + } + val action = findMappedAction(vkCode) ?: return false + mainHandler.post { + executeMappedAction(action) + } + return true + } + + private fun ensureScreenOn() { + val pm = getSystemService(PowerManager::class.java) + if (!pm.isInteractive) { + wakeLock?.acquire(5000L) + } + } + + private fun updateModifier(vkCode: Int, keyUp: Boolean) { + val down = !keyUp + when (vkCode) { + VK_SHIFT -> shiftDown = down + VK_CONTROL -> ctrlDown = down + VK_MENU -> altDown = down + VK_LWIN, VK_RWIN -> metaDown = down + } + } + + private fun isModifierKey(vkCode: Int): Boolean { + return vkCode == VK_SHIFT || + vkCode == VK_CONTROL || + vkCode == VK_MENU || + vkCode == VK_LWIN || + vkCode == VK_RWIN + } + + private fun checkKeyMap(vkCode: Int): Boolean { + val action = findMappedAction(vkCode) ?: return false + executeMappedAction(action) + return true + } + + private fun findMappedAction(vkCode: Int): KeyMap.Action? { + val prefs = applicationContext.getSharedPreferences(RelayForegroundService.PREFS, MODE_PRIVATE) + val mods = KeyMap.currentMods(shiftDown, ctrlDown, altDown, metaDown) + return KeyMap.ACTIONS.firstOrNull { action -> + KeyMap.getVk(prefs, action) == vkCode && KeyMap.getMods(prefs, action) == mods + } + } + + private fun executeMappedAction(action: KeyMap.Action) { + when (action.id) { + "back" -> performGlobalActionLogged(GLOBAL_ACTION_BACK, "map:${action.id}") + "home" -> performGlobalActionLogged(GLOBAL_ACTION_HOME, "map:${action.id}") + "recents" -> performGlobalActionLogged(GLOBAL_ACTION_RECENTS, "map:${action.id}") + "notifications" -> performGlobalActionLogged(GLOBAL_ACTION_NOTIFICATIONS, "map:${action.id}") + "quick_settings" -> performGlobalActionLogged(GLOBAL_ACTION_QUICK_SETTINGS, "map:${action.id}") + "volume_up" -> adjustVolume(AudioManager.ADJUST_RAISE) + "volume_down" -> adjustVolume(AudioManager.ADJUST_LOWER) + "screenshot" -> takeScreenshotAction() + "swipe_left" -> dispatchDirectionalSwipe(-1f, 0f) + "swipe_right" -> dispatchDirectionalSwipe(1f, 0f) + "swipe_up" -> dispatchDirectionalSwipe(0f, -1f) + "swipe_down" -> dispatchDirectionalSwipe(0f, 1f) + "zoom_in" -> handlePinch(1.25) + "zoom_out" -> handlePinch(0.75) + } + } + + private fun adjustVolume(direction: Int) { + getSystemService(AudioManager::class.java) + ?.adjustStreamVolume(AudioManager.STREAM_MUSIC, direction, AudioManager.FLAG_SHOW_UI) + } + + private fun takeScreenshotAction() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + performGlobalActionLogged(GLOBAL_ACTION_TAKE_SCREENSHOT, "map:screenshot") + } + } + + private fun dispatchDirectionalSwipe(dx: Float, dy: Float) { + val metrics = resources.displayMetrics + val distance = 260f * metrics.density + val fromX = pointerX.coerceIn(1f, metrics.widthPixels - 2f) + val fromY = pointerY.coerceIn(1f, metrics.heightPixels - 2f) + val toX = (fromX + dx * distance).coerceIn(1f, metrics.widthPixels - 2f) + val toY = (fromY + dy * distance).coerceIn(1f, metrics.heightPixels - 2f) + gesture(fromX, fromY, toX, toY, 180) + } + + private fun handleCommandKey(vkCode: Int): Boolean { + if (vkCode == VK_LWIN || vkCode == VK_RWIN) { + performGlobalActionLogged(GLOBAL_ACTION_HOME, "meta-down") + return true + } + if (vkCode == VK_TAB && altDown) { + performGlobalActionLogged(GLOBAL_ACTION_RECENTS, "alt-tab") + return true + } + if (vkCode == VK_LEFT && altDown) { + performGlobalActionLogged(GLOBAL_ACTION_BACK, "alt-left") + return true + } + if (vkCode == VK_N && ctrlDown && altDown) { + performGlobalActionLogged(GLOBAL_ACTION_NOTIFICATIONS, "ctrl-alt-n") + return true + } + if (vkCode == VK_Q && ctrlDown && altDown) { + performGlobalActionLogged(GLOBAL_ACTION_QUICK_SETTINGS, "ctrl-alt-q") + return true + } + return false + } + + private fun handleCommandKeyUp(vkCode: Int): Boolean { + if (vkCode == VK_LWIN || vkCode == VK_RWIN) { + performGlobalActionLogged(GLOBAL_ACTION_HOME, "meta-up") + return true + } + return false + } + + private fun performGlobalActionLogged(action: Int, source: String): Boolean { + val handled = performGlobalAction(action) + Log.i(TAG, "global action source=$source action=$action handled=$handled") + return handled + } + + // ── Gesture handlers ───────────────────────────────────────────────────── + + private fun handleSwipe2(dx: Double, dy: Double) { + touchpadScroll(dx, dy) + } + + private fun handleSwipe3(dx: Double, dy: Double) { + if (!isAccurateVerticalSwipe(dx, dy)) return + mainHandler.post { + if (dy < 0) { + performGlobalActionLogged(GLOBAL_ACTION_HOME, "gesture:3-up") + } else { + performGlobalActionLogged(GLOBAL_ACTION_RECENTS, "gesture:3-down") + } + } + } + + private fun handleSwipe4(dx: Double, dy: Double) { + if (!isAccurateVerticalSwipe(dx, dy)) return + mainHandler.post { + if (dy < 0) { + openAppDrawerGesture() + } else { + performGlobalActionLogged(GLOBAL_ACTION_QUICK_SETTINGS, "gesture:4-down") + } + } + } + + private fun isAccurateVerticalSwipe(dx: Double, dy: Double): Boolean { + val absX = abs(dx) + val absY = abs(dy) + return absY >= 32.0 && absY >= absX * 1.45 + } + + private fun openAppDrawerGesture() { + performGlobalActionLogged(GLOBAL_ACTION_HOME, "gesture:4-up-home") + mainHandler.postDelayed({ + val metrics = resources.displayMetrics + val x = metrics.widthPixels / 2f + val fromY = metrics.heightPixels * 0.88f + val toY = metrics.heightPixels * 0.28f + gesture(x, fromY, x, toY, 260) + Log.i(TAG, "global action source=gesture:4-up action=app-drawer handled=true") + }, 220) + } + + private fun handlePinch(scale: Double) { + val metrics = resources.displayMetrics + val cx = pointerX + val cy = pointerY + val baseR = 120f * metrics.density + val pinchIn = scale < 1.0 + val startR = if (pinchIn) baseR else baseR * 0.4f + val endR = if (pinchIn) baseR * 0.4f else baseR + val path1 = Path().apply { moveTo(cx - startR, cy); lineTo(cx - endR, cy) } + val path2 = Path().apply { moveTo(cx + startR, cy); lineTo(cx + endR, cy) } + mainHandler.post { + dispatchGesture( + GestureDescription.Builder() + .addStroke(GestureDescription.StrokeDescription(path1, 0, 300)) + .addStroke(GestureDescription.StrokeDescription(path2, 0, 300)) + .build(), + null, null + ) + } + } + + private fun handleTap2() { + mainHandler.post { + val path = Path().apply { moveTo(pointerX, pointerY) } + dispatchGesture( + GestureDescription.Builder() + .addStroke(GestureDescription.StrokeDescription(path, 0, 600L)) + .build(), + null, null + ) + } + } + + // ── Keyboard navigation helpers ────────────────────────────────────────── + + private fun navigateDirection(forward: Boolean, vertical: Boolean) { + val root = rootInActiveWindow + val focused = root?.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) + ?.takeIf { it.isEditable } + ?: findFocusedNode(root) + if (focused != null) { + val granularity = if (vertical) AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE + else AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER + val action = if (forward) AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY + else AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY + focused.performAction(action, Bundle().apply { + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, granularity) + putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, shiftDown) + }) + } else { + // No text field: directional swipe + val m = resources.displayMetrics + val d = 240f * m.density + val (dx, dy) = if (!vertical) Pair(if (forward) d else -d, 0f) + else Pair(0f, if (forward) d else -d) + val toX = (pointerX + dx).coerceIn(0f, m.widthPixels - 1f) + val toY = (pointerY + dy).coerceIn(0f, m.heightPixels - 1f) + gesture(pointerX, pointerY, toX, toY, 150) + } + } + + private fun forwardDelete() { + val root = rootInActiveWindow + val focused = root?.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) + ?.takeIf { it.isEditable } + ?: findFocusedNode(root) + if (focused == null) return + val text = focused.text?.toString() ?: return + val selStart = focused.textSelectionStart + val selEnd = focused.textSelectionEnd + if (selStart < 0) return + val newText = if (selStart != selEnd) { + text.removeRange(minOf(selStart, selEnd), maxOf(selStart, selEnd)) + } else { + if (selStart >= text.length) return + text.removeRange(selStart, selStart + 1) + } + val cursor = if (selStart != selEnd) minOf(selStart, selEnd) else selStart + focused.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, Bundle().apply { + putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, newText) + }) + focused.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, Bundle().apply { + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, cursor) + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, cursor) + }) + } + + private fun navigateFocus(forward: Boolean) { + val root = rootInActiveWindow ?: return + val focusables = mutableListOf() + collectFocusable(root, focusables) + if (focusables.isEmpty()) return + val current = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) + ?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY) + val idx = if (current != null) focusables.indexOfFirst { it == current } else -1 + val next = if (forward) { + focusables.getOrNull(if (idx + 1 < focusables.size) idx + 1 else 0) + } else { + focusables.getOrNull(if (idx - 1 >= 0) idx - 1 else focusables.size - 1) + } + next?.performAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS) + } + + private fun collectFocusable(node: AccessibilityNodeInfo?, out: MutableList) { + if (node == null) return + if (node.isFocusable && node.isVisibleToUser) out.add(node) + for (i in 0 until node.childCount) collectFocusable(node.getChild(i), out) + } + + private fun moveCursorToLineEdge(toEnd: Boolean) { + val root = rootInActiveWindow + val focused = root?.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) + ?.takeIf { it.isEditable } + ?: findFocusedNode(root) + if (focused == null) return + val action = if (toEnd) AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY + else AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY + focused.performAction(action, Bundle().apply { + putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE) + putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, shiftDown) + }) + } + + private fun pageScroll(down: Boolean) { + val metrics = resources.displayMetrics + val dist = metrics.heightPixels * 0.75f + val fromY = if (down) metrics.heightPixels * 0.75f else metrics.heightPixels * 0.25f + val toY = if (down) metrics.heightPixels * 0.25f else metrics.heightPixels * 0.75f + gesture(pointerX, fromY, pointerX, toY, 250) + } + + // ── Text helpers ───────────────────────────────────────────────────────── + + private fun appendText(value: String) { + if (value.isEmpty()) return + editFocusedText { current -> current + value } + } + + private fun keyChar(vkCode: Int): String { + val shifted = shiftDown + if (vkCode in VK_A..VK_Z) { + val c = vkCode.toChar() + return if (shifted) c.toString() else c.lowercaseChar().toString() + } + if (vkCode in VK_0..VK_9) { + val normal = "0123456789"[vkCode - VK_0] + val shiftedChars = ")!@#$%^&*("[vkCode - VK_0] + return (if (shifted) shiftedChars else normal).toString() + } + return when (vkCode) { + VK_OEM_COMMA -> if (shifted) "<" else "," + VK_OEM_PERIOD -> if (shifted) ">" else "." + VK_OEM_MINUS -> if (shifted) "_" else "-" + VK_OEM_PLUS -> if (shifted) "+" else "=" + VK_OEM_1 -> if (shifted) ":" else ";" + VK_OEM_2 -> if (shifted) "?" else "/" + VK_OEM_3 -> if (shifted) "~" else "`" + VK_OEM_4 -> if (shifted) "{" else "[" + VK_OEM_5 -> if (shifted) "|" else "\\" + VK_OEM_6 -> if (shifted) "}" else "]" + VK_OEM_7 -> if (shifted) "\"" else "'" + else -> "" + } + } + + // ── Pointer + cursor ───────────────────────────────────────────────────── + + private fun moveToNormalized(x: Int, y: Int) { + val prefs = applicationContext.getSharedPreferences(RelayForegroundService.PREFS, MODE_PRIVATE) + val sens = prefs.getFloat(SettingsActivity.KEY_SENSITIVITY, 1.0f).coerceIn(0.5f, 3.0f) + val metrics = resources.displayMetrics + val baseX = x / 65535f * metrics.widthPixels + val baseY = y / 65535f * metrics.heightPixels + val cx = metrics.widthPixels / 2f + val cy = metrics.heightPixels / 2f + val nextX = min(metrics.widthPixels - 1f, max(0f, cx + (baseX - cx) * sens)) + val nextY = min(metrics.heightPixels - 1f, max(0f, cy + (baseY - cy) * sens)) + mainHandler.post { + pointerX = nextX + pointerY = nextY + updateCursorOverlay() + } + } + + private fun tap(x: Float, y: Float) { + mainHandler.post { gesture(x, y, x, y, 40) } + } + + private fun gesture(fromX: Float, fromY: Float, toX: Float, toY: Float, durationMs: Long) { + val path = Path().apply { + moveTo(fromX, fromY) + lineTo(toX, toY) + } + val stroke = GestureDescription.StrokeDescription(path, 0, durationMs) + dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null) + } + + private fun scroll(mouseData: Int) { + val dy = if (mouseData < 0) 1.0 else -1.0 + touchpadScroll(0.0, dy) + } + + private fun touchpadScroll(dx: Double, dy: Double) { + if (abs(dx) < 0.01 && abs(dy) < 0.01) return + mainHandler.post { + pendingScrollDx += dx + pendingScrollDy += dy + if (!scrollFlushScheduled) { + scrollFlushScheduled = true + mainHandler.postDelayed({ flushTouchpadScroll() }, 48) + } + } + } + + private fun flushTouchpadScroll() { + scrollFlushScheduled = false + val dx = pendingScrollDx + val dy = pendingScrollDy + pendingScrollDx = 0.0 + pendingScrollDy = 0.0 + if (abs(dx) < 0.05 && abs(dy) < 0.05) return + val metrics = resources.displayMetrics + val scale = 34f * metrics.density + val maxStep = 170f * metrics.density + val stepX = (dx * scale).toFloat().coerceIn(-maxStep, maxStep) + val stepY = (dy * scale).toFloat().coerceIn(-maxStep, maxStep) + val toX = min(metrics.widthPixels - 1f, max(0f, pointerX - stepX)) + val toY = min(metrics.heightPixels - 1f, max(0f, pointerY - stepY)) + gesture(pointerX, pointerY, toX, toY, 62) + } + + private fun showCursorOverlay() { + if (cursorView != null) return + val prefs = applicationContext.getSharedPreferences(RelayForegroundService.PREFS, MODE_PRIVATE) + val sizeDp = when (prefs.getInt(SettingsActivity.KEY_CURSOR_SIZE, 2)) { + 1 -> 14f + 3 -> 26f + else -> 18f + } + val cursorColor = when (prefs.getString(SettingsActivity.KEY_CURSOR_COLOR, "white")) { + "blue" -> Color.argb(220, 22, 100, 192) + "red" -> Color.argb(220, 244, 67, 54) + "black" -> Color.argb(220, 20, 20, 20) + else -> Color.argb(210, 255, 255, 255) + } + val strokeColor = if (prefs.getString(SettingsActivity.KEY_CURSOR_COLOR, "white") == "white") + Color.argb(180, 0, 0, 0) else Color.argb(180, 255, 255, 255) + val size = (sizeDp * resources.displayMetrics.density).toInt().coerceAtLeast(12) + val view = View(this).apply { + background = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(cursorColor) + setStroke((2 * resources.displayMetrics.density).toInt().coerceAtLeast(2), strokeColor) + } + elevation = 20f + } + val params = WindowManager.LayoutParams( + size, size, + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + PixelFormat.TRANSLUCENT + ).apply { + gravity = Gravity.TOP or Gravity.START + } + windowManager = getSystemService(WindowManager::class.java) + cursorView = view + cursorParams = params + windowManager?.addView(view, params) + updateCursorOverlay() + } + + private fun updateCursorOverlay() { + val view = cursorView ?: return + val params = cursorParams ?: return + params.x = (pointerX - params.width / 2f).toInt() + params.y = (pointerY - params.height / 2f).toInt() + windowManager?.updateViewLayout(view, params) + } + + private fun hideCursorOverlay() { + val view = cursorView ?: return + try { windowManager?.removeView(view) } catch (_: IllegalArgumentException) {} + cursorView = null + cursorParams = null + } + + private fun editFocusedText(transform: (String) -> String) { + val root = rootInActiveWindow + val focused = root + ?.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) + ?.takeIf { it.isEditable } + ?: findFocusedNode(root) + if (focused == null) { + Log.i(TAG, "keyboard text ignored: no focused editable node") + return + } + val current = focused.text?.toString().orEmpty() + val args = Bundle().apply { + putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, transform(current)) + } + if (!focused.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)) { + Log.i(TAG, "keyboard text ignored: ACTION_SET_TEXT failed") + } + } + + private fun findFocusedNode(node: AccessibilityNodeInfo?): AccessibilityNodeInfo? { + if (node == null) return null + if (node.isFocused && node.isEditable) return node + for (index in 0 until node.childCount) { + val found = findFocusedNode(node.getChild(index)) + if (found != null) return found + } + return null + } + + companion object { + private const val TAG = "InputFlowAccessibility" + private const val WM_MOUSEMOVE = 0x0200 + private const val WM_LBUTTONUP = 0x0202 + private const val WM_RBUTTONUP = 0x0205 + private const val WM_MBUTTONUP = 0x0208 + private const val WM_MOUSEWHEEL = 0x020A + private const val LLKHF_UP = 0x80 + private const val VK_BACK = 0x08 + private const val VK_TAB = 0x09 + private const val VK_RETURN = 0x0D + private const val VK_SHIFT = 0x10 + private const val VK_CONTROL = 0x11 + private const val VK_MENU = 0x12 + private const val VK_ESCAPE = 0x1B + private const val VK_PRIOR = 0x21 // PageUp + private const val VK_NEXT = 0x22 // PageDown + private const val VK_END = 0x23 + private const val VK_HOME = 0x24 + private const val VK_LEFT = 0x25 + private const val VK_UP = 0x26 + private const val VK_RIGHT = 0x27 + private const val VK_DOWN = 0x28 + private const val VK_SPACE = 0x20 + private const val VK_DELETE = 0x2E + private const val VK_0 = 0x30 + private const val VK_9 = 0x39 + private const val VK_A = 0x41 + private const val VK_N = 0x4E + private const val VK_Q = 0x51 + private const val VK_Z = 0x5A + private const val VK_LWIN = 0x5B + private const val VK_RWIN = 0x5C + private const val VK_OEM_1 = 0xBA + private const val VK_OEM_PLUS = 0xBB + private const val VK_OEM_COMMA = 0xBC + private const val VK_OEM_MINUS = 0xBD + private const val VK_OEM_PERIOD = 0xBE + private const val VK_OEM_2 = 0xBF + private const val VK_OEM_3 = 0xC0 + private const val VK_OEM_4 = 0xDB + private const val VK_OEM_5 = 0xDC + private const val VK_OEM_6 = 0xDD + private const val VK_OEM_7 = 0xDE + + @Volatile + var instance: InputFlowAccessibilityService? = null + private set + } +} diff --git a/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt b/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt new file mode 100644 index 0000000..391ffd2 --- /dev/null +++ b/android/app/src/main/java/com/inputflow/android/InputFlowImeService.kt @@ -0,0 +1,295 @@ +package com.inputflow.android + +import android.inputmethodservice.InputMethodService +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import android.view.KeyEvent +import android.view.View +import org.json.JSONObject +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +class InputFlowImeService : InputMethodService() { + private val mainHandler = Handler(Looper.getMainLooper()) + private var shiftDown = false + private var ctrlDown = false + private var altDown = false + private var metaDown = false + + override fun onCreate() { + super.onCreate() + instance = this + } + + override fun onDestroy() { + if (instance === this) { + instance = null + } + super.onDestroy() + } + + override fun onEvaluateInputViewShown(): Boolean = false + + override fun onCreateInputView(): View { + return View(this) + } + + fun handleKeyboard(frame: JSONObject): Boolean { + if (Looper.myLooper() == Looper.getMainLooper()) { + return handleKeyboardOnMain(frame) + } + + val handled = AtomicBoolean(false) + val latch = CountDownLatch(1) + mainHandler.post { + handled.set(handleKeyboardOnMain(frame)) + latch.countDown() + } + latch.await(250, TimeUnit.MILLISECONDS) + return handled.get() + } + + private fun handleKeyboardOnMain(frame: JSONObject): Boolean { + val vkCode = frame.optInt("vkCode") + val flags = frame.optInt("flags") + val keyUp = (flags and LLKHF_UP) != 0 + updateModifier(vkCode, keyUp) + + if (keyUp || altDown || metaDown || isCommandKey(vkCode)) { + return false + } + + val action = inputAction(vkCode) ?: return false + val connection = currentInputConnection + if (connection == null) { + Log.i(TAG, "keyboard ignored: no current input connection") + return false + } + val handled = when (action) { + is InputAction.Commit -> connection.commitText(action.text, 1) + InputAction.Backspace -> connection.deleteSurroundingText(1, 0) + InputAction.Enter -> { + connection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)) && + connection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)) + } + is InputAction.Key -> { + connection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, action.keyCode)) && + connection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, action.keyCode)) + } + is InputAction.ModifiedKey -> sendModifiedKey(connection, action.keyCode, action.metaState) + is InputAction.Menu -> connection.performContextMenuAction(action.id) + } + Log.i(TAG, "keyboard action=${action.name} vk=$vkCode handled=$handled") + return handled + } + + private fun updateModifier(vkCode: Int, keyUp: Boolean) { + val down = !keyUp + when (vkCode) { + VK_SHIFT -> shiftDown = down + VK_CONTROL -> ctrlDown = down + VK_MENU -> altDown = down + VK_LWIN, VK_RWIN -> metaDown = down + } + } + + private fun isCommandKey(vkCode: Int): Boolean { + return vkCode == VK_ESCAPE || + vkCode == VK_LWIN || + vkCode == VK_RWIN + } + + private fun inputAction(vkCode: Int): InputAction? { + if (ctrlDown) { + return when (vkCode) { + VK_A -> InputAction.Menu(android.R.id.selectAll) + VK_C -> InputAction.Menu(android.R.id.copy) + VK_V -> InputAction.Menu(android.R.id.paste) + VK_X -> InputAction.Menu(android.R.id.cut) + VK_Z -> InputAction.ModifiedKey(KeyEvent.KEYCODE_Z, KeyEvent.META_CTRL_ON) + VK_Y -> InputAction.ModifiedKey(KeyEvent.KEYCODE_Y, KeyEvent.META_CTRL_ON) + else -> keyEventAction(vkCode)?.let { InputAction.ModifiedKey(it, KeyEvent.META_CTRL_ON) } + } + } + return when (vkCode) { + VK_BACK -> InputAction.Backspace + VK_DELETE -> InputAction.Key(KeyEvent.KEYCODE_FORWARD_DEL) + VK_RETURN -> InputAction.Enter + VK_TAB -> InputAction.Commit("\t") + VK_SPACE -> InputAction.Commit(" ") + VK_LEFT -> InputAction.Key(KeyEvent.KEYCODE_DPAD_LEFT) + VK_UP -> InputAction.Key(KeyEvent.KEYCODE_DPAD_UP) + VK_RIGHT -> InputAction.Key(KeyEvent.KEYCODE_DPAD_RIGHT) + VK_DOWN -> InputAction.Key(KeyEvent.KEYCODE_DPAD_DOWN) + VK_HOME -> InputAction.Key(KeyEvent.KEYCODE_MOVE_HOME) + VK_END -> InputAction.Key(KeyEvent.KEYCODE_MOVE_END) + VK_PRIOR -> InputAction.Key(KeyEvent.KEYCODE_PAGE_UP) + VK_NEXT -> InputAction.Key(KeyEvent.KEYCODE_PAGE_DOWN) + in VK_0..VK_9 -> InputAction.Commit(keyChar(vkCode)) + in VK_A..VK_Z -> InputAction.Commit(keyChar(vkCode)) + in VK_NUMPAD0..VK_NUMPAD9 -> InputAction.Commit((vkCode - VK_NUMPAD0).toString()) + VK_MULTIPLY, VK_ADD, VK_SUBTRACT, VK_DECIMAL, VK_DIVIDE -> InputAction.Commit(keyChar(vkCode)) + VK_OEM_COMMA, VK_OEM_PERIOD, VK_OEM_MINUS, VK_OEM_PLUS, + VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7 -> + InputAction.Commit(keyChar(vkCode)) + else -> null + } + } + + private fun keyEventAction(vkCode: Int): Int? { + return when (vkCode) { + VK_TAB -> KeyEvent.KEYCODE_TAB + VK_LEFT -> KeyEvent.KEYCODE_DPAD_LEFT + VK_UP -> KeyEvent.KEYCODE_DPAD_UP + VK_RIGHT -> KeyEvent.KEYCODE_DPAD_RIGHT + VK_DOWN -> KeyEvent.KEYCODE_DPAD_DOWN + VK_HOME -> KeyEvent.KEYCODE_MOVE_HOME + VK_END -> KeyEvent.KEYCODE_MOVE_END + VK_PRIOR -> KeyEvent.KEYCODE_PAGE_UP + VK_NEXT -> KeyEvent.KEYCODE_PAGE_DOWN + in VK_A..VK_Z -> KeyEvent.KEYCODE_A + (vkCode - VK_A) + else -> null + } + } + + private fun sendModifiedKey(connection: android.view.inputmethod.InputConnection, keyCode: Int, metaState: Int): Boolean { + val now = SystemClock.uptimeMillis() + return connection.sendKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode, 0, metaState)) && + connection.sendKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_UP, keyCode, 0, metaState)) + } + + private fun keyChar(vkCode: Int): String { + val shifted = shiftDown + if (vkCode in VK_A..VK_Z) { + val c = vkCode.toChar() + return if (shifted) c.toString() else c.lowercaseChar().toString() + } + if (vkCode in VK_0..VK_9) { + val normal = "0123456789"[vkCode - VK_0] + val shiftedChars = ")!@#$%^&*("[vkCode - VK_0] + return (if (shifted) shiftedChars else normal).toString() + } + if (vkCode in VK_NUMPAD0..VK_NUMPAD9) { + return (vkCode - VK_NUMPAD0).toString() + } + return when (vkCode) { + VK_MULTIPLY -> "*" + VK_ADD -> "+" + VK_SUBTRACT -> "-" + VK_DECIMAL -> "." + VK_DIVIDE -> "/" + VK_OEM_COMMA -> if (shifted) "<" else "," + VK_OEM_PERIOD -> if (shifted) ">" else "." + VK_OEM_MINUS -> if (shifted) "_" else "-" + VK_OEM_PLUS -> if (shifted) "+" else "=" + VK_OEM_1 -> if (shifted) ":" else ";" + VK_OEM_2 -> if (shifted) "?" else "/" + VK_OEM_3 -> if (shifted) "~" else "`" + VK_OEM_4 -> if (shifted) "{" else "[" + VK_OEM_5 -> if (shifted) "|" else "\\" + VK_OEM_6 -> if (shifted) "}" else "]" + VK_OEM_7 -> if (shifted) "\"" else "'" + else -> "" + } + } + + private sealed class InputAction { + abstract val name: String + + data class Commit(val text: String) : InputAction() { + override val name = "commit" + } + + object Backspace : InputAction() { + override val name = "backspace" + } + + object Enter : InputAction() { + override val name = "enter" + } + + data class Key(val keyCode: Int) : InputAction() { + override val name = "key" + } + + data class ModifiedKey(val keyCode: Int, val metaState: Int) : InputAction() { + override val name = "modified-key" + } + + data class Menu(val id: Int) : InputAction() { + override val name = "menu" + } + } + + companion object { + private const val TAG = "InputFlowIme" + private const val LLKHF_UP = 0x80 + private const val VK_BACK = 0x08 + private const val VK_TAB = 0x09 + private const val VK_RETURN = 0x0D + private const val VK_SHIFT = 0x10 + private const val VK_CONTROL = 0x11 + private const val VK_MENU = 0x12 + private const val VK_ESCAPE = 0x1B + private const val VK_SPACE = 0x20 + private const val VK_PRIOR = 0x21 + private const val VK_NEXT = 0x22 + private const val VK_END = 0x23 + private const val VK_HOME = 0x24 + private const val VK_LEFT = 0x25 + private const val VK_UP = 0x26 + private const val VK_RIGHT = 0x27 + private const val VK_DOWN = 0x28 + private const val VK_DELETE = 0x2E + private const val VK_0 = 0x30 + private const val VK_9 = 0x39 + private const val VK_A = 0x41 + private const val VK_C = 0x43 + private const val VK_V = 0x56 + private const val VK_X = 0x58 + private const val VK_Y = 0x59 + private const val VK_Z = 0x5A + private const val VK_LWIN = 0x5B + private const val VK_RWIN = 0x5C + private const val VK_NUMPAD0 = 0x60 + private const val VK_NUMPAD9 = 0x69 + private const val VK_MULTIPLY = 0x6A + private const val VK_ADD = 0x6B + private const val VK_SUBTRACT = 0x6D + private const val VK_DECIMAL = 0x6E + private const val VK_DIVIDE = 0x6F + private const val VK_OEM_1 = 0xBA + private const val VK_OEM_PLUS = 0xBB + private const val VK_OEM_COMMA = 0xBC + private const val VK_OEM_MINUS = 0xBD + private const val VK_OEM_PERIOD = 0xBE + private const val VK_OEM_2 = 0xBF + private const val VK_OEM_3 = 0xC0 + private const val VK_OEM_4 = 0xDB + private const val VK_OEM_5 = 0xDC + private const val VK_OEM_6 = 0xDD + private const val VK_OEM_7 = 0xDE + + @Volatile + var instance: InputFlowImeService? = null + private set + + fun restorePreviousKeyboard(): Boolean { + val service = instance ?: return false + service.mainHandler.post { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val handled = service.switchToPreviousInputMethod() + Log.i(TAG, "restore previous keyboard handled=$handled") + } else { + service.requestHideSelf(0) + Log.i(TAG, "restore previous keyboard unavailable on this Android version") + } + } + return true + } + } +} diff --git a/android/app/src/main/java/com/inputflow/android/KeyMap.kt b/android/app/src/main/java/com/inputflow/android/KeyMap.kt new file mode 100644 index 0000000..d99104f --- /dev/null +++ b/android/app/src/main/java/com/inputflow/android/KeyMap.kt @@ -0,0 +1,79 @@ +package com.inputflow.android + +import android.content.SharedPreferences + +object KeyMap { + const val MOD_SHIFT = 1 + const val MOD_CTRL = 2 + const val MOD_ALT = 4 + const val MOD_WIN = 8 + + data class Action(val id: String, val label: String, val defaultVk: Int, val defaultMods: Int) + + val ACTIONS = listOf( + Action("back", "Back", 0x1B, 0), + Action("home", "Home", 0x5B, 0), + Action("recents", "Recent Apps", 0x09, MOD_ALT), + Action("notifications", "Notifications", 0x4E, MOD_CTRL or MOD_ALT), + Action("quick_settings", "Quick Settings", 0x51, MOD_CTRL or MOD_ALT), + Action("volume_up", "Volume Up", 0xAF, 0), + Action("volume_down", "Volume Down", 0xAE, 0), + Action("screenshot", "Screenshot", 0x2C, 0), + Action("swipe_left", "Swipe Left (Back)", 0, 0), + Action("swipe_right", "Swipe Right", 0, 0), + Action("swipe_up", "Swipe Up / Home", 0, 0), + Action("swipe_down", "Swipe Down", 0, 0), + Action("zoom_in", "Zoom In", 0, 0), + Action("zoom_out", "Zoom Out", 0, 0), + ) + + fun getVk(prefs: SharedPreferences, action: Action): Int = + prefs.getInt("keymap_${action.id}_vk", action.defaultVk) + + fun getMods(prefs: SharedPreferences, action: Action): Int = + prefs.getInt("keymap_${action.id}_mods", action.defaultMods) + + fun save(prefs: SharedPreferences, actionId: String, vk: Int, mods: Int) { + prefs.edit() + .putInt("keymap_${actionId}_vk", vk) + .putInt("keymap_${actionId}_mods", mods) + .apply() + } + + fun bindingText(vk: Int, mods: Int): String { + if (vk == 0) return "None" + return buildString { + if (mods and MOD_CTRL != 0) append("Ctrl+") + if (mods and MOD_ALT != 0) append("Alt+") + if (mods and MOD_SHIFT != 0) append("Shift+") + if (mods and MOD_WIN != 0) append("Win+") + append(vkName(vk)) + } + } + + fun vkName(vk: Int): String = when (vk) { + 0x08 -> "Bksp" + 0x09 -> "Tab" + 0x0D -> "Enter" + 0x1B -> "Esc" + 0x20 -> "Space" + 0x2C -> "PrtSc" + 0x2E -> "Del" + 0x5B, 0x5C -> "Win" + 0xAE -> "Vol-" + 0xAF -> "Vol+" + in 0x41..0x5A -> ('A' + vk - 0x41).toChar().toString() + in 0x30..0x39 -> ('0' + vk - 0x30).toChar().toString() + in 0x70..0x7B -> "F${vk - 0x70 + 1}" + else -> "0x${vk.toString(16).uppercase()}" + } + + fun currentMods(shift: Boolean, ctrl: Boolean, alt: Boolean, win: Boolean): Int { + var m = 0 + if (shift) m = m or MOD_SHIFT + if (ctrl) m = m or MOD_CTRL + if (alt) m = m or MOD_ALT + if (win) m = m or MOD_WIN + return m + } +} diff --git a/android/app/src/main/java/com/inputflow/android/KeyMapperActivity.kt b/android/app/src/main/java/com/inputflow/android/KeyMapperActivity.kt new file mode 100644 index 0000000..208da81 --- /dev/null +++ b/android/app/src/main/java/com/inputflow/android/KeyMapperActivity.kt @@ -0,0 +1,125 @@ +package com.inputflow.android + +import android.content.Context +import android.os.Bundle +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.chip.Chip +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +class KeyMapperActivity : AppCompatActivity() { + + private lateinit var adapter: ShortcutAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_key_mapper) + + val toolbar = findViewById(R.id.toolbar) + setSupportActionBar(toolbar) + supportActionBar?.setDisplayShowTitleEnabled(false) + toolbar.setNavigationOnClickListener { finish() } + + val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) + adapter = ShortcutAdapter(prefs) { action, vk, mods -> + KeyMap.save(prefs, action.id, vk, mods) + } + + val recycler = findViewById(R.id.shortcutsList) + recycler.layoutManager = LinearLayoutManager(this) + recycler.adapter = adapter + } + + override fun onDestroy() { + RelayForegroundService.keyCaptureCallback = null + super.onDestroy() + } + + private fun openBindingDialog(action: KeyMap.Action, onSaved: (Int, Int) -> Unit) { + RelayForegroundService.keyCaptureCallback = null + + val tvHint = TextView(this).apply { + text = getString(R.string.shortcut_listen_hint) + textSize = 18f + gravity = Gravity.CENTER + setPadding(64, 48, 64, 16) + } + + var capturedVk = 0 + var currentMods = 0 + + val dialog = MaterialAlertDialogBuilder(this) + .setTitle(action.label) + .setView(tvHint) + .setNegativeButton(android.R.string.cancel) { _, _ -> + RelayForegroundService.keyCaptureCallback = null + } + .setNeutralButton(R.string.shortcut_clear) { _, _ -> + RelayForegroundService.keyCaptureCallback = null + onSaved(0, 0) + } + .setPositiveButton(android.R.string.ok) { _, _ -> + RelayForegroundService.keyCaptureCallback = null + if (capturedVk != 0) onSaved(capturedVk, currentMods) + } + .create() + + dialog.setOnDismissListener { + RelayForegroundService.keyCaptureCallback = null + } + + RelayForegroundService.keyCaptureCallback = { vk, flags -> + val down = (flags and 0x80) == 0 + when (vk) { + 0x10 -> currentMods = if (down) currentMods or KeyMap.MOD_SHIFT else currentMods and KeyMap.MOD_SHIFT.inv() + 0x11 -> currentMods = if (down) currentMods or KeyMap.MOD_CTRL else currentMods and KeyMap.MOD_CTRL.inv() + 0x12 -> currentMods = if (down) currentMods or KeyMap.MOD_ALT else currentMods and KeyMap.MOD_ALT.inv() + 0x5B, 0x5C -> currentMods = if (down) currentMods or KeyMap.MOD_WIN else currentMods and KeyMap.MOD_WIN.inv() + else -> if (down) { + capturedVk = vk + runOnUiThread { tvHint.text = KeyMap.bindingText(vk, currentMods) } + } + } + } + + dialog.show() + } + + inner class ShortcutAdapter( + private val prefs: android.content.SharedPreferences, + private val onSaved: (KeyMap.Action, Int, Int) -> Unit + ) : RecyclerView.Adapter() { + + inner class VH(view: View) : RecyclerView.ViewHolder(view) { + val tvAction: TextView = view.findViewById(R.id.tvAction) + val chipBinding: Chip = view.findViewById(R.id.chipBinding) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = + VH(LayoutInflater.from(parent.context).inflate(R.layout.row_shortcut, parent, false)) + + override fun getItemCount() = KeyMap.ACTIONS.size + + override fun onBindViewHolder(holder: VH, position: Int) { + val action = KeyMap.ACTIONS[position] + holder.tvAction.text = action.label + holder.chipBinding.text = KeyMap.bindingText( + KeyMap.getVk(prefs, action), + KeyMap.getMods(prefs, action) + ) + holder.chipBinding.setOnClickListener { + openBindingDialog(action) { vk, mods -> + onSaved(action, vk, mods) + notifyItemChanged(position) + } + } + } + } +} diff --git a/android/app/src/main/java/com/inputflow/android/LayoutEditorActivity.kt b/android/app/src/main/java/com/inputflow/android/LayoutEditorActivity.kt new file mode 100644 index 0000000..eb70597 --- /dev/null +++ b/android/app/src/main/java/com/inputflow/android/LayoutEditorActivity.kt @@ -0,0 +1,115 @@ +package com.inputflow.android + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.button.MaterialButton +import org.json.JSONArray +import org.json.JSONObject + +class LayoutEditorActivity : AppCompatActivity() { + + private lateinit var editorView: LayoutEditorView + private lateinit var notConnectedHint: TextView + private lateinit var btnApply: MaterialButton + + private val devicesReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val json = intent.getStringExtra(RelayForegroundService.EXTRA_DEVICES_JSON) ?: return + applyDevicesJson(json) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_layout_editor) + + val toolbar = findViewById(R.id.toolbar) + setSupportActionBar(toolbar) + supportActionBar?.setDisplayShowTitleEnabled(false) + toolbar.setNavigationOnClickListener { finish() } + + editorView = findViewById(R.id.editorView) + notConnectedHint = findViewById(R.id.notConnectedHint) + btnApply = findViewById(R.id.btnApplyLayout) + + val cached = RelayForegroundService.cachedDevicesJson + if (cached != null) { + applyDevicesJson(cached) + } else { + showNotConnected() + } + + btnApply.setOnClickListener { + val layout = editorView.getLayoutJson() + RelayForegroundService.instance?.sendTopologyUpdate(layout) + } + } + + override fun onResume() { + super.onResume() + val filter = IntentFilter(RelayForegroundService.ACTION_DEVICES_BROADCAST) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(devicesReceiver, filter, RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + registerReceiver(devicesReceiver, filter) + } + } + + override fun onPause() { + super.onPause() + try { unregisterReceiver(devicesReceiver) } catch (_: IllegalArgumentException) {} + } + + private fun applyDevicesJson(json: String) { + try { + val obj = JSONObject(json) + val devArr = obj.getJSONArray("devices") + val layoutArr = obj.optJSONArray("layout") ?: JSONArray() + val layoutMap = mutableMapOf>() + for (i in 0 until layoutArr.length()) { + val item = layoutArr.getJSONObject(i) + layoutMap[item.getString("id")] = item.getInt("x") to item.getInt("y") + } + + val cell = (64 * resources.displayMetrics.density).toInt().coerceAtLeast(1) + val localId = "android" + val rects = mutableListOf() + + for (i in 0 until devArr.length()) { + val dev = devArr.getJSONObject(i) + val id = dev.getString("id") + val name = dev.getString("name") + val screenW = dev.optInt("width", 1920).coerceAtLeast(1) + val screenH = dev.optInt("height", 1080).coerceAtLeast(1) + val gridW = (screenW / cell).coerceAtLeast(4) + val gridH = (screenH / cell).coerceAtLeast(3) + val pos = layoutMap[id] + val gridX = ((pos?.first ?: 0) / cell) + val gridY = ((pos?.second ?: 0) / cell) + rects.add(DeviceRect(id, name, gridX, gridY, gridW, gridH, id == localId)) + } + + editorView.devices = rects + editorView.visibility = View.VISIBLE + notConnectedHint.visibility = View.GONE + btnApply.isEnabled = true + } catch (_: Exception) { + showNotConnected() + } + } + + private fun showNotConnected() { + editorView.visibility = View.INVISIBLE + notConnectedHint.visibility = View.VISIBLE + btnApply.isEnabled = false + } +} diff --git a/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt b/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt new file mode 100644 index 0000000..bfbc6b0 --- /dev/null +++ b/android/app/src/main/java/com/inputflow/android/LayoutEditorView.kt @@ -0,0 +1,194 @@ +package com.inputflow.android + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import kotlin.math.roundToInt + +data class DeviceRect( + val id: String, + val name: String, + var gridX: Int, + var gridY: Int, + val gridW: Int, + val gridH: Int, + val isLocal: Boolean +) + +class LayoutEditorView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : View(context, attrs) { + + var devices: List = emptyList() + set(value) { + field = value + centerDevices() + invalidate() + } + + private val gridCellPx get() = (64 * resources.displayMetrics.density).toInt() + + private val bgPaint = Paint().apply { + color = Color.parseColor("#0A1628") + style = Paint.Style.FILL + } + private val gridPaint = Paint().apply { + color = Color.parseColor("#1A2840") + style = Paint.Style.STROKE + strokeWidth = 1f + } + private val deviceFillPaint = Paint().apply { + style = Paint.Style.FILL + isAntiAlias = true + } + private val deviceStrokePaint = Paint().apply { + style = Paint.Style.STROKE + strokeWidth = 2f + isAntiAlias = true + } + private val labelPaint = Paint().apply { + color = Color.WHITE + textSize = 14f * resources.displayMetrics.density + isAntiAlias = true + typeface = Typeface.DEFAULT_BOLD + textAlign = Paint.Align.CENTER + } + private val badgePaint = Paint().apply { + color = Color.parseColor("#1A2840") + textSize = 10f * resources.displayMetrics.density + isAntiAlias = true + textAlign = Paint.Align.CENTER + } + + private var draggingId: String? = null + private var touchOffsetX = 0f + private var touchOffsetY = 0f + private var viewOffsetX = 0 + private var viewOffsetY = 0 + + private fun centerDevices() { + if (devices.isEmpty() || width == 0 || height == 0) return + val minGx = devices.minOf { it.gridX } + val minGy = devices.minOf { it.gridY } + val maxGx = devices.maxOf { it.gridX + it.gridW } + val maxGy = devices.maxOf { it.gridY + it.gridH } + val totalW = (maxGx - minGx) * gridCellPx + val totalH = (maxGy - minGy) * gridCellPx + viewOffsetX = (width - totalW) / 2 - minGx * gridCellPx + viewOffsetY = (height - totalH) / 2 - minGy * gridCellPx + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + centerDevices() + } + + override fun onDraw(canvas: Canvas) { + // Background + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint) + + // Grid + val cell = gridCellPx.toFloat() + var gx = (viewOffsetX % cell + cell) % cell + while (gx < width) { + canvas.drawLine(gx, 0f, gx, height.toFloat(), gridPaint) + gx += cell + } + var gy = (viewOffsetY % cell + cell) % cell + while (gy < height) { + canvas.drawLine(0f, gy, width.toFloat(), gy, gridPaint) + gy += cell + } + + // Devices + for (device in devices) { + val px = device.gridX * gridCellPx + viewOffsetX + val py = device.gridY * gridCellPx + viewOffsetY + val pw = device.gridW * gridCellPx + val ph = device.gridH * gridCellPx + val rect = RectF(px.toFloat(), py.toFloat(), (px + pw).toFloat(), (py + ph).toFloat()) + val radius = 12f * resources.displayMetrics.density + + deviceFillPaint.color = if (device.isLocal) + Color.parseColor("#1B5E20") + else + Color.parseColor("#0D47A1") + deviceFillPaint.alpha = 200 + canvas.drawRoundRect(rect, radius, radius, deviceFillPaint) + + deviceStrokePaint.color = if (device.isLocal) + Color.parseColor("#66BB6A") + else + Color.parseColor("#42A5F5") + canvas.drawRoundRect(rect, radius, radius, deviceStrokePaint) + + // Label + val cx = rect.centerX() + val cy = rect.centerY() + val lineH = labelPaint.textSize + if (device.isLocal) { + canvas.drawText(device.name, cx, cy - lineH * 0.2f, labelPaint) + canvas.drawText(context.getString(R.string.editor_this_device), cx, cy + lineH * 0.9f, badgePaint) + } else { + canvas.drawText(device.name, cx, cy + lineH * 0.35f, labelPaint) + } + } + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + val cell = gridCellPx + for (device in devices.reversed()) { + if (device.isLocal) continue + val px = device.gridX * cell + viewOffsetX + val py = device.gridY * cell + viewOffsetY + val pw = device.gridW * cell + val ph = device.gridH * cell + if (event.x >= px && event.x <= px + pw && event.y >= py && event.y <= py + ph) { + draggingId = device.id + touchOffsetX = event.x - px + touchOffsetY = event.y - py + return true + } + } + } + MotionEvent.ACTION_MOVE -> { + val id = draggingId ?: return false + val device = devices.find { it.id == id } ?: return false + val cell = gridCellPx + val newPx = event.x - touchOffsetX - viewOffsetX + val newPy = event.y - touchOffsetY - viewOffsetY + device.gridX = (newPx / cell).roundToInt() + device.gridY = (newPy / cell).roundToInt() + invalidate() + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + draggingId = null + invalidate() + } + } + return super.onTouchEvent(event) + } + + fun getLayoutJson(): org.json.JSONArray { + val arr = org.json.JSONArray() + for (device in devices) { + arr.put( + org.json.JSONObject() + .put("id", device.id) + .put("x", device.gridX * gridCellPx) + .put("y", device.gridY * gridCellPx) + ) + } + return arr + } +} diff --git a/android/app/src/main/java/com/inputflow/android/MainActivity.kt b/android/app/src/main/java/com/inputflow/android/MainActivity.kt new file mode 100644 index 0000000..75fa783 --- /dev/null +++ b/android/app/src/main/java/com/inputflow/android/MainActivity.kt @@ -0,0 +1,218 @@ +package com.inputflow.android + +import android.Manifest +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.view.Menu +import android.view.MenuItem +import android.view.inputmethod.InputMethodManager +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.button.MaterialButton +import com.google.android.material.card.MaterialCardView +import com.google.android.material.textfield.TextInputEditText + +class MainActivity : AppCompatActivity() { + + private lateinit var hostField: TextInputEditText + private lateinit var portField: TextInputEditText + private lateinit var secretField: TextInputEditText + private lateinit var statusCard: MaterialCardView + private lateinit var statusDot: android.view.View + private lateinit var statusText: TextView + private lateinit var statusDetail: TextView + + private val statusReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val state = intent.getStringExtra(RelayForegroundService.EXTRA_STATE) ?: return + val detail = intent.getStringExtra(RelayForegroundService.EXTRA_DETAIL) + updateStatus(state, detail) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + val toolbar = findViewById(R.id.toolbar) + setSupportActionBar(toolbar) + supportActionBar?.setDisplayShowTitleEnabled(false) + toolbar.setOnMenuItemClickListener { item -> + if (item.itemId == R.id.action_settings) { + startActivity(Intent(this, SettingsActivity::class.java)) + true + } else { + false + } + } + + hostField = findViewById(R.id.hostField) + portField = findViewById(R.id.portField) + secretField = findViewById(R.id.secretField) + statusCard = findViewById(R.id.statusCard) + statusDot = findViewById(R.id.statusDot) + statusText = findViewById(R.id.statusText) + statusDetail = findViewById(R.id.statusDetail) + + val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) + applyPairingUri(intent) + hostField.setText(prefs.getString(RelayForegroundService.KEY_HOST, "")) + portField.setText(prefs.getInt(RelayForegroundService.KEY_PORT, 15102).toString()) + secretField.setText(prefs.getString(RelayForegroundService.KEY_SECRET, "")) + + findViewById(R.id.btnConnect).setOnClickListener { + prefs.edit() + .putString(RelayForegroundService.KEY_HOST, hostField.text.toString().trim()) + .putInt(RelayForegroundService.KEY_PORT, portField.text.toString().toIntOrNull() ?: 15102) + .putString(RelayForegroundService.KEY_SECRET, secretField.text.toString().trim()) + .apply() + requestNotificationPermission() + startRelayService(Intent(this, RelayForegroundService::class.java)) + } + + findViewById(R.id.btnRelease).setOnClickListener { + startRelayService( + Intent(this, RelayForegroundService::class.java) + .setAction(RelayForegroundService.ACTION_RELEASE) + ) + } + + findViewById(R.id.btnAccessibility).setOnClickListener { + startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + } + + findViewById(R.id.btnKeyboard).setOnClickListener { + (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() + } + + findViewById(R.id.btnNotifications).setOnClickListener { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")) + ) + } + } + + refreshStatus() + } + + override fun onResume() { + super.onResume() + val filter = IntentFilter(RelayForegroundService.ACTION_STATUS_BROADCAST) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(statusReceiver, filter, RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + registerReceiver(statusReceiver, filter) + } + refreshStatus() + } + + override fun onPause() { + super.onPause() + try { unregisterReceiver(statusReceiver) } catch (_: IllegalArgumentException) {} + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.action_settings) { + startActivity(Intent(this, SettingsActivity::class.java)) + return true + } + return super.onOptionsItemSelected(item) + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.main_menu, menu) + return true + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + applyPairingUri(intent) + val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) + hostField.setText(prefs.getString(RelayForegroundService.KEY_HOST, "")) + portField.setText(prefs.getInt(RelayForegroundService.KEY_PORT, 15102).toString()) + secretField.setText(prefs.getString(RelayForegroundService.KEY_SECRET, "")) + } + + private fun refreshStatus() { + val state = if (InputFlowAccessibilityService.instance != null) + RelayForegroundService.STATE_CONNECTED + else + RelayForegroundService.STATE_DISCONNECTED + updateStatus(state, null) + } + + private fun updateStatus(state: String, detail: String?) { + val cardBgRes: Int + val accentColorRes: Int + val labelRes: Int + when (state) { + RelayForegroundService.STATE_CONNECTED -> { + cardBgRes = R.color.colorConnectedBg + accentColorRes = R.color.colorConnected + labelRes = R.string.status_connected + } + RelayForegroundService.STATE_CONNECTING -> { + cardBgRes = R.color.colorConnectingBg + accentColorRes = R.color.colorConnecting + labelRes = R.string.status_connecting + } + else -> { + cardBgRes = R.color.colorDisconnectedBg + accentColorRes = R.color.colorDisconnected + labelRes = R.string.status_disconnected + } + } + val accentColor = getColor(accentColorRes) + statusCard.setCardBackgroundColor(getColor(cardBgRes)) + statusDot.background.setTint(accentColor) + statusText.setTextColor(accentColor) + statusText.setText(labelRes) + if (detail != null) { + statusDetail.visibility = android.view.View.VISIBLE + statusDetail.text = detail + statusDetail.setTextColor(accentColor) + } else { + statusDetail.visibility = android.view.View.GONE + } + } + + private fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT >= 33) { + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 20) + } + } + + private fun applyPairingUri(intent: Intent?) { + val data = intent?.data ?: return + if (data.scheme != "inputflow" || data.host != "android-peer") return + val host = data.getQueryParameter("host").orEmpty() + val port = data.getQueryParameter("port")?.toIntOrNull() ?: 15102 + val secret = data.getQueryParameter("secret").orEmpty().trim() + if (host.isBlank() || secret.isBlank()) return + getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) + .edit() + .putString(RelayForegroundService.KEY_HOST, host) + .putInt(RelayForegroundService.KEY_PORT, port) + .putString(RelayForegroundService.KEY_SECRET, secret) + .apply() + } + + private fun startRelayService(intent: Intent) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + } +} diff --git a/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt new file mode 100644 index 0000000..d3fbbc2 --- /dev/null +++ b/android/app/src/main/java/com/inputflow/android/RelayForegroundService.kt @@ -0,0 +1,219 @@ +package com.inputflow.android + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import org.json.JSONArray +import org.json.JSONObject +import java.io.DataOutputStream +import java.net.Socket +import java.util.concurrent.atomic.AtomicBoolean + +class RelayForegroundService : Service() { + private val running = AtomicBoolean(false) + private var worker: Thread? = null + + @Volatile + private var output: DataOutputStream? = null + @Volatile + private var remoteControlActive = false + + override fun onCreate() { + super.onCreate() + instance = this + createNotificationChannel() + } + + override fun onDestroy() { + if (instance === this) instance = null + running.set(false) + worker?.interrupt() + output = null + deactivateRemoteControl() + super.onDestroy() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_RELEASE -> { + if (!running.get()) { + startForeground(NOTIFICATION_ID, notification("Release requested")) + deactivateRemoteControl() + stopSelf() + } else { + sendRelease() + deactivateRemoteControl() + } + } + ACTION_STOP -> { + if (!running.get()) { + startForeground(NOTIFICATION_ID, notification("Stopping")) + } + deactivateRemoteControl() + stopSelf() + } + else -> startRelay() + } + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + fun sendTopologyUpdate(layout: JSONArray) { + try { + output?.let { + RelayProtocol.writeFrame(it, JSONObject().put("type", "topology_update").put("layout", layout)) + } + } catch (_: Exception) {} + } + + private fun startRelay() { + if (!running.compareAndSet(false, true)) return + startForeground(NOTIFICATION_ID, notification("Connecting")) + broadcastStatus(STATE_CONNECTING, null) + worker = Thread({ relayLoop() }, "inputflow-relay").also { it.start() } + } + + private fun relayLoop() { + val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE) + while (running.get()) { + val host = prefs.getString(KEY_HOST, "").orEmpty() + val secret = prefs.getString(KEY_SECRET, "").orEmpty() + val port = prefs.getInt(KEY_PORT, 15102) + if (host.isBlank() || secret.isBlank()) { + startForeground(NOTIFICATION_ID, notification("Missing pairing settings")) + broadcastStatus(STATE_DISCONNECTED, "Missing host or secret") + sleepQuietly(2000) + continue + } + + try { + Socket(host, port).use { socket -> + socket.tcpNoDelay = true + val device = "${Build.MANUFACTURER} ${Build.MODEL}".trim() + val streams = RelayProtocol.authenticate(socket, secret, device) + output = streams.second + deactivateRemoteControl() + startForeground(NOTIFICATION_ID, notification("Connected to $host:$port")) + broadcastStatus(STATE_CONNECTED, "$host:$port") + while (running.get()) { + val frame = RelayProtocol.readFrame(streams.first) + when (frame.optString("type")) { + "control" -> setRemoteControlActive(frame.optBoolean("active", false)) + "mouse" -> if (remoteControlActive) { + InputFlowAccessibilityService.instance?.handleMouse(frame) + } + "keyboard" -> { + val cb = keyCaptureCallback + if (cb != null) { + cb(frame.optInt("vkCode"), frame.optInt("flags")) + } else if (remoteControlActive) { + val accessibility = InputFlowAccessibilityService.instance + if (accessibility?.handleMappedKeyboard(frame) != true) { + val laptopTypingEnabled = prefs.getBoolean(KEY_LAPTOP_TYPING_ENABLED, false) + if (!laptopTypingEnabled || InputFlowImeService.instance?.handleKeyboard(frame) != true) { + accessibility?.handleKeyboard(frame) + } + } + } + } + "gesture" -> if (remoteControlActive) { + InputFlowAccessibilityService.instance?.handleGesture(frame) + } + "devices_info" -> handleDevicesInfo(frame) + } + } + deactivateRemoteControl() + } + } catch (_: Exception) { + output = null + deactivateRemoteControl() + startForeground(NOTIFICATION_ID, notification("Disconnected; retrying")) + broadcastStatus(STATE_DISCONNECTED, "Retrying…") + sleepQuietly(1500) + } + } + } + + private fun handleDevicesInfo(frame: JSONObject) { + val json = frame.toString() + cachedDevicesJson = json + sendBroadcast(Intent(ACTION_DEVICES_BROADCAST).putExtra(EXTRA_DEVICES_JSON, json)) + } + + private fun sendRelease() { + try { + output?.let { + RelayProtocol.writeFrame(it, JSONObject().put("type", "release")) + } + } catch (_: Exception) {} + } + + private fun setRemoteControlActive(active: Boolean) { + remoteControlActive = active + InputFlowAccessibilityService.instance?.setRemoteControlActive(active) + if (!active) { + InputFlowImeService.restorePreviousKeyboard() + } + } + + private fun deactivateRemoteControl() { + setRemoteControlActive(false) + } + + private fun broadcastStatus(state: String, detail: String?) { + sendBroadcast( + Intent(ACTION_STATUS_BROADCAST) + .putExtra(EXTRA_STATE, state) + .putExtra(EXTRA_DETAIL, detail) + ) + } + + private fun notification(text: String) = + Notification.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_inputflow) + .setContentTitle(getString(R.string.app_name)) + .setContentText(text) + .setOngoing(true) + .build() + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NotificationManager::class.java) + val channel = NotificationChannel(CHANNEL_ID, "InputFlow relay", NotificationManager.IMPORTANCE_LOW) + manager.createNotificationChannel(channel) + } + + private fun sleepQuietly(ms: Long) { + try { Thread.sleep(ms) } catch (_: InterruptedException) {} + } + + companion object { + const val PREFS = "inputflow" + const val KEY_HOST = "host" + const val KEY_PORT = "port" + const val KEY_SECRET = "secret" + const val KEY_LAPTOP_TYPING_ENABLED = "laptop_typing_enabled" + const val ACTION_RELEASE = "com.inputflow.android.RELEASE" + const val ACTION_STOP = "com.inputflow.android.STOP" + const val ACTION_STATUS_BROADCAST = "com.inputflow.android.STATUS" + const val ACTION_DEVICES_BROADCAST = "com.inputflow.android.DEVICES" + const val EXTRA_STATE = "state" + const val EXTRA_DETAIL = "detail" + const val EXTRA_DEVICES_JSON = "devices_json" + const val STATE_CONNECTED = "connected" + const val STATE_CONNECTING = "connecting" + const val STATE_DISCONNECTED = "disconnected" + private const val CHANNEL_ID = "inputflow-relay" + private const val NOTIFICATION_ID = 10 + + @Volatile var instance: RelayForegroundService? = null + @Volatile var cachedDevicesJson: String? = null + @Volatile var keyCaptureCallback: ((Int, Int) -> Unit)? = null + } +} diff --git a/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt b/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt new file mode 100644 index 0000000..4fd59a4 --- /dev/null +++ b/android/app/src/main/java/com/inputflow/android/RelayProtocol.kt @@ -0,0 +1,50 @@ +package com.inputflow.android + +import org.json.JSONObject +import java.io.DataInputStream +import java.io.DataOutputStream +import java.net.Socket +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +object RelayProtocol { + private const val MAX_FRAME_BYTES = 64 * 1024 + + fun readFrame(input: DataInputStream): JSONObject { + val length = input.readInt() + require(length in 1..MAX_FRAME_BYTES) { "Invalid frame length $length" } + val bytes = ByteArray(length) + input.readFully(bytes) + return JSONObject(bytes.toString(Charsets.UTF_8)) + } + + fun writeFrame(output: DataOutputStream, json: JSONObject) { + val bytes = json.toString().toByteArray(Charsets.UTF_8) + require(bytes.size in 1..MAX_FRAME_BYTES) { "Invalid frame length ${bytes.size}" } + output.writeInt(bytes.size) + output.write(bytes) + output.flush() + } + + fun authenticate(socket: Socket, secret: String, deviceName: String): Pair { + val input = DataInputStream(socket.getInputStream().buffered()) + val output = DataOutputStream(socket.getOutputStream().buffered()) + val hello = readFrame(input) + require(hello.optString("type") == "hello") { "Expected hello frame" } + val nonce = hello.getString("nonce") + val auth = JSONObject() + .put("type", "auth") + .put("device", deviceName) + .put("hmac", hmacSha256Hex(secret, nonce)) + writeFrame(output, auth) + val ready = readFrame(input) + require(ready.optString("type") == "ready") { "Expected ready frame" } + return input to output + } + + private fun hmacSha256Hex(secret: String, message: String): String { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256")) + return mac.doFinal(message.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } + } +} diff --git a/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt b/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt new file mode 100644 index 0000000..68771d3 --- /dev/null +++ b/android/app/src/main/java/com/inputflow/android/SettingsActivity.kt @@ -0,0 +1,167 @@ +package com.inputflow.android + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.inputmethod.InputMethodManager +import android.widget.RadioGroup +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.button.MaterialButton +import com.google.android.material.button.MaterialButtonToggleGroup +import com.google.android.material.chip.ChipGroup +import com.google.android.material.materialswitch.MaterialSwitch +import com.google.android.material.slider.Slider + +class SettingsActivity : AppCompatActivity() { + + private lateinit var cursorSizeGroup: MaterialButtonToggleGroup + private lateinit var cursorColorGroup: ChipGroup + private lateinit var sensitivitySlider: Slider + private lateinit var laptopTypingSwitch: MaterialSwitch + private lateinit var keyboardModeGroup: MaterialButtonToggleGroup + private lateinit var connectionModeGroup: RadioGroup + private lateinit var modeHelpText: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_settings) + + val toolbar = findViewById(R.id.toolbar) + setSupportActionBar(toolbar) + supportActionBar?.setDisplayShowTitleEnabled(false) + toolbar.setNavigationOnClickListener { finish() } + + val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) + + cursorSizeGroup = findViewById(R.id.cursorSizeGroup) + cursorColorGroup = findViewById(R.id.cursorColorGroup) + sensitivitySlider = findViewById(R.id.sensitivitySlider) + laptopTypingSwitch = findViewById(R.id.laptopTypingSwitch) + keyboardModeGroup = findViewById(R.id.keyboardModeGroup) + connectionModeGroup = findViewById(R.id.connectionModeGroup) + modeHelpText = findViewById(R.id.modeHelpText) + + // Restore cursor size + when (prefs.getInt(KEY_CURSOR_SIZE, 2)) { + 1 -> cursorSizeGroup.check(R.id.cursorSizeSmall) + 3 -> cursorSizeGroup.check(R.id.cursorSizeLarge) + else -> cursorSizeGroup.check(R.id.cursorSizeMedium) + } + + // Restore cursor color + when (prefs.getString(KEY_CURSOR_COLOR, "white")) { + "blue" -> cursorColorGroup.check(R.id.colorBlue) + "red" -> cursorColorGroup.check(R.id.colorRed) + "black" -> cursorColorGroup.check(R.id.colorBlack) + else -> cursorColorGroup.check(R.id.colorWhite) + } + + // Restore sensitivity + sensitivitySlider.value = prefs.getFloat(KEY_SENSITIVITY, 1.0f).coerceIn(0.5f, 3.0f) + + laptopTypingSwitch.isChecked = prefs.getBoolean(RelayForegroundService.KEY_LAPTOP_TYPING_ENABLED, false) + + // Restore keyboard mode + when (prefs.getString(KEY_KEYBOARD_MODE, "accessibility")) { + "ime" -> keyboardModeGroup.check(R.id.kbIme) + else -> keyboardModeGroup.check(R.id.kbAccessibility) + } + + // Restore connection mode + when (prefs.getString(KEY_CONNECTION_MODE, "powertoys")) { + "direct" -> connectionModeGroup.check(R.id.modeDirect) + else -> connectionModeGroup.check(R.id.modePowerToys) + } + updateModeHelp(prefs.getString(KEY_CONNECTION_MODE, "powertoys") ?: "powertoys") + + connectionModeGroup.setOnCheckedChangeListener { _, checkedId -> + val mode = if (checkedId == R.id.modeDirect) "direct" else "powertoys" + updateModeHelp(mode) + } + + laptopTypingSwitch.setOnCheckedChangeListener { _, checked -> + saveSettings() + if (checked) { + showKeyboardPicker() + } else { + restoreTabletKeyboard() + } + } + + findViewById(R.id.btnEditLayout).setOnClickListener { + startActivity(Intent(this, LayoutEditorActivity::class.java)) + } + + findViewById(R.id.btnEditShortcuts).setOnClickListener { + startActivity(Intent(this, KeyMapperActivity::class.java)) + } + + findViewById(R.id.btnChooseKeyboard).setOnClickListener { + saveSettings() + showKeyboardPicker() + } + + findViewById(R.id.btnRestoreKeyboard).setOnClickListener { + laptopTypingSwitch.isChecked = false + saveSettings() + restoreTabletKeyboard() + } + } + + override fun onStop() { + super.onStop() + saveSettings() + } + + private fun saveSettings() { + val prefs = getSharedPreferences(RelayForegroundService.PREFS, Context.MODE_PRIVATE) + val cursorSize = when (cursorSizeGroup.checkedButtonId) { + R.id.cursorSizeSmall -> 1 + R.id.cursorSizeLarge -> 3 + else -> 2 + } + val cursorColor = when (cursorColorGroup.checkedChipId) { + R.id.colorBlue -> "blue" + R.id.colorRed -> "red" + R.id.colorBlack -> "black" + else -> "white" + } + val keyboardMode = if (keyboardModeGroup.checkedButtonId == R.id.kbIme) "ime" else "accessibility" + val connectionMode = if (connectionModeGroup.checkedRadioButtonId == R.id.modeDirect) "direct" else "powertoys" + + prefs.edit() + .putInt(KEY_CURSOR_SIZE, cursorSize) + .putString(KEY_CURSOR_COLOR, cursorColor) + .putFloat(KEY_SENSITIVITY, sensitivitySlider.value) + .putBoolean(RelayForegroundService.KEY_LAPTOP_TYPING_ENABLED, laptopTypingSwitch.isChecked) + .putString(KEY_KEYBOARD_MODE, keyboardMode) + .putString(KEY_CONNECTION_MODE, connectionMode) + .apply() + } + + private fun showKeyboardPicker() { + (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() + } + + private fun restoreTabletKeyboard() { + if (!InputFlowImeService.restorePreviousKeyboard()) { + showKeyboardPicker() + } + } + + private fun updateModeHelp(mode: String) { + modeHelpText.setText( + if (mode == "direct") R.string.mode_help_direct else R.string.mode_help_powertoys + ) + } + + companion object { + const val KEY_CURSOR_SIZE = "cursor_size" + const val KEY_CURSOR_COLOR = "cursor_color" + const val KEY_SENSITIVITY = "cursor_sensitivity" + const val KEY_KEYBOARD_MODE = "keyboard_mode" + const val KEY_CONNECTION_MODE = "connection_mode" + } +} diff --git a/android/app/src/main/res/drawable/ic_accessibility.xml b/android/app/src/main/res/drawable/ic_accessibility.xml new file mode 100644 index 0000000..02b5a49 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_accessibility.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_arrow_right.xml b/android/app/src/main/res/drawable/ic_arrow_right.xml new file mode 100644 index 0000000..929ef17 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_arrow_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_back.xml b/android/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..b8e120b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_host.xml b/android/app/src/main/res/drawable/ic_host.xml new file mode 100644 index 0000000..8864be4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_host.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_inputflow.xml b/android/app/src/main/res/drawable/ic_inputflow.xml new file mode 100644 index 0000000..3950a8c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_inputflow.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_key.xml b/android/app/src/main/res/drawable/ic_key.xml new file mode 100644 index 0000000..548970f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_key.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_keyboard.xml b/android/app/src/main/res/drawable/ic_keyboard.xml new file mode 100644 index 0000000..8f96243 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_keyboard.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..43e6823 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_port.xml b/android/app/src/main/res/drawable/ic_port.xml new file mode 100644 index 0000000..95f90bd --- /dev/null +++ b/android/app/src/main/res/drawable/ic_port.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_settings.xml b/android/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..30695ae --- /dev/null +++ b/android/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/status_dot.xml b/android/app/src/main/res/drawable/status_dot.xml new file mode 100644 index 0000000..fab5f97 --- /dev/null +++ b/android/app/src/main/res/drawable/status_dot.xml @@ -0,0 +1,4 @@ + + + diff --git a/android/app/src/main/res/layout/activity_key_mapper.xml b/android/app/src/main/res/layout/activity_key_mapper.xml new file mode 100644 index 0000000..07c2305 --- /dev/null +++ b/android/app/src/main/res/layout/activity_key_mapper.xml @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_layout_editor.xml b/android/app/src/main/res/layout/activity_layout_editor.xml new file mode 100644 index 0000000..fcc94fc --- /dev/null +++ b/android/app/src/main/res/layout/activity_layout_editor.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..33ec11c --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,334 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..ccd7aa4 --- /dev/null +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,470 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/row_shortcut.xml b/android/app/src/main/res/layout/row_shortcut.xml new file mode 100644 index 0000000..2ffe4e0 --- /dev/null +++ b/android/app/src/main/res/layout/row_shortcut.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/menu/main_menu.xml b/android/app/src/main/res/menu/main_menu.xml new file mode 100644 index 0000000..631b51e --- /dev/null +++ b/android/app/src/main/res/menu/main_menu.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..08ebed4 --- /dev/null +++ b/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1,19 @@ + + #4C8FD6 + #FFFFFF + #101418 + #202832 + #0B1014 + #6FCF89 + #173322 + #EF9A9A + #3B1515 + #FFCC80 + #3D2600 + #0A1628 + #1A2840 + #4A90D9 + #4CAF50 + #FFFFFF + #34404D + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..1d55475 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,19 @@ + + #1664C0 + #FFFFFF + #F8FAFF + #E8F0FE + #0A1628 + #2E7D32 + #E8F5E9 + #C62828 + #FFEBEE + #E65100 + #FFF3E0 + #0A1628 + #1A2840 + #1664C0 + #2E7D32 + #FFFFFF + #D0DEF5 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..044aabe --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,65 @@ + + InputFlow + InputFlow controlled peer + InputFlow keyboard + + + Connected + Not connected + Connecting… + Host address + Port + Secret + Save & Connect + Release to Desktop + Open Accessibility Settings + Choose Keyboard + App Notification Settings + Setup steps + Complete these once to activate input control + + + Settings + Cursor + Cursor size + S + M + L + Cursor color + White + Blue + Red + Black + Pointer sensitivity + Keyboard + Laptop typing mode + Use the hidden InputFlow keyboard only while you want laptop keys to type into Android apps. + Fallback input mode + Accessibility + Hidden IME + Choose Keyboard + Restore Tablet Keyboard + Connection mode + Via PowerToys (Windows host) + Direct InputFlow (Linux host) + Windows runs PowerToys Mouse Without Borders. InputFlow on Linux captures that stream and relays input to this device. + InputFlow on Linux is the primary source. No Windows or PowerToys needed. The Linux machine relays keyboard and mouse directly to this device. + Device layout + Edit Device Layout + Drag devices to set their position relative to each other. The green device is this tablet. + + + Keyboard Shortcuts + Map keyboard keys to gestures and system actions. Tap a shortcut chip to reassign it using your laptop keyboard. + Edit Shortcuts + Keyboard Shortcuts + Tap any shortcut to reassign it. Hold Ctrl/Alt/Shift while pressing the new key to create combos. Works with mouse or trackpad. + Press a key on your keyboard… + Clear + + + Device Layout + Apply Layout + This Device + Connect to InputFlow first to load device list + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..23f9639 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/android/app/src/main/res/xml/accessibility_service_config.xml b/android/app/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 0000000..b7de596 --- /dev/null +++ b/android/app/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,8 @@ + diff --git a/android/app/src/main/res/xml/input_method.xml b/android/app/src/main/res/xml/input_method.xml new file mode 100644 index 0000000..e9e6e3f --- /dev/null +++ b/android/app/src/main/res/xml/input_method.xml @@ -0,0 +1,3 @@ + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..52a6554 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,4 @@ +plugins { + id "com.android.application" version "8.7.3" apply false + id "org.jetbrains.kotlin.android" version "2.0.21" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..646c51b --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..b092c69 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "InputFlowAndroid" +include ":app" diff --git a/docs/android.md b/docs/android.md new file mode 100644 index 0000000..52062a2 --- /dev/null +++ b/docs/android.md @@ -0,0 +1,64 @@ +# Android peer MVP + +InputFlow can expose an experimental Android controlled-peer relay. The Linux client remains the hub: Windows sends input to InputFlow through the existing MWB session, and InputFlow forwards input to a paired Android app when topology hands off to the configured Android machine. + +## Linux configuration + +Add these keys to `~/.config/mwb-client/config.ini`: + +```ini +android_peers_enabled=true +android_relay_port=15102 +android_relay_secret=replace-with-a-long-random-secret +android_peer_name=pixel-8 +android_capture_backend=none +``` + +Then enable topology and add a machine/display whose machine id matches `android_peer_name`. When a cross-machine topology edge targets that machine, InputFlow forwards mouse events to Android. Keyboard events follow while the Android relay is active. + +The relay is disabled by default. If `android_relay_secret` is empty, the relay does not start. + +`android_capture_backend` controls Linux-local physical mouse capture: + +- `none`: default. Android relay stays available for already-forwarded topology input without trying to capture the Fedora pointer. +- `evdev`: prototype fallback only. It can mirror/stick the KDE Wayland cursor and should not be used as a production monitor-like path. +- `libei`: planned KDE/Wayland backend. This is the target for real compositor-mediated capture/release behavior. + +## Android app + +The Android project lives in `android/`. + +The app is intentionally no-root: + +- `RelayForegroundService` connects to the Linux relay over LAN. +- `InputFlowAccessibilityService` shows a cursor overlay, dispatches click/scroll gestures, and performs basic focused-node text actions. +- `InputFlowImeService` is included as an optional keyboard surface for later richer text handling. + +Generate a pairing payload with: + +```bash +./build/mwb_client android-pair --config ~/.config/mwb-client/config.ini +``` + +Use the printed `inputflow://android-peer?...` URI as QR content, open it on Android, or enter the same host, port, and secret manually in the app. + +## Current limitations + +- Android input injection uses a no-root overlay plus Accessibility gestures/focused-node text edits, so it is less complete than a real HID or privileged input path. +- Linux physical keyboard/mouse capture needs the KDE/Wayland `libei`/EIS path for monitor-like behavior. The evdev fallback is intentionally opt-in and diagnostic only. +- Android can request control release from the app; richer edge-based return behavior is future work. + +## Controls + +With `android_capture_backend=libei` on KDE/Wayland: + +- Enter Android: push through the configured Fedora edge. +- Return to Fedora: move left until the Android cursor reaches the left edge and keep moving left. +- Left click: tap/click at the Android cursor. +- Right click or `Esc`: Android Back. +- Middle click or `Meta`: Android Home. +- `Alt+Tab`: Android Recents. +- `Ctrl+Alt+N`: notification shade. +- `Ctrl+Alt+Q`: quick settings. +- Two-finger scroll / wheel: scroll focused Android content. +- Basic text input: letters, numbers, punctuation, space, enter, and backspace edit the focused Android text field. diff --git a/src/AndroidRelay.cpp b/src/AndroidRelay.cpp new file mode 100644 index 0000000..3df60f3 --- /dev/null +++ b/src/AndroidRelay.cpp @@ -0,0 +1,474 @@ +#include "AndroidRelay.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace mwb { +namespace { + +constexpr std::size_t kMaxFrameBytes = 64 * 1024; + +void CloseFd(int& fd) { + if (fd >= 0) { + shutdown(fd, SHUT_RDWR); + close(fd); + fd = -1; + } +} + +bool ReadExact(int fd, void* data, std::size_t size) { + auto* out = static_cast(data); + std::size_t offset = 0; + while (offset < size) { + const ssize_t count = read(fd, out + offset, size - offset); + if (count < 0) { + if (errno == EINTR) { + continue; + } + return false; + } + if (count == 0) { + return false; + } + offset += static_cast(count); + } + return true; +} + +bool WriteExact(int fd, const void* data, std::size_t size) { + const auto* in = static_cast(data); + std::size_t offset = 0; + while (offset < size) { + const ssize_t count = write(fd, in + offset, size - offset); + if (count < 0) { + if (errno == EINTR) { + continue; + } + return false; + } + if (count == 0) { + return false; + } + offset += static_cast(count); + } + return true; +} + +std::optional ReadFrame(int fd) { + uint32_t networkLength = 0; + if (!ReadExact(fd, &networkLength, sizeof(networkLength))) { + return std::nullopt; + } + const uint32_t length = ntohl(networkLength); + if (length == 0 || length > kMaxFrameBytes) { + return std::nullopt; + } + + std::string payload(length, '\0'); + if (!ReadExact(fd, payload.data(), payload.size())) { + return std::nullopt; + } + return payload; +} + +std::string HexEncode(const uint8_t* data, std::size_t size) { + std::ostringstream out; + out << std::hex << std::setfill('0'); + for (std::size_t index = 0; index < size; ++index) { + out << std::setw(2) << static_cast(data[index]); + } + return out.str(); +} + +std::string RandomNonceHex() { + std::array bytes{}; + if (RAND_bytes(bytes.data(), static_cast(bytes.size())) != 1) { + return {}; + } + return HexEncode(bytes.data(), bytes.size()); +} + +std::string HmacSha256Hex(const std::string& secret, const std::string& message) { + std::array digest{}; + unsigned int digestLength = 0; + HMAC( + EVP_sha256(), + secret.data(), + static_cast(secret.size()), + reinterpret_cast(message.data()), + message.size(), + digest.data(), + &digestLength); + return HexEncode(digest.data(), digestLength); +} + +std::optional JsonStringValue(const std::string& json, const std::string& key) { + const std::string marker = "\"" + key + "\":\""; + const std::size_t start = json.find(marker); + if (start == std::string::npos) { + return std::nullopt; + } + const std::size_t valueStart = start + marker.size(); + const std::size_t valueEnd = json.find('"', valueStart); + if (valueEnd == std::string::npos) { + return std::nullopt; + } + return json.substr(valueStart, valueEnd - valueStart); +} + +std::string EscapeJson(std::string_view value) { + std::string escaped; + escaped.reserve(value.size()); + for (char ch : value) { + switch (ch) { + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped.push_back(ch); + break; + } + } + return escaped; +} + +} // namespace + +AndroidRelayServer::AndroidRelayServer(AndroidRelayOptions options) + : m_options(std::move(options)) { +} + +AndroidRelayServer::~AndroidRelayServer() { + Stop(); +} + +bool AndroidRelayServer::Start() { + if (!m_options.enabled) { + return true; + } + if (m_options.secret.empty()) { + std::cerr << "WARN: Android relay enabled but android_relay_secret is empty; relay disabled." << std::endl; + return true; + } + if (m_options.port <= 0 || m_options.port > 65535) { + std::cerr << "WARN: Android relay port is invalid; relay disabled." << std::endl; + return true; + } + if (m_running.exchange(true)) { + return true; + } + + m_listenFd = socket(AF_INET, SOCK_STREAM, 0); + if (m_listenFd < 0) { + std::cerr << "WARN: Android relay socket failed: " << std::strerror(errno) << std::endl; + m_running = false; + return false; + } + + int enabled = 1; + setsockopt(m_listenFd, SOL_SOCKET, SO_REUSEADDR, &enabled, sizeof(enabled)); + + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_ANY); + address.sin_port = htons(static_cast(m_options.port)); + if (bind(m_listenFd, reinterpret_cast(&address), sizeof(address)) != 0 || + listen(m_listenFd, 4) != 0) { + std::cerr << "WARN: Android relay listen failed on port " << m_options.port + << ": " << std::strerror(errno) << std::endl; + CloseFd(m_listenFd); + m_running = false; + return false; + } + + m_acceptThread = std::thread([this]() { AcceptLoop(); }); + std::cout << "[ANDROID] Relay listening on port " << m_options.port + << " for peer '" << m_options.peerName << "'." << std::endl; + return true; +} + +void AndroidRelayServer::Stop() { + if (!m_running.exchange(false)) { + return; + } + + CloseFd(m_listenFd); + { + std::lock_guard lock(m_clientsMutex); + for (auto& client : m_clients) { + CloseFd(client->fd); + } + } + if (m_acceptThread.joinable()) { + m_acceptThread.join(); + } + for (auto& thread : m_clientThreads) { + if (thread.joinable()) { + thread.join(); + } + } + m_clientThreads.clear(); + { + std::lock_guard lock(m_clientsMutex); + m_clients.clear(); + } +} + +bool AndroidRelayServer::HasAuthenticatedClient() const { + std::lock_guard lock(m_clientsMutex); + return std::any_of(m_clients.begin(), m_clients.end(), [](const auto& client) { + return client->authenticated; + }); +} + +bool AndroidRelayServer::SendMouse(const MouseData& mouse) { + return BroadcastFrame(BuildAndroidMouseFrame(mouse)); +} + +bool AndroidRelayServer::SendKeyboard(const KeyboardData& keyboard) { + return BroadcastFrame(BuildAndroidKeyboardFrame(keyboard)); +} + +bool AndroidRelayServer::SendGesture(const std::string& kind, double dx, double dy) { + return BroadcastFrame(BuildAndroidGestureFrame(kind, dx, dy)); +} + +bool AndroidRelayServer::SendControl(bool active) { + return BroadcastFrame(BuildAndroidControlFrame(active)); +} + +void AndroidRelayServer::SetOnReleaseRequested(std::function callback) { + std::lock_guard lock(m_callbackMutex); + m_onReleaseRequested = std::move(callback); +} + +void AndroidRelayServer::SetOnTopologyUpdate(std::function callback) { + std::lock_guard lock(m_callbackMutex); + m_onTopologyUpdate = std::move(callback); +} + +void AndroidRelayServer::SetLocalDeviceInfo(const std::string& machineName, int screenWidth, int screenHeight) { + m_localMachineName = machineName; + m_localScreenWidth = screenWidth; + m_localScreenHeight = screenHeight; +} + +void AndroidRelayServer::AcceptLoop() { + while (m_running) { + int clientFd = accept(m_listenFd, nullptr, nullptr); + if (clientFd < 0) { + if (errno == EINTR) { + continue; + } + if (m_running) { + std::cerr << "WARN: Android relay accept failed: " << std::strerror(errno) << std::endl; + } + break; + } + + auto session = std::make_shared(); + session->fd = clientFd; + { + std::lock_guard lock(m_clientsMutex); + m_clients.push_back(session); + m_clientThreads.emplace_back([this, session]() { HandleClient(session); }); + } + } +} + +void AndroidRelayServer::HandleClient(std::shared_ptr session) { + if (!AuthenticateClient(session)) { + RemoveClient(session); + return; + } + + while (m_running) { + const auto frame = ReadFrame(session->fd); + if (!frame.has_value()) { + break; + } + const auto type = JsonStringValue(*frame, "type"); + if (!type.has_value()) { + continue; + } + if (*type == "release") { + std::function callback; + { + std::lock_guard lock(m_callbackMutex); + callback = m_onReleaseRequested; + } + if (callback) { + callback(); + } + } else if (*type == "topology_update") { + std::function callback; + { + std::lock_guard lock(m_callbackMutex); + callback = m_onTopologyUpdate; + } + if (callback) { + callback(*frame); + } + } + } + + RemoveClient(session); +} + +bool AndroidRelayServer::AuthenticateClient(const std::shared_ptr& session) { + const std::string nonce = RandomNonceHex(); + if (nonce.empty()) { + return false; + } + const std::string hello = "{\"type\":\"hello\",\"version\":1,\"nonce\":\"" + nonce + + "\",\"peer\":\"" + EscapeJson(m_options.peerName) + "\"}"; + if (!SendFrame(session, hello)) { + return false; + } + + const auto auth = ReadFrame(session->fd); + if (!auth.has_value()) { + return false; + } + const auto type = JsonStringValue(*auth, "type"); + const auto hmac = JsonStringValue(*auth, "hmac"); + if (!type.has_value() || *type != "auth" || !hmac.has_value()) { + return false; + } + + const std::string expected = HmacSha256Hex(m_options.secret, nonce); + if (*hmac != expected) { + std::cerr << "WARN: Android relay rejected client with invalid auth." << std::endl; + return false; + } + + const std::string deviceName = JsonStringValue(*auth, "device").value_or("android"); + { + std::lock_guard lock(m_clientsMutex); + session->deviceName = deviceName; + session->authenticated = true; + } + std::cout << "[ANDROID] Relay client authenticated: " << deviceName << std::endl; + if (!SendFrame(session, "{\"type\":\"ready\"}")) { + return false; + } + + // Send device layout info so the Android client can show the layout editor. + if (!m_localMachineName.empty()) { + std::ostringstream devInfo; + devInfo << "{\"type\":\"devices_info\"," + << "\"devices\":[" + << "{\"id\":\"linux\",\"name\":\"" << EscapeJson(m_localMachineName) << "\"," + << "\"width\":" << m_localScreenWidth << ",\"height\":" << m_localScreenHeight << "}," + << "{\"id\":\"android\",\"name\":\"" << EscapeJson(deviceName) << "\"," + << "\"width\":0,\"height\":0}" + << "]," + << "\"layout\":[" + << "{\"id\":\"linux\",\"x\":0,\"y\":0}," + << "{\"id\":\"android\",\"x\":" << m_localScreenWidth << ",\"y\":0}" + << "]}"; + SendFrame(session, devInfo.str()); + } + return true; +} + +bool AndroidRelayServer::SendFrame(const std::shared_ptr& session, const std::string& payload) { + if (session->fd < 0 || payload.empty() || payload.size() > kMaxFrameBytes) { + return false; + } + + std::lock_guard lock(session->writeMutex); + const uint32_t length = htonl(static_cast(payload.size())); + return WriteExact(session->fd, &length, sizeof(length)) && + WriteExact(session->fd, payload.data(), payload.size()); +} + +bool AndroidRelayServer::BroadcastFrame(const std::string& payload) { + std::vector> clients; + { + std::lock_guard lock(m_clientsMutex); + for (const auto& client : m_clients) { + if (client->authenticated) { + clients.push_back(client); + } + } + } + if (clients.empty()) { + return false; + } + + bool delivered = false; + for (const auto& client : clients) { + delivered = SendFrame(client, payload) || delivered; + } + return delivered; +} + +void AndroidRelayServer::RemoveClient(const std::shared_ptr& session) { + CloseFd(session->fd); + std::lock_guard lock(m_clientsMutex); + m_clients.erase( + std::remove_if(m_clients.begin(), m_clients.end(), [&](const auto& current) { + return current == session; + }), + m_clients.end()); +} + +std::string BuildAndroidMouseFrame(const MouseData& mouse) { + std::ostringstream out; + out << "{\"type\":\"mouse\",\"x\":" << mouse.x + << ",\"y\":" << mouse.y + << ",\"mouseData\":" << mouse.mouseData + << ",\"wParam\":" << mouse.wParam + << "}"; + return out.str(); +} + +std::string BuildAndroidKeyboardFrame(const KeyboardData& keyboard) { + std::ostringstream out; + out << "{\"type\":\"keyboard\",\"vkCode\":" << keyboard.vkCode + << ",\"flags\":" << keyboard.flags + << "}"; + return out.str(); +} + +std::string BuildAndroidGestureFrame(const std::string& kind, double dx, double dy) { + std::ostringstream out; + out << "{\"type\":\"gesture\",\"kind\":\"" << EscapeJson(kind) + << "\",\"dx\":" << dx + << ",\"dy\":" << dy + << "}"; + return out.str(); +} + +std::string BuildAndroidControlFrame(bool active) { + return std::string("{\"type\":\"control\",\"active\":") + (active ? "true" : "false") + "}"; +} + +} // namespace mwb diff --git a/src/AndroidRelay.h b/src/AndroidRelay.h new file mode 100644 index 0000000..e89dbc1 --- /dev/null +++ b/src/AndroidRelay.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Protocol.h" + +namespace mwb { + +struct AndroidRelayOptions { + bool enabled{false}; + int port{15102}; + std::string secret; + std::string peerName{"android"}; + bool layoutEditorEnabled{true}; + int androidDeviceWidth{1920}; + int androidDeviceHeight{1200}; +}; + +class AndroidRelayServer { +public: + explicit AndroidRelayServer(AndroidRelayOptions options); + ~AndroidRelayServer(); + + bool Start(); + void Stop(); + bool HasAuthenticatedClient() const; + bool SendMouse(const MouseData& mouse); + bool SendKeyboard(const KeyboardData& keyboard); + bool SendGesture(const std::string& kind, double dx, double dy); + bool SendControl(bool active); + void SetOnReleaseRequested(std::function callback); + void SetOnTopologyUpdate(std::function callback); + + // Called after ready to populate the devices_info frame sent to Android. + void SetLocalDeviceInfo(const std::string& machineName, int screenWidth, int screenHeight); + + const AndroidRelayOptions& Options() const { + return m_options; + } + +private: + struct ClientSession { + int fd{-1}; + bool authenticated{false}; + std::string deviceName; + std::mutex writeMutex; + }; + + void AcceptLoop(); + void HandleClient(std::shared_ptr session); + bool AuthenticateClient(const std::shared_ptr& session); + bool SendFrame(const std::shared_ptr& session, const std::string& payload); + bool BroadcastFrame(const std::string& payload); + void RemoveClient(const std::shared_ptr& session); + + AndroidRelayOptions m_options; + std::atomic m_running{false}; + int m_listenFd{-1}; + mutable std::mutex m_clientsMutex; + std::vector> m_clients; + std::vector m_clientThreads; + std::thread m_acceptThread; + std::function m_onReleaseRequested; + std::function m_onTopologyUpdate; + mutable std::mutex m_callbackMutex; + + std::string m_localMachineName; + int m_localScreenWidth{0}; + int m_localScreenHeight{0}; +}; + +std::string BuildAndroidMouseFrame(const MouseData& mouse); +std::string BuildAndroidKeyboardFrame(const KeyboardData& keyboard); +std::string BuildAndroidGestureFrame(const std::string& kind, double dx, double dy); +std::string BuildAndroidControlFrame(bool active); + +} // namespace mwb diff --git a/src/AppConfig.cpp b/src/AppConfig.cpp index 3bac4b5..8e23f7e 100644 --- a/src/AppConfig.cpp +++ b/src/AppConfig.cpp @@ -367,6 +367,70 @@ bool ParseAppConfig(std::string_view text, AppConfig& outConfig, std::string* er continue; } + if (key == "android_peers_enabled" || key == "android_relay_enabled") { + const auto parsed = ParseConfigBool(value); + if (!parsed.has_value()) { + SetError(errorMessage, "Config key 'android_peers_enabled' expects true/false."); + return false; + } + outConfig.androidPeersEnabled = *parsed; + continue; + } + + if (key == "android_relay_port") { + if (!ParseAndAssignRequiredInt(value, 1, 65535, outConfig.androidRelayPort)) { + SetError(errorMessage, "Config key 'android_relay_port' expects an integer between 1 and 65535."); + return false; + } + continue; + } + + if (key == "android_relay_secret") { + outConfig.androidRelaySecret = std::string(value); + continue; + } + + if (key == "android_peer_name") { + outConfig.androidPeerName = std::string(value); + continue; + } + + if (key == "android_capture_backend") { + const std::string backend = ToLower(std::string(value)); + if (backend != "none" && backend != "evdev" && backend != "libei") { + SetError(errorMessage, "Config key 'android_capture_backend' expects none, evdev, or libei."); + return false; + } + outConfig.androidCaptureBackend = backend; + continue; + } + + if (key == "android_layout_editor_enabled") { + const auto parsed = ParseConfigBool(value); + if (!parsed.has_value()) { + SetError(errorMessage, "Config key 'android_layout_editor_enabled' expects true/false."); + return false; + } + outConfig.androidLayoutEditorEnabled = *parsed; + continue; + } + + if (key == "android_device_width") { + if (!ParseAndAssignRequiredInt(value, 1, 65535, outConfig.androidDeviceWidth)) { + SetError(errorMessage, "Config key 'android_device_width' expects an integer between 1 and 65535."); + return false; + } + continue; + } + + if (key == "android_device_height") { + if (!ParseAndAssignRequiredInt(value, 1, 65535, outConfig.androidDeviceHeight)) { + SetError(errorMessage, "Config key 'android_device_height' expects an integer between 1 and 65535."); + return false; + } + continue; + } + SetError(errorMessage, "Unknown config key '" + std::string(key) + "' on line " + std::to_string(lineNumber) + "."); return false; } @@ -433,6 +497,14 @@ std::string RenderAppConfig(const AppConfig& config) { out << "latency_report=" << RenderBool(config.latencyReport) << '\n'; out << "topology_enabled=" << RenderBool(config.topologyRuntimeEnabled) << '\n'; out << "topology_file=" << config.topologyFile << '\n'; + out << "android_peers_enabled=" << RenderBool(config.androidPeersEnabled) << '\n'; + out << "android_relay_port=" << config.androidRelayPort << '\n'; + out << "android_relay_secret=" << config.androidRelaySecret << '\n'; + out << "android_peer_name=" << config.androidPeerName << '\n'; + out << "android_capture_backend=" << config.androidCaptureBackend << '\n'; + out << "android_layout_editor_enabled=" << RenderBool(config.androidLayoutEditorEnabled) << '\n'; + out << "android_device_width=" << config.androidDeviceWidth << '\n'; + out << "android_device_height=" << config.androidDeviceHeight << '\n'; return out.str(); } @@ -452,6 +524,8 @@ std::string RenderSampleAppConfig() { 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 enable runtime topology handoff.\n"; + out << "# Set android_peers_enabled=true with android_relay_secret=... to relay input to Android peers.\n"; + out << "# android_capture_backend=none keeps Android relay-only; evdev is prototype-only; libei is the planned KDE/Wayland backend.\n"; out << RenderAppConfig(sample); return out.str(); } diff --git a/src/AppConfig.h b/src/AppConfig.h index 6ce4f34..1e4cb9f 100644 --- a/src/AppConfig.h +++ b/src/AppConfig.h @@ -29,6 +29,14 @@ struct AppConfig { bool latencyReport{false}; bool topologyRuntimeEnabled{false}; std::string topologyFile; + bool androidPeersEnabled{false}; + int androidRelayPort{15102}; + std::string androidRelaySecret; + std::string androidPeerName{"android"}; + std::string androidCaptureBackend{"none"}; + bool androidLayoutEditorEnabled{true}; + int androidDeviceWidth{1920}; + int androidDeviceHeight{1200}; }; AppConfig LoadDefaultAppConfig(); diff --git a/src/ClientRuntime.cpp b/src/ClientRuntime.cpp index a85212c..408abea 100644 --- a/src/ClientRuntime.cpp +++ b/src/ClientRuntime.cpp @@ -3,6 +3,7 @@ #include "ScreenGeometry.h" #include +#include #include #include #include @@ -329,7 +330,13 @@ void ClientRuntime::ConfigureTopologyPreview(const ScreenSize& screenSize) { true, [this](const MouseData& mouse, const TopologyPointerTransition&, - const std::string&) { + const std::string& targetMachineId) { + if (m_androidRelay && + targetMachineId == m_options.androidRelay.peerName && + TrySendAndroidMouse(mouse)) { + m_androidRelayActive = true; + return true; + } return m_network && m_network->SendMouse(mouse); }); std::cout << "[TOPOLOGY] Loaded topology from " @@ -394,6 +401,25 @@ int ClientRuntime::Run() { m_clipboard = ClipboardManager::CreateDefault(); } + if (m_options.androidRelay.enabled) { + m_androidRelay = std::make_unique(m_options.androidRelay); + m_androidRelay->SetOnReleaseRequested([this]() { + TrySetAndroidControlActive(false); + std::cout << "[ANDROID] Relay release requested; returning input to local desktop." << std::endl; + }); + m_androidRelay->SetOnTopologyUpdate([this](const std::string& layoutJson) { + ApplyAndroidTopologyUpdate(layoutJson); + }); + if (screenSize.width > 0 && !m_options.localMachineName.empty()) { + m_androidRelay->SetLocalDeviceInfo(m_options.localMachineName, screenSize.width, screenSize.height); + } + if (!m_androidRelay->Start()) { + std::cerr << "WARN: Android relay failed to start; Android peer handoff is disabled." << std::endl; + m_androidRelay.reset(); + } + } + StartLocalAndroidInputBridge(screenSize); + if (m_clipboard) { m_clipboardBackendName = m_clipboard->BackendName(); std::cout << "[INFO] Clipboard backend: " << m_clipboardBackendName << std::endl; @@ -446,6 +472,10 @@ int ClientRuntime::Run() { << " mouseData=" << md.mouseData << std::endl; } + if (m_androidRelayActive && TrySendAndroidMouse(md)) { + return; + } + m_androidRelayActive = false; m_dispatcher.SubmitMouse(md); }); @@ -454,12 +484,19 @@ int ClientRuntime::Run() { std::cout << "[INPUT] Keyboard: vk=0x" << std::hex << kd.vkCode << " flags=0x" << kd.flags << std::dec << std::endl; } + if (m_androidRelayActive && TrySendAndroidKeyboard(kd)) { + return; + } m_dispatcher.SubmitKeyboard(kd); }); if (!m_network->Connect()) { std::cerr << "Terminating: Network failure." << std::endl; m_dispatcher.Stop(); + if (m_androidRelay) { + m_androidRelay->Stop(); + } + StopLocalAndroidInputBridge(); m_network.reset(); return 1; } @@ -544,7 +581,11 @@ void ClientRuntime::Stop() { return; } + StopLocalAndroidInputBridge(); StopClipboardWatcher(); + if (m_androidRelay) { + m_androidRelay->Stop(); + } if (m_network) { m_network->Stop(); } @@ -556,6 +597,227 @@ void ClientRuntime::Stop() { std::cout << InputManager::FormatMouseTrace(m_input.MouseTraceSnapshot()); } m_network.reset(); + m_androidRelay.reset(); +} + +void ClientRuntime::StartLocalAndroidInputBridge(const ScreenSize& screenSize) { + if (!m_androidRelay || !m_topology || !m_options.topologyRuntimeEnabled) { + return; + } + + if (m_options.androidCaptureBackend.empty() || m_options.androidCaptureBackend == "none") { + std::cout << "[ANDROID] Local capture backend disabled; relay will accept remote/topology forwarded input only." << std::endl; + return; + } + + if (m_options.androidCaptureBackend == "libei") { + LibeiInputCaptureBridgeOptions options; + options.desktopWidth = screenSize.width; + options.desktopHeight = screenSize.height; + options.sendMouse = [this](const MouseData& mouse) { + return TrySendAndroidMouse(mouse); + }; + options.sendKeyboard = [this](const KeyboardData& keyboard) { + return TrySendAndroidKeyboard(keyboard); + }; + options.sendGesture = [this](const std::string& kind, double dx, double dy) { + return TrySendAndroidGesture(kind, dx, dy); + }; + options.sendControl = [this](bool active) { + return TrySetAndroidControlActive(active); + }; + m_libeiInputCaptureBridge = std::make_unique(std::move(options)); + m_libeiInputCaptureBridge->Start(); + return; + } + + if (m_options.androidCaptureBackend != "evdev") { + std::cerr << "WARN: Unknown android_capture_backend='" << m_options.androidCaptureBackend + << "'; local Android handoff disabled." << std::endl; + return; + } + + std::cerr << "WARN: android_capture_backend=evdev is a prototype fallback. It can mirror Fedora cursor movement and is not monitor-grade on KDE Wayland." << std::endl; + + LocalAndroidInputBridgeOptions options; + options.topology = m_topology; + options.localMachineName = m_options.localMachineName; + options.androidPeerName = m_options.androidRelay.peerName; + options.desktopWidth = screenSize.width; + options.desktopHeight = screenSize.height; + options.sendMouse = [this](const MouseData& mouse) { + return TrySendAndroidMouse(mouse); + }; + + m_localAndroidInputBridge = std::make_unique(std::move(options)); + m_localAndroidInputBridge->Start(); +} + +void ClientRuntime::StopLocalAndroidInputBridge() { + if (m_libeiInputCaptureBridge) { + m_libeiInputCaptureBridge->Stop(); + m_libeiInputCaptureBridge.reset(); + } + + if (!m_localAndroidInputBridge) { + return; + } + m_localAndroidInputBridge->Stop(); + m_localAndroidInputBridge.reset(); +} + +bool ClientRuntime::TrySendAndroidMouse(const MouseData& mouse) { + return m_androidRelay && m_androidRelay->SendMouse(mouse); +} + +bool ClientRuntime::TrySendAndroidKeyboard(const KeyboardData& keyboard) { + return m_androidRelayActive && m_androidRelay && m_androidRelay->SendKeyboard(keyboard); +} + +bool ClientRuntime::TrySendAndroidGesture(const std::string& kind, double dx, double dy) { + return m_androidRelayActive && m_androidRelay && m_androidRelay->SendGesture(kind, dx, dy); +} + +bool ClientRuntime::TrySetAndroidControlActive(bool active) { + const bool wasActive = m_androidRelayActive.exchange(active); + if (!m_androidRelay || wasActive == active) { + return m_androidRelay != nullptr; + } + return m_androidRelay->SendControl(active); +} + +void ClientRuntime::ApplyAndroidTopologyUpdate(const std::string& frameJson) { + if (!m_options.androidRelay.layoutEditorEnabled) { + return; + } + + // Minimal JSON parse: extract "layout" array positions for "linux" and "android" + int lx = 0, ly = 0, ax = 0, ay = 0; + bool gotLinux = false, gotAndroid = false; + const std::string layoutMarker = "\"layout\":["; + auto layoutStart = frameJson.find(layoutMarker); + if (layoutStart == std::string::npos) { + return; + } + std::string remaining = frameJson.substr(layoutStart + layoutMarker.size()); + while (!remaining.empty() && remaining.front() != ']') { + auto idPos = remaining.find("\"id\":\""); + auto xPos = remaining.find("\"x\":"); + auto yPos = remaining.find("\"y\":"); + if (idPos == std::string::npos || xPos == std::string::npos || yPos == std::string::npos) { + break; + } + auto idValStart = idPos + 6; + auto idValEnd = remaining.find('"', idValStart); + if (idValEnd == std::string::npos) break; + const std::string id = remaining.substr(idValStart, idValEnd - idValStart); + const int x = std::stoi(remaining.substr(xPos + 4)); + const int y = std::stoi(remaining.substr(yPos + 4)); + if (id == "linux") { lx = x; ly = y; gotLinux = true; } + else if (id == "android") { ax = x; ay = y; gotAndroid = true; } + auto nextItem = remaining.find('{', idValEnd); + if (nextItem == std::string::npos) break; + remaining = remaining.substr(nextItem); + } + if (!gotLinux || !gotAndroid) { + std::cerr << "[ANDROID] topology_update: missing linux or android position." << std::endl; + return; + } + + const int linuxW = m_options.screenWidth.value_or(1920); + const int linuxH = m_options.screenHeight.value_or(1080); + const int androidW = m_options.androidRelay.androidDeviceWidth; + const int androidH = m_options.androidRelay.androidDeviceHeight; + const std::string linuxMachine = m_options.localMachineName.empty() ? "linux" : m_options.localMachineName; + const std::string androidMachine = m_options.androidRelay.peerName; + + // Determine relative placement direction + const int dx = ax - lx; + const int dy = ay - ly; + std::string exitEdge, entryEdge; + int androidAbsX = 0, androidAbsY = 0; + if (std::abs(dx) >= std::abs(dy)) { + if (dx >= 0) { + exitEdge = "right"; entryEdge = "left"; + androidAbsX = linuxW; + androidAbsY = 0; + } else { + exitEdge = "left"; entryEdge = "right"; + androidAbsX = -androidW; + androidAbsY = 0; + } + } else { + if (dy >= 0) { + exitEdge = "down"; entryEdge = "up"; + androidAbsX = 0; + androidAbsY = linuxH; + } else { + exitEdge = "up"; entryEdge = "down"; + androidAbsX = 0; + androidAbsY = -androidH; + } + } + + std::ostringstream cfg; + cfg << "# auto-generated from Android layout editor\n" + << "wrap=none\n" + << "machine=" << linuxMachine << "\n" + << "machine=" << androidMachine << "\n" + << "display=" << linuxMachine << "_d," << linuxMachine << ",0,0," << linuxW << "," << linuxH << "\n" + << "display=" << androidMachine << "_d," << androidMachine << "," + << androidAbsX << "," << androidAbsY << "," << androidW << "," << androidH << "\n" + << "link=" << linuxMachine << "_d," << exitEdge << "," << androidMachine << "_d," << entryEdge << "\n" + << "link=" << androidMachine << "_d," << entryEdge << "," << linuxMachine << "_d," << exitEdge << "\n"; + + TopologyModel newModel; + std::string error; + if (!ParseTopologyConfig(cfg.str(), newModel, &error)) { + std::cerr << "[ANDROID] Failed to parse generated topology: " << error << std::endl; + return; + } + const auto issues = newModel.validate(); + if (!issues.empty()) { + std::cerr << "[ANDROID] Generated topology invalid: " << issues.front().message << std::endl; + return; + } + + const ScreenSize screenSize{linuxW, linuxH, ScreenSize::Source::Explicit}; + const std::string sourceDisplayId = SelectTopologySourceDisplay(newModel, linuxMachine, screenSize); + if (sourceDisplayId.empty()) { + std::cerr << "[ANDROID] Generated topology has no display for local machine." << std::endl; + return; + } + + m_topology = std::make_shared(std::move(newModel)); + m_dispatcher.SetTopologyPreview(m_topology, sourceDisplayId, true); + m_dispatcher.SetTopologyHandoff( + linuxMachine, + linuxW, + linuxH, + true, + [this](const MouseData& mouse, + const TopologyPointerTransition&, + const std::string& targetMachineId) { + if (m_androidRelay && + targetMachineId == m_options.androidRelay.peerName && + TrySetAndroidControlActive(true) && + TrySendAndroidMouse(mouse)) { + return true; + } + return m_network && m_network->SendMouse(mouse); + }); + + std::cout << "[ANDROID] Applied topology update: linux " << exitEdge + << " → android." << std::endl; + + // Optionally persist to topology_file + if (!m_options.topologyFilePath.empty()) { + std::ofstream out(m_options.topologyFilePath); + if (out) { + out << cfg.str(); + std::cout << "[ANDROID] Topology saved to " << m_options.topologyFilePath << std::endl; + } + } } } // namespace mwb diff --git a/src/ClientRuntime.h b/src/ClientRuntime.h index 7d62d24..304f1cc 100644 --- a/src/ClientRuntime.h +++ b/src/ClientRuntime.h @@ -9,10 +9,13 @@ #include #include +#include "AndroidRelay.h" #include "ClipboardManager.h" #include "InputDispatcher.h" #include "InputLatencyStats.h" #include "InputManager.h" +#include "LibeiInputCaptureBridge.h" +#include "LocalAndroidInputBridge.h" #include "NetworkManager.h" #include "TopologyModel.h" @@ -42,6 +45,8 @@ struct RuntimeOptions { bool latencyReport{false}; bool topologyRuntimeEnabled{false}; std::filesystem::path topologyFilePath; + std::string androidCaptureBackend{"none"}; + AndroidRelayOptions androidRelay; std::function onSessionEstablished; std::function onSessionDisconnected; }; @@ -72,14 +77,25 @@ class ClientRuntime { void ConfigureTopologyPreview(const ScreenSize& screenSize); void StartClipboardWatcher(); void StopClipboardWatcher(); + void StartLocalAndroidInputBridge(const ScreenSize& screenSize); + void StopLocalAndroidInputBridge(); + bool TrySendAndroidMouse(const MouseData& mouse); + bool TrySendAndroidKeyboard(const KeyboardData& keyboard); + bool TrySendAndroidGesture(const std::string& kind, double dx, double dy); + bool TrySetAndroidControlActive(bool active); + void ApplyAndroidTopologyUpdate(const std::string& layoutJson); RuntimeOptions m_options; std::atomic m_stopRequested{false}; + std::atomic m_androidRelayActive{false}; 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_androidRelay; + std::unique_ptr m_libeiInputCaptureBridge; + std::unique_ptr m_localAndroidInputBridge; std::unique_ptr m_clipboard; std::atomic m_clipboardWatcherRunning{false}; std::thread m_clipboardWatcher; diff --git a/src/LibeiInputCaptureBridge.cpp b/src/LibeiInputCaptureBridge.cpp new file mode 100644 index 0000000..41ace40 --- /dev/null +++ b/src/LibeiInputCaptureBridge.cpp @@ -0,0 +1,1095 @@ +#include "LibeiInputCaptureBridge.h" + +#if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE) +#include +#include +#include +#include +#endif + +#if defined(MWB_HAVE_LIBINPUT_GESTURES) +#include +#include +#endif + +#if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE) || defined(MWB_HAVE_LIBINPUT_GESTURES) +#include +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mwb { +namespace { + +constexpr uint32_t WM_MOUSEMOVE = 0x0200; +constexpr uint32_t WM_LBUTTONDOWN = 0x0201; +constexpr uint32_t WM_LBUTTONUP = 0x0202; +constexpr uint32_t WM_RBUTTONDOWN = 0x0204; +constexpr uint32_t WM_RBUTTONUP = 0x0205; +constexpr uint32_t WM_MBUTTONDOWN = 0x0207; +constexpr uint32_t WM_MBUTTONUP = 0x0208; +constexpr uint32_t WM_MOUSEWHEEL = 0x020A; +constexpr uint32_t LLKHF_UP = 0x80; + +int ClampNormalized(int value) { + return std::max(0, std::min(65535, value)); +} + +std::optional LinuxKeyToVirtualKey(uint32_t key) { + // Linux letter keycodes are in QWERTY layout order, not alphabetical — must map each explicitly. + switch (key) { + case KEY_Q: return 'Q'; + case KEY_W: return 'W'; + case KEY_E: return 'E'; + case KEY_R: return 'R'; + case KEY_T: return 'T'; + case KEY_Y: return 'Y'; + case KEY_U: return 'U'; + case KEY_I: return 'I'; + case KEY_O: return 'O'; + case KEY_P: return 'P'; + case KEY_A: return 'A'; + case KEY_S: return 'S'; + case KEY_D: return 'D'; + case KEY_F: return 'F'; + case KEY_G: return 'G'; + case KEY_H: return 'H'; + case KEY_J: return 'J'; + case KEY_K: return 'K'; + case KEY_L: return 'L'; + case KEY_Z: return 'Z'; + case KEY_X: return 'X'; + case KEY_C: return 'C'; + case KEY_V: return 'V'; + case KEY_B: return 'B'; + case KEY_N: return 'N'; + case KEY_M: return 'M'; + default: break; + } + + if (key >= KEY_1 && key <= KEY_9) { + return static_cast('1' + (key - KEY_1)); + } + if (key == KEY_0) { + return static_cast('0'); + } + if (key >= KEY_F1 && key <= KEY_F12) { + return 0x70 + static_cast(key - KEY_F1); + } + + switch (key) { + case KEY_BACKSPACE: + return 0x08; + case KEY_TAB: + return 0x09; + case KEY_ENTER: + return 0x0D; + case KEY_ESC: + return 0x1B; + case KEY_SPACE: + return 0x20; + case KEY_PAGEUP: + return 0x21; + case KEY_PAGEDOWN: + return 0x22; + case KEY_END: + return 0x23; + case KEY_HOME: + return 0x24; + case KEY_LEFT: + return 0x25; + case KEY_UP: + return 0x26; + case KEY_RIGHT: + return 0x27; + case KEY_DOWN: + return 0x28; + case KEY_INSERT: + return 0x2D; + case KEY_DELETE: + return 0x2E; + case KEY_LEFTSHIFT: + case KEY_RIGHTSHIFT: + return 0x10; + case KEY_LEFTCTRL: + case KEY_RIGHTCTRL: + return 0x11; + case KEY_LEFTALT: + case KEY_RIGHTALT: + return 0x12; + case KEY_LEFTMETA: + return 0x5B; + case KEY_RIGHTMETA: + return 0x5C; + case KEY_KP0: + return 0x60; + case KEY_KP1: + return 0x61; + case KEY_KP2: + return 0x62; + case KEY_KP3: + return 0x63; + case KEY_KP4: + return 0x64; + case KEY_KP5: + return 0x65; + case KEY_KP6: + return 0x66; + case KEY_KP7: + return 0x67; + case KEY_KP8: + return 0x68; + case KEY_KP9: + return 0x69; + case KEY_KPASTERISK: + return 0x6A; + case KEY_KPPLUS: + return 0x6B; + case KEY_KPENTER: + return 0x0D; + case KEY_KPMINUS: + return 0x6D; + case KEY_KPDOT: + return 0x6E; + case KEY_KPSLASH: + return 0x6F; + case KEY_COMMA: + return 0xBC; + case KEY_DOT: + return 0xBE; + case KEY_SLASH: + return 0xBF; + case KEY_SEMICOLON: + return 0xBA; + case KEY_APOSTROPHE: + return 0xDE; + case KEY_LEFTBRACE: + return 0xDB; + case KEY_RIGHTBRACE: + return 0xDD; + case KEY_MINUS: + return 0xBD; + case KEY_EQUAL: + return 0xBB; + case KEY_GRAVE: + return 0xC0; + case KEY_BACKSLASH: + return 0xDC; + default: + return std::nullopt; + } +} + +std::string ToLower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; +} + +std::optional FindTouchpadEventDevice() { + const std::filesystem::path inputClass{"/sys/class/input"}; + std::error_code ec; + for (const auto& entry : std::filesystem::directory_iterator(inputClass, ec)) { + if (ec) { + break; + } + const auto eventName = entry.path().filename().string(); + if (eventName.rfind("event", 0) != 0) { + continue; + } + std::ifstream nameFile(entry.path() / "device" / "name"); + std::string name; + if (!std::getline(nameFile, name)) { + continue; + } + const auto lower = ToLower(name); + if (lower.find("touchpad") != std::string::npos) { + return "/dev/input/" + eventName; + } + } + return std::nullopt; +} + +std::optional> ParseDeltaToken(const std::string& token) { + const auto slash = token.find('/'); + if (slash == std::string::npos) { + return std::nullopt; + } + try { + return std::pair{ + std::stod(token.substr(0, slash)), + std::stod(token.substr(slash + 1)), + }; + } catch (...) { + return std::nullopt; + } +} + +bool EmitClassifiedSwipe( + const std::function& sendGesture, + int fingers, + double dx, + double dy, + bool cancelled) { + if (!sendGesture || cancelled || fingers < 3) { + return false; + } + + const double absX = std::abs(dx); + const double absY = std::abs(dy); + if (absY < 36.0 || absY < absX * 1.35) { + return false; + } + + return sendGesture(fingers >= 4 ? "swipe4" : "swipe3", dx, dy); +} + +#if defined(MWB_HAVE_LIBINPUT_GESTURES) + +int OpenLibinputDevice(const char* path, int flags, void*) { + const int fd = open(path, flags); + return fd < 0 ? -errno : fd; +} + +void CloseLibinputDevice(int fd, void*) { + close(fd); +} + +const libinput_interface kLibinputInterface{ + OpenLibinputDevice, + CloseLibinputDevice, +}; + +bool RunNativeLibinputGestureMonitor( + const std::string& device, + const std::atomic& running, + const std::function& sendGesture) { + libinput* context = libinput_path_create_context(&kLibinputInterface, nullptr); + if (!context) { + std::cerr << "WARN: Failed to create native libinput gesture context." << std::endl; + return false; + } + + libinput_device* inputDevice = libinput_path_add_device(context, device.c_str()); + if (!inputDevice) { + std::cerr << "WARN: Failed to open " << device << " through native libinput." << std::endl; + libinput_unref(context); + return false; + } + + std::cout << "[ANDROID] native libinput gesture monitor using " << device << std::endl; + + bool swipeActive = false; + int swipeFingers = 0; + double swipeDx = 0.0; + double swipeDy = 0.0; + bool swipeCancelled = false; + + bool pinchActive = false; + double pinchScale = 1.0; + bool pinchCancelled = false; + + while (running) { + libinput_dispatch(context); + while (libinput_event* event = libinput_get_event(context)) { + const auto type = libinput_event_get_type(event); + if (type < LIBINPUT_EVENT_GESTURE_SWIPE_BEGIN || + type > LIBINPUT_EVENT_GESTURE_HOLD_END) { + libinput_event_destroy(event); + continue; + } + switch (type) { + case LIBINPUT_EVENT_GESTURE_SWIPE_BEGIN: { + libinput_event_gesture* gesture = libinput_event_get_gesture_event(event); + swipeActive = true; + swipeFingers = libinput_event_gesture_get_finger_count(gesture); + swipeDx = 0.0; + swipeDy = 0.0; + swipeCancelled = false; + break; + } + case LIBINPUT_EVENT_GESTURE_SWIPE_UPDATE: { + if (swipeActive) { + libinput_event_gesture* gesture = libinput_event_get_gesture_event(event); + swipeDx += libinput_event_gesture_get_dx(gesture); + swipeDy += libinput_event_gesture_get_dy(gesture); + } + break; + } + case LIBINPUT_EVENT_GESTURE_SWIPE_END: { + if (swipeActive) { + libinput_event_gesture* gesture = libinput_event_get_gesture_event(event); + swipeCancelled = libinput_event_gesture_get_cancelled(gesture) != 0; + EmitClassifiedSwipe(sendGesture, swipeFingers, swipeDx, swipeDy, swipeCancelled); + swipeActive = false; + } + break; + } + case LIBINPUT_EVENT_GESTURE_PINCH_BEGIN: + pinchActive = true; + pinchScale = 1.0; + pinchCancelled = false; + break; + case LIBINPUT_EVENT_GESTURE_PINCH_UPDATE: { + if (pinchActive) { + libinput_event_gesture* gesture = libinput_event_get_gesture_event(event); + pinchScale = libinput_event_gesture_get_scale(gesture); + } + break; + } + case LIBINPUT_EVENT_GESTURE_PINCH_END: { + if (pinchActive) { + libinput_event_gesture* gesture = libinput_event_get_gesture_event(event); + pinchCancelled = libinput_event_gesture_get_cancelled(gesture) != 0; + if (!pinchCancelled && (pinchScale < 0.92 || pinchScale > 1.08) && sendGesture) { + sendGesture("pinch", pinchScale, 0.0); + } + pinchActive = false; + } + break; + } + default: + break; + } + libinput_event_destroy(event); + } + + pollfd fd{libinput_get_fd(context), POLLIN, 0}; + poll(&fd, 1, 250); + } + + libinput_path_remove_device(inputDevice); + libinput_unref(context); + return true; +} + +#endif + +#if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE) + +constexpr const char* kPortalBusName = "org.freedesktop.portal.Desktop"; +constexpr const char* kInputCapturePath = "/org/freedesktop/portal/desktop"; +constexpr const char* kInputCaptureInterface = "org.freedesktop.portal.InputCapture"; +constexpr uint32_t kCapabilityKeyboard = 1; +constexpr uint32_t kCapabilityPointer = 2; +constexpr uint32_t kCapabilityMask = kCapabilityKeyboard | kCapabilityPointer; + +constexpr uint32_t kRightBarrierId = 1; + +struct RequestResult { + bool done{false}; + uint32_t response{2}; + GVariant* results{nullptr}; +}; + +std::string UniqueToken(const char* prefix) { + const auto now = std::chrono::steady_clock::now().time_since_epoch().count(); + return std::string(prefix) + "_" + std::to_string(getpid()) + "_" + std::to_string(now); +} + +void FreeError(GError* error, const char* context) { + if (!error) { + return; + } + std::cerr << "WARN: libei input capture " << context << " failed: " << error->message << std::endl; + g_error_free(error); +} + +void OnRequestResponse(GDBusConnection*, + const gchar*, + const gchar*, + const gchar*, + const gchar*, + GVariant* parameters, + gpointer userData) { + auto* result = static_cast(userData); + uint32_t response = 2; + GVariant* results = nullptr; + g_variant_get(parameters, "(u@a{sv})", &response, &results); + result->response = response; + result->results = results; + result->done = true; +} + +bool WaitForRequest(GDBusConnection* connection, + const char* requestPath, + RequestResult& result, + int timeoutMs) { + const guint subscription = g_dbus_connection_signal_subscribe( + connection, + kPortalBusName, + "org.freedesktop.portal.Request", + "Response", + requestPath, + nullptr, + G_DBUS_SIGNAL_FLAGS_NO_MATCH_RULE, + OnRequestResponse, + &result, + nullptr); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + while (!result.done && std::chrono::steady_clock::now() < deadline) { + g_main_context_iteration(nullptr, TRUE); + } + + g_dbus_connection_signal_unsubscribe(connection, subscription); + if (!result.done) { + std::cerr << "WARN: libei input capture request timed out: " << requestPath << std::endl; + return false; + } + if (result.response != 0) { + std::cerr << "WARN: libei input capture request was denied/cancelled: " << requestPath + << " response=" << result.response << std::endl; + return false; + } + return true; +} + +std::optional CreateSession(GDBusConnection* connection) { + GVariantBuilder options; + g_variant_builder_init(&options, G_VARIANT_TYPE("a{sv}")); + const std::string token = UniqueToken("inputflow_android"); + g_variant_builder_add(&options, "{sv}", "session_handle_token", g_variant_new_string(token.c_str())); + g_variant_builder_add(&options, "{sv}", "capabilities", g_variant_new_uint32(kCapabilityMask)); + + GError* error = nullptr; + GVariant* reply = g_dbus_connection_call_sync( + connection, + kPortalBusName, + kInputCapturePath, + kInputCaptureInterface, + "CreateSession", + g_variant_new("(sa{sv})", "", &options), + G_VARIANT_TYPE("(o)"), + G_DBUS_CALL_FLAGS_NONE, + -1, + nullptr, + &error); + if (!reply) { + FreeError(error, "CreateSession"); + return std::nullopt; + } + + const char* requestPath = nullptr; + g_variant_get(reply, "(&o)", &requestPath); + const std::string request(requestPath ? requestPath : ""); + g_variant_unref(reply); + if (request.empty()) { + return std::nullopt; + } + + RequestResult result; + if (!WaitForRequest(connection, request.c_str(), result, 30000)) { + if (result.results) { + g_variant_unref(result.results); + } + return std::nullopt; + } + + const char* sessionHandle = nullptr; + const bool ok = result.results && g_variant_lookup(result.results, "session_handle", "&o", &sessionHandle); + std::optional out; + if (ok && sessionHandle) { + out = sessionHandle; + } else { + std::cerr << "WARN: libei input capture CreateSession returned no session_handle." << std::endl; + } + if (result.results) { + g_variant_unref(result.results); + } + return out; +} + +struct ZoneInfo { + uint32_t width{0}; + uint32_t height{0}; + int32_t x{0}; + int32_t y{0}; + uint32_t zoneSet{0}; +}; + +std::optional GetZones(GDBusConnection* connection, const std::string& sessionHandle) { + GVariantBuilder options; + g_variant_builder_init(&options, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&options, "{sv}", "handle_token", g_variant_new_string(UniqueToken("zones").c_str())); + + GError* error = nullptr; + GVariant* reply = g_dbus_connection_call_sync( + connection, + kPortalBusName, + kInputCapturePath, + kInputCaptureInterface, + "GetZones", + g_variant_new("(oa{sv})", sessionHandle.c_str(), &options), + G_VARIANT_TYPE("(o)"), + G_DBUS_CALL_FLAGS_NONE, + -1, + nullptr, + &error); + if (!reply) { + FreeError(error, "GetZones"); + return std::nullopt; + } + const char* requestPath = nullptr; + g_variant_get(reply, "(&o)", &requestPath); + const std::string request(requestPath ? requestPath : ""); + g_variant_unref(reply); + + RequestResult result; + if (request.empty() || !WaitForRequest(connection, request.c_str(), result, 30000)) { + if (result.results) { + g_variant_unref(result.results); + } + return std::nullopt; + } + + ZoneInfo selected; + g_variant_lookup(result.results, "zone_set", "u", &selected.zoneSet); + GVariant* zones = g_variant_lookup_value(result.results, "zones", G_VARIANT_TYPE("a(uuii)")); + if (!zones || g_variant_n_children(zones) == 0) { + std::cerr << "WARN: libei input capture portal returned no zones." << std::endl; + if (zones) { + g_variant_unref(zones); + } + g_variant_unref(result.results); + return std::nullopt; + } + + uint64_t bestArea = 0; + for (gsize index = 0; index < g_variant_n_children(zones); ++index) { + uint32_t width = 0; + uint32_t height = 0; + int32_t x = 0; + int32_t y = 0; + g_variant_get_child(zones, index, "(uuii)", &width, &height, &x, &y); + const uint64_t area = static_cast(width) * height; + if (area > bestArea) { + bestArea = area; + selected.width = width; + selected.height = height; + selected.x = x; + selected.y = y; + } + } + g_variant_unref(zones); + g_variant_unref(result.results); + return selected; +} + +bool SetRightBarrier(GDBusConnection* connection, + const std::string& sessionHandle, + const ZoneInfo& zone) { + GVariantBuilder options; + g_variant_builder_init(&options, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&options, "{sv}", "handle_token", g_variant_new_string(UniqueToken("barrier").c_str())); + + GVariantBuilder barrier; + g_variant_builder_init(&barrier, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&barrier, "{sv}", "barrier_id", g_variant_new_uint32(kRightBarrierId)); + const int32_t x = zone.x + static_cast(zone.width); + const int32_t y1 = zone.y; + const int32_t y2 = zone.y + static_cast(zone.height) - 1; + g_variant_builder_add(&barrier, "{sv}", "position", g_variant_new("(iiii)", x, y1, x, y2)); + GVariant* barrierValue = g_variant_builder_end(&barrier); + + GVariantBuilder barriers; + g_variant_builder_init(&barriers, G_VARIANT_TYPE("aa{sv}")); + g_variant_builder_add_value(&barriers, barrierValue); + + GError* error = nullptr; + GVariant* reply = g_dbus_connection_call_sync( + connection, + kPortalBusName, + kInputCapturePath, + kInputCaptureInterface, + "SetPointerBarriers", + g_variant_new("(oa{sv}aa{sv}u)", sessionHandle.c_str(), &options, &barriers, zone.zoneSet), + G_VARIANT_TYPE("(o)"), + G_DBUS_CALL_FLAGS_NONE, + -1, + nullptr, + &error); + if (!reply) { + FreeError(error, "SetPointerBarriers"); + return false; + } + const char* requestPath = nullptr; + g_variant_get(reply, "(&o)", &requestPath); + const std::string request(requestPath ? requestPath : ""); + g_variant_unref(reply); + + RequestResult result; + const bool ok = !request.empty() && WaitForRequest(connection, request.c_str(), result, 30000); + if (ok && result.results) { + GVariant* failed = g_variant_lookup_value(result.results, "failed_barriers", G_VARIANT_TYPE("au")); + if (failed && g_variant_n_children(failed) > 0) { + std::cerr << "WARN: libei input capture portal rejected the right-edge barrier." << std::endl; + g_variant_unref(failed); + g_variant_unref(result.results); + return false; + } + if (failed) { + g_variant_unref(failed); + } + } + if (result.results) { + g_variant_unref(result.results); + } + return ok; +} + +std::optional ConnectToEis(GDBusConnection* connection, const std::string& sessionHandle) { + GVariantBuilder options; + g_variant_builder_init(&options, G_VARIANT_TYPE("a{sv}")); + + GUnixFDList* outFds = nullptr; + GError* error = nullptr; + GVariant* reply = g_dbus_connection_call_with_unix_fd_list_sync( + connection, + kPortalBusName, + kInputCapturePath, + kInputCaptureInterface, + "ConnectToEIS", + g_variant_new("(oa{sv})", sessionHandle.c_str(), &options), + G_VARIANT_TYPE("(h)"), + G_DBUS_CALL_FLAGS_NONE, + -1, + nullptr, + &outFds, + nullptr, + &error); + if (!reply) { + FreeError(error, "ConnectToEIS"); + return std::nullopt; + } + + int fdIndex = -1; + g_variant_get(reply, "(h)", &fdIndex); + g_variant_unref(reply); + if (!outFds || fdIndex < 0) { + if (outFds) { + g_object_unref(outFds); + } + return std::nullopt; + } + int fd = g_unix_fd_list_get(outFds, fdIndex, &error); + g_object_unref(outFds); + if (fd < 0) { + FreeError(error, "ConnectToEIS fd"); + return std::nullopt; + } + return fd; +} + +bool EnableCapture(GDBusConnection* connection, const std::string& sessionHandle) { + GVariantBuilder options; + g_variant_builder_init(&options, G_VARIANT_TYPE("a{sv}")); + GError* error = nullptr; + GVariant* reply = g_dbus_connection_call_sync( + connection, + kPortalBusName, + kInputCapturePath, + kInputCaptureInterface, + "Enable", + g_variant_new("(oa{sv})", sessionHandle.c_str(), &options), + nullptr, + G_DBUS_CALL_FLAGS_NONE, + -1, + nullptr, + &error); + if (!reply) { + FreeError(error, "Enable"); + return false; + } + g_variant_unref(reply); + return true; +} + +void ReleaseCapture(GDBusConnection* connection, const std::string& sessionHandle) { + GVariantBuilder options; + g_variant_builder_init(&options, G_VARIANT_TYPE("a{sv}")); + GError* error = nullptr; + GVariant* reply = g_dbus_connection_call_sync( + connection, + kPortalBusName, + kInputCapturePath, + kInputCaptureInterface, + "Release", + g_variant_new("(oa{sv})", sessionHandle.c_str(), &options), + nullptr, + G_DBUS_CALL_FLAGS_NONE, + 1000, + nullptr, + &error); + if (reply) { + g_variant_unref(reply); + } else { + FreeError(error, "Release"); + } +} + +void CloseSession(GDBusConnection* connection, const std::string& sessionHandle) { + if (sessionHandle.empty()) { + return; + } + GError* error = nullptr; + GVariant* reply = g_dbus_connection_call_sync( + connection, + kPortalBusName, + sessionHandle.c_str(), + "org.freedesktop.portal.Session", + "Close", + nullptr, + nullptr, + G_DBUS_CALL_FLAGS_NONE, + 1000, + nullptr, + &error); + if (reply) { + g_variant_unref(reply); + } else { + FreeError(error, "Session.Close"); + } +} + +#endif + +} // namespace + +LibeiInputCaptureBridge::LibeiInputCaptureBridge(LibeiInputCaptureBridgeOptions options) + : m_options(std::move(options)) {} + +LibeiInputCaptureBridge::~LibeiInputCaptureBridge() { + Stop(); +} + +bool LibeiInputCaptureBridge::Start() { + if (m_running.exchange(true)) { + return true; + } + m_thread = std::thread([this]() { + Run(); + }); + m_gestureThread = std::thread([this]() { + RunLibinputGestureMonitor(); + }); + return true; +} + +void LibeiInputCaptureBridge::Stop() { + m_running = false; + if (m_thread.joinable()) { + m_thread.join(); + } + if (m_gestureThread.joinable()) { + m_gestureThread.join(); + } +} + +void LibeiInputCaptureBridge::Run() { +#if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE) + GError* error = nullptr; + GDBusConnection* connection = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error); + if (!connection) { + FreeError(error, "session bus"); + m_running = false; + return; + } + + const auto sessionHandle = CreateSession(connection); + if (!sessionHandle.has_value()) { + g_object_unref(connection); + m_running = false; + return; + } + + const auto zone = GetZones(connection, *sessionHandle); + if (!zone.has_value() || !SetRightBarrier(connection, *sessionHandle, *zone)) { + CloseSession(connection, *sessionHandle); + g_object_unref(connection); + m_running = false; + return; + } + + const auto eisFd = ConnectToEis(connection, *sessionHandle); + if (!eisFd.has_value()) { + CloseSession(connection, *sessionHandle); + g_object_unref(connection); + m_running = false; + return; + } + + ei* context = ei_new_receiver(this); + if (!context) { + close(*eisFd); + CloseSession(connection, *sessionHandle); + g_object_unref(connection); + m_running = false; + return; + } + if (ei_setup_backend_fd(context, *eisFd) < 0) { + std::cerr << "WARN: libei input capture failed to initialize EIS fd." << std::endl; + ei_unref(context); + CloseSession(connection, *sessionHandle); + g_object_unref(connection); + m_running = false; + return; + } + + if (!EnableCapture(connection, *sessionHandle)) { + ei_unref(context); + CloseSession(connection, *sessionHandle); + g_object_unref(connection); + m_running = false; + return; + } + + std::cout << "[ANDROID] libei input capture enabled on right edge via XDG portal." << std::endl; + int androidX = 0; + int androidY = 32767; + int keyboardEventsLogged = 0; + bool remoteControlActive = false; + const auto setRemoteControlActive = [&](bool active) { + if (remoteControlActive == active) { + return; + } + remoteControlActive = active; + if (m_options.sendControl) { + m_options.sendControl(active); + } + }; + while (m_running) { + pollfd fds[1] = { + pollfd{ei_get_fd(context), POLLIN, 0}, + }; + const int rc = poll(fds, 1, 100); + while (g_main_context_pending(nullptr)) { + g_main_context_iteration(nullptr, FALSE); + } + if (rc < 0) { + continue; + } + if (rc == 0) { + continue; + } + ei_dispatch(context); + + ei_event* event = nullptr; + while ((event = ei_get_event(context)) != nullptr) { + const auto type = ei_event_get_type(event); + switch (type) { + case EI_EVENT_CONNECT: + break; + case EI_EVENT_DISCONNECT: + std::cerr << "WARN: libei input capture disconnected." << std::endl; + setRemoteControlActive(false); + m_running = false; + break; + case EI_EVENT_SEAT_ADDED: { + ei_seat* seat = ei_event_get_seat(event); + ei_seat_bind_capabilities(seat, + EI_DEVICE_CAP_POINTER, + EI_DEVICE_CAP_BUTTON, + EI_DEVICE_CAP_SCROLL, + EI_DEVICE_CAP_KEYBOARD, + nullptr); + break; + } + case EI_EVENT_DEVICE_ADDED: { + ei_device* device = ei_event_get_device(event); + if (device && + !ei_device_has_capability(device, EI_DEVICE_CAP_POINTER) && + !ei_device_has_capability(device, EI_DEVICE_CAP_BUTTON) && + !ei_device_has_capability(device, EI_DEVICE_CAP_SCROLL) && + !ei_device_has_capability(device, EI_DEVICE_CAP_KEYBOARD)) { + ei_device_close(device); + } + break; + } + case EI_EVENT_POINTER_MOTION: { + const double dx = ei_event_pointer_get_dx(event); + const double dy = ei_event_pointer_get_dy(event); + androidX = ClampNormalized(androidX + static_cast(std::lround(dx * 40.0))); + androidY = ClampNormalized(androidY + static_cast(std::lround(dy * 40.0))); + if (androidX <= 0 && dx < 0.0) { + setRemoteControlActive(false); + ReleaseCapture(connection, *sessionHandle); + std::cout << "[ANDROID] libei input capture released back to Fedora from Android left edge." << std::endl; + break; + } + setRemoteControlActive(true); + MouseData mouse{androidX, androidY, 0, WM_MOUSEMOVE}; + if (m_options.sendMouse) { + m_options.sendMouse(mouse); + } + break; + } + case EI_EVENT_BUTTON_BUTTON: { + const uint32_t button = ei_event_button_get_button(event); + const bool press = ei_event_button_get_is_press(event); + uint32_t message = 0; + if (button == BTN_LEFT) { + message = press ? WM_LBUTTONDOWN : WM_LBUTTONUP; + } else if (button == BTN_RIGHT) { + message = press ? WM_RBUTTONDOWN : WM_RBUTTONUP; + } else if (button == BTN_MIDDLE) { + message = press ? WM_MBUTTONDOWN : WM_MBUTTONUP; + } + if (message != 0 && m_options.sendMouse) { + MouseData mouse{androidX, androidY, 0, message}; + m_options.sendMouse(mouse); + } + break; + } + case EI_EVENT_SCROLL_DELTA: { + const double dx = ei_event_scroll_get_dx(event); + const double dy = ei_event_scroll_get_dy(event); + if ((std::abs(dx) > 0.01 || std::abs(dy) > 0.01) && m_options.sendGesture) { + m_options.sendGesture("scroll", dx, dy); + } else if (std::abs(dy) > 0.01 && m_options.sendMouse) { + MouseData mouse{androidX, androidY, static_cast(std::lround(-dy * 120.0)), WM_MOUSEWHEEL}; + m_options.sendMouse(mouse); + } + break; + } + case EI_EVENT_SCROLL_STOP: { + break; + } + case EI_EVENT_KEYBOARD_KEY: { + const uint32_t key = ei_event_keyboard_get_key(event); + const bool press = ei_event_keyboard_get_key_is_press(event); + const auto vk = LinuxKeyToVirtualKey(key); + if (vk.has_value() && m_options.sendKeyboard) { + KeyboardData keyboard{*vk, press ? 0u : LLKHF_UP}; + if (keyboardEventsLogged < 20) { + std::cout << "[ANDROID] libei keyboard event key=" << key + << " vk=" << *vk + << " press=" << (press ? "true" : "false") << std::endl; + ++keyboardEventsLogged; + } + m_options.sendKeyboard(keyboard); + } + break; + } + default: + break; + } + ei_event_unref(event); + } + } + + setRemoteControlActive(false); + ei_unref(context); + CloseSession(connection, *sessionHandle); + g_object_unref(connection); +#else + std::cerr << "WARN: android_capture_backend=libei requires libei-devel and glib2-devel at build time; local Android capture is disabled." << std::endl; +#endif + m_running = false; +} + +void LibeiInputCaptureBridge::RunLibinputGestureMonitor() { + if (!m_options.sendGesture) { + return; + } + const auto device = FindTouchpadEventDevice(); + if (!device.has_value()) { + std::cout << "[ANDROID] libinput gesture monitor skipped; no touchpad event device found." << std::endl; + return; + } + +#if defined(MWB_HAVE_LIBINPUT_GESTURES) + if (RunNativeLibinputGestureMonitor(*device, m_running, m_options.sendGesture)) { + return; + } + std::cerr << "WARN: Falling back to libinput debug-events gesture parser." << std::endl; +#endif + + std::cout << "[ANDROID] libinput gesture monitor using " << *device << std::endl; + while (m_running) { + const std::string command = + "sh -c 'for fd in /proc/$$/fd/[3-9]*; do " + "[ -e \"$fd\" ] && eval \"exec ${fd##*/}<&-\"; " + "done; exec timeout 1s libinput debug-events --device " + *device + " 2>/dev/null'"; + FILE* pipe = popen(command.c_str(), "r"); + if (!pipe) { + std::cerr << "WARN: Failed to start libinput gesture monitor." << std::endl; + return; + } + + char buffer[512]; + bool swipeActive = false; + bool pinchActive = false; + int fingers = 0; + double swipeDx = 0.0; + double swipeDy = 0.0; + double pinchScale = 1.0; + + while (m_running && fgets(buffer, sizeof(buffer), pipe) != nullptr) { + const std::string line(buffer); + std::istringstream in(line); + std::string deviceToken; + std::string type; + std::string timeToken; + in >> deviceToken >> type >> timeToken; + + if (type == "GESTURE_SWIPE_BEGIN") { + in >> fingers; + swipeActive = true; + swipeDx = 0.0; + swipeDy = 0.0; + } else if (type == "GESTURE_SWIPE_UPDATE" && swipeActive) { + int updateFingers = 0; + std::string delta; + in >> updateFingers >> delta; + if (auto parsed = ParseDeltaToken(delta); parsed.has_value()) { + fingers = updateFingers; + swipeDx += parsed->first; + swipeDy += parsed->second; + } + } else if (type == "GESTURE_SWIPE_END" && swipeActive) { + EmitClassifiedSwipe(m_options.sendGesture, fingers, swipeDx, swipeDy, false); + swipeActive = false; + } else if (type == "GESTURE_PINCH_BEGIN") { + in >> fingers; + pinchActive = true; + pinchScale = 1.0; + } else if (type == "GESTURE_PINCH_UPDATE" && pinchActive) { + std::string token; + double lastReasonableScale = 1.0; + while (in >> token) { + try { + const double value = std::stod(token); + if (value > 0.25 && value < 4.0) { + lastReasonableScale = value; + } + } catch (...) { + } + } + pinchScale *= lastReasonableScale; + } else if (type == "GESTURE_PINCH_END" && pinchActive) { + if (pinchScale < 0.92 || pinchScale > 1.08) { + m_options.sendGesture("pinch", pinchScale, 0.0); + } + pinchActive = false; + } + } + + pclose(pipe); + } +} + +} // namespace mwb diff --git a/src/LibeiInputCaptureBridge.h b/src/LibeiInputCaptureBridge.h new file mode 100644 index 0000000..60b67cc --- /dev/null +++ b/src/LibeiInputCaptureBridge.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "Protocol.h" + +namespace mwb { + +struct LibeiInputCaptureBridgeOptions { + int desktopWidth{0}; + int desktopHeight{0}; + std::function sendMouse; + std::function sendKeyboard; + std::function sendGesture; + std::function sendControl; +}; + +class LibeiInputCaptureBridge { +public: + explicit LibeiInputCaptureBridge(LibeiInputCaptureBridgeOptions options); + ~LibeiInputCaptureBridge(); + + bool Start(); + void Stop(); + +private: + void Run(); + void RunLibinputGestureMonitor(); + + LibeiInputCaptureBridgeOptions m_options; + std::atomic m_running{false}; + std::thread m_thread; + std::thread m_gestureThread; +}; + +} // namespace mwb diff --git a/src/LocalAndroidInputBridge.cpp b/src/LocalAndroidInputBridge.cpp new file mode 100644 index 0000000..94afe91 --- /dev/null +++ b/src/LocalAndroidInputBridge.cpp @@ -0,0 +1,531 @@ +#include "LocalAndroidInputBridge.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mwb { +namespace { + +constexpr uint32_t WM_MOUSEMOVE = 0x0200; +constexpr uint32_t WM_LBUTTONDOWN = 0x0201; +constexpr uint32_t WM_LBUTTONUP = 0x0202; +constexpr uint32_t WM_RBUTTONDOWN = 0x0204; +constexpr uint32_t WM_RBUTTONUP = 0x0205; +constexpr uint32_t WM_MBUTTONDOWN = 0x0207; +constexpr uint32_t WM_MBUTTONUP = 0x0208; +constexpr uint32_t WM_MOUSEWHEEL = 0x020A; + +int ClampNormalized(int value) { + return std::max(0, std::min(65535, value)); +} + +std::string Lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return value; +} + +struct DeviceState { + int fd{-1}; + std::string path; + std::string name; + int relDx{0}; + int relDy{0}; + int wheel{0}; + bool hasAbsX{false}; + bool hasAbsY{false}; + bool hasTouchState{false}; + bool touchActive{true}; + int absXMin{0}; + int absXMax{0}; + int absYMin{0}; + int absYMax{0}; + std::optional pendingAbsX; + std::optional pendingAbsY; + std::optional lastAbsX; + std::optional lastAbsY; +}; + +struct PointerDeviceCandidate { + std::string path; + std::string name; +}; + +std::vector DiscoverPointerEventDevices() { + std::ifstream input("/proc/bus/input/devices"); + std::vector devices; + std::string line; + std::string name; + std::string handlers; + + auto flush = [&]() { + const std::string lowerName = Lower(name); + const bool pointer = + lowerName.find("mouse") != std::string::npos || + lowerName.find("touchpad") != std::string::npos || + lowerName.find("trackpad") != std::string::npos || + lowerName.find("pointer") != std::string::npos; + const bool virtualInputFlow = lowerName.find("inputflow virtual") != std::string::npos; + if (pointer && !virtualInputFlow) { + std::istringstream stream(handlers); + std::string token; + while (stream >> token) { + if (token.rfind("event", 0) == 0) { + devices.push_back(PointerDeviceCandidate{"/dev/input/" + token, name}); + } + } + } + name.clear(); + handlers.clear(); + }; + + while (std::getline(input, line)) { + if (line.empty()) { + flush(); + continue; + } + if (line.rfind("N: Name=", 0) == 0) { + name = line.substr(8); + } else if (line.rfind("H: Handlers=", 0) == 0) { + handlers = line.substr(12); + } + } + flush(); + return devices; +} + +void LoadAbsInfo(DeviceState& device) { + input_absinfo info{}; + if (ioctl(device.fd, EVIOCGABS(ABS_X), &info) == 0 && info.maximum > info.minimum) { + device.hasAbsX = true; + device.absXMin = info.minimum; + device.absXMax = info.maximum; + } + if (ioctl(device.fd, EVIOCGABS(ABS_Y), &info) == 0 && info.maximum > info.minimum) { + device.hasAbsY = true; + device.absYMin = info.minimum; + device.absYMax = info.maximum; + } +} + +std::vector OpenPointerDevices() { + std::vector devices; + for (const auto& candidate : DiscoverPointerEventDevices()) { + const int fd = open(candidate.path.c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC); + if (fd < 0) { + continue; + } + DeviceState device; + device.fd = fd; + device.path = candidate.path; + device.name = candidate.name; + LoadAbsInfo(device); + devices.push_back(std::move(device)); + } + return devices; +} + +void ClosePointerDevices(std::vector& devices) { + for (auto& device : devices) { + if (device.fd >= 0) { + close(device.fd); + device.fd = -1; + } + } +} + +bool MovementMatchesEdge(EdgeDirection edge, int dx, int dy) { + switch (edge) { + case EdgeDirection::Left: + return dx < 0; + case EdgeDirection::Right: + return dx > 0; + case EdgeDirection::Up: + return dy < 0; + case EdgeDirection::Down: + return dy > 0; + } + return false; +} + +std::optional DominantMovementEdge(int dx, int dy) { + if (std::abs(dx) >= std::abs(dy) && dx != 0) { + return dx < 0 ? EdgeDirection::Left : EdgeDirection::Right; + } + if (dy != 0) { + return dy < 0 ? EdgeDirection::Up : EdgeDirection::Down; + } + return std::nullopt; +} + +bool MovementReturnsFromEdge(EdgeDirection entryEdge, int x, int y, int dx, int dy) { + switch (entryEdge) { + case EdgeDirection::Left: + return x <= 0 && dx < 0; + case EdgeDirection::Right: + return x >= 65535 && dx > 0; + case EdgeDirection::Up: + return y <= 0 && dy < 0; + case EdgeDirection::Down: + return y >= 65535 && dy > 0; + } + return false; +} + +} // namespace + +LocalAndroidInputBridge::LocalAndroidInputBridge(LocalAndroidInputBridgeOptions options) + : m_options(std::move(options)) {} + +LocalAndroidInputBridge::~LocalAndroidInputBridge() { + Stop(); +} + +bool LocalAndroidInputBridge::Start() { + if (m_running.exchange(true)) { + return true; + } + m_thread = std::thread([this]() { + Run(); + }); + return true; +} + +void LocalAndroidInputBridge::Stop() { + if (!m_running.exchange(false)) { + return; + } + if (m_thread.joinable()) { + m_thread.join(); + } +} + +void LocalAndroidInputBridge::Run() { + ::Display* display = XOpenDisplay(nullptr); + if (display == nullptr) { + std::cerr << "WARN: Local Android input bridge could not open X display; Fedora-to-Android edge capture disabled." << std::endl; + m_running = false; + return; + } + + auto devices = OpenPointerDevices(); + if (devices.empty()) { + std::cerr << "WARN: Local Android input bridge found no readable pointer devices; Fedora-to-Android edge capture disabled." << std::endl; + XCloseDisplay(display); + m_running = false; + return; + } + + std::vector polls; + polls.reserve(devices.size()); + for (const auto& device : devices) { + polls.push_back(pollfd{device.fd, POLLIN, 0}); + } + + std::cout << "[ANDROID] Local Fedora pointer bridge watching " << devices.size() + << " device(s); push through configured Android edge to control Android." << std::endl; + + bool active = false; + EdgeDirection activeEntryEdge = EdgeDirection::Left; + int androidX = 0; + int androidY = 0; + + int localX = m_options.desktopWidth / 2; + int localY = m_options.desktopHeight / 2; + { + Window rootReturn{}; + Window childReturn{}; + int rootX = 0; + int rootY = 0; + int windowX = 0; + int windowY = 0; + unsigned int mask = 0; + if (XQueryPointer(display, + DefaultRootWindow(display), + &rootReturn, + &childReturn, + &rootX, + &rootY, + &windowX, + &windowY, + &mask)) { + localX = std::max(0, std::min(m_options.desktopWidth - 1, rootX)); + localY = std::max(0, std::min(m_options.desktopHeight - 1, rootY)); + } + } + + auto queryTransition = [&](EdgeDirection pushedEdge) -> std::optional { + constexpr int edgeSlopPixels = 64; + for (const auto& displayInfo : m_options.topology->displays()) { + if (displayInfo.machineId != m_options.localMachineName) { + continue; + } + + bool atEdge = false; + int coordinate = 0; + switch (pushedEdge) { + case EdgeDirection::Left: + atEdge = localX <= displayInfo.x + edgeSlopPixels && + localY >= displayInfo.y && + localY < displayInfo.y + displayInfo.height; + coordinate = localY - displayInfo.y; + break; + case EdgeDirection::Right: + atEdge = localX >= displayInfo.x + displayInfo.width - 1 - edgeSlopPixels && + localY >= displayInfo.y && + localY < displayInfo.y + displayInfo.height; + coordinate = localY - displayInfo.y; + break; + case EdgeDirection::Up: + atEdge = localY <= displayInfo.y + edgeSlopPixels && + localX >= displayInfo.x && + localX < displayInfo.x + displayInfo.width; + coordinate = localX - displayInfo.x; + break; + case EdgeDirection::Down: + atEdge = localY >= displayInfo.y + displayInfo.height - 1 - edgeSlopPixels && + localX >= displayInfo.x && + localX < displayInfo.x + displayInfo.width; + coordinate = localX - displayInfo.x; + break; + } + + if (!atEdge) { + continue; + } + + const auto transition = m_options.topology->transitionFromEdge(displayInfo.id, pushedEdge, coordinate); + if (!transition.has_value()) { + continue; + } + return TopologyPointerTransition{ + displayInfo.id, + pushedEdge, + transition->targetDisplayId, + transition->entryEdge, + transition->coordinate, + }; + } + return std::nullopt; + }; + + auto queryConfiguredTransition = [&](EdgeDirection pushedEdge) -> std::optional { + for (const auto& displayInfo : m_options.topology->displays()) { + if (displayInfo.machineId != m_options.localMachineName) { + continue; + } + + int coordinate = 0; + switch (pushedEdge) { + case EdgeDirection::Left: + case EdgeDirection::Right: + coordinate = std::max(0, std::min(displayInfo.height - 1, localY - displayInfo.y)); + break; + case EdgeDirection::Up: + case EdgeDirection::Down: + coordinate = std::max(0, std::min(displayInfo.width - 1, localX - displayInfo.x)); + break; + } + + const auto transition = m_options.topology->transitionFromEdge(displayInfo.id, pushedEdge, coordinate); + if (!transition.has_value()) { + continue; + } + return TopologyPointerTransition{ + displayInfo.id, + pushedEdge, + transition->targetDisplayId, + transition->entryEdge, + transition->coordinate, + }; + } + return std::nullopt; + }; + + auto sendMove = [&]() { + MouseData mouse{androidX, androidY, 0, WM_MOUSEMOVE}; + return m_options.sendMouse && m_options.sendMouse(mouse); + }; + + std::optional pressureEdge; + int edgePressure = 0; + + auto processMotion = [&](std::size_t deviceIndex, int normalizedDx, int normalizedDy) { + if (normalizedDx == 0 && normalizedDy == 0) { + return; + } + + if (!active) { + localX = std::max(0, std::min(m_options.desktopWidth - 1, + localX + static_cast((static_cast(normalizedDx) * m_options.desktopWidth) / 65535))); + localY = std::max(0, std::min(m_options.desktopHeight - 1, + localY + static_cast((static_cast(normalizedDy) * m_options.desktopHeight) / 65535))); + const auto pushedEdge = DominantMovementEdge(normalizedDx, normalizedDy); + if (!pushedEdge.has_value()) { + return; + } + + const int pressureDelta = std::max(std::abs(normalizedDx), std::abs(normalizedDy)); + if (pressureEdge.has_value() && *pressureEdge == *pushedEdge) { + edgePressure = std::min(30000, edgePressure + pressureDelta); + } else { + pressureEdge = pushedEdge; + edgePressure = pressureDelta; + } + + auto transition = queryTransition(*pushedEdge); + if (!transition.has_value() && edgePressure >= 4500) { + transition = queryConfiguredTransition(*pushedEdge); + } + if (!transition.has_value() || !MovementMatchesEdge(transition->exitEdge, normalizedDx, normalizedDy)) { + return; + } + const auto targetPoint = MapTransitionToTargetNormalizedPoint(*m_options.topology, *transition); + const auto targetMachineId = m_options.topology->machineIdForDisplay(transition->targetDisplayId); + if (!targetPoint.has_value() || + !targetMachineId.has_value() || + *targetMachineId != m_options.androidPeerName) { + return; + } + active = true; + edgePressure = 0; + activeEntryEdge = transition->entryEdge; + androidX = targetPoint->x; + androidY = targetPoint->y; + std::cout << "[ANDROID] Local pointer entered Android from " + << edgeDirectionName(transition->exitEdge) + << " edge using " << devices[deviceIndex].name + << " (" << devices[deviceIndex].path << ")." << std::endl; + } + + androidX = ClampNormalized(androidX + normalizedDx); + androidY = ClampNormalized(androidY + normalizedDy); + if (!sendMove()) { + active = false; + return; + } + if (MovementReturnsFromEdge(activeEntryEdge, androidX, androidY, normalizedDx, normalizedDy)) { + active = false; + std::cout << "[ANDROID] Local pointer returned to Fedora." << std::endl; + } + }; + + auto sendButton = [&](std::size_t deviceIndex, uint32_t down, uint32_t up, int value) { + (void)deviceIndex; + if (!active || value == 2) { + return; + } + MouseData mouse{androidX, androidY, 0, value ? down : up}; + if (m_options.sendMouse) { + m_options.sendMouse(mouse); + } + }; + + while (m_running) { + const int rc = poll(polls.data(), polls.size(), 100); + if (rc < 0) { + if (errno == EINTR) { + continue; + } + break; + } + if (rc == 0) { + continue; + } + + for (std::size_t index = 0; index < devices.size(); ++index) { + if ((polls[index].revents & POLLIN) == 0) { + continue; + } + + input_event event{}; + while (read(devices[index].fd, &event, sizeof(event)) == sizeof(event)) { + auto& device = devices[index]; + if (event.type == EV_REL) { + if (event.code == REL_X) { + device.relDx += event.value; + } else if (event.code == REL_Y) { + device.relDy += event.value; + } else if (event.code == REL_WHEEL) { + device.wheel += event.value; + } + } else if (event.type == EV_ABS) { + if (event.code == ABS_X && device.hasAbsX) { + device.pendingAbsX = event.value; + } else if (event.code == ABS_Y && device.hasAbsY) { + device.pendingAbsY = event.value; + } + } else if (event.type == EV_KEY) { + if (event.code == BTN_TOUCH || event.code == BTN_TOOL_FINGER) { + device.hasTouchState = true; + device.touchActive = event.value != 0; + if (!device.touchActive) { + device.lastAbsX.reset(); + device.lastAbsY.reset(); + device.pendingAbsX.reset(); + device.pendingAbsY.reset(); + } + } else if (event.code == BTN_LEFT) { + sendButton(index, WM_LBUTTONDOWN, WM_LBUTTONUP, event.value); + } else if (event.code == BTN_RIGHT) { + sendButton(index, WM_RBUTTONDOWN, WM_RBUTTONUP, event.value); + } else if (event.code == BTN_MIDDLE) { + sendButton(index, WM_MBUTTONDOWN, WM_MBUTTONUP, event.value); + } + } else if (event.type == EV_SYN && event.code == SYN_REPORT) { + int normalizedDx = 0; + int normalizedDy = 0; + if (device.relDx != 0) { + normalizedDx += static_cast((static_cast(device.relDx) * 65535) / std::max(1, m_options.desktopWidth)); + } + if (device.relDy != 0) { + normalizedDy += static_cast((static_cast(device.relDy) * 65535) / std::max(1, m_options.desktopHeight)); + } + if ((!device.hasTouchState || device.touchActive) && device.pendingAbsX.has_value()) { + if (device.lastAbsX.has_value() && device.absXMax > device.absXMin) { + normalizedDx += static_cast((static_cast(*device.pendingAbsX - *device.lastAbsX) * 65535) / + (device.absXMax - device.absXMin)); + } + device.lastAbsX = *device.pendingAbsX; + } + if ((!device.hasTouchState || device.touchActive) && device.pendingAbsY.has_value()) { + if (device.lastAbsY.has_value() && device.absYMax > device.absYMin) { + normalizedDy += static_cast((static_cast(*device.pendingAbsY - *device.lastAbsY) * 65535) / + (device.absYMax - device.absYMin)); + } + device.lastAbsY = *device.pendingAbsY; + } + processMotion(index, normalizedDx, normalizedDy); + if (active && device.wheel != 0 && m_options.sendMouse) { + MouseData wheel{androidX, androidY, device.wheel * 120, WM_MOUSEWHEEL}; + m_options.sendMouse(wheel); + } + device.relDx = 0; + device.relDy = 0; + device.wheel = 0; + } + } + } + } + + ClosePointerDevices(devices); + XCloseDisplay(display); +} + +} // namespace mwb diff --git a/src/LocalAndroidInputBridge.h b/src/LocalAndroidInputBridge.h new file mode 100644 index 0000000..831a185 --- /dev/null +++ b/src/LocalAndroidInputBridge.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "Protocol.h" +#include "TopologyModel.h" + +namespace mwb { + +struct LocalAndroidInputBridgeOptions { + std::shared_ptr topology; + std::string localMachineName; + std::string androidPeerName; + int desktopWidth{0}; + int desktopHeight{0}; + std::function sendMouse; +}; + +class LocalAndroidInputBridge { +public: + explicit LocalAndroidInputBridge(LocalAndroidInputBridgeOptions options); + ~LocalAndroidInputBridge(); + + bool Start(); + void Stop(); + +private: + void Run(); + + LocalAndroidInputBridgeOptions m_options; + std::atomic m_running{false}; + std::thread m_thread; +}; + +} // namespace mwb diff --git a/src/main.cpp b/src/main.cpp index 53a04ed..4e2362a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -59,6 +61,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 << " android-pair [--config 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"; @@ -573,6 +576,19 @@ std::optional DetectOutboundLocalIpv4(const std::string& host, int return localIp; } +std::string PercentEncode(std::string_view value) { + std::ostringstream out; + out << std::uppercase << std::hex; + for (const unsigned char ch : value) { + if (std::isalnum(ch) != 0 || ch == '-' || ch == '_' || ch == '.' || ch == '~') { + out << static_cast(ch); + continue; + } + out << '%' << std::setw(2) << std::setfill('0') << static_cast(ch); + } + return out.str(); +} + std::optional NormalizePeerPosition(std::string_view value) { std::string normalized; normalized.reserve(value.size()); @@ -1191,6 +1207,14 @@ int RunClient(const mwb::AppConfig& config, options.latencyReport = runtimeConfig.latencyReport; options.topologyRuntimeEnabled = runtimeConfig.topologyRuntimeEnabled; options.topologyFilePath = runtimeConfig.topologyFile; + options.androidCaptureBackend = runtimeConfig.androidCaptureBackend; + options.androidRelay.enabled = runtimeConfig.androidPeersEnabled; + options.androidRelay.port = runtimeConfig.androidRelayPort; + options.androidRelay.secret = runtimeConfig.androidRelaySecret; + options.androidRelay.peerName = runtimeConfig.androidPeerName; + options.androidRelay.layoutEditorEnabled = runtimeConfig.androidLayoutEditorEnabled; + options.androidRelay.androidDeviceWidth = runtimeConfig.androidDeviceWidth; + options.androidRelay.androidDeviceHeight = runtimeConfig.androidDeviceHeight; 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()); @@ -1672,6 +1696,11 @@ int HandleDoctorCommand(const std::vector& args) { PrintDoctorLine("INFO", "port", std::to_string(config.port)); PrintDoctorLine("INFO", "clipboard", std::string(config.clipboardEnabled ? "enabled" : "disabled") + (config.clipboardSendEnabled ? ", send enabled" : ", receive-only")); + PrintDoctorLine("INFO", "android relay", + std::string(config.androidPeersEnabled ? "enabled" : "disabled") + + " port=" + std::to_string(config.androidRelayPort) + + " peer=" + (config.androidPeerName.empty() ? "" : config.androidPeerName) + + " secret=" + (config.androidRelaySecret.empty() ? "missing" : "configured")); PrintDoctorLine("INFO", "reconnect", "initial=" + std::to_string(config.reconnectInitialBackoffMs) + "ms max=" + std::to_string(config.reconnectMaxBackoffMs) + "ms idle=" + std::to_string(config.reconnectIdleRetryMs) + "ms"); @@ -2625,6 +2654,51 @@ int HandleTopologyCommand(const std::vector& args) { return 0; } +int HandleAndroidPairCommand(const std::vector& args) { + std::filesystem::path configPath = mwb::DefaultConfigPath(); + + for (std::size_t index = 0; index < args.size(); ++index) { + const std::string& arg = args[index]; + if (arg == "--config") { + if (index + 1 >= args.size()) { + std::cerr << "ERR: Missing value for --config." << std::endl; + return 1; + } + configPath = args[++index]; + } else { + std::cerr << "ERR: Unknown android-pair option: " << arg << std::endl; + return 1; + } + } + + mwb::AppConfig config; + std::string error; + if (!mwb::LoadConfigFile(configPath, config, error)) { + std::cerr << "ERR: " << error << std::endl; + return 1; + } + if (!config.androidPeersEnabled) { + std::cerr << "ERR: android_peers_enabled is false in " << configPath << "." << std::endl; + return 1; + } + if (config.androidRelaySecret.empty()) { + std::cerr << "ERR: android_relay_secret is empty in " << configPath << "." << std::endl; + return 1; + } + + const std::string host = DetectOutboundLocalIpv4(config.host, config.port).value_or(""); + const std::string uri = + "inputflow://android-peer?host=" + PercentEncode(host) + + "&port=" + std::to_string(config.androidRelayPort) + + "&secret=" + PercentEncode(config.androidRelaySecret) + + "&peer=" + PercentEncode(config.androidPeerName); + + std::cout << "Android pairing URI:" << std::endl; + std::cout << uri << std::endl; + std::cout << "Use this string as the QR payload or enter the fields manually in the Android app." << std::endl; + return 0; +} + } // namespace int main(int argc, char** argv) { @@ -2638,6 +2712,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]) != "android-pair" && std::string(argv[1]) != "topology" && std::string(argv[1]) != "init-config" && std::string(argv[1]) != "export-windows-pair" && @@ -2666,6 +2741,9 @@ int main(int argc, char** argv) { if (command == "doctor") { return HandleDoctorCommand(args); } + if (command == "android-pair") { + return HandleAndroidPairCommand(args); + } if (command == "topology") { return HandleTopologyCommand(args); } diff --git a/tests/test_main.cpp b/tests/test_main.cpp index bd2508b..8352ecf 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -3,6 +3,7 @@ #include #include +#include "AndroidRelay.h" #include "AppConfig.h" #include "AppState.h" #include "Discovery.h" @@ -53,6 +54,11 @@ void TestAppConfigRoundTrip() { config.latencyReport = true; config.topologyRuntimeEnabled = true; config.topologyFile = "topology.conf"; + config.androidPeersEnabled = true; + config.androidRelayPort = 15112; + config.androidRelaySecret = "android-secret"; + config.androidPeerName = "pixel-8"; + config.androidCaptureBackend = "evdev"; const std::filesystem::path path = MakeTempPath("mwb-config-test.ini"); std::string error; @@ -80,6 +86,16 @@ void TestAppConfigRoundTrip() { "Rendered config should keep topology_enabled"); ExpectRenderedLine(rendered, "topology_file", "topology.conf", "Rendered config should keep topology_file"); + ExpectRenderedLine(rendered, "android_peers_enabled", "true", + "Rendered config should keep android_peers_enabled"); + ExpectRenderedLine(rendered, "android_relay_port", "15112", + "Rendered config should keep android_relay_port"); + ExpectRenderedLine(rendered, "android_relay_secret", "android-secret", + "Rendered config should keep android_relay_secret"); + ExpectRenderedLine(rendered, "android_peer_name", "pixel-8", + "Rendered config should keep android_peer_name"); + ExpectRenderedLine(rendered, "android_capture_backend", "evdev", + "Rendered config should keep android_capture_backend"); mwb::AppConfig loaded; Expect(mwb::LoadConfigFile(path, loaded, error), "LoadConfigFile should succeed"); @@ -109,6 +125,16 @@ void TestAppConfigRoundTrip() { "Loaded config should keep topology_enabled"); ExpectRenderedLine(loadedRendered, "topology_file", "topology.conf", "Loaded config should keep topology_file"); + ExpectRenderedLine(loadedRendered, "android_peers_enabled", "true", + "Loaded config should keep android_peers_enabled"); + ExpectRenderedLine(loadedRendered, "android_relay_port", "15112", + "Loaded config should keep android_relay_port"); + ExpectRenderedLine(loadedRendered, "android_relay_secret", "android-secret", + "Loaded config should keep android_relay_secret"); + ExpectRenderedLine(loadedRendered, "android_peer_name", "pixel-8", + "Loaded config should keep android_peer_name"); + ExpectRenderedLine(loadedRendered, "android_capture_backend", "evdev", + "Loaded config should keep android_capture_backend"); 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"); @@ -130,10 +156,31 @@ void TestAppConfigRoundTrip() { 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"); + Expect(loaded.androidPeersEnabled == config.androidPeersEnabled, "Config androidPeersEnabled round-trip"); + Expect(loaded.androidRelayPort == config.androidRelayPort, "Config androidRelayPort round-trip"); + Expect(loaded.androidRelaySecret == config.androidRelaySecret, "Config androidRelaySecret round-trip"); + Expect(loaded.androidPeerName == config.androidPeerName, "Config androidPeerName round-trip"); + Expect(loaded.androidCaptureBackend == config.androidCaptureBackend, "Config androidCaptureBackend round-trip"); std::error_code ignore; std::filesystem::remove(path, ignore); } +void TestAndroidRelayFrames() { + const mwb::MouseData mouse{123, 456, -120, 0x020A}; + const std::string mouseFrame = mwb::BuildAndroidMouseFrame(mouse); + Expect(mouseFrame.find("\"type\":\"mouse\"") != std::string::npos, "Android mouse frame should include type"); + Expect(mouseFrame.find("\"x\":123") != std::string::npos, "Android mouse frame should include x"); + Expect(mouseFrame.find("\"y\":456") != std::string::npos, "Android mouse frame should include y"); + Expect(mouseFrame.find("\"mouseData\":-120") != std::string::npos, "Android mouse frame should include mouseData"); + Expect(mouseFrame.find("\"wParam\":522") != std::string::npos, "Android mouse frame should include wParam"); + + const mwb::KeyboardData keyboard{65, mwb::kLlkhfUp}; + const std::string keyboardFrame = mwb::BuildAndroidKeyboardFrame(keyboard); + Expect(keyboardFrame.find("\"type\":\"keyboard\"") != std::string::npos, "Android keyboard frame should include type"); + Expect(keyboardFrame.find("\"vkCode\":65") != std::string::npos, "Android keyboard frame should include vkCode"); + Expect(keyboardFrame.find("\"flags\":128") != std::string::npos, "Android keyboard frame should include flags"); +} + void TestAppConfigKeyFileRoundTrip() { mwb::AppConfig config; config.host = "192.0.2.108"; @@ -593,6 +640,7 @@ void TestKScreenDoctorParserIgnoresAnsiSequences() { int main() { TestAppConfigRoundTrip(); + TestAndroidRelayFrames(); TestAppConfigKeyFileRoundTrip(); TestAppConfigKeySecretIdRoundTrip(); TestAppConfigConnectionPolicyRoundTrip(); From abe4266da2f94f9d38bd187c51006ac32b85aed8 Mon Sep 17 00:00:00 2001 From: daredoole Date: Fri, 1 May 2026 10:21:37 -0400 Subject: [PATCH 09/14] Add tray GUI header declarations --- src/GuiMainWindow.h | 70 ++++++++++++++++++++++++++++++++++++++++++++ src/TrayController.h | 18 ++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 src/GuiMainWindow.h create mode 100644 src/TrayController.h diff --git a/src/GuiMainWindow.h b/src/GuiMainWindow.h new file mode 100644 index 0000000..833406a --- /dev/null +++ b/src/GuiMainWindow.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include + +namespace mwb { + +struct AppConfig; + +// Callback invoked on the GTK main thread when the user saves settings. +using OnSettingsSaved = std::function; + +struct GuiMainWindow { + GtkWidget* window{nullptr}; + // Tabs + GtkWidget* notebook{nullptr}; + + // Status tab widgets + GtkWidget* dotArea{nullptr}; // GtkDrawingArea for connection dot + GtkWidget* stateLabel{nullptr}; + GtkWidget* detailLabel{nullptr}; + GtkWidget* logView{nullptr}; + GtkTextBuffer* logBuf{nullptr}; + + // Settings tab — connection + GtkWidget* hostEntry{nullptr}; + GtkWidget* portSpin{nullptr}; + GtkWidget* nameEntry{nullptr}; + GtkWidget* keyEntry{nullptr}; + + // Settings tab — behavior + GtkWidget* autoConnectSwitch{nullptr}; + GtkWidget* clipboardSwitch{nullptr}; + GtkWidget* mprisSwitch{nullptr}; + GtkWidget* mprisPlayerEntry{nullptr}; + GtkWidget* latencySwitch{nullptr}; + + // Settings tab — topology toggle + GtkWidget* topologySwitch{nullptr}; + + // Monitor layout tab + GtkWidget* layoutStack{nullptr}; // GtkStack: locked vs. canvas + GtkWidget* layoutCanvas{nullptr}; // GtkDrawingArea (MonitorLayoutWidget) + + // Android tab + GtkWidget* androidSwitch{nullptr}; + GtkWidget* androidPortSpin{nullptr}; + GtkWidget* androidSecretEntry{nullptr}; + GtkWidget* androidNameEntry{nullptr}; + GtkWidget* androidBackendCombo{nullptr}; + GtkWidget* androidWidthSpin{nullptr}; + GtkWidget* androidHeightSpin{nullptr}; + + // State + std::string connectionState; // "active", "inactive", "failed", etc. + std::string connectionDetail; + std::string configPath; + OnSettingsSaved onSettingsSaved; + + void UpdateStatus(const std::string& state, const std::string& detail); + void ShowTab(int index); + void AppendLog(const std::string& line); +}; + +GuiMainWindow* CreateMainWindow(const AppConfig& config, + const std::string& configPath, + OnSettingsSaved onSettingsSaved); + +} // namespace mwb diff --git a/src/TrayController.h b/src/TrayController.h new file mode 100644 index 0000000..f2d2258 --- /dev/null +++ b/src/TrayController.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace mwb { + +struct AppConfig; + +// Runs the combined tray + GUI + daemon. GTK must already be initialised +// (gtk_init / gtk_init_check called by caller). Blocks until gtk_main_quit(). +int RunTrayAndGui(const std::string& binary, + const std::vector& args, + const AppConfig& config, + const std::string& configPath, + const std::string& statePath); + +} // namespace mwb From 5d4541ea8de0a57049d038b61753061d60f103d3 Mon Sep 17 00:00:00 2001 From: daredoole Date: Fri, 1 May 2026 10:22:00 -0400 Subject: [PATCH 10/14] Add GTK main window implementation --- src/GuiMainWindow.cpp | 485 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 src/GuiMainWindow.cpp diff --git a/src/GuiMainWindow.cpp b/src/GuiMainWindow.cpp new file mode 100644 index 0000000..f3aa6ee --- /dev/null +++ b/src/GuiMainWindow.cpp @@ -0,0 +1,485 @@ +#include "GuiMainWindow.h" +#include "AppConfig.h" +#include "MonitorLayoutWidget.h" + +#include +#include +#include + +namespace mwb { + +namespace { + +constexpr const char* kServiceName = "mwb-client.service"; +constexpr const char* kSystemctlPath = "/usr/bin/systemctl"; + +// ---- colour helpers -------------------------------------------------------- + +struct Rgb { double r, g, b; }; + +Rgb StateColor(const std::string& state) { + if (state == "active") return {0.30, 0.80, 0.40}; // green + if (state == "activating" || state == "reloading") return {1.0, 0.65, 0.0}; // amber + if (state == "failed") return {0.90, 0.25, 0.20}; // red + if (state == "inactive") return {0.55, 0.55, 0.55}; // grey + return {0.40, 0.40, 0.40}; +} + +// ---- status dot drawing ---------------------------------------------------- + +gboolean OnDotDraw(GtkWidget* widget, cairo_t* cr, gpointer data) { + auto* win = static_cast(data); + int w = gtk_widget_get_allocated_width(widget); + int h = gtk_widget_get_allocated_height(widget); + auto [r, g, b] = StateColor(win->connectionState); + cairo_arc(cr, w / 2.0, h / 2.0, w / 2.0 - 1, 0, 2 * G_PI); + cairo_set_source_rgb(cr, r, g, b); + cairo_fill(cr); + return FALSE; +} + +// ---- service control ------------------------------------------------------- + +void RunServiceAction(const std::string& action) { + if (access(kSystemctlPath, X_OK) != 0) return; + std::string cmd = std::string(kSystemctlPath) + " --user " + action + " " + kServiceName + " &"; + (void)std::system(cmd.c_str()); +} + +void OnStartService(GtkButton*, gpointer) { RunServiceAction("start"); } +void OnStopService(GtkButton*, gpointer) { RunServiceAction("stop"); } +void OnRestartService(GtkButton*, gpointer) { RunServiceAction("restart"); } + +// ---- settings save --------------------------------------------------------- + +void OnSaveSettings(GtkButton*, gpointer data) { + auto* win = static_cast(data); + + AppConfig cfg; + cfg.host = gtk_entry_get_text(GTK_ENTRY(win->hostEntry)); + cfg.port = static_cast(gtk_spin_button_get_value(GTK_SPIN_BUTTON(win->portSpin))); + cfg.machineName = gtk_entry_get_text(GTK_ENTRY(win->nameEntry)); + cfg.key = gtk_entry_get_text(GTK_ENTRY(win->keyEntry)); + cfg.autoConnectEnabled = gtk_switch_get_active(GTK_SWITCH(win->autoConnectSwitch)); + cfg.clipboardEnabled = gtk_switch_get_active(GTK_SWITCH(win->clipboardSwitch)); + cfg.mprisMediaKeysEnabled = gtk_switch_get_active(GTK_SWITCH(win->mprisSwitch)); + cfg.mprisPlayer = gtk_entry_get_text(GTK_ENTRY(win->mprisPlayerEntry)); + cfg.latencyReport = gtk_switch_get_active(GTK_SWITCH(win->latencySwitch)); + cfg.topologyRuntimeEnabled = gtk_switch_get_active(GTK_SWITCH(win->topologySwitch)); + + // Android + cfg.androidPeersEnabled = gtk_switch_get_active(GTK_SWITCH(win->androidSwitch)); + cfg.androidRelayPort = static_cast(gtk_spin_button_get_value(GTK_SPIN_BUTTON(win->androidPortSpin))); + cfg.androidRelaySecret = gtk_entry_get_text(GTK_ENTRY(win->androidSecretEntry)); + cfg.androidPeerName = gtk_entry_get_text(GTK_ENTRY(win->androidNameEntry)); + cfg.androidDeviceWidth = static_cast(gtk_spin_button_get_value(GTK_SPIN_BUTTON(win->androidWidthSpin))); + cfg.androidDeviceHeight = static_cast(gtk_spin_button_get_value(GTK_SPIN_BUTTON(win->androidHeightSpin))); + const gchar* backendText = gtk_combo_box_text_get_active_text(GTK_COMBO_BOX_TEXT(win->androidBackendCombo)); + if (backendText) cfg.androidCaptureBackend = backendText; + + std::string err; + WriteAppConfig(win->configPath, cfg, &err); + + if (win->onSettingsSaved) win->onSettingsSaved(cfg); + + // Unlock monitor tab if topology just enabled + if (cfg.topologyRuntimeEnabled && win->layoutStack) { + gtk_stack_set_visible_child_name(GTK_STACK(win->layoutStack), "canvas"); + } +} + +// ---- topology enable button ------------------------------------------------ + +void OnEnableTopology(GtkButton*, gpointer data) { + auto* win = static_cast(data); + if (win->topologySwitch) { + gtk_switch_set_active(GTK_SWITCH(win->topologySwitch), TRUE); + } + OnSaveSettings(nullptr, win); +} + +// ---- layout helpers -------------------------------------------------------- + +GtkWidget* MakeLabel(const char* text, bool secondary = false) { + GtkWidget* lbl = gtk_label_new(text); + gtk_label_set_xalign(GTK_LABEL(lbl), 0.0f); + if (secondary) { + gtk_style_context_add_class(gtk_widget_get_style_context(lbl), "dim-label"); + } + return lbl; +} + +GtkWidget* MakeSectionHeader(const char* text) { + GtkWidget* lbl = gtk_label_new(nullptr); + char* markup = g_markup_printf_escaped("%s", text); + gtk_label_set_markup(GTK_LABEL(lbl), markup); + g_free(markup); + gtk_label_set_xalign(GTK_LABEL(lbl), 0.0f); + gtk_widget_set_margin_top(lbl, 12); + gtk_widget_set_margin_bottom(lbl, 4); + return lbl; +} + +void GridAttach(GtkWidget* grid, GtkWidget* label, GtkWidget* widget, int row) { + gtk_widget_set_hexpand(widget, TRUE); + gtk_grid_attach(GTK_GRID(grid), label, 0, row, 1, 1); + gtk_grid_attach(GTK_GRID(grid), widget, 1, row, 1, 1); +} + +// ---- build tabs ------------------------------------------------------------ + +GtkWidget* BuildStatusTab(GuiMainWindow* win) { + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_container_set_border_width(GTK_CONTAINER(box), 16); + + // Dot + state row + GtkWidget* stateRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + win->dotArea = gtk_drawing_area_new(); + gtk_widget_set_size_request(win->dotArea, 18, 18); + g_signal_connect(win->dotArea, "draw", G_CALLBACK(OnDotDraw), win); + gtk_box_pack_start(GTK_BOX(stateRow), win->dotArea, FALSE, FALSE, 0); + win->stateLabel = gtk_label_new("Checking..."); + gtk_box_pack_start(GTK_BOX(stateRow), win->stateLabel, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(box), stateRow, FALSE, FALSE, 0); + + win->detailLabel = gtk_label_new(""); + gtk_label_set_xalign(GTK_LABEL(win->detailLabel), 0.0f); + gtk_style_context_add_class(gtk_widget_get_style_context(win->detailLabel), "dim-label"); + gtk_box_pack_start(GTK_BOX(box), win->detailLabel, FALSE, FALSE, 0); + + // Buttons + GtkWidget* btnRow = gtk_button_box_new(GTK_ORIENTATION_HORIZONTAL); + gtk_button_box_set_layout(GTK_BUTTON_BOX(btnRow), GTK_BUTTONBOX_START); + gtk_box_set_spacing(GTK_BOX(btnRow), 6); + + GtkWidget* btnStart = gtk_button_new_with_label("Start"); + GtkWidget* btnStop = gtk_button_new_with_label("Stop"); + GtkWidget* btnRestart = gtk_button_new_with_label("Restart"); + g_signal_connect(btnStart, "clicked", G_CALLBACK(OnStartService), win); + g_signal_connect(btnStop, "clicked", G_CALLBACK(OnStopService), win); + g_signal_connect(btnRestart, "clicked", G_CALLBACK(OnRestartService), win); + gtk_container_add(GTK_CONTAINER(btnRow), btnStart); + gtk_container_add(GTK_CONTAINER(btnRow), btnStop); + gtk_container_add(GTK_CONTAINER(btnRow), btnRestart); + gtk_box_pack_start(GTK_BOX(box), btnRow, FALSE, FALSE, 4); + + // Log + gtk_box_pack_start(GTK_BOX(box), MakeLabel("Recent events:"), FALSE, FALSE, 0); + win->logBuf = gtk_text_buffer_new(nullptr); + win->logView = gtk_text_view_new_with_buffer(win->logBuf); + gtk_text_view_set_editable(GTK_TEXT_VIEW(win->logView), FALSE); + gtk_text_view_set_monospace(GTK_TEXT_VIEW(win->logView), TRUE); + gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(win->logView), GTK_WRAP_CHAR); + GtkWidget* scroll = gtk_scrolled_window_new(nullptr, nullptr); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), + GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); + gtk_widget_set_size_request(scroll, -1, 150); + gtk_container_add(GTK_CONTAINER(scroll), win->logView); + gtk_box_pack_start(GTK_BOX(box), scroll, TRUE, TRUE, 0); + + return box; +} + +GtkWidget* BuildSettingsTab(GuiMainWindow* win, const AppConfig& cfg) { + GtkWidget* outerScroll = gtk_scrolled_window_new(nullptr, nullptr); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(outerScroll), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_container_set_border_width(GTK_CONTAINER(box), 16); + gtk_container_add(GTK_CONTAINER(outerScroll), box); + + int row = 0; + GtkWidget* grid = gtk_grid_new(); + gtk_grid_set_column_spacing(GTK_GRID(grid), 12); + gtk_grid_set_row_spacing(GTK_GRID(grid), 6); + + // Section: Connection + gtk_box_pack_start(GTK_BOX(box), MakeSectionHeader("Connection"), FALSE, FALSE, 0); + + win->hostEntry = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(win->hostEntry), cfg.host.c_str()); + gtk_entry_set_placeholder_text(GTK_ENTRY(win->hostEntry), "hostname or IP"); + GridAttach(grid, MakeLabel("Host"), win->hostEntry, row++); + + win->portSpin = gtk_spin_button_new_with_range(1, 65535, 1); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(win->portSpin), cfg.port); + GridAttach(grid, MakeLabel("Port"), win->portSpin, row++); + + win->nameEntry = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(win->nameEntry), cfg.machineName.c_str()); + gtk_entry_set_placeholder_text(GTK_ENTRY(win->nameEntry), "this machine's name"); + GridAttach(grid, MakeLabel("Machine name"), win->nameEntry, row++); + + win->keyEntry = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(win->keyEntry), cfg.key.c_str()); + gtk_entry_set_visibility(GTK_ENTRY(win->keyEntry), FALSE); + gtk_entry_set_placeholder_text(GTK_ENTRY(win->keyEntry), "security key"); + GridAttach(grid, MakeLabel("Key"), win->keyEntry, row++); + + gtk_box_pack_start(GTK_BOX(box), grid, FALSE, FALSE, 0); + + // Section: Behaviour + GtkWidget* grid2 = gtk_grid_new(); + gtk_grid_set_column_spacing(GTK_GRID(grid2), 12); + gtk_grid_set_row_spacing(GTK_GRID(grid2), 6); + int r2 = 0; + + gtk_box_pack_start(GTK_BOX(box), MakeSectionHeader("Behaviour"), FALSE, FALSE, 0); + + win->autoConnectSwitch = gtk_switch_new(); + gtk_switch_set_active(GTK_SWITCH(win->autoConnectSwitch), cfg.autoConnectEnabled); + GridAttach(grid2, MakeLabel("Auto-connect"), win->autoConnectSwitch, r2++); + + win->clipboardSwitch = gtk_switch_new(); + gtk_switch_set_active(GTK_SWITCH(win->clipboardSwitch), cfg.clipboardEnabled); + GridAttach(grid2, MakeLabel("Clipboard sync"), win->clipboardSwitch, r2++); + + win->mprisSwitch = gtk_switch_new(); + gtk_switch_set_active(GTK_SWITCH(win->mprisSwitch), cfg.mprisMediaKeysEnabled); + GridAttach(grid2, MakeLabel("Media keys (MPRIS)"), win->mprisSwitch, r2++); + + win->mprisPlayerEntry = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(win->mprisPlayerEntry), cfg.mprisPlayer.c_str()); + gtk_entry_set_placeholder_text(GTK_ENTRY(win->mprisPlayerEntry), "e.g. spotify (blank = any)"); + GridAttach(grid2, MakeLabel("MPRIS player"), win->mprisPlayerEntry, r2++); + + win->latencySwitch = gtk_switch_new(); + gtk_switch_set_active(GTK_SWITCH(win->latencySwitch), cfg.latencyReport); + GridAttach(grid2, MakeLabel("Latency report"), win->latencySwitch, r2++); + + gtk_box_pack_start(GTK_BOX(box), grid2, FALSE, FALSE, 0); + + // Section: Topology + GtkWidget* grid3 = gtk_grid_new(); + gtk_grid_set_column_spacing(GTK_GRID(grid3), 12); + gtk_grid_set_row_spacing(GTK_GRID(grid3), 6); + + gtk_box_pack_start(GTK_BOX(box), MakeSectionHeader("Monitor Layout"), FALSE, FALSE, 0); + + win->topologySwitch = gtk_switch_new(); + gtk_switch_set_active(GTK_SWITCH(win->topologySwitch), cfg.topologyRuntimeEnabled); + GridAttach(grid3, MakeLabel("Enable topology mode"), win->topologySwitch, 0); + + GtkWidget* topoHint = MakeLabel("Required for the monitor configurator and cross-device edge transitions.", true); + gtk_label_set_line_wrap(GTK_LABEL(topoHint), TRUE); + gtk_grid_attach(GTK_GRID(grid3), topoHint, 0, 1, 2, 1); + + gtk_box_pack_start(GTK_BOX(box), grid3, FALSE, FALSE, 0); + + // Save button + gtk_box_pack_start(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL), FALSE, FALSE, 8); + GtkWidget* saveBtn = gtk_button_new_with_label("Save Settings"); + gtk_style_context_add_class(gtk_widget_get_style_context(saveBtn), "suggested-action"); + gtk_widget_set_halign(saveBtn, GTK_ALIGN_START); + g_signal_connect(saveBtn, "clicked", G_CALLBACK(OnSaveSettings), win); + gtk_box_pack_start(GTK_BOX(box), saveBtn, FALSE, FALSE, 0); + + return outerScroll; +} + +GtkWidget* BuildMonitorTab(GuiMainWindow* win, const AppConfig& cfg) { + GtkWidget* outerBox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + + win->layoutStack = gtk_stack_new(); + + // Locked page + GtkWidget* lockedBox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); + gtk_widget_set_valign(lockedBox, GTK_ALIGN_CENTER); + gtk_widget_set_halign(lockedBox, GTK_ALIGN_CENTER); + + GtkWidget* lockLabel = gtk_label_new(nullptr); + gtk_label_set_markup(GTK_LABEL(lockLabel), + "🔒\n" + "Monitor configurator is locked\n" + "Enable topology mode in Settings to arrange displays\nand configure edge transitions between machines."); + gtk_label_set_justify(GTK_LABEL(lockLabel), GTK_JUSTIFY_CENTER); + gtk_label_set_line_wrap(GTK_LABEL(lockLabel), TRUE); + gtk_box_pack_start(GTK_BOX(lockedBox), lockLabel, FALSE, FALSE, 0); + + GtkWidget* enableBtn = gtk_button_new_with_label("Enable Topology Mode"); + gtk_widget_set_halign(enableBtn, GTK_ALIGN_CENTER); + g_signal_connect(enableBtn, "clicked", G_CALLBACK(OnEnableTopology), win); + gtk_box_pack_start(GTK_BOX(lockedBox), enableBtn, FALSE, FALSE, 0); + + gtk_stack_add_named(GTK_STACK(win->layoutStack), lockedBox, "locked"); + + // Canvas page + GtkWidget* canvasBox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + MonitorLayoutWidget* mlw = CreateMonitorLayoutWidget(cfg); + win->layoutCanvas = mlw->widget; + gtk_widget_set_size_request(win->layoutCanvas, -1, 320); + gtk_box_pack_start(GTK_BOX(canvasBox), win->layoutCanvas, TRUE, TRUE, 0); + + GtkWidget* btnBar = gtk_button_box_new(GTK_ORIENTATION_HORIZONTAL); + gtk_button_box_set_layout(GTK_BUTTON_BOX(btnBar), GTK_BUTTONBOX_END); + gtk_box_set_spacing(GTK_BOX(btnBar), 6); + gtk_container_set_border_width(GTK_CONTAINER(btnBar), 8); + + GtkWidget* resetBtn = gtk_button_new_with_label("Reset"); + g_signal_connect(resetBtn, "clicked", G_CALLBACK(MonitorLayoutWidgetReset), mlw); + GtkWidget* applyBtn = gtk_button_new_with_label("Apply Layout"); + gtk_style_context_add_class(gtk_widget_get_style_context(applyBtn), "suggested-action"); + g_signal_connect(applyBtn, "clicked", G_CALLBACK(MonitorLayoutWidgetApply), mlw); + + gtk_container_add(GTK_CONTAINER(btnBar), resetBtn); + gtk_container_add(GTK_CONTAINER(btnBar), applyBtn); + gtk_box_pack_start(GTK_BOX(canvasBox), btnBar, FALSE, FALSE, 0); + + gtk_stack_add_named(GTK_STACK(win->layoutStack), canvasBox, "canvas"); + + gtk_stack_set_visible_child_name(GTK_STACK(win->layoutStack), + cfg.topologyRuntimeEnabled ? "canvas" : "locked"); + + gtk_box_pack_start(GTK_BOX(outerBox), win->layoutStack, TRUE, TRUE, 0); + return outerBox; +} + +GtkWidget* BuildAndroidTab(GuiMainWindow* win, const AppConfig& cfg) { + GtkWidget* outerScroll = gtk_scrolled_window_new(nullptr, nullptr); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(outerScroll), + GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_container_set_border_width(GTK_CONTAINER(box), 16); + gtk_container_add(GTK_CONTAINER(outerScroll), box); + + gtk_box_pack_start(GTK_BOX(box), MakeSectionHeader("Android Relay"), FALSE, FALSE, 0); + + GtkWidget* grid = gtk_grid_new(); + gtk_grid_set_column_spacing(GTK_GRID(grid), 12); + gtk_grid_set_row_spacing(GTK_GRID(grid), 6); + int row = 0; + + win->androidSwitch = gtk_switch_new(); + gtk_switch_set_active(GTK_SWITCH(win->androidSwitch), cfg.androidPeersEnabled); + GridAttach(grid, MakeLabel("Enable Android peers"), win->androidSwitch, row++); + + win->androidPortSpin = gtk_spin_button_new_with_range(1, 65535, 1); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(win->androidPortSpin), cfg.androidRelayPort); + GridAttach(grid, MakeLabel("Relay port"), win->androidPortSpin, row++); + + win->androidSecretEntry = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(win->androidSecretEntry), cfg.androidRelaySecret.c_str()); + gtk_entry_set_visibility(GTK_ENTRY(win->androidSecretEntry), FALSE); + gtk_entry_set_placeholder_text(GTK_ENTRY(win->androidSecretEntry), "shared secret"); + GridAttach(grid, MakeLabel("Secret"), win->androidSecretEntry, row++); + + win->androidNameEntry = gtk_entry_new(); + gtk_entry_set_text(GTK_ENTRY(win->androidNameEntry), cfg.androidPeerName.c_str()); + gtk_entry_set_placeholder_text(GTK_ENTRY(win->androidNameEntry), "android"); + GridAttach(grid, MakeLabel("Peer name"), win->androidNameEntry, row++); + + win->androidBackendCombo = gtk_combo_box_text_new(); + gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(win->androidBackendCombo), "none", "none"); + gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(win->androidBackendCombo), "libei", "libei"); + gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(win->androidBackendCombo), "local", "local"); + gtk_combo_box_set_active_id(GTK_COMBO_BOX(win->androidBackendCombo), + cfg.androidCaptureBackend.empty() ? "none" : cfg.androidCaptureBackend.c_str()); + GridAttach(grid, MakeLabel("Capture backend"), win->androidBackendCombo, row++); + + gtk_box_pack_start(GTK_BOX(box), MakeSectionHeader("Device Dimensions"), FALSE, FALSE, 0); + + win->androidWidthSpin = gtk_spin_button_new_with_range(0, 7680, 1); + win->androidHeightSpin = gtk_spin_button_new_with_range(0, 4320, 1); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(win->androidWidthSpin), cfg.androidDeviceWidth); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(win->androidHeightSpin), cfg.androidDeviceHeight); + GridAttach(grid, MakeLabel("Device width"), win->androidWidthSpin, row++); + GridAttach(grid, MakeLabel("Device height"), win->androidHeightSpin, row++); + + gtk_box_pack_start(GTK_BOX(box), grid, FALSE, FALSE, 0); + + gtk_box_pack_start(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL), FALSE, FALSE, 8); + GtkWidget* saveBtn = gtk_button_new_with_label("Save Settings"); + gtk_style_context_add_class(gtk_widget_get_style_context(saveBtn), "suggested-action"); + gtk_widget_set_halign(saveBtn, GTK_ALIGN_START); + g_signal_connect(saveBtn, "clicked", G_CALLBACK(OnSaveSettings), win); + gtk_box_pack_start(GTK_BOX(box), saveBtn, FALSE, FALSE, 0); + + return outerScroll; +} + +} // namespace + +// ---- public API ------------------------------------------------------------ + +GuiMainWindow* CreateMainWindow(const AppConfig& config, + const std::string& cfgPath, + OnSettingsSaved onSettingsSaved) { + auto* win = new GuiMainWindow(); + win->configPath = cfgPath; + win->onSettingsSaved = std::move(onSettingsSaved); + + win->window = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_window_set_title(GTK_WINDOW(win->window), "InputFlow"); + gtk_window_set_default_size(GTK_WINDOW(win->window), 560, 500); + gtk_window_set_resizable(GTK_WINDOW(win->window), TRUE); + + g_signal_connect(win->window, "delete-event", + G_CALLBACK(+[](GtkWidget* w, GdkEvent*, gpointer) -> gboolean { + gtk_widget_hide(w); + return TRUE; // don't destroy, just hide + }), nullptr); + + win->notebook = gtk_notebook_new(); + gtk_container_add(GTK_CONTAINER(win->window), win->notebook); + + gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook), + BuildStatusTab(win), gtk_label_new("Status")); + gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook), + BuildSettingsTab(win, config), gtk_label_new("Settings")); + gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook), + BuildMonitorTab(win, config), gtk_label_new("Monitor Layout")); + gtk_notebook_append_page(GTK_NOTEBOOK(win->notebook), + BuildAndroidTab(win, config), gtk_label_new("Android")); + + gtk_widget_show_all(win->window); + gtk_widget_hide(win->window); // start hidden; shown from tray + return win; +} + +void GuiMainWindow::UpdateStatus(const std::string& state, const std::string& detail) { + connectionState = state; + connectionDetail = detail; + + if (stateLabel) { + std::string text; + if (state == "active") text = "Running"; + else if (state == "activating") text = "Connecting…"; + else if (state == "inactive") text = "Stopped"; + else if (state == "failed") text = "Error"; + else text = state.empty() ? "Unknown" : state; + gtk_label_set_text(GTK_LABEL(stateLabel), text.c_str()); + } + if (detailLabel && !detail.empty()) { + gtk_label_set_text(GTK_LABEL(detailLabel), detail.c_str()); + } + if (dotArea) { + gtk_widget_queue_draw(dotArea); + } +} + +void GuiMainWindow::ShowTab(int index) { + gtk_widget_show(window); + gtk_window_present(GTK_WINDOW(window)); + gtk_notebook_set_current_page(GTK_NOTEBOOK(notebook), index); +} + +void GuiMainWindow::AppendLog(const std::string& line) { + if (!logBuf) return; + GtkTextIter end; + gtk_text_buffer_get_end_iter(logBuf, &end); + gtk_text_buffer_insert(logBuf, &end, (line + "\n").c_str(), -1); + // keep last 200 lines + int lineCount = gtk_text_buffer_get_line_count(logBuf); + if (lineCount > 200) { + GtkTextIter start, cut; + gtk_text_buffer_get_start_iter(logBuf, &start); + gtk_text_buffer_get_iter_at_line(logBuf, &cut, lineCount - 200); + gtk_text_buffer_delete(logBuf, &start, &cut); + } + // scroll to bottom + GtkTextIter endIter; + gtk_text_buffer_get_end_iter(logBuf, &endIter); + gtk_text_view_scroll_to_iter(GTK_TEXT_VIEW(logView), &endIter, 0.0, FALSE, 0.0, 1.0); +} + +} // namespace mwb From 05e57c4a6dce30812cdc10dfc0a204428f639b45 Mon Sep 17 00:00:00 2001 From: daredoole Date: Fri, 1 May 2026 10:22:10 -0400 Subject: [PATCH 11/14] Add monitor layout widget declarations --- src/MonitorLayoutWidget.h | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/MonitorLayoutWidget.h diff --git a/src/MonitorLayoutWidget.h b/src/MonitorLayoutWidget.h new file mode 100644 index 0000000..324d4de --- /dev/null +++ b/src/MonitorLayoutWidget.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +namespace mwb { + +struct AppConfig; + +struct LayoutMachine { + std::string id; + std::string label; + bool isLocal{false}; + // position in canvas pixels (mutable, drag target) + double x{0}, y{0}; + double w{160}, h{100}; +}; + +struct MonitorLayoutWidget { + GtkWidget* widget{nullptr}; // GtkDrawingArea + std::vector machines; + std::string configPath; + int dragIndex{-1}; + double dragOffX{0}, dragOffY{0}; +}; + +// Creates a MonitorLayoutWidget populated from cfg.topologyFile (if set). +// Returns heap-allocated; caller owns it (kept alive by GtkWidget lifetime via +// g_object_set_data). +MonitorLayoutWidget* CreateMonitorLayoutWidget(const AppConfig& cfg); + +// Callbacks wired to GTK buttons (signature matches GCallback). +void MonitorLayoutWidgetApply(GtkButton* btn, gpointer data); +void MonitorLayoutWidgetReset(GtkButton* btn, gpointer data); + +} // namespace mwb From 7524c1efacbf003d7fd86efb05bb980a073ded08 Mon Sep 17 00:00:00 2001 From: daredoole Date: Fri, 1 May 2026 10:42:42 -0400 Subject: [PATCH 12/14] Embed tray GUI runtime --- CMakeLists.txt | 10 + .../usr/lib/systemd/user/mwb-client.service | 2 +- src/MonitorLayoutWidget.cpp | 313 ++++++++++++++++++ src/TrayController.cpp | 291 ++++++++++++++-- src/main.cpp | 45 +++ 5 files changed, 632 insertions(+), 29 deletions(-) create mode 100644 src/MonitorLayoutWidget.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e180d6b..7416169 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -248,6 +248,16 @@ endif() if (PkgConfig_FOUND) pkg_check_modules(MWB_TRAY_DEPS QUIET IMPORTED_TARGET gtk+-3.0 ayatana-appindicator3-0.1) if (MWB_TRAY_DEPS_FOUND) + # Embed tray + GUI window into the main mwb_client binary + target_sources(mwb_client PRIVATE + src/TrayController.cpp + src/GuiMainWindow.cpp + src/MonitorLayoutWidget.cpp + ) + target_compile_definitions(mwb_client PRIVATE MWB_HAVE_GTK_GUI=1) + target_link_libraries(mwb_client PRIVATE PkgConfig::MWB_TRAY_DEPS) + + # Keep standalone mwb_tray as a fallback (no embedded runtime) add_executable(mwb_tray src/TrayController.cpp) target_link_libraries(mwb_tray PRIVATE PkgConfig::MWB_TRAY_DEPS) mwb_apply_sanitizers(mwb_tray) diff --git a/packaging/usr/lib/systemd/user/mwb-client.service b/packaging/usr/lib/systemd/user/mwb-client.service index f358b9c..2bb1401 100644 --- a/packaging/usr/lib/systemd/user/mwb-client.service +++ b/packaging/usr/lib/systemd/user/mwb-client.service @@ -5,7 +5,7 @@ ConditionPathExists=%h/.config/mwb-client/config.ini [Service] Type=simple -ExecStart=/usr/bin/mwb_client run --config %h/.config/mwb-client/config.ini +ExecStart=/usr/bin/mwb_client gui --config %h/.config/mwb-client/config.ini Restart=on-failure RestartSec=3s NoNewPrivileges=true diff --git a/src/MonitorLayoutWidget.cpp b/src/MonitorLayoutWidget.cpp new file mode 100644 index 0000000..8fc8dbc --- /dev/null +++ b/src/MonitorLayoutWidget.cpp @@ -0,0 +1,313 @@ +#include "MonitorLayoutWidget.h" +#include "AppConfig.h" +#include "TopologyModel.h" + +#include +#include +#include +#include + +namespace mwb { + +namespace { + +constexpr double kGridStep = 48.0; +constexpr double kPad = 8.0; +constexpr double kRadius = 6.0; +constexpr double kLocalR = 0.10, kLocalG = 0.40, kLocalB = 0.75; // blue +constexpr double kRemoteR = 0.23, kRemoteG = 0.30, kRemoteB = 0.38; // slate + +void RoundedRect(cairo_t* cr, double x, double y, double w, double h, double r) { + cairo_new_sub_path(cr); + cairo_arc(cr, x + w - r, y + r, r, -G_PI / 2, 0); + cairo_arc(cr, x + w - r, y + h - r, r, 0, G_PI / 2); + cairo_arc(cr, x + r, y + h - r, r, G_PI / 2, G_PI); + cairo_arc(cr, x + r, y + r, r, G_PI, 3 * G_PI / 2); + cairo_close_path(cr); +} + +gboolean OnDraw(GtkWidget* widget, cairo_t* cr, gpointer data) { + auto* mlw = static_cast(data); + int W = gtk_widget_get_allocated_width(widget); + int H = gtk_widget_get_allocated_height(widget); + + // Background + cairo_set_source_rgb(cr, 0.039, 0.086, 0.157); // #0A1628 + cairo_paint(cr); + + // Grid lines + cairo_set_source_rgba(cr, 0.102, 0.157, 0.251, 0.6); // #1A2840 + cairo_set_line_width(cr, 1.0); + for (double x = 0; x < W; x += kGridStep) { + cairo_move_to(cr, x, 0); cairo_line_to(cr, x, H); + } + for (double y = 0; y < H; y += kGridStep) { + cairo_move_to(cr, 0, y); cairo_line_to(cr, W, y); + } + cairo_stroke(cr); + + // Machines + for (const auto& m : mlw->machines) { + if (m.isLocal) { + cairo_set_source_rgb(cr, kLocalR, kLocalG, kLocalB); + } else { + cairo_set_source_rgb(cr, kRemoteR, kRemoteG, kRemoteB); + } + RoundedRect(cr, m.x, m.y, m.w, m.h, kRadius); + cairo_fill(cr); + + // Border + cairo_set_source_rgba(cr, 1, 1, 1, 0.15); + cairo_set_line_width(cr, 1.0); + RoundedRect(cr, m.x, m.y, m.w, m.h, kRadius); + cairo_stroke(cr); + + // Label + cairo_set_source_rgb(cr, 1, 1, 1); + cairo_select_font_face(cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_BOLD); + cairo_set_font_size(cr, 12.0); + cairo_text_extents_t te; + cairo_text_extents(cr, m.label.c_str(), &te); + cairo_move_to(cr, + m.x + (m.w - te.width) / 2 - te.x_bearing, + m.y + m.h / 2 - te.height / 2 - te.y_bearing); + cairo_show_text(cr, m.label.c_str()); + + if (m.isLocal) { + cairo_set_font_size(cr, 9.0); + const char* badge = "THIS DEVICE"; + cairo_text_extents(cr, badge, &te); + cairo_move_to(cr, + m.x + (m.w - te.width) / 2 - te.x_bearing, + m.y + m.h / 2 + 14); + cairo_set_source_rgba(cr, 1, 1, 1, 0.6); + cairo_show_text(cr, badge); + } + } + + return FALSE; +} + +int HitTest(const MonitorLayoutWidget* mlw, double px, double py) { + for (int i = static_cast(mlw->machines.size()) - 1; i >= 0; --i) { + const auto& m = mlw->machines[i]; + if (px >= m.x && px <= m.x + m.w && py >= m.y && py <= m.y + m.h) { + return i; + } + } + return -1; +} + +gboolean OnButtonPress(GtkWidget*, GdkEventButton* ev, gpointer data) { + auto* mlw = static_cast(data); + if (ev->button != 1) return FALSE; + int idx = HitTest(mlw, ev->x, ev->y); + if (idx < 0) return FALSE; + mlw->dragIndex = idx; + mlw->dragOffX = ev->x - mlw->machines[idx].x; + mlw->dragOffY = ev->y - mlw->machines[idx].y; + return TRUE; +} + +gboolean OnMotion(GtkWidget* widget, GdkEventMotion* ev, gpointer data) { + auto* mlw = static_cast(data); + if (mlw->dragIndex < 0) return FALSE; + auto& m = mlw->machines[mlw->dragIndex]; + m.x = ev->x - mlw->dragOffX; + m.y = ev->y - mlw->dragOffY; + gtk_widget_queue_draw(widget); + return TRUE; +} + +gboolean OnButtonRelease(GtkWidget* widget, GdkEventButton* ev, gpointer data) { + auto* mlw = static_cast(data); + if (ev->button != 1 || mlw->dragIndex < 0) return FALSE; + // Snap to grid + auto& m = mlw->machines[mlw->dragIndex]; + m.x = std::round(m.x / kGridStep) * kGridStep; + m.y = std::round(m.y / kGridStep) * kGridStep; + mlw->dragIndex = -1; + gtk_widget_queue_draw(widget); + return TRUE; +} + +void PopulateFromConfig(MonitorLayoutWidget* mlw, const AppConfig& cfg) { + mlw->machines.clear(); + + TopologyModel topo; + if (!cfg.topologyFile.empty()) { + std::string err; + (void)LoadTopologyConfig(cfg.topologyFile, topo, &err); + } + + const auto& machines = topo.machines(); + const auto& displays = topo.displays(); + + if (machines.empty()) { + // No topology — add two placeholder machines + LayoutMachine local; + local.id = "linux"; + local.label = cfg.machineName.empty() ? "This PC" : cfg.machineName; + local.isLocal = true; + local.x = kGridStep; local.y = kGridStep; + local.w = 192; local.h = 120; + mlw->machines.push_back(local); + + if (cfg.androidPeersEnabled) { + LayoutMachine android; + android.id = "android"; + android.label = cfg.androidPeerName.empty() ? "Android" : cfg.androidPeerName; + android.x = kGridStep + 192 + kGridStep; + android.y = kGridStep; + android.w = 128; android.h = 120; + mlw->machines.push_back(android); + } + return; + } + + // Map machine ID → first display for position + double offsetX = kGridStep; + for (const auto& mach : machines) { + LayoutMachine lm; + lm.id = mach.id; + lm.label = mach.id; + + // Sum display widths/heights for bounding box + int maxW = 0, maxH = 0; + int baseX = 0, baseY = 0; + bool first = true; + for (const auto& disp : displays) { + if (disp.machineId != mach.id) continue; + if (first) { baseX = disp.x; baseY = disp.y; first = false; } + maxW = std::max(maxW, disp.x - baseX + disp.width); + maxH = std::max(maxH, disp.y - baseY + disp.height); + } + + const double scale = 0.1; + lm.w = std::max(96.0, maxW * scale); + lm.h = std::max(60.0, maxH * scale); + lm.x = offsetX; + lm.y = kGridStep; + lm.isLocal = (mach.id == "linux" || (!cfg.machineName.empty() && mach.id == cfg.machineName)); + offsetX += lm.w + kGridStep; + mlw->machines.push_back(lm); + } +} + +// Build a minimal topology INI string from current machine positions +std::string BuildTopologyIni(const MonitorLayoutWidget* mlw, const AppConfig& cfg) { + std::ostringstream oss; + oss << "[wrap]\nmode=none\n\n"; + + for (const auto& m : mlw->machines) { + oss << "[machine]\nid=" << m.id << "\n\n"; + } + + // Compute pixel coordinates (scale canvas back to screen space) + // Use a nominal 1920 width for the local machine + const double scale = 10.0; // 0.1 scale used in PopulateFromConfig + for (const auto& m : mlw->machines) { + const int px = static_cast(m.x * scale); + const int py = static_cast(m.y * scale); + const int pw = m.isLocal ? 1920 : cfg.androidDeviceWidth; + const int ph = m.isLocal ? 1080 : cfg.androidDeviceHeight; + oss << "[display]\n" + << "id=" << m.id << "-1\n" + << "machine=" << m.id << "\n" + << "x=" << px << "\ny=" << py << "\n" + << "width=" << pw << "\nheight=" << ph << "\n\n"; + } + + // Add links between adjacent machines (sharing an edge within tolerance) + const double tol = kGridStep * 2; + for (std::size_t i = 0; i < mlw->machines.size(); ++i) { + for (std::size_t j = i + 1; j < mlw->machines.size(); ++j) { + const auto& a = mlw->machines[i]; + const auto& b = mlw->machines[j]; + // b is to the right of a + if (std::abs((a.x + a.w) - b.x) < tol && + std::abs(a.y - b.y) < tol) { + oss << "[link]\nsource=" << a.id << "-1\nexit=right\n" + << "target=" << b.id << "-1\nentry=left\n\n"; + oss << "[link]\nsource=" << b.id << "-1\nexit=left\n" + << "target=" << a.id << "-1\nentry=right\n\n"; + } + // b is to the left of a + if (std::abs((b.x + b.w) - a.x) < tol && + std::abs(a.y - b.y) < tol) { + oss << "[link]\nsource=" << b.id << "-1\nexit=right\n" + << "target=" << a.id << "-1\nentry=left\n\n"; + oss << "[link]\nsource=" << a.id << "-1\nexit=left\n" + << "target=" << b.id << "-1\nentry=right\n\n"; + } + // b is below a + if (std::abs((a.y + a.h) - b.y) < tol && + std::abs(a.x - b.x) < tol) { + oss << "[link]\nsource=" << a.id << "-1\nexit=down\n" + << "target=" << b.id << "-1\nentry=up\n\n"; + oss << "[link]\nsource=" << b.id << "-1\nexit=up\n" + << "target=" << a.id << "-1\nentry=down\n\n"; + } + } + } + + return oss.str(); +} + +} // namespace + +MonitorLayoutWidget* CreateMonitorLayoutWidget(const AppConfig& cfg) { + auto* mlw = new MonitorLayoutWidget(); + mlw->configPath = cfg.topologyFile; + + PopulateFromConfig(mlw, cfg); + + mlw->widget = gtk_drawing_area_new(); + gtk_widget_add_events(mlw->widget, + GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK); + + g_signal_connect(mlw->widget, "draw", G_CALLBACK(OnDraw), mlw); + g_signal_connect(mlw->widget, "button-press-event", G_CALLBACK(OnButtonPress), mlw); + g_signal_connect(mlw->widget, "button-release-event", G_CALLBACK(OnButtonRelease), mlw); + g_signal_connect(mlw->widget, "motion-notify-event", G_CALLBACK(OnMotion), mlw); + + // Attach mlw lifetime to widget so it's freed when widget is destroyed + g_object_set_data_full(G_OBJECT(mlw->widget), "mlw", mlw, + [](gpointer p) { delete static_cast(p); }); + + return mlw; +} + +void MonitorLayoutWidgetApply(GtkButton*, gpointer data) { + auto* mlw = static_cast(data); + if (mlw->configPath.empty()) return; + + // Load current config to preserve other settings + AppConfig cfg; + std::string err; + (void)LoadAppConfig(mlw->configPath, cfg, &err); + + const std::string ini = BuildTopologyIni(mlw, cfg); + + // Write to topology file (use config path parent / topology.ini if not set) + std::string topoPath = cfg.topologyFile; + if (topoPath.empty()) { + topoPath = std::filesystem::path(mlw->configPath).parent_path() / "topology.ini"; + cfg.topologyFile = topoPath; + (void)WriteAppConfig(mlw->configPath, cfg, &err); + } + + std::ofstream f(topoPath); + if (f) f << ini; +} + +void MonitorLayoutWidgetReset(GtkButton*, gpointer data) { + auto* mlw = static_cast(data); + AppConfig cfg; + std::string err; + (void)LoadAppConfig(mlw->configPath, cfg, &err); + PopulateFromConfig(mlw, cfg); + gtk_widget_queue_draw(mlw->widget); +} + +} // namespace mwb diff --git a/src/TrayController.cpp b/src/TrayController.cpp index 5615158..d40415f 100644 --- a/src/TrayController.cpp +++ b/src/TrayController.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -12,9 +13,18 @@ #include #include #include +#include #include #include +#ifdef MWB_HAVE_GTK_GUI +#include "TrayController.h" +#include "GuiMainWindow.h" +#include "AppConfig.h" +#include "AppState.h" +#include "ClientRuntime.h" +#endif + namespace { constexpr const char* kAppName = "InputFlow"; @@ -46,6 +56,10 @@ struct TrayContext { std::string controllerPath; std::string iconThemePath; std::string lastState; +#ifdef MWB_HAVE_GTK_GUI + mwb::GuiMainWindow* mainWindow{nullptr}; + std::atomic* stopFlag{nullptr}; +#endif }; std::optional RunCommandCapture(const std::string& command) { @@ -393,7 +407,11 @@ void UpdateIndicatorVisuals(TrayContext* context, const std::string& state) { gtk_widget_set_sensitive(context->stopItem, active || starting); gtk_widget_set_sensitive(context->restartItem, active); +#ifdef MWB_HAVE_GTK_GUI + const bool controllerAvailable = (context->mainWindow != nullptr); +#else const bool controllerAvailable = !context->controllerPath.empty(); +#endif gtk_widget_set_sensitive(context->editSettingsItem, controllerAvailable); gtk_widget_set_sensitive(context->editConnectionItem, controllerAvailable); gtk_widget_set_sensitive(context->healthCheckItem, controllerAvailable); @@ -472,46 +490,73 @@ gboolean RefreshStatus(gpointer userData) { void OnOpenController(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(0); return; } +#endif (void)LaunchController(context); } void OnEditSettings(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(1); return; } +#endif (void)LaunchController(context, {"settings"}); } void OnEditConnectionBehavior(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(1); return; } +#endif (void)LaunchController(context, {"connection"}); } void OnHealthCheck(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(0); return; } +#endif (void)LaunchController(context, {"health-check"}); } void OnDiagnosticsBundle(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(0); return; } +#endif (void)LaunchController(context, {"diagnostics-bundle"}); } void OnConnectionQuality(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(0); return; } +#endif (void)LaunchController(context, {"connection-quality"}); } void OnGuidedPairing(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(1); return; } +#endif (void)LaunchController(context, {"guided-pairing"}); } void OnDiscoverPeers(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(0); return; } +#endif (void)LaunchController(context, {"discover"}); } void OnShowPeers(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(0); return; } +#endif (void)LaunchController(context, {"peers"}); } @@ -532,6 +577,9 @@ void OnRestartService(GtkMenuItem*, gpointer userData) { void OnShowStatus(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(0); return; } +#endif (void)LaunchController(context, {"status"}); } @@ -542,10 +590,26 @@ void OnShowTrayHelp(GtkMenuItem*, gpointer userData) { void OnInstallDesktopEntries(GtkMenuItem*, gpointer userData) { auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(1); return; } +#endif (void)LaunchController(context, {"install-desktop-entry"}); } -void OnQuit(GtkMenuItem*, gpointer) { +void OnOpenMonitorLayout(GtkMenuItem*, gpointer userData) { + auto* context = static_cast(userData); +#ifdef MWB_HAVE_GTK_GUI + if (context->mainWindow) { context->mainWindow->ShowTab(2); return; } +#endif +} + +void OnQuit(GtkMenuItem*, gpointer userData) { +#ifdef MWB_HAVE_GTK_GUI + auto* context = static_cast(userData); + if (context->stopFlag) context->stopFlag->store(true); +#else + (void)userData; +#endif gtk_main_quit(); } @@ -558,46 +622,41 @@ GtkWidget* AddMenuItem(GtkWidget* menu, const char* label, GCallback callback, g } // namespace -int main(int argc, char** argv) { - const int instanceLockFd = AcquireSingleInstanceLock(); - if (instanceLockFd == -2) { - return 0; - } - - g_set_prgname(kIndicatorId); - g_set_application_name(kAppName); - - gtk_init(&argc, &argv); +// ---- shared tray-building helper (used by both standalone and embedded) ----- - TrayContext context; - context.controllerPath = ResolveControllerPath(); - context.iconThemePath = ResolveIconThemePath(); +namespace { +void BuildTrayMenu(TrayContext& context) { GtkWidget* menu = gtk_menu_new(); context.statusItem = gtk_menu_item_new_with_label("Service: Checking..."); gtk_widget_set_sensitive(context.statusItem, FALSE); gtk_menu_shell_append(GTK_MENU_SHELL(menu), context.statusItem); gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); - context.editSettingsItem = AddMenuItem(menu, "Settings", G_CALLBACK(OnEditSettings), &context); - context.editConnectionItem = AddMenuItem(menu, "Connection Behavior", G_CALLBACK(OnEditConnectionBehavior), &context); - context.healthCheckItem = AddMenuItem(menu, "Health Check", G_CALLBACK(OnHealthCheck), &context); - context.diagnosticsBundleItem = AddMenuItem(menu, "Diagnostics Bundle", G_CALLBACK(OnDiagnosticsBundle), &context); - context.connectionQualityItem = AddMenuItem(menu, "Connection Quality", G_CALLBACK(OnConnectionQuality), &context); - context.guidedPairingItem = AddMenuItem(menu, "Guided Pairing", G_CALLBACK(OnGuidedPairing), &context); - context.discoverPeersItem = AddMenuItem(menu, "Discover Peers", G_CALLBACK(OnDiscoverPeers), &context); - context.showPeersItem = AddMenuItem(menu, "Known Peers", G_CALLBACK(OnShowPeers), &context); +#ifdef MWB_HAVE_GTK_GUI + AddMenuItem(menu, "Open Dashboard", G_CALLBACK(OnOpenController), &context); + AddMenuItem(menu, "Monitor Layout", G_CALLBACK(OnOpenMonitorLayout), &context); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); +#endif + + context.editSettingsItem = AddMenuItem(menu, "Settings", G_CALLBACK(OnEditSettings), &context); + context.editConnectionItem = AddMenuItem(menu, "Connection Behavior", G_CALLBACK(OnEditConnectionBehavior),&context); + context.healthCheckItem = AddMenuItem(menu, "Health Check", G_CALLBACK(OnHealthCheck), &context); + context.diagnosticsBundleItem = AddMenuItem(menu, "Diagnostics Bundle", G_CALLBACK(OnDiagnosticsBundle), &context); + context.connectionQualityItem = AddMenuItem(menu, "Connection Quality", G_CALLBACK(OnConnectionQuality), &context); + context.guidedPairingItem = AddMenuItem(menu, "Guided Pairing", G_CALLBACK(OnGuidedPairing), &context); + context.discoverPeersItem = AddMenuItem(menu, "Discover Peers", G_CALLBACK(OnDiscoverPeers), &context); + context.showPeersItem = AddMenuItem(menu, "Known Peers", G_CALLBACK(OnShowPeers), &context); gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); - context.startItem = AddMenuItem(menu, "Start Service", G_CALLBACK(OnStartService), &context); - context.stopItem = AddMenuItem(menu, "Stop Service", G_CALLBACK(OnStopService), &context); + context.startItem = AddMenuItem(menu, "Start Service", G_CALLBACK(OnStartService), &context); + context.stopItem = AddMenuItem(menu, "Stop Service", G_CALLBACK(OnStopService), &context); context.restartItem = AddMenuItem(menu, "Restart Service", G_CALLBACK(OnRestartService), &context); gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); - - context.showStatusItem = AddMenuItem(menu, "Show Service Details", G_CALLBACK(OnShowStatus), &context); - context.installDesktopEntriesItem = AddMenuItem(menu, "Install Desktop Entries", G_CALLBACK(OnInstallDesktopEntries), &context); - context.trayHelpItem = AddMenuItem(menu, "Tray Visibility Help", G_CALLBACK(OnShowTrayHelp), &context); + context.showStatusItem = AddMenuItem(menu, "Show Service Details", G_CALLBACK(OnShowStatus), &context); + context.installDesktopEntriesItem = AddMenuItem(menu, "Install Desktop Entries", G_CALLBACK(OnInstallDesktopEntries),&context); + context.trayHelpItem = AddMenuItem(menu, "Tray Visibility Help", G_CALLBACK(OnShowTrayHelp), &context); gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); AddMenuItem(menu, "Quit", G_CALLBACK(OnQuit), &context); @@ -621,10 +680,186 @@ int main(int argc, char** argv) { UpdateIndicatorVisuals(&context, QueryServiceState()); app_indicator_set_status(context.indicator, APP_INDICATOR_STATUS_ACTIVE); g_timeout_add_seconds(30, RefreshStatus, &context); +} + +} // namespace + +// ---- standalone mwb_tray entry point (no embedded runtime) ----------------- + +#ifndef MWB_HAVE_GTK_GUI +int main(int argc, char** argv) { + const int instanceLockFd = AcquireSingleInstanceLock(); + if (instanceLockFd == -2) { + return 0; + } + + g_set_prgname(kIndicatorId); + g_set_application_name(kAppName); + + gtk_init(&argc, &argv); + + TrayContext context; + context.controllerPath = ResolveControllerPath(); + context.iconThemePath = ResolveIconThemePath(); + + BuildTrayMenu(context); + MaybeShowStartupHint(context); + + gtk_main(); + if (instanceLockFd >= 0) { + close(instanceLockFd); + } + return 0; +} +#endif // !MWB_HAVE_GTK_GUI + +// ---- embedded GUI + runtime entry point (mwb_client gui subcommand) -------- + +#ifdef MWB_HAVE_GTK_GUI +namespace mwb { + +namespace { + +struct StatusUpdate { + TrayContext* tray; + GuiMainWindow* win; + std::string state; + std::string detail; +}; + +gboolean ApplyStatusOnMainThread(gpointer data) { + auto* upd = static_cast(data); + if (upd->win) upd->win->UpdateStatus(upd->state, upd->detail); + if (upd->tray) UpdateIndicatorVisuals(upd->tray, upd->state); + delete upd; + return G_SOURCE_REMOVE; +} + +void PostStatus(TrayContext* tray, GuiMainWindow* win, + const std::string& state, const std::string& detail) { + auto* upd = new StatusUpdate{tray, win, state, detail}; + g_idle_add(ApplyStatusOnMainThread, upd); +} + +} // namespace + +int RunTrayAndGui(const std::string& binary, + const std::vector& args, + const AppConfig& config, + const std::string& configPath, + const std::string& statePath) { + const int instanceLockFd = AcquireSingleInstanceLock(); + if (instanceLockFd == -2) { + std::cerr << "InputFlow GUI is already running." << std::endl; + return 0; + } + + g_set_prgname(kIndicatorId); + g_set_application_name(kAppName); + + // GTK already initialised by caller via gtk_init_check + + // Shared stop flag for daemon thread + std::atomic stopFlag{false}; + + // Build GUI window + GuiMainWindow* mainWin = CreateMainWindow( + config, configPath, + [](const AppConfig&) { /* settings saved; daemon will reload on restart */ }); + + // Build tray + TrayContext context; + context.iconThemePath = ResolveIconThemePath(); + context.mainWindow = mainWin; + context.stopFlag = &stopFlag; + + BuildTrayMenu(context); + + // Seed status + PostStatus(&context, mainWin, QueryServiceState(), ""); + + // Start daemon runtime in background thread + AppConfig runtimeConfig = config; + std::thread daemonThread([&]() { + // Minimal state/option wiring (mirrors RunClient in main.cpp) + AppState state; + (void)EnsureLocalMachineId(state); + + RuntimeOptions options; + options.host = runtimeConfig.host; + options.key = runtimeConfig.key; + options.port = runtimeConfig.port; + options.clipboardEnabled = runtimeConfig.clipboardEnabled; + options.clipboardSendEnabled = runtimeConfig.clipboardSendEnabled; + options.clipboardForcePoll = runtimeConfig.clipboardForcePoll; + options.clipboardPollMs = runtimeConfig.clipboardPollMs; + options.autoConnectEnabled = runtimeConfig.autoConnectEnabled; + options.reconnectInitialBackoffMs = runtimeConfig.reconnectInitialBackoffMs; + options.reconnectMaxBackoffMs = runtimeConfig.reconnectMaxBackoffMs; + options.reconnectIdleRetryMs = runtimeConfig.reconnectIdleRetryMs; + options.screenWidth = runtimeConfig.screenWidth; + options.screenHeight = runtimeConfig.screenHeight; + options.mprisMediaKeysEnabled = runtimeConfig.mprisMediaKeysEnabled; + options.mprisPlayer = runtimeConfig.mprisPlayer; + options.localMachineId = state.localMachineId; + options.localMachineName = runtimeConfig.machineName; + options.latencyReport = runtimeConfig.latencyReport; + options.topologyRuntimeEnabled = runtimeConfig.topologyRuntimeEnabled; + options.topologyFilePath = runtimeConfig.topologyFile; + options.androidCaptureBackend = runtimeConfig.androidCaptureBackend; + options.androidRelay.enabled = runtimeConfig.androidPeersEnabled; + options.androidRelay.port = runtimeConfig.androidRelayPort; + options.androidRelay.secret = runtimeConfig.androidRelaySecret; + options.androidRelay.peerName = runtimeConfig.androidPeerName; + options.androidRelay.layoutEditorEnabled = runtimeConfig.androidLayoutEditorEnabled; + options.androidRelay.androidDeviceWidth = runtimeConfig.androidDeviceWidth; + options.androidRelay.androidDeviceHeight = runtimeConfig.androidDeviceHeight; + + options.onSessionEstablished = [&](const std::string& host, int port, + const std::string& remoteName, uint32_t, uint32_t) { + PostStatus(&context, mainWin, "active", host + ":" + std::to_string(port) + " (" + remoteName + ")"); + if (mainWin) { + auto* logMsg = new std::string("Connected to " + host + ":" + std::to_string(port)); + g_idle_add([](gpointer p) -> gboolean { + auto* msg = static_cast(p); + // mainWin captured by pointer — safe since GTK window outlives runtime + delete msg; + return G_SOURCE_REMOVE; + }, logMsg); + } + }; + options.onSessionDisconnected = [&]() { + PostStatus(&context, mainWin, "inactive", "Disconnected"); + }; + + ClientRuntime runtime(std::move(options)); + + // Stopper thread: when GTK quits it sets stopFlag → we call runtime.Stop() + std::thread stopper([&]() { + while (!stopFlag.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + runtime.Stop(); + }); + + (void)runtime.Run(); + + stopFlag.store(true); + if (stopper.joinable()) stopper.join(); + }); gtk_main(); + + stopFlag.store(true); + if (daemonThread.joinable()) { + daemonThread.join(); + } if (instanceLockFd >= 0) { close(instanceLockFd); } + delete mainWin; return 0; } + +} // namespace mwb +#endif // MWB_HAVE_GTK_GUI diff --git a/src/main.cpp b/src/main.cpp index 4e2362a..21c4331 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -36,6 +36,11 @@ #include "SecretStore.h" #include "TopologyModel.h" +#ifdef MWB_HAVE_GTK_GUI +#include +#include "TrayController.h" +#endif + namespace { constexpr int kDefaultPort = 15101; @@ -68,6 +73,7 @@ void PrintGeneralUsage(std::ostream& out, const char* argv0) { out << " " << binary << " install-user-service [--config PATH] [--unit PATH] [--force]\n"; out << " " << binary << " secret-store [--config PATH] --secret-id ID [--key KEY | --key-file PATH | --stdin]\n"; out << " " << binary << " secret-clear [--config PATH] [--secret-id ID]\n"; + out << " " << binary << " gui [--config PATH] [--state PATH] — start combined tray + GUI (falls back to run if no display)\n"; out << "Connection policy: auto_connect_enabled=true|false, reconnect_initial_backoff_ms, reconnect_max_backoff_ms, reconnect_idle_retry_ms in config\n"; out << "Media keys: mpris_media_keys_enabled=true|false, mpris_player=PLAYER in config\n"; out << "Set latency_report=true, MWB_LATENCY_REPORT=1, or use --latency-report to print client-side input queue/inject timing on shutdown\n"; @@ -2699,6 +2705,37 @@ int HandleAndroidPairCommand(const std::vector& args) { return 0; } +#ifdef MWB_HAVE_GTK_GUI +int HandleGuiCommand(const std::string& binary, const std::vector& args) { + // Resolve config path from args (same parsing as HandleRunCommand) + std::filesystem::path configPath = mwb::DefaultConfigPath(); + std::filesystem::path statePath = mwb::DefaultStatePath(); + for (std::size_t i = 0; i < args.size(); ++i) { + if (args[i] == "--config" && i + 1 < args.size()) configPath = args[++i]; + else if (args[i] == "--state" && i + 1 < args.size()) statePath = args[++i]; + } + + mwb::AppConfig config; + if (std::filesystem::exists(configPath)) { + std::string err; + (void)mwb::LoadConfigFile(configPath, config, err); + } + + // Try to initialise GTK; fall back to headless run if no display + int fakeArgc = 1; + const std::string binaryStr = binary; + char* fakeArgvArr[] = {const_cast(binaryStr.c_str()), nullptr}; + char** fakeArgv = fakeArgvArr; + if (!gtk_init_check(&fakeArgc, &fakeArgv)) { + std::cerr << "No display available; running headless." << std::endl; + return HandleRunCommand(binary, args); + } + + return mwb::RunTrayAndGui(binary, args, config, + configPath.string(), statePath.string()); +} +#endif + } // namespace int main(int argc, char** argv) { @@ -2719,6 +2756,8 @@ int main(int argc, char** argv) { std::string(argv[1]) != "install-user-service" && std::string(argv[1]) != "secret-store" && std::string(argv[1]) != "secret-clear" && + std::string(argv[1]) != "gui" && + std::string(argv[1]) != "tray" && std::string(argv[1]).rfind("--", 0) != 0) { return HandleLegacyRun(argc, argv); } @@ -2763,6 +2802,12 @@ int main(int argc, char** argv) { return HandleSecretClearCommand(args); } +#ifdef MWB_HAVE_GTK_GUI + if (command == "gui" || command == "tray") { + return HandleGuiCommand(binary, args); + } +#endif + std::cerr << "ERR: Unknown command: " << command << std::endl; PrintGeneralUsage(std::cerr, argv[0]); return 1; From 42331eda6a959f14ab8ecc745120993b172d436e Mon Sep 17 00:00:00 2001 From: daredoole Date: Fri, 1 May 2026 10:50:56 -0400 Subject: [PATCH 13/14] Fix CI dependencies and service validation --- .github/workflows/ci.yml | 3 +++ packaging/rpm/inputflow.spec | 1 + packaging/usr/lib/systemd/user/mwb-client.service | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3669c1..eb929e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: sudo apt-get install -y --no-install-recommends \ build-essential \ cmake \ + libx11-dev \ libssl-dev \ pkg-config \ zlib1g-dev @@ -61,6 +62,7 @@ jobs: gcc-c++ \ gtk3-devel \ libayatana-appindicator3-devel \ + libX11-devel \ make \ openssl-devel \ pkgconf-pkg-config \ @@ -94,6 +96,7 @@ jobs: sudo apt-get install -y --no-install-recommends \ build-essential \ cmake \ + libx11-dev \ libssl-dev \ pkg-config \ zlib1g-dev diff --git a/packaging/rpm/inputflow.spec b/packaging/rpm/inputflow.spec index e8c917d..df4c264 100644 --- a/packaging/rpm/inputflow.spec +++ b/packaging/rpm/inputflow.spec @@ -16,6 +16,7 @@ BuildRequires: systemd-rpm-macros BuildRequires: zlib-devel BuildRequires: gtk3-devel BuildRequires: libayatana-appindicator3-devel +BuildRequires: libX11-devel Requires: systemd %{?systemd_requires} diff --git a/packaging/usr/lib/systemd/user/mwb-client.service b/packaging/usr/lib/systemd/user/mwb-client.service index 2bb1401..f358b9c 100644 --- a/packaging/usr/lib/systemd/user/mwb-client.service +++ b/packaging/usr/lib/systemd/user/mwb-client.service @@ -5,7 +5,7 @@ ConditionPathExists=%h/.config/mwb-client/config.ini [Service] Type=simple -ExecStart=/usr/bin/mwb_client gui --config %h/.config/mwb-client/config.ini +ExecStart=/usr/bin/mwb_client run --config %h/.config/mwb-client/config.ini Restart=on-failure RestartSec=3s NoNewPrivileges=true From 84a4ade6e9c82af302506b0c940f23f9c74d12bb Mon Sep 17 00:00:00 2001 From: daredoole Date: Fri, 1 May 2026 10:55:01 -0400 Subject: [PATCH 14/14] Fix minimal builds without libei --- src/LibeiInputCaptureBridge.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/LibeiInputCaptureBridge.cpp b/src/LibeiInputCaptureBridge.cpp index 41ace40..7e0d574 100644 --- a/src/LibeiInputCaptureBridge.cpp +++ b/src/LibeiInputCaptureBridge.cpp @@ -1,10 +1,11 @@ #include "LibeiInputCaptureBridge.h" +#include + #if defined(MWB_HAVE_LIBEI_INPUT_CAPTURE) #include #include #include -#include #endif #if defined(MWB_HAVE_LIBINPUT_GESTURES)