From 06edfb6c7bdf7b8b5bcee2cb8518c7389ca158d0 Mon Sep 17 00:00:00 2001 From: daredoole Date: Tue, 28 Apr 2026 20:08:46 -0400 Subject: [PATCH 1/2] 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 007b5bc81abf1e74427d5a535e108d17958fb580 Mon Sep 17 00:00:00 2001 From: Darrian <49536135+daredoole@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:29:25 -0400 Subject: [PATCH 2/2] Wire topology preview and layout wizard (#13) --- .gitignore | 1 + CMakeLists.txt | 13 + README.md | 16 +- docs/beta-workflow.md | 25 +- docs/compatibility.md | 5 +- docs/migration.md | 6 +- docs/topology.md | 170 ++++++++++++ mwb-desktop-ui.sh | 370 +++++++++++++++++++++++++- src/AppConfig.cpp | 18 ++ src/AppConfig.h | 2 + src/ClientRuntime.cpp | 90 +++++++ src/ClientRuntime.h | 8 +- src/Discovery.cpp | 58 ++++- src/InputDispatcher.cpp | 99 +++++++ src/InputDispatcher.h | 26 ++ src/NetworkManager.cpp | 8 + src/NetworkManager.h | 1 + src/PeerRecovery.cpp | 41 +++ src/PeerRecovery.h | 5 + src/TopologyModel.cpp | 400 +++++++++++++++++++++++++++++ src/TopologyModel.h | 38 +++ src/main.cpp | 187 +++++++++++++- tests/simple.topology | 7 + tests/test_main.cpp | 49 ++++ tests/test_topology_model.cpp | 114 ++++++++ tests/topology_config_docs_test.py | 146 +++++++++++ 26 files changed, 1869 insertions(+), 34 deletions(-) create mode 100644 docs/topology.md create mode 100644 tests/simple.topology create mode 100644 tests/topology_config_docs_test.py 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 6cd1945..c9aa86a 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,12 +187,22 @@ 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) 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 3ac7156..9403b89 100644 --- a/README.md +++ b/README.md @@ -15,10 +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. **Pair with Windows:** +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. -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`. +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). @@ -98,9 +102,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 expectations](docs/topology.md) - [Migration from other keyboard/mouse sharing tools](docs/migration.md) - [Compatibility matrix and platform caveats](docs/compatibility.md) @@ -111,7 +117,11 @@ 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 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: diff --git a/docs/beta-workflow.md b/docs/beta-workflow.md index 47fc90c..131f432 100644 --- a/docs/beta-workflow.md +++ b/docs/beta-workflow.md @@ -12,7 +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. 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 \ @@ -31,6 +33,27 @@ Keep the exported `.ps1` private because it contains pairing material. Delete it ![Pairing helper walkthrough](screenshots/pairing-helper.svg) +## Advanced Topology/Layout Wizard + +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. 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`: + +```ini +topology_enabled=true +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/compatibility.md b/docs/compatibility.md index 3c43421..86f9805 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 | 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 @@ -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 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 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 e092783..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. 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 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 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 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 `wrap=none` with explicit links until validation output matches the intended handoff behavior. diff --git a/docs/topology.md b/docs/topology.md new file mode 100644 index 0000000..4e6feb0 --- /dev/null +++ b/docs/topology.md @@ -0,0 +1,170 @@ +# Topology Files + +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. + +## Normal Single-Monitor Setup + +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 **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. + - **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: + +| 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 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 + +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..521d6cb 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,65 @@ 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" +} + +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)}" @@ -468,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" @@ -660,7 +770,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 +778,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() { @@ -874,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 @@ -891,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" @@ -901,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')")" @@ -958,17 +1082,217 @@ 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: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 + 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 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 + + mkdir -p "$topology_dir" + printf '%s\n' "$topology_content" >"$topology_path" + write_topology_config_keys "$topology_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." +} + +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 + 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 - choice="$(zenity --list --title="$APP_NAME guided pairing" --width=620 --height=360 \ - --text="Use this flow to discover Windows, save Linux settings, export the Windows helper, then verify the setup." \ + 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. Topology is optional; skip it for one Linux monitor." \ --column="Step" \ "1. Discover Windows peer and save settings" \ "2. Edit settings manually" \ "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 ;; @@ -976,6 +1300,9 @@ guided_pairing() { "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 @@ -1197,7 +1524,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;DisableTopology;TopologyWizard;ExplainTopology;HealthCheck;DiagnosticsBundle;ConnectionQuality;OpenSettings;OpenConnectionBehavior;ShowTrayHelp;ShowStatus;StartService;RestartService;StopService; [Desktop Action GuidedPairing] Name=Guided Pairing @@ -1207,6 +1534,18 @@ 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=Advanced Topology/Layout +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 @@ -1268,9 +1607,12 @@ 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" \ + "Use PowerToys Layout Only" \ + "Advanced Topology/Layout" \ + "Explain Current Topology" \ "Health Check" \ "Diagnostics Bundle" \ "Connection Quality" \ @@ -1287,6 +1629,9 @@ main_menu() { case "$choice" in "Guided Pairing") guided_pairing ;; + "Use PowerToys Layout Only") disable_topology ;; + "Advanced Topology/Layout") layout_wizard ;; + "Explain Current Topology") explain_topology ;; "Health Check") health_check ;; "Diagnostics Bundle") diagnostics_bundle ;; "Connection Quality") connection_quality ;; @@ -1315,6 +1660,9 @@ 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 ;; diagnostics-bundle|diagnostics) diagnostics_bundle ;; connection-quality|quality) connection_quality ;; @@ -1330,7 +1678,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|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" diff --git a/src/AppConfig.cpp b/src/AppConfig.cpp index d990cd6..3bac4b5 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 enable runtime topology handoff.\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..a85212c 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,66 @@ ClientRuntime::ScreenSize ClientRuntime::DetectScreenSize() const { return ScreenSize{kFallbackScreenWidth, kFallbackScreenHeight, ScreenSize::Source::Fallback}; } +void ClientRuntime::ConfigureTopologyPreview(const ScreenSize& screenSize) { + m_dispatcher.SetTopologyPreview(nullptr, {}, false); + m_dispatcher.SetTopologyHandoff({}, 0, 0, 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); + 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 + << " with cross-machine handoff enforcement enabled." << std::endl; +} + int ClientRuntime::Run() { const ScreenSize screenSize = DetectScreenSize(); if (screenSize.source == ScreenSize::Source::Fallback) { @@ -278,6 +367,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/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/InputDispatcher.cpp b/src/InputDispatcher.cpp index f393c85..46e7760 100644 --- a/src/InputDispatcher.cpp +++ b/src/InputDispatcher.cpp @@ -1,6 +1,7 @@ #include "InputDispatcher.h" #include +#include #include namespace mwb { @@ -113,6 +114,26 @@ 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::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 = @@ -180,6 +201,75 @@ 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; + } + + 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); +} + +void InputDispatcher::TraceTopologyPreviewTransition(const TopologyPointerTransition& transition) const { + if (!m_topologyTraceEnabled) { + return; + } + + 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) + << " as mouse x=" << handoffMouse.x << " y=" << handoffMouse.y + << " targetMachine=" << *targetMachineId << std::endl; + return true; +} + void InputDispatcher::Run() { while (true) { InputEvent event{}; @@ -195,6 +285,15 @@ 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 { m_input.InjectKeyboard(event.keyboard); diff --git a/src/InputDispatcher.h b/src/InputDispatcher.h index 5f05a14..816150e 100644 --- a/src/InputDispatcher.h +++ b/src/InputDispatcher.h @@ -3,18 +3,25 @@ #include #include #include +#include #include #include +#include +#include #include #include "InputManager.h" #include "InputLatencyStats.h" #include "Protocol.h" +#include "TopologyModel.h" namespace mwb { class InputDispatcher { public: + using TopologyHandoffCallback = + std::function; + explicit InputDispatcher(InputManager& input, std::shared_ptr latencyStats = nullptr); ~InputDispatcher(); @@ -23,6 +30,14 @@ 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); + void SetTopologyHandoff(std::string localMachineId, + int desktopWidth, + int desktopHeight, + bool enabled, + TopologyHandoffCallback callback); private: struct InputEvent { @@ -40,9 +55,20 @@ 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; + 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/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/TopologyModel.cpp b/src/TopologyModel.cpp index e2035e4..2ea3817 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,125 @@ 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 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; } @@ -153,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; @@ -418,4 +553,269 @@ 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, + }; +} + +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)}; + 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..b3ab345 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,19 @@ struct TransitionResult { int coordinate{0}; }; +struct TopologyPointerTransition { + std::string sourceDisplayId; + EdgeDirection exitEdge{EdgeDirection::Right}; + std::string targetDisplayId; + EdgeDirection entryEdge{EdgeDirection::Left}; + int coordinate{0}; +}; + +struct TopologyNormalizedPoint { + int x{0}; + int y{0}; +}; + enum class TopologyIssueCode { DuplicateMachine, DuplicateDisplay, @@ -76,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; @@ -95,4 +112,25 @@ 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); + +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); + } // namespace mwb diff --git a/src/main.cpp b/src/main.cpp index 12119c8..53a04ed 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"; @@ -960,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; } @@ -1168,6 +1189,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()); @@ -1580,7 +1603,8 @@ int HandleDiscoverCommand(const std::string& binary, 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) { @@ -2473,6 +2638,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" && @@ -2500,6 +2666,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 diff --git a/tests/test_main.cpp b/tests/test_main.cpp index 1d24f75..bd2508b 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); } @@ -493,6 +505,42 @@ void TestCollectRecoveryCandidateHostsForConfiguredHostname() { } } +void TestCollectRecoveryDiscoveredHostsUsesApprovedNamesOnly() { + mwb::AppState state; + state.peers.push_back(mwb::PeerState{"192.0.2.107", "WIN-PC", 15101, true, false, 100, 300}); + state.peers.push_back(mwb::PeerState{"192.0.2.108", "OTHER-PC", 15101, true, false, 110, 400}); + + 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; @@ -561,6 +609,7 @@ int main() { TestCollectRecoveryPeerNamesForConfiguredHostname(); TestCollectRecoveryCandidateHostsForConfiguredIpv4(); TestCollectRecoveryCandidateHostsForConfiguredHostname(); + TestCollectRecoveryDiscoveredHostsUsesApprovedNamesOnly(); TestDiscoveryZeroHosts(); TestKScreenDoctorSingleOutputGeometry(); TestKScreenDoctorMultiOutputBoundingBox(); diff --git a/tests/test_topology_model.cpp b/tests/test_topology_model.cpp index 53fab94..22eff0f 100644 --- a/tests/test_topology_model.cpp +++ b/tests/test_topology_model.cpp @@ -211,6 +211,114 @@ 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"); +} + +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() { @@ -224,6 +332,12 @@ int main() { TestValidationRejectsMissingDisplaysForLinks(); TestValidationRejectsContradictoryDuplicateEdgeLinks(); TestValidationRejectsImpossibleEdgeMappings(); + TestParseTopologyConfigAcceptsLineBasedFormat(); + TestParseTopologyConfigRejectsInvalidLines(); + TestPointerTransitionResolverUsesAbsoluteEdges(); + TestMachineScopedPointerResolverFindsDisplayAtDesktopEdge(); + TestMachineScopedPointerResolverRejectsUnlinkedEdges(); + TestTargetNormalizedPointMapsEntryEdge(); 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()